Skip to content
Closed
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
33 changes: 30 additions & 3 deletions src/code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1448,6 +1448,17 @@ ${
const tag = document.querySelector(selector);
if (tag && value && tag.getAttribute('content') !== value) tag.setAttribute('content', value);
}
function getCanonicalUrl() {
const pageId = getPage() || SLUG_TO_PAGE[getSlug()];
if (pageId) {
const slug = PAGE_TO_SLUG[pageId];
const path = slug === undefined ? '/' + pageId : (slug ? '/' + slug : '');
return 'https://' + MY_DOMAIN + path;
}
const isNotFound = NOT_FOUND_PATH !== null && location.pathname === NOT_FOUND_PATH;
return 'https://' + MY_DOMAIN + (isNotFound ? '/404' : '');
}

function applyMeta() {
const slug = location.pathname === NOT_FOUND_PATH ? '404'
: PAGE_TO_SLUG[getPage()] ?? (slugs.includes(getSlug()) ? getSlug() : null);
Expand All @@ -1464,14 +1475,22 @@ ${
setContent('meta[name="description"]', description);
setContent('meta[property="og:description"]', description);
setContent('meta[name="twitter:description"]', description);
const canonicalUrl = getCanonicalUrl();
const canonical = document.querySelector('link[rel="canonical"]');
if (canonical && canonical.getAttribute('href') !== canonicalUrl) {
canonical.setAttribute('href', canonicalUrl);
}
setContent('meta[property="og:url"]', canonicalUrl);
setContent('meta[name="twitter:url"]', canonicalUrl);
}
new MutationObserver(applyMeta).observe(document.head, {
subtree: true,
childList: true,
characterData: true,
attributes: true,
attributeFilter: ['content'],
attributeFilter: ['content', 'href'],
});
applyMeta();
const observer = new MutationObserver(function() {
if (redirected) return;
const nav = document.querySelector('.notion-topbar');
Expand All @@ -1480,6 +1499,7 @@ ${
|| mobileNav && mobileNav.firstChild) {
redirected = true;
updateSlug();
applyMeta();
// Notion only exposes ThemeStore via __console in debug sessions
if (window.__console?.environment?.ThemeStore) addDarkModeButton(nav ? 'web' : 'mobile');
const onpopstate = window.onpopstate;
Expand All @@ -1492,6 +1512,7 @@ ${
}
if (onpopstate) onpopstate.apply(this, [].slice.call(arguments));
updateSlug();
applyMeta();
};
}
});
Expand All @@ -1503,6 +1524,7 @@ ${
if (!redirected) {
redirected = true;
updateSlug();
applyMeta();
const onpopstate = window.onpopstate;
window.onpopstate = function() {
if (slugs.includes(getSlug())) {
Expand All @@ -1513,13 +1535,16 @@ ${
}
if (onpopstate) onpopstate.apply(this, [].slice.call(arguments));
updateSlug();
applyMeta();
};
}
}, 2000);
const replaceState = window.history.replaceState;
window.history.replaceState = function(state) {
if (arguments[1] !== 'bypass' && (slugs.includes(getSlug()) || location.pathname === NOT_FOUND_PATH)) return;
return replaceState.apply(window.history, arguments);
const result = replaceState.apply(window.history, arguments);
applyMeta();
return result;
};
const pushState = window.history.pushState;
window.history.pushState = function(state) {
Expand All @@ -1529,7 +1554,9 @@ ${
if (id && pages.includes(id)) {
arguments[2] = '/' + PAGE_TO_SLUG[id];
}
return pushState.apply(window.history, arguments);
const result = pushState.apply(window.history, arguments);
applyMeta();
return result;
};
const open = window.XMLHttpRequest.prototype.open;
window.XMLHttpRequest.prototype.open = function() {
Expand Down
104 changes: 104 additions & 0 deletions test/generated-worker.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { runInNewContext } from 'node:vm';
import { before, test } from 'node:test';
import { gzipSync } from 'node:zlib';
import { createServer } from 'vite';
Expand Down Expand Up @@ -310,3 +311,106 @@ test('the home page keeps its canonical URL and metadata', async (t) => {
title: 'Home title',
});
});


function fakeTag(initialAttributes) {
const attributes = { ...initialAttributes };
return {
getAttribute: (name) => attributes[name] ?? null,
setAttribute: (name, value) => { attributes[name] = value; },
};
}

