@glw907/cairn-cms 0.14.0 → 0.18.0

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.
Files changed (96) hide show
  1. package/dist/auth/crypto.d.ts +8 -2
  2. package/dist/auth/crypto.d.ts.map +1 -1
  3. package/dist/auth/crypto.js +12 -2
  4. package/dist/auth/store.d.ts +2 -0
  5. package/dist/auth/store.d.ts.map +1 -1
  6. package/dist/auth/store.js +17 -5
  7. package/dist/components/EditPage.svelte +13 -8
  8. package/dist/components/EditPage.svelte.d.ts +3 -1
  9. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  10. package/dist/content/compose.d.ts.map +1 -1
  11. package/dist/content/compose.js +1 -0
  12. package/dist/content/links.d.ts +14 -0
  13. package/dist/content/links.d.ts.map +1 -0
  14. package/dist/content/links.js +41 -0
  15. package/dist/content/manifest.d.ts +55 -0
  16. package/dist/content/manifest.d.ts.map +1 -0
  17. package/dist/content/manifest.js +98 -0
  18. package/dist/content/types.d.ts +10 -1
  19. package/dist/content/types.d.ts.map +1 -1
  20. package/dist/delivery/content-index.d.ts.map +1 -1
  21. package/dist/delivery/content-index.js +11 -9
  22. package/dist/delivery/feeds.d.ts +1 -1
  23. package/dist/delivery/feeds.d.ts.map +1 -1
  24. package/dist/delivery/feeds.js +31 -16
  25. package/dist/delivery/index.d.ts +1 -0
  26. package/dist/delivery/index.d.ts.map +1 -1
  27. package/dist/delivery/index.js +1 -0
  28. package/dist/delivery/manifest.d.ts +13 -0
  29. package/dist/delivery/manifest.d.ts.map +1 -0
  30. package/dist/delivery/manifest.js +31 -0
  31. package/dist/delivery/site-indexes.d.ts.map +1 -1
  32. package/dist/delivery/site-indexes.js +9 -1
  33. package/dist/env.d.ts.map +1 -1
  34. package/dist/env.js +14 -0
  35. package/dist/github/repo.d.ts +21 -0
  36. package/dist/github/repo.d.ts.map +1 -1
  37. package/dist/github/repo.js +79 -0
  38. package/dist/github/signing.d.ts +12 -0
  39. package/dist/github/signing.d.ts.map +1 -1
  40. package/dist/github/signing.js +22 -0
  41. package/dist/index.d.ts +4 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +5 -0
  44. package/dist/render/pipeline.d.ts +14 -1
  45. package/dist/render/pipeline.d.ts.map +1 -1
  46. package/dist/render/pipeline.js +22 -3
  47. package/dist/render/resolve-links.d.ts +8 -0
  48. package/dist/render/resolve-links.d.ts.map +1 -0
  49. package/dist/render/resolve-links.js +36 -0
  50. package/dist/render/sanitize-schema.d.ts +20 -0
  51. package/dist/render/sanitize-schema.d.ts.map +1 -0
  52. package/dist/render/sanitize-schema.js +57 -0
  53. package/dist/sveltekit/auth-routes.d.ts.map +1 -1
  54. package/dist/sveltekit/auth-routes.js +29 -11
  55. package/dist/sveltekit/content-routes.d.ts +3 -0
  56. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  57. package/dist/sveltekit/content-routes.js +31 -4
  58. package/dist/sveltekit/guard.d.ts +1 -1
  59. package/dist/sveltekit/guard.d.ts.map +1 -1
  60. package/dist/sveltekit/guard.js +25 -10
  61. package/dist/sveltekit/nav-routes.js +2 -2
  62. package/dist/sveltekit/public-routes.d.ts +2 -0
  63. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  64. package/dist/sveltekit/public-routes.js +3 -2
  65. package/dist/sveltekit/types.d.ts +6 -0
  66. package/dist/sveltekit/types.d.ts.map +1 -1
  67. package/package.json +3 -2
  68. package/src/lib/auth/crypto.ts +14 -2
  69. package/src/lib/auth/store.ts +18 -5
  70. package/src/lib/components/EditPage.svelte +13 -8
  71. package/src/lib/content/compose.ts +1 -0
  72. package/src/lib/content/links.ts +48 -0
  73. package/src/lib/content/manifest.ts +138 -0
  74. package/src/lib/content/types.ts +10 -3
  75. package/src/lib/delivery/content-index.ts +12 -9
  76. package/src/lib/delivery/feeds.ts +34 -19
  77. package/src/lib/delivery/index.ts +1 -0
  78. package/src/lib/delivery/manifest.ts +38 -0
  79. package/src/lib/delivery/site-indexes.ts +13 -1
  80. package/src/lib/env.ts +13 -0
  81. package/src/lib/github/repo.ts +103 -0
  82. package/src/lib/github/signing.ts +32 -0
  83. package/src/lib/index.ts +16 -0
  84. package/src/lib/render/pipeline.ts +33 -3
  85. package/src/lib/render/resolve-links.ts +42 -0
  86. package/src/lib/render/sanitize-schema.ts +66 -0
  87. package/src/lib/sveltekit/auth-routes.ts +30 -11
  88. package/src/lib/sveltekit/content-routes.ts +38 -6
  89. package/src/lib/sveltekit/guard.ts +25 -10
  90. package/src/lib/sveltekit/nav-routes.ts +2 -2
  91. package/src/lib/sveltekit/public-routes.ts +5 -3
  92. package/src/lib/sveltekit/types.ts +5 -1
  93. package/dist/render/sanitize.d.ts +0 -8
  94. package/dist/render/sanitize.d.ts.map +0 -1
  95. package/dist/render/sanitize.js +0 -26
  96. package/src/lib/render/sanitize.ts +0 -27
