Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 29 additions & 20 deletions src/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -1195,18 +1200,18 @@ ${
}

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 <head> HTML first so it runs before Notion's scripts (Issue #147)
if (CUSTOM_HEAD !== '') {
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(\`<link rel="canonical" href="\${escapeHtml(canonicalUrl)}">\`, { html: true });
element.append(\`<meta name="robots" content="index, follow">\`, { html: true });

Expand Down Expand Up @@ -1269,8 +1274,9 @@ ${
element.append(\`<meta property="og:locale" content="\${escapeHtml(OG_LOCALE)}">\`, { 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(\`<meta property="og:image" content="\${escapeHtml(generatedUrl)}">\`, { html: true });
element.append(\`<meta property="og:image:width" content="1200">\`, { html: true });
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down
64 changes: 64 additions & 0 deletions test/generated-worker.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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('<html></html>', { 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 <head> HTML, og:url, and the <title> 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',
});
});
Loading