From e2fa134e605d3fca7b5780003d65e7319272e77f Mon Sep 17 00:00:00 2001 From: shigahi Date: Wed, 28 Oct 2026 11:40:11 +0100 Subject: [PATCH] Give pages without a slug their own canonical URL Pages that aren't mapped to a slug were rewritten with the root slug, so they declared the home page as canonical URL and og:url and took over its per-page metadata, both in the worker and in the client script. Search engines could treat them as duplicates of the home page. Use the page ID path as their canonical URL (so /Title- and / share one), and apply no per-page metadata or generated OG image to them. Closes #190 Co-Authored-By: Claude Opus 5.5 --- src/code.ts | 49 +++++++++++++++----------- test/generated-worker.test.mjs | 64 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 20 deletions(-) diff --git a/src/code.ts b/src/code.ts index b40f23e..2fc0d62 100644 --- a/src/code.ts +++ b/src/code.ts @@ -1085,19 +1085,25 @@ ${ return notFoundResponse(url); } - // Get current slug from page ID for canonical URL + // A page without a slug is canonical at its page ID, so that it isn't + // taken for a duplicate of the home page (#190) const pageIdMatch = url.pathname.match(/[0-9a-f]{32}/); const pageId = pageIdMatch ? pageIdMatch[0] : ''; - const currentSlug = PAGE_TO_SLUG[pageId] || ''; + if (PAGE_TO_SLUG[pageId] !== undefined) { + return appendJavascript(response, SLUG_TO_PAGE, PAGE_TO_SLUG[pageId], url); + } + return appendJavascript(response, SLUG_TO_PAGE, null, url, pageId ? '/' + pageId : url.pathname); + } - return appendJavascript(response, SLUG_TO_PAGE, currentSlug, url); + // Per-page metadata (Issue #11); pages without a slug (null) have none + function pageMetadata(slug) { + return (slug !== null && PAGE_METADATA[slug]) || {}; } class MetaRewriter { - constructor(slug) { - this.slug = slug; - // Get page-specific metadata or use defaults (Issue #11) - this.metadata = PAGE_METADATA[slug] || {}; + constructor(slug, canonicalUrl) { + this.canonicalUrl = canonicalUrl; + this.metadata = pageMetadata(slug); } element(element) { const pageTitle = this.metadata.title || PAGE_TITLE; @@ -1152,8 +1158,7 @@ ${ // Set canonical URL for og:url and twitter:url (Issue #9) if (element.getAttribute('property') === 'og:url' || element.getAttribute('name') === 'twitter:url') { - const canonicalUrl = 'https://' + MY_DOMAIN + (this.slug ? '/' + this.slug : ''); - element.setAttribute('content', canonicalUrl); + element.setAttribute('content', this.canonicalUrl); } if (element.getAttribute('name') === 'apple-itunes-app') { element.remove(); @@ -1195,10 +1200,10 @@ ${ } class HeadRewriter { - constructor(slug) { + constructor(slug, canonicalUrl) { this.slug = slug; - // Get page-specific metadata or use defaults (Issue #11) - this.metadata = PAGE_METADATA[slug] || {}; + this.canonicalUrl = canonicalUrl; + this.metadata = pageMetadata(slug); } element(element) { // Add custom HTML first so it runs before Notion's scripts (Issue #147) @@ -1206,7 +1211,7 @@ ${ element.prepend(CUSTOM_HEAD, { html: true }); } // Add canonical URL and robots meta tag for SEO (Issue #8 & #9) - const canonicalUrl = 'https://' + MY_DOMAIN + (this.slug ? '/' + this.slug : ''); + const canonicalUrl = this.canonicalUrl; element.append(\`\`, { html: true }); element.append(\`\`, { html: true }); @@ -1269,8 +1274,9 @@ ${ element.append(\`\`, { html: true }); } - // Add auto-generated OG image if no custom image is set (Issue #36) - if (!effectiveOgImage && OG_IMAGE_GENERATION_ENABLED) { + // Add auto-generated OG image if no custom image is set (Issue #36). + // Images are only generated for pages with a slug. + if (!effectiveOgImage && OG_IMAGE_GENERATION_ENABLED && this.slug !== null) { const generatedUrl = \`https://\${MY_DOMAIN}/og-image/\${this.slug}\`; element.append(\`\`, { html: true }); element.append(\`\`, { html: true }); @@ -1444,8 +1450,9 @@ ${ } function applyMeta() { const slug = location.pathname === NOT_FOUND_PATH ? '404' - : PAGE_TO_SLUG[getPage()] ?? (slugs.includes(getSlug()) ? getSlug() : ''); - const page = META.pages[slug] || {}; + : PAGE_TO_SLUG[getPage()] ?? (slugs.includes(getSlug()) ? getSlug() : null); + // Like on the server, pages without a slug get no per-page metadata + const page = (slug !== null && META.pages[slug]) || {}; let title = page.title || META.title; if (!title && META.brand && document.title.endsWith(' | Notion')) { title = document.title.slice(0, -' | Notion'.length) + ' | ' + META.brand; @@ -1648,9 +1655,11 @@ ${ return appendJavascript(response, SLUG_TO_PAGE, '404', url); } - async function appendJavascript(res, SLUG_TO_PAGE, slug, url) { - const metaRewriter = new MetaRewriter(slug); - const headRewriter = new HeadRewriter(slug); + // slug is null for a page without one; path is then its canonical path + async function appendJavascript(res, SLUG_TO_PAGE, slug, url, path = slug ? '/' + slug : '') { + const canonicalUrl = 'https://' + MY_DOMAIN + path; + const metaRewriter = new MetaRewriter(slug, canonicalUrl); + const headRewriter = new HeadRewriter(slug, canonicalUrl); const linkRewriter = new LinkRewriter(); const contentType = res.headers.get('content-type'); let transformed = new HTMLRewriter() diff --git a/test/generated-worker.test.mjs b/test/generated-worker.test.mjs index c91edd5..1bfc461 100644 --- a/test/generated-worker.test.mjs +++ b/test/generated-worker.test.mjs @@ -246,3 +246,67 @@ test('Content Signals add a group for all crawlers when the rules have none', as assert.equal(await robotsTxt({ robotsRules, contentSignals: { 'ai-train': 'no' } }), `User-agent: *\nContent-Signal: ai-train=no\n\nUser-agent: GPTBot\nDisallow: /${SITEMAP}`); }); + +// Requests a page and returns the HTMLRewriter handlers the worker registers for it +async function rewriteHandlers(t, path, config) { + const handlers = {}; + globalThis.HTMLRewriter = class { + on(selector, handler) { handlers[selector] = handler; return this; } + transform(response) { return response; } + }; + t.after(() => { delete globalThis.HTMLRewriter; }); + t.mock.method(globalThis, 'fetch', async () => new Response('', { headers: { 'Content-Type': 'text/html' } })); + await run(await loadWorker(config), new Request(`https://notes.example.com${path}`)); + return handlers; +} + +// What the head and meta handlers produce: appended HTML, og:url, and the text if set +function rewrittenHead(handlers) { + const head = { appended: [], append(html) { head.appended.push(html); }, prepend() {} }; + handlers.head.element(head); + const attributes = { property: 'og:url' }; + handlers.meta.element({ + getAttribute: (name) => attributes[name] ?? null, + setAttribute: (name, value) => { attributes[name] = value; }, + }); + const title = { tagName: 'title', text: null, getAttribute: () => null, setInnerContent: (text) => { title.text = text; } }; + handlers.title.element(title); + return { + canonical: head.appended.find((html) => html.startsWith('<link rel="canonical"'))?.match(/href="([^"]*)"/)[1], + ogUrl: attributes.content, + ogImage: head.appended.find((html) => html.startsWith('<meta property="og:image" '))?.match(/content="([^"]*)"/)[1], + title: title.text, + }; +} + +const HOME_METADATA = { + pageMetadata: { '': { title: 'Home title' } }, + ogImageGeneration: { enabled: true }, +}; +const UNMAPPED = '44444444444444444444444444444444'; + +test('a page without a slug is canonical at its page ID, without home page metadata', async (t) => { + const head = rewrittenHead(await rewriteHandlers(t, `/Some-Page-${UNMAPPED}`, HOME_METADATA)); + assert.deepEqual(head, { + canonical: `https://notes.example.com/${UNMAPPED}`, + ogUrl: `https://notes.example.com/${UNMAPPED}`, + ogImage: undefined, + title: null, + }); +}); + +test('a mapped page opened by page ID is canonical at its slug', async (t) => { + const head = rewrittenHead(await rewriteHandlers(t, `/${OTHER}`, HOME_METADATA)); + assert.equal(head.canonical, 'https://notes.example.com/chapter'); + assert.equal(head.ogImage, 'https://notes.example.com/og-image/chapter'); +}); + +test('the home page keeps its canonical URL and metadata', async (t) => { + const head = rewrittenHead(await rewriteHandlers(t, '/', HOME_METADATA)); + assert.deepEqual(head, { + canonical: 'https://notes.example.com', + ogUrl: 'https://notes.example.com', + ogImage: 'https://notes.example.com/og-image/', + title: 'Home title', + }); +});