@@ -0,0 +1,42 @@
1
+ // cairn-cms: the cairn: link resolver, an mdast step in the render pipeline (content-graph design).
2
+ // It runs before remark-rehype, so the rewritten href passes through the sanitize floor exactly as
3
+ // any other anchor. The per-call resolver is read off the VFile (set by renderMarkdown), so the
4
+ // processor is still built once. A miss either marks the link broken (preview) or throws (build),
5
+ // decided by the injected resolver.
6
+ import { visit } from 'unist-util-visit';
7
+ import type { VFile } from 'vfile';
8
+ import { parseCairnToken, type LinkResolve } from '../content/links.js';
9
+
10
+ /** The VFile data key the renderer sets the per-call resolver under. */
11
+ export const CAIRN_RESOLVE = 'cairnResolve';
12
+
13
+ interface LinkNode {
14
+ url: string;
15
+ data?: { hProperties?: Record<string, unknown> };
16
+ }
17
+
18
+ /** Resolve cairn: link nodes against the VFile's resolver. A non-cairn href and a malformed token
19
+ * pass through. A missing target is marked with the cairn-broken-link class (the resolver returns
20
+ * undefined) or, when the resolver throws, the error propagates and fails the build. */
21
+ export function remarkResolveCairnLinks() {
22
+ return (tree: unknown, file: VFile): void => {
23
+ const resolve = file.data[CAIRN_RESOLVE] as LinkResolve | undefined;
24
+ if (!resolve) return;
25
+ visit(tree as Parameters<typeof visit>[0], 'link', (node: LinkNode) => {
26
+ const ref = parseCairnToken(node.url);
27
+ if (!ref) return;
28
+ const url = resolve(ref); // may throw (build backstop); propagates out of render
29
+ if (url) {
30
+ node.url = url;
31
+ return;
32
+ }
33
+ // Missing target in the preview: mark it broken and neutralize the href, keeping the text.
34
+ node.url = '#';
35
+ node.data = node.data ?? {};
36
+ const props = (node.data.hProperties = node.data.hProperties ?? {});
37
+ const existing = Array.isArray(props.className) ? (props.className as string[]) : [];
38
+ props.className = [...existing, 'cairn-broken-link'];
39
+ props.title = 'Broken internal link';
40
+ });
41
+ };
42
+ }
@@ -0,0 +1,66 @@
1
+ import { defaultSchema, type Schema } from 'hast-util-sanitize';
2
+ import type { Root, Element } from 'hast';
3
+ import { visit } from 'unist-util-visit';
4
+ import { dataAttrProp, type ComponentRegistry } from './registry.js';
5
+
6
+ // The fixed directive markers the stamp writes and the dispatch reads. They are inert data
7
+ // attributes, never a script vector, and must survive the floor so the dispatch still runs.
8
+ const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataIcon', 'dataRole', 'dataRise'];
9
+
10
+ /**
11
+ * Build the delivery sanitize schema. Starts from hast-util-sanitize's defaultSchema, the
12
+ * GitHub-lineage allowlist that strips scripts, inline event handlers, and javascript:/data: URLs,
13
+ * then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
14
+ * dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
15
+ * the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
16
+ * on anchors are admitted. A site extends the result through `extend`, always starting from this
17
+ * safe base, so it can add to the allowlist but not weaken the core strip.
18
+ */
19
+ export function buildSanitizeSchema(
20
+ registry: ComponentRegistry,
21
+ extend?: (defaults: Schema) => Schema,
22
+ ): Schema {
23
+ const attrMarkers = registry.defs.flatMap((d) => (d.attributes ?? []).map((a) => dataAttrProp(a.key)));
24
+ const markers = [...FIXED_MARKERS, ...attrMarkers];
25
+ const attributes = defaultSchema.attributes ?? {};
26
+ // defaultSchema's `a` entry carries a className tuple (`['className', 'data-footnote-backref']`)
27
+ // that restricts a link's class to that one value. A per-tag tuple wins over a bare `*` entry, so
28
+ // it would strip an author's link class. Drop that tuple before admitting a free-form `className`.
29
+ const anchorAttrs = (attributes.a ?? []).filter(
30
+ (entry) => !(Array.isArray(entry) && entry[0] === 'className'),
31
+ );
32
+ // Admit the inert `cairn:` href scheme on top of the default protocol allowlist. The render
33
+ // resolver rewrites a `cairn:` link to a live permalink before delivery; an unresolved one
34
+ // survives the floor in its inert token form (a visible unresolved-link signal), never as an
35
+ // executable vector. The dangerous-protocol strip (javascript:, data:) is preserved.
36
+ const protocols = defaultSchema.protocols ?? {};
37
+ const schema: Schema = {
38
+ ...defaultSchema,
39
+ tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
40
+ attributes: {
41
+ ...attributes,
42
+ '*': [...(attributes['*'] ?? []), 'className', ...markers],
43
+ a: [...anchorAttrs, 'className', 'target', 'rel'],
44
+ },
45
+ protocols: {
46
+ ...protocols,
47
+ href: [...(protocols.href ?? []), 'cairn'],
48
+ },
49
+ };
50
+ return extend ? extend(schema) : schema;
51
+ }
52
+
53
+ /**
54
+ * Force rel="noopener noreferrer" on every target="_blank" anchor, to prevent reverse-tabnabbing.
55
+ * hast-util-sanitize runs no per-node hook, so this small transform carries the behavior the old
56
+ * DOMPurify preview pass enforced, now on the delivered output as well.
57
+ */
58
+ export function rehypeAnchorRel() {
59
+ return (tree: Root) => {
60
+ visit(tree, 'element', (node: Element) => {
61
+ if (node.tagName === 'a' && node.properties?.target === '_blank') {
62
+ node.properties.rel = 'noopener noreferrer';
63
+ }
64
+ });
65
+ };
66
+ }
@@ -9,9 +9,10 @@ import {
9
9
  hashToken,
10
10
  TOKEN_TTL_MS,
11
11
  SESSION_TTL_MS,
12
- COOKIE_NAME,
12
+ SEND_COOLDOWN_MS,
13
+ sessionCookieName,
13
14
  } from '../auth/crypto.js';
14
- import { findEditor, issueToken, consumeToken, createSession, deleteSession } from '../auth/store.js';
15
+ import { findEditor, issueToken, consumeToken, createSession, deleteSession, recentlyIssued } from '../auth/store.js';
15
16
  import { buildMagicLinkMessage, cloudflareSend, type AuthBranding, type SendMagicLink } from '../email.js';
16
17
  import type { RequestContext } from './types.js';
17
18
 
@@ -37,11 +38,27 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
37
38
 
38
39
  const editor = email ? await findEditor(db, email) : null;
39
40
  if (editor) {
40
- const token = generateToken();
41
41
  const now = Date.now();
42
- await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
43
- const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
44
- await send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link }));
42
+ // Per-email cooldown: skip the reissue and send when a token for this email was issued within
43
+ // the window, so the endpoint cannot flood an editor's inbox. The response is unchanged, so
44
+ // the non-leak property holds.
45
+ if (!(await recentlyIssued(db, email, now - SEND_COOLDOWN_MS))) {
46
+ const token = generateToken();
47
+ await issueToken(db, email, await hashToken(token), now + TOKEN_TTL_MS, now);
48
+ const link = `${origin}/admin/auth/confirm?token=${encodeURIComponent(token)}`;
49
+ // The token row is the security-critical write the email depends on, so it is awaited. The
50
+ // send is a post-response side effect, handed to waitUntil so a slow email provider does not
51
+ // hold the response. An absent waitUntil (local dev, tests) falls back to await. A send
52
+ // failure is logged so observability survives a backgrounded send.
53
+ const sending = send(env, buildMagicLinkMessage({ to: email, branding: config.branding, link })).catch(
54
+ (err) => console.error('cairn: magic-link send failed', err),
55
+ );
56
+ // adapter-cloudflare exposes the ExecutionContext as platform.ctx; platform.context is a
57
+ // deprecated alias kept as a fallback so an adapter that drops it keeps backgrounding.
58
+ const ctx = event.platform?.ctx ?? event.platform?.context;
59
+ if (ctx?.waitUntil) ctx.waitUntil(sending);
60
+ else await sending;
61
+ }
45
62
  }
