dh-remixer-sdk 0.0.45-fa836ab → 0.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dh-remixer-sdk",
3
- "version": "0.0.45-fa836ab",
3
+ "version": "0.0.45",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "templates",
@@ -26,7 +26,11 @@ const DEVELOP_LD_JSON = (name, description) => ({
26
26
  sameAs: [],
27
27
  });
28
28
 
29
+ const ROUTE_TABLE = { routerFound: true, complete: true, hasCatchAll: true, patterns: ["/", "/about"] };
30
+ const notFoundPrerenders = [];
31
+
29
32
  const read = (file) => fs.readFileSync(path.join(BUILD_DIR, file), "utf8");
33
+ const exists = (file) => fs.existsSync(path.join(BUILD_DIR, file));
30
34
  const ldJson = (html) => JSON.parse(html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/)[1]);
31
35
  let resolveRobotsTxt;
32
36
  let buildSitemapXml;
@@ -45,7 +49,7 @@ before(async () => {
45
49
  const { markStaticHead } = await import("./inject-metadata-head.mjs");
46
50
  const { writeHelmetFile } = await import("./fs-utils.mjs");
47
51
  const { parseHtmlForContactInfo, applyBusinessLdJson } = await import("./parse-html-for-contact-info.mjs");
48
- const { writeSiteFiles } = await import("./site-files.mjs");
52
+ const { writeNotFoundPageInTime, writeSiteFiles } = await import("./site-files.mjs");
49
53
  ({ resolveRobotsTxt } = await import("./robots-txt.mjs"));
50
54
  ({ buildSitemapXml } = await import("./build-sitemap-xml.mjs"));
51
55
 
@@ -67,7 +71,11 @@ before(async () => {
67
71
  await parseHtmlForContactInfo(html);
68
72
  }
69
73
  await applyBusinessLdJson();
70
- await writeSiteFiles({ sitemapRoutes: ["/", "/about"], orphanRoutes: [], routeTable: { routerFound: false } });
74
+ await writeSiteFiles({ sitemapRoutes: ["/", "/about"], orphanRoutes: [], routeTable: ROUTE_TABLE });
75
+ await writeNotFoundPageInTime({
76
+ routeTable: ROUTE_TABLE,
77
+ prerender: async (route) => (notFoundPrerenders.push(route), SPA_INDEX),
78
+ });
71
79
  });
72
80
 
73
81
  test("without a settings file the JSON-LD is develop's minus the invented price range, with no FAQPage", () => {
@@ -89,9 +97,16 @@ test("without a settings file the only head changes are the rebased loopback can
89
97
  assert.match(about, /<!-- remixer:integrations:head --><meta name="google-site-verification" content="abc" \/><!-- \/remixer:integrations:head -->/);
90
98
  });
91
99
 
92
- test("without a settings file the site's own llms.txt is kept and the additions are written", () => {
100
+ test("without a settings file the site's own llms.txt is kept and no llms additions are written", () => {
93
101
  assert.equal(read("llms.txt"), "# Wilton House\n\n> Curated by the site.\n");
94
- assert.ok(fs.existsSync(path.join(BUILD_DIR, "llms-full.txt")));
95
- assert.ok(fs.existsSync(path.join(BUILD_DIR, "index.md")));
96
- assert.ok(fs.existsSync(path.join(BUILD_DIR, "about.md")));
102
+ assert.equal(exists("llms-full.txt"), false);
103
+ assert.equal(exists("index.md"), false);
104
+ assert.equal(exists("about.md"), false);
105
+ });
106
+
107
+ test("without a settings file there is no 404.html, _routes.json or seo-review.json, and no not-found prerender", () => {
108
+ assert.equal(exists("404.html"), false);
109
+ assert.equal(exists("_routes.json"), false);
110
+ assert.equal(exists("seo-review.json"), false);
111
+ assert.deepEqual(notFoundPrerenders, []);
97
112
  });
@@ -2,7 +2,7 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { SEO_SETTINGS } from "./vars.mjs";
4
4
  import {
5
- hasAccessSettings,
5
+ hasSeoSettings,
6
6
  isAccessAllowed,
7
7
  isPrivateSite,
8
8
  siteOrigin,
@@ -63,7 +63,7 @@ export function presetRobotsTxt(settings, origin) {
63
63
  }
64
64
 
65
65
  export function resolveRobotsTxt(existing, targetDomain, settings = {}) {
66
- if (hasAccessSettings(settings) || isPrivateSite(settings)) {
66
+ if (hasSeoSettings(settings) || isPrivateSite(settings)) {
67
67
  return presetRobotsTxt(settings, siteOrigin(settings, targetDomain));
68
68
  }
69
69
 
@@ -0,0 +1,67 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const BUILD_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "ssg-seo-only-files-"));
8
+ process.env.REMIXER_BUILD_DIR = BUILD_DIR;
9
+ process.env.NEXT_PUBLIC_DOMAIN = "wiltonhouse.com";
10
+ delete process.env.REMIXER_SEO_SETTINGS;
11
+ delete process.env.GEO_CODER_API_KEY;
12
+
13
+ const { writeNotFoundPageInTime, writeSiteFiles } = await import("./site-files.mjs");
14
+
15
+ const SEO_ONLY_FILES = ["llms.txt", "llms-full.txt", "index.md", "about.md", "404.html", "_routes.json", "seo-review.json"];
16
+ const ROUTE_TABLE = { routerFound: true, complete: true, hasCatchAll: false, patterns: ["/", "/about"] };
17
+
18
+ const ALL_DEFAULT_SETTINGS = {
19
+ siteUrl: "https://wiltonhouse.com",
20
+ access: { search: true, assistants: true, training: true, llmsTxt: true, indexNow: true },
21
+ business: {
22
+ name: null,
23
+ type: null,
24
+ address: null,
25
+ phone: null,
26
+ email: null,
27
+ hours: [],
28
+ hoursNote: null,
29
+ priceRange: null,
30
+ serviceArea: null,
31
+ links: [],
32
+ siteIconUrl: null,
33
+ },
34
+ pages: {},
35
+ redirects: [],
36
+ private: false,
37
+ screenshots: true,
38
+ };
39
+ const PLATFORM_HOST_MARKER = { siteUrl: "https://wilton.example-cdn.com", private: true };
40
+
41
+ const LONG = "Honest cocktails and small plates in a warm room on Main Street. ".repeat(3);
42
+ const page = (title) =>
43
+ `<!doctype html><html lang="en"><head><title>${title}</title><meta name="description" content="${title} at Wilton House."></head><body><div id="root"><main><h1>${title}</h1><p>${LONG}</p></main></div></body></html>`;
44
+
45
+ async function seoOnlyFilesBuiltWith(settings) {
46
+ fs.rmSync(BUILD_DIR, { recursive: true, force: true });
47
+ fs.mkdirSync(path.join(BUILD_DIR, "about"), { recursive: true });
48
+ fs.writeFileSync(path.join(BUILD_DIR, "index.html"), page("Wilton House"));
49
+ fs.writeFileSync(path.join(BUILD_DIR, "about", "index.html"), page("About"));
50
+
51
+ await writeSiteFiles({ sitemapRoutes: ["/", "/about"], orphanRoutes: [], routeTable: ROUTE_TABLE }, settings);
52
+ await writeNotFoundPageInTime({ routeTable: ROUTE_TABLE, prerender: async () => null }, settings);
53
+
54
+ return SEO_ONLY_FILES.filter((file) => fs.existsSync(path.join(BUILD_DIR, file)));
55
+ }
56
+
57
+ test("an activated project with every setting at its default gets every SEO-only file", async () => {
58
+ assert.deepEqual(await seoOnlyFilesBuiltWith(ALL_DEFAULT_SETTINGS), SEO_ONLY_FILES);
59
+ });
60
+
61
+ test("the platform-host build of a project that never switched SEO on gets no SEO-only file", async () => {
62
+ assert.deepEqual(await seoOnlyFilesBuiltWith(PLATFORM_HOST_MARKER), []);
63
+ });
64
+
65
+ test("an empty settings object gets no SEO-only file", async () => {
66
+ assert.deepEqual(await seoOnlyFilesBuiltWith({}), []);
67
+ });
@@ -51,7 +51,7 @@ export function pageSettings(settings, route) {
51
51
  return key && isPlainObject(pages[key]) ? pages[key] : undefined;
52
52
  }
53
53
 
54
- export const hasSeoSettings = (settings) => isPlainObject(settings) && Object.keys(settings).length > 0;
54
+ export const hasSeoSettings = (settings) => isPlainObject(settings?.access);
55
55
 
56
56
  export const isPrivateSite = (settings) => settings?.private === true;
57
57
 
@@ -61,8 +61,6 @@ export const isRouteHidden = (settings, route) =>
61
61
  export const isRouteIndexable = (settings, route) =>
62
62
  !isPrivateSite(settings) && !isRouteHidden(settings, route);
63
63
 
64
- export const hasAccessSettings = (settings) => isPlainObject(settings?.access);
65
-
66
64
  export const isAccessAllowed = (settings, key) =>
67
65
  settings?.access?.[key] !== false;
68
66
 
@@ -5,6 +5,7 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import {
7
7
  flattenRoutes,
8
+ hasSeoSettings,
8
9
  isAccessAllowed,
9
10
  isRouteIndexable,
10
11
  normalizeRoutePath,
@@ -36,6 +37,13 @@ test("a valid settings file is read as is", () => {
36
37
  assert.deepEqual(readSeoSettings(writeTemp(JSON.stringify(settings))), settings);
37
38
  });
38
39
 
40
+ test("only the settings of a project that switched SEO on count as SEO settings", () => {
41
+ assert.equal(hasSeoSettings({}), false);
42
+ assert.equal(hasSeoSettings({ siteUrl: "https://wilton.example-cdn.com", private: true }), false);
43
+ assert.equal(hasSeoSettings({ siteUrl: "https://wiltonhouse.com", access: { search: true }, private: false }), true);
44
+ assert.equal(hasSeoSettings({ siteUrl: "https://wilton.example-cdn.com", access: { search: true }, private: true }), true);
45
+ });
46
+
39
47
  test("routes are normalised on both sides of the page lookup", () => {
40
48
  const settings = { pages: { "/about/": { title: "About" }, "/": { title: "Home" } } };
41
49
 
@@ -16,6 +16,7 @@ import { STATIC_HEAD_ATTR } from "./inject-metadata-head.mjs";
16
16
  import { OPTIONAL_WORK_RESERVE_MS, RUN_CLOCK } from "./run-clock.mjs";
17
17
  import {
18
18
  flattenRoutes,
19
+ hasSeoSettings,
19
20
  isAccessAllowed,
20
21
  isPrivateSite,
21
22
  normalizeRoutePath,
@@ -150,6 +151,10 @@ export async function writeNotFoundPageInTime(
150
151
  { routeTable, prerender, clock = RUN_CLOCK },
151
152
  settings = SEO_SETTINGS,
152
153
  ) {
154
+ if (!hasSeoSettings(settings)) {
155
+ console.log("[skipped] 404.html (SEO not switched on)");
156
+ return;
157
+ }
153
158
  let prerendered = null;
154
159
  if (routeTable?.hasCatchAll && clock.hasTimeFor(OPTIONAL_WORK_RESERVE_MS)) {
155
160
  prerendered = await prerender(NOT_FOUND_PROBE_ROUTE).catch((err) => {
@@ -169,8 +174,12 @@ export async function writeSiteFiles(
169
174
  await writeSitemapXml(sitemapRoutes, settings);
170
175
  await writeRobotsTxt(REMIXER_BUILD_DIR, TARGET_DOMAIN, settings);
171
176
  await optionalStep("_redirects.json", () => writeRedirectsJson(settings));
172
- await optionalStep("_routes.json", () => writeRoutesJson(routeTable));
173
177
  await optionalStep("IndexNow key", () => writeIndexNowKeyFile(settings));
178
+ if (!hasSeoSettings(settings)) {
179
+ console.log(`[skipped] _routes.json, llms files and ${REVIEW_FILE} (SEO not switched on)`);
180
+ return;
181
+ }
182
+ await optionalStep("_routes.json", () => writeRoutesJson(routeTable));
174
183
  await optionalStep("llms.txt", () => writeLlmsFiles(flattenRoutes(sitemapRoutes), settings));
175
184
  await optionalStep(REVIEW_FILE, () => writeSeoReview({ sitemapRoutes, orphanRoutes }, settings));
176
185
  }
@@ -118,13 +118,29 @@ test("the IndexNow key file is written only when IndexNow is on and the key is w
118
118
  });
119
119
 
120
120
  const PRERENDERED_NOT_FOUND = `<html lang="en"><head><title>Not found | Site</title></head><body><h1>We could not find that page</h1></body></html>`;
121
+ const SWITCHED_ON = { access: { search: true, assistants: true, training: true, llmsTxt: true, indexNow: true } };
121
122
  const clockAt = (elapsedMs) => createRunClock({ now: () => elapsedMs });
122
123
 
124
+ test("without SEO settings the not-found page is neither prerendered nor written", async () => {
125
+ fs.rmSync(path.join(BUILD_DIR, "404.html"), { force: true });
126
+ const rendered = [];
127
+
128
+ for (const settings of [{}, { siteUrl: "https://wilton.example-cdn.com", private: true }]) {
129
+ await writeNotFoundPageInTime(
130
+ { routeTable: { hasCatchAll: true }, prerender: async (route) => (rendered.push(route), PRERENDERED_NOT_FOUND), clock: clockAt(0) },
131
+ settings,
132
+ );
133
+ }
134
+
135
+ assert.deepEqual(rendered, []);
136
+ assert.equal(existsInBuild("404.html"), false);
137
+ });
138
+
123
139
  test("the not-found page is prerendered when the catch-all exists and enough time remains", async () => {
124
140
  const rendered = [];
125
141
  await writeNotFoundPageInTime(
126
142
  { routeTable: { hasCatchAll: true }, prerender: async (route) => (rendered.push(route), PRERENDERED_NOT_FOUND), clock: clockAt(300_000) },
127
- {},
143
+ SWITCHED_ON,
128
144
  );
129
145
 
130
146
  assert.deepEqual(rendered, [NOT_FOUND_PROBE_ROUTE]);
@@ -135,7 +151,7 @@ test("the not-found page falls back to the plain page when less than 120 seconds
135
151
  const rendered = [];
136
152
  await writeNotFoundPageInTime(
137
153
  { routeTable: { hasCatchAll: true }, prerender: async (route) => (rendered.push(route), PRERENDERED_NOT_FOUND), clock: clockAt(810_000 - 119_000) },
138
- {},
154
+ SWITCHED_ON,
139
155
  );
140
156
 
141
157
  assert.deepEqual(rendered, []);
@@ -145,7 +161,8 @@ test("the not-found page falls back to the plain page when less than 120 seconds
145
161
 
146
162
  test("without a catch-all the plain page is written and nothing is prerendered", async () => {
147
163
  const rendered = [];
148
- await writeNotFoundPageInTime({ routeTable: { hasCatchAll: false }, prerender: async (route) => rendered.push(route), clock: clockAt(0) }, {});
164
+ fs.rmSync(path.join(BUILD_DIR, "404.html"), { force: true });
165
+ await writeNotFoundPageInTime({ routeTable: { hasCatchAll: false }, prerender: async (route) => rendered.push(route), clock: clockAt(0) }, SWITCHED_ON);
149
166
 
150
167
  assert.deepEqual(rendered, []);
151
168
  assert.match(readBuild("404.html"), /<h1>Page not found<\/h1>/);
@@ -38,7 +38,14 @@ const read = (file) => fs.readFileSync(path.join(BUILD_DIR, file), "utf8");
38
38
  before(async () => {
39
39
  fs.mkdirSync(path.join(BUILD_DIR, "assets"), { recursive: true });
40
40
  fs.writeFileSync(path.join(BUILD_DIR, "index.html"), SPA_INDEX);
41
- fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ siteUrl: SITE_URL, private: false }));
41
+ fs.writeFileSync(
42
+ SETTINGS_FILE,
43
+ JSON.stringify({
44
+ siteUrl: SITE_URL,
45
+ access: { search: true, assistants: true, training: true, llmsTxt: true, indexNow: true },
46
+ private: false,
47
+ }),
48
+ );
42
49
 
43
50
  process.env.REMIXER_BUILD_DIR = BUILD_DIR;
44
51
  process.env.REMIXER_SEO_SETTINGS = SETTINGS_FILE;
@@ -11,7 +11,7 @@ type RuntimeAuthHeaders = {
11
11
  tokenSource: 'anonymous' | 'session';
12
12
  };
13
13
 
14
- async function runtimeAuthHeaders(url: string, method: string): Promise<RuntimeAuthHeaders> {
14
+ async function runtimeAuthHeaders(url: string, method: string, nonce?: string): Promise<RuntimeAuthHeaders> {
15
15
  const session = await getProjectSession();
16
16
  const tokenSource = session?.access_token ? 'session' : 'anonymous';
17
17
  const token = session?.access_token ?? await getAnonymousToken();
@@ -19,7 +19,7 @@ async function runtimeAuthHeaders(url: string, method: string): Promise<RuntimeA
19
19
  return {
20
20
  headers: {
21
21
  Authorization: `Bearer ${token}`,
22
- DPoP: await createDpopProof(url, method),
22
+ DPoP: await createDpopProof(url, method, nonce),
23
23
  },
24
24
  tokenSource,
25
25
  };
@@ -34,8 +34,9 @@ export async function runtimeJsonFetch(
34
34
  method: 'GET' | 'POST',
35
35
  body?: RemixerRecordData,
36
36
  retryOnUnauthorized = true,
37
+ dpopNonce?: string,
37
38
  ) {
38
- const { headers, tokenSource } = await runtimeAuthHeaders(url, method);
39
+ const { headers, tokenSource } = await runtimeAuthHeaders(url, method, dpopNonce);
39
40
  const response = await fetch(url, {
40
41
  method,
41
42
  headers: {
@@ -45,9 +46,19 @@ export async function runtimeJsonFetch(
45
46
  body: body ? JSON.stringify(body) : undefined,
46
47
  });
47
48
 
48
- if (response.status === 401 && retryOnUnauthorized && tokenSource === 'anonymous') {
49
- clearAnonymousToken();
50
- return runtimeJsonFetch(url, method, body, false);
49
+ if (response.status === 401) {
50
+ // RFC 9449 §9: the server challenges an expired/skewed proof with a DPoP-Nonce. Echo it back
51
+ // in a re-signed proof and retry once — the client's clock error becomes irrelevant. Gated on
52
+ // `!dpopNonce` so this never loops: a retry that already carried a nonce and still 401'd falls
53
+ // through to the token-retry path below instead.
54
+ const challengeNonce = response.headers.get('DPoP-Nonce');
55
+ if (challengeNonce && !dpopNonce) {
56
+ return runtimeJsonFetch(url, method, body, retryOnUnauthorized, challengeNonce);
57
+ }
58
+ if (retryOnUnauthorized && tokenSource === 'anonymous') {
59
+ clearAnonymousToken();
60
+ return runtimeJsonFetch(url, method, body, false);
61
+ }
51
62
  }
52
63
 
53
64
  return response;
@@ -104,7 +104,7 @@ export async function getThumbprint() {
104
104
  return cachedThumbprint;
105
105
  }
106
106
 
107
- export async function createDpopProof(url: string, method: string) {
107
+ export async function createDpopProof(url: string, method: string, nonce?: string) {
108
108
  const { privateKey } = await getDpopKeyPair();
109
109
  const publicJwk = await getPublicJwk();
110
110
  const header = {
@@ -117,6 +117,7 @@ export async function createDpopProof(url: string, method: string) {
117
117
  htu: url,
118
118
  iat: Math.floor(Date.now() / 1000),
119
119
  jti: crypto.randomUUID(),
120
+ ...(nonce ? { nonce } : {}),
120
121
  };
121
122
  const signingInput = `${base64Url(new TextEncoder().encode(JSON.stringify(header)))}.${base64Url(new TextEncoder().encode(JSON.stringify(payload)))}`;
122
123
  const signature = await crypto.subtle.sign(
@@ -113,7 +113,15 @@ function findPage(pages: Record<string, RemixerSeoPage> | undefined, path: strin
113
113
  return key ? pages[key] : undefined;
114
114
  }
115
115
 
116
- export function RemixerSeo() {
116
+ function hasPageOverrides() {
117
+ return Object.keys(getRemixerSeo()?.pages ?? {}).length > 0;
118
+ }
119
+
120
+ function isFramed() {
121
+ return window.parent !== window;
122
+ }
123
+
124
+ function RemixerSeoOverrides() {
117
125
  const snapshot = useSyncExternalStore(subscribeToHead, headSnapshot, () => '/#0');
118
126
  const seo = getRemixerSeo();
119
127
  const page = findPage(seo?.pages, currentPath());
@@ -138,4 +146,12 @@ export function RemixerSeo() {
138
146
  );
139
147
  }
140
148
 
149
+ export function RemixerSeo() {
150
+ if (!hasPageOverrides() && !isFramed()) {
151
+ return null;
152
+ }
153
+
154
+ return <RemixerSeoOverrides />;
155
+ }
156
+
141
157
  export default RemixerSeo;