async function runClientMetadata(t, path) {
const handlers = await rewriteHandlers(t, path, HOME_METADATA);
const appended = [];
handlers.body.element({ append: (html) => appended.push(html) });
const script = appended.join('').match(/<script>([\s\S]*?)<\/script>/)?.[1];
assert.ok(script, 'generated worker should append its client script');

const tags = {
canonical: fakeTag({ href: 'https://notes.example.com/Notion-Title-11111111111111111111111111111111' }),
ogUrl: fakeTag({ content: '/Notion-Title-11111111111111111111111111111111' }),
twitterUrl: fakeTag({ content: '/Notion-Title-11111111111111111111111111111111' }),
};
const selectors = new Map([
['link[rel="canonical"]', tags.canonical],
['meta[property="og:url"]', tags.ogUrl],
['meta[name="twitter:url"]', tags.twitterUrl],
]);
const observers = [];
class TestMutationObserver {
constructor(callback) { this.callback = callback; observers.push(this); }
observe() {}
}
const location = { protocol: 'https:', host: 'notes.example.com', pathname: path };
const history = {
state: {},
pushState(_state, _title, url) {
if (url !== undefined) location.pathname = new URL(url, 'https://notes.example.com').pathname;
},
replaceState(_state, _title, url) {
if (url !== undefined) location.pathname = new URL(url, 'https://notes.example.com').pathname;
},
};
const window = { history, XMLHttpRequest: { prototype: { open() {} } } };
const document = {
head: {},
body: { classList: { contains: () => false, add() {}, remove() {} } },
title: 'Home | Notion',
createElement: () => ({ addEventListener() {}, appendChild() {} }),
querySelector: (selector) => selectors.get(selector) || null,
};

runInNewContext(script, {
window,
document,
location,
MutationObserver: TestMutationObserver,
URL,
setTimeout: () => 0,
});
return { tags, observers, window, location };
}

test('client metadata preserves canonical URLs after Notion changes the head and during navigation', async (t) => {
const page = await runClientMetadata(t, '/');
const expectedHome = 'https://notes.example.com';
assert.equal(page.tags.canonical.getAttribute('href'), expectedHome);
assert.equal(page.tags.ogUrl.getAttribute('content'), expectedHome);
assert.equal(page.tags.twitterUrl.getAttribute('content'), expectedHome);

// A later Notion head rewrite must not replace the Worknot URLs.
for (const [tag, attribute] of [
[page.tags.canonical, 'href'],
[page.tags.ogUrl, 'content'],
[page.tags.twitterUrl, 'content'],
]) {
tag.setAttribute(attribute, '/Notion-Title-11111111111111111111111111111111');
}
page.observers[0].callback([]);
assert.equal(page.tags.canonical.getAttribute('href'), expectedHome);
assert.equal(page.tags.ogUrl.getAttribute('content'), expectedHome);
assert.equal(page.tags.twitterUrl.getAttribute('content'), expectedHome);

// The same generated client script handles mapped and unmapped page navigation.
page.window.history.pushState({}, '', '/' + OTHER);
assert.equal(page.location.pathname, '/chapter');
assert.equal(page.tags.canonical.getAttribute('href'), 'https://notes.example.com/chapter');
assert.equal(page.tags.ogUrl.getAttribute('content'), 'https://notes.example.com/chapter');
assert.equal(page.tags.twitterUrl.getAttribute('content'), 'https://notes.example.com/chapter');

page.window.history.pushState({}, '', '/' + UNMAPPED);
assert.equal(page.location.pathname, '/' + UNMAPPED);
const expectedUnmapped = 'https://notes.example.com/' + UNMAPPED;
assert.equal(page.tags.canonical.getAttribute('href'), expectedUnmapped);
assert.equal(page.tags.ogUrl.getAttribute('content'), expectedUnmapped);
assert.equal(page.tags.twitterUrl.getAttribute('content'), expectedUnmapped);

// replaceState is also used by the client router and must refresh route metadata.
page.window.history.replaceState({}, '', '/' + OTHER);
const expectedMapped = 'https://notes.example.com/chapter';
assert.equal(page.tags.canonical.getAttribute('href'), expectedMapped);
assert.equal(page.tags.ogUrl.getAttribute('content'), expectedMapped);
assert.equal(page.tags.twitterUrl.getAttribute('content'), expectedMapped);
});