46
63
  return { sent: true };
47
64
  }
@@ -83,11 +100,12 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
83
100
 
84
101
  const id = generateSessionId();
85
102
  await createSession(db, id, email, now + SESSION_TTL_MS, now);
86
- event.cookies.set(COOKIE_NAME, id, {
103
+ const secure = event.url.protocol === 'https:';
104
+ event.cookies.set(sessionCookieName(secure), id, {
87
105
  path: '/',
88
106
  httpOnly: true,
89
- // Secure on HTTPS (every real deploy); off on local http dev so the cookie sticks.
90
- secure: event.url.protocol === 'https:',
107
+ // __Host- needs Secure unconditionally on https; local http dev drops the prefix and Secure.
108
+ secure,
91
109
  sameSite: 'lax',
92
110
  maxAge: Math.floor(SESSION_TTL_MS / 1000),
93
111
  });
@@ -97,9 +115,10 @@ export function createAuthRoutes(config: AuthRoutesConfig) {
97
115
  /** POST /admin/auth/logout. Deletes the session row and clears the cookie. */
98
116
  async function logoutAction(event: RequestContext): Promise<never> {
99
117
  const db = requireDb(event.platform?.env ?? {});
100
- const id = event.cookies.get(COOKIE_NAME);
118
+ const name = sessionCookieName(event.url.protocol === 'https:');
119
+ const id = event.cookies.get(name);
101
120
  if (id) await deleteSession(db, id);
102
- event.cookies.delete(COOKIE_NAME, { path: '/' });
121
+ event.cookies.delete(name, { path: '/' });
103
122
  throw redirect(303, '/admin/login');
104
123
  }
105
124
 
@@ -7,8 +7,9 @@ import { findConcept } from '../content/concepts.js';
7
7
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
8
8
  import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
9
9
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
10
- import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
11
- import { installationToken } from '../github/signing.js';
10
+ import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
11
+ import { cachedInstallationToken } from '../github/signing.js';
12
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, type LinkTarget } from '../content/manifest.js';
12
13
  import { CommitConflictError } from '../github/types.js';
13
14
  import type { CairnRuntime, ConceptDescriptor, FrontmatterField } from '../content/types.js';
14
15
  import type { Editor, Role } from '../auth/types.js';
@@ -63,6 +64,8 @@ export interface EditData {
63
64
  isNew: boolean;
64
65
  saved: boolean;
65
66
  error: string | null;
67
+ /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
68
+ linkTargets: LinkTarget[];
66
69
  }
67
70
 
68
71
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
@@ -96,7 +99,7 @@ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): Conce
96
99
 
97
100
  export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
98
101
  const mintToken =
99
- deps.mintToken ?? ((env: GithubKeyEnv) => installationToken(appCredentials(runtime.backend, env)));
102
+ deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
100
103
 
101
104
  /** Layout load for every admin page: the nav, the user, and the active path. */
102
105
  function layoutLoad(event: ContentEvent): LayoutData {
@@ -207,6 +210,20 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
207
210
 
208
211
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
209
212
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
213
+
214
+ let linkTargets: LinkTarget[] = [];
215
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
216
+ if (manifestRaw !== null) {
217
+ linkTargets = parseManifest(manifestRaw).entries.map((e) => ({
218
+ concept: e.concept,
219
+ id: e.id,
220
+ permalink: e.permalink,
221
+ title: e.title,
222
+ date: e.date,
223
+ draft: e.draft,
224
+ }));
225
+ }
226
+
210
227
  return {
211
228
  conceptId: concept.id,
212
229
  id,
@@ -218,6 +235,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
218
235
  isNew,
219
236
  saved: event.url.searchParams.get('saved') === '1',
220
237
  error: event.url.searchParams.get('error'),
238
+ linkTargets,
221
239
  };
222
240
  }
223
241
 
@@ -249,11 +267,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
249
267
 
250
268
  const markdown = serializeMarkdown(result.data, body);
251
269
  const token = await mintToken(event.platform?.env ?? {});
270
+
271
+ // Read the committed manifest, upsert this entry's row, and commit content and manifest in one
272
+ // commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
273
+ // and verifies the manifest, so this incremental patch is the cheap request-time path. On a
274
+ // 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
275
+ // leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
276
+ // it with npm run cairn:manifest to recover.
277
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
278
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
279
+ const row = manifestEntryFromFile(concept, { path, raw: markdown });
280
+ const nextManifest = serializeManifest(upsertEntry(manifest, row));
281
+
252
282
  try {
253
- await commitFile(
283
+ await commitFiles(
254
284
  runtime.backend,
255
- path,
256
- markdown,
285
+ [
286
+ { path, content: markdown },
287
+ { path: runtime.manifestPath, content: nextManifest },
288
+ ],
257
289
  { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
258
290
  token,
259
291
  );
@@ -3,7 +3,7 @@
3
3
  // stays free of a site's App.* ambient types.
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { resolveSession } from '../auth/store.js';
6
- import { COOKIE_NAME } from '../auth/crypto.js';
6
+ import { sessionCookieName } from '../auth/crypto.js';
7
7
  import type { Editor } from '../auth/types.js';
8
8
  import type { HandleInput, RequestContext } from './types.js';
9
9
 
@@ -16,19 +16,34 @@ function isAdminPath(pathname: string): boolean {
16
16
  return pathname === '/admin' || pathname.startsWith('/admin/');
17
17
  }
18
18
 
19
- /** The SvelteKit `Handle` that guards `/admin/**`. */
19
+ /**
20
+ * Attach the baseline security headers to an admin response. No full CSP; see the auth-hardening
21
+ * design. frame-ancestors is the modern clickjacking control and the one CSP directive included.
22
+ */
23
+ function applySecurityHeaders(headers: Headers): void {
24
+ headers.set('X-Content-Type-Options', 'nosniff');
25
+ headers.set('X-Frame-Options', 'DENY');
26
+ headers.set('Content-Security-Policy', "frame-ancestors 'none'");
27
+ headers.set('Referrer-Policy', 'no-referrer');
28
+ headers.set('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
29
+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
30
+ }
31
+
32
+ /** The SvelteKit `Handle` that guards `/admin/**` and hardens admin responses. */
20
33
  export function createAuthGuard() {
21
34
  return async function handle({ event, resolve }: HandleInput): Promise<Response> {
22
35
  const { pathname } = event.url;
23
- if (!isAdminPath(pathname) || isPublicAdminPath(pathname)) {
24
- return resolve(event);
36
+ if (!isAdminPath(pathname)) return resolve(event);
37
+ if (!isPublicAdminPath(pathname)) {
38
+ const env = event.platform?.env ?? {};
39
+ const id = event.cookies.get(sessionCookieName(event.url.protocol === 'https:'));
40
+ const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
41
+ if (!editor) throw redirect(303, '/admin/login');
42
+ event.locals.editor = editor;
25
43
  }
26
- const env = event.platform?.env ?? {};
27
- const id = event.cookies.get(COOKIE_NAME);
28
- const editor = id && env.AUTH_DB ? await resolveSession(env.AUTH_DB, id, Date.now()) : null;
29
- if (!editor) throw redirect(303, '/admin/login');
30
- event.locals.editor = editor;
31
- return resolve(event);
44
+ const response = await resolve(event);
45
+ applySecurityHeaders(response.headers);
46
+ return response;
32
47
  };
33
48
  }
34
49
 
@@ -3,7 +3,7 @@
3
3
  // and commit paths are unit-testable against a fetch double with an injected token.
4
4
  import { redirect, error } from '@sveltejs/kit';
5
5
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
6
- import { installationToken } from '../github/signing.js';
6
+ import { cachedInstallationToken } from '../github/signing.js';
7
7
  import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
8
8
  import { CommitConflictError } from '../github/types.js';
9
9
  import { parseSiteConfig, extractMenu, validateNavTree, setMenu, type NavNode } from '../nav/site-config.js';
@@ -45,7 +45,7 @@ function isConflict(err: unknown): boolean {
45
45
 
46
46
  export function createNavRoutes(runtime: CairnRuntime, deps: NavRoutesDeps = {}) {
47
47
  const mintToken =
48
- deps.mintToken ?? ((env: GithubKeyEnv) => installationToken(appCredentials(runtime.backend, env)));
48
+ deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
49
49
 
50
50
  /** List page-like concepts (routable, not dated) for the URL picker. Best-effort per concept. */
51
51
  async function pageOptions(token: string): Promise<NavPageOption[]> {
@@ -9,11 +9,13 @@ import type { SiteIndex } from '../delivery/site-index.js';
9
9
  import { buildSeoMeta } from '../delivery/seo.js';
10
10
  import type { SeoMeta } from '../delivery/seo.js';
11
11
  import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
12
+ import { buildLinkResolver } from '../delivery/manifest.js';
13
+ import type { LinkResolve } from '../content/links.js';
12
14
 
13
15
  /** Injected dependencies for the public loaders. */
14
16
  export interface PublicRoutesDeps {
15
17
  site: SiteIndex;
16
- render: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
18
+ render: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
17
19
  origin: string;
18
20
  /** Site name for og:site_name and the SEO head. */
19
21
  siteName: string;
@@ -83,9 +85,9 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
83
85
  ...(image ? { image } : {}),
84
86
  ...(fields.robots ? { robots: fields.robots } : {}),
85
87
  ...(fields.author ? { author: fields.author } : {}),
86
- feeds,
88
+ ...(entry.date ? { feeds } : {}),
87
89
  });
88
- return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
90
+ return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
89
91
  }
90
92
 
91
93
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
@@ -21,7 +21,11 @@ export interface RequestContext {
21
21
  request: Request;
22
22
  cookies: CookieJar;
23
23
  locals: { editor?: Editor | null };
24
- platform?: { env?: AuthEnv };
24
+ platform?: {
25
+ env?: AuthEnv;
26
+ ctx?: { waitUntil(promise: Promise<unknown>): void };
27
+ context?: { waitUntil(promise: Promise<unknown>): void };
28
+ };
25
29
  // Required so a site cannot silently drop the confirm page's Referrer-Policy header
26
30
  // (spec 7.1). A real SvelteKit RequestEvent always supplies it.
27
31
  setHeaders(headers: Record<string, string>): void;
@@ -1,8 +0,0 @@
1
- /**
2
- * Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
3
- * handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
4
- * Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
5
- * reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
6
- */
7
- export declare function sanitizePreviewHtml(html: string): Promise<string>;
8
- //# sourceMappingURL=sanitize.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"sanitize.d.ts","sourceRoot":"","sources":["../../src/lib/render/sanitize.ts"],"names":[],"mappings":"AAOA;;;;;GAKG;AACH,wBAAsB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAavE"}
@@ -1,26 +0,0 @@
1
- // The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
2
- // so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
3
- // DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
4
- // loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
5
- // server import of this file pulls in nothing.
6
- let purify = null;
7
- /**
8
- * Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
9
- * handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
10
- * Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
11
- * reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
12
- */
13
- export async function sanitizePreviewHtml(html) {
14
- if (!purify) {
15
- const mod = await import('dompurify');
16
- purify = mod.default;
17
- purify.addHook('afterSanitizeAttributes', (node) => {
18
- if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
19
- node.setAttribute('rel', 'noopener noreferrer');
20
- }
21
- });
22
- }
23
- // ADD_ATTR: ['target'] allows target="_blank" through so the afterSanitizeAttributes hook
24
- // can enforce rel="noopener noreferrer" on those anchors before they reach the DOM.
25
- return purify.sanitize(html, { ADD_ATTR: ['target'] });
26
- }
@@ -1,27 +0,0 @@
1
- // The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
2
- // so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
3
- // DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
4
- // loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
5
- // server import of this file pulls in nothing.
6
- let purify: { sanitize(html: string, config?: Record<string, unknown>): string; addHook(event: string, cb: (node: Element) => void): void } | null = null;
7
-
8
- /**
9
- * Sanitize rendered preview HTML before it reaches `{@html}`. Strips scripts, inline event
10
- * handlers, and dangerous URL schemes (`javascript:`, `data:`) while keeping ordinary formatting.
11
- * Also forces `rel="noopener noreferrer"` on any anchor with `target="_blank"` to prevent
12
- * reverse-tabnabbing. Browser-only; resolves the same string DOMPurify would return.
13
- */
14
- export async function sanitizePreviewHtml(html: string): Promise<string> {
15
- if (!purify) {
16
- const mod = await import('dompurify');
17
- purify = mod.default;
18
- purify.addHook('afterSanitizeAttributes', (node) => {
19
- if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
20
- node.setAttribute('rel', 'noopener noreferrer');
21
- }
22
- });
23
- }
24
- // ADD_ATTR: ['target'] allows target="_blank" through so the afterSanitizeAttributes hook
25
- // can enforce rel="noopener noreferrer" on those anchors before they reach the DOM.
26
- return purify.sanitize(html, { ADD_ATTR: ['target'] });
27
- }