@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,138 @@
1
+ // cairn-cms: the content manifest, a committed JSON projection of the corpus (content-graph
2
+ // design). The files in git stay the source of truth; the manifest exists so request-time admin
3
+ // code reads the content graph without an N+1 GitHub crawl. The build regenerates and verifies
4
+ // it; the save path patches one entry and commits it with the content in one commit. Each entry
5
+ // carries its identity and its outbound cairn: edges, so the manifest is the link graph.
6
+ import { idFromFilename, slugFromId } from './ids.js';
7
+ import { parseMarkdown } from './frontmatter.js';
8
+ import { permalink } from './permalink.js';
9
+ import { extractCairnLinks, type CairnRef, type LinkResolve } from './links.js';
10
+ import type { ConceptDescriptor } from './types.js';
11
+
12
+ /** One entry's projection: its identity, routing, draft flag, and outbound cairn: edges. */
13
+ export interface ManifestEntry {
14
+ id: string;
15
+ concept: string;
16
+ title: string;
17
+ date?: string;
18
+ permalink: string;
19
+ draft: boolean;
20
+ links: CairnRef[];
21
+ }
22
+
23
+ /** The whole corpus as one committed file. `version` guards a future shape migration. */
24
+ export interface Manifest {
25
+ version: 1;
26
+ entries: ManifestEntry[];
27
+ }
28
+
29
+ /** The minimal entry view the preview resolver and (later) the picker read. */
30
+ export interface LinkTarget {
31
+ concept: string;
32
+ id: string;
33
+ permalink: string;
34
+ title: string;
35
+ date?: string;
36
+ draft: boolean;
37
+ }
38
+
39
+ function basename(path: string): string {
40
+ const slash = path.lastIndexOf('/');
41
+ return slash >= 0 ? path.slice(slash + 1) : path;
42
+ }
43
+
44
+ /** Mirror content-index's frontmatter coercion: a present non-empty string, else undefined. */
45
+ function asString(value: unknown): string | undefined {
46
+ return typeof value === 'string' && value.trim() ? value : undefined;
47
+ }
48
+
49
+ /** Mirror content-index's date coercion: an unquoted YAML date is a JS Date, a string is sliced. */
50
+ function asDate(value: unknown): string | undefined {
51
+ if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
52
+ if (typeof value === 'string') return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
53
+ return undefined;
54
+ }
55
+
56
+ /** Build one manifest entry from a content file. Drafts are included and flagged. */
57
+ export function manifestEntryFromFile(descriptor: ConceptDescriptor, file: { path: string; raw: string }): ManifestEntry {
58
+ const id = idFromFilename(basename(file.path));
59
+ // Use the same slug rule content-index uses, so the manifest's permalink for an entry always
60
+ // equals content-index's permalink for it. A cairn link must resolve to one URL whether the
61
+ // admin preview reads the manifest or the public build reads the content index.
62
+ const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
63
+ const { frontmatter, body } = parseMarkdown(file.raw);
64
+ const date = asDate(frontmatter.date);
65
+ return {
66
+ id,
67
+ concept: descriptor.id,
68
+ title: asString(frontmatter.title) ?? id,
69
+ date,
70
+ permalink: permalink(descriptor, { id, slug, date }),
71
+ draft: frontmatter.draft === true,
72
+ links: extractCairnLinks(body),
73
+ };
74
+ }
75
+
76
+ /** An empty manifest, the starting point when no committed file exists yet. */
77
+ export function emptyManifest(): Manifest {
78
+ return { version: 1, entries: [] };
79
+ }
80
+
81
+ function compareRef(a: CairnRef, b: CairnRef): number {
82
+ return a.concept.localeCompare(b.concept) || a.id.localeCompare(b.id);
83
+ }
84
+
85
+ /** Serialize canonically: entries sorted by concept then id, links sorted and deduped, a fixed key
86
+ * order, two-space pretty, and a trailing newline, so the committed file diffs cleanly in a PR. */
87
+ export function serializeManifest(manifest: Manifest): string {
88
+ const entries = [...manifest.entries].sort(compareRef).map((e) => ({
89
+ id: e.id,
90
+ concept: e.concept,
91
+ title: e.title,
92
+ ...(e.date ? { date: e.date } : {}),
93
+ permalink: e.permalink,
94
+ draft: e.draft,
95
+ links: [...e.links].sort(compareRef).map((r) => ({ concept: r.concept, id: r.id })),
96
+ }));
97
+ return `${JSON.stringify({ version: 1, entries }, null, 2)}\n`;
98
+ }
99
+
100
+ /** Parse a committed manifest. Throws on malformed JSON or the wrong shape. */
101
+ export function parseManifest(raw: string): Manifest {
102
+ const data = JSON.parse(raw) as unknown;
103
+ if (!data || typeof data !== 'object' || !Array.isArray((data as { entries?: unknown }).entries)) {
104
+ throw new Error('content manifest: malformed file, expected { version, entries: [] }');
105
+ }
106
+ return { version: 1, entries: (data as Manifest).entries };
107
+ }
108
+
109
+ /** Throw if the committed manifest drifts from what the corpus says. Both sides are compared in the
110
+ * canonical serialized form, so semantic equality never spuriously fails. The build calls this so a
111
+ * raw-git content edit, which leaves the committed manifest stale, fails the build loudly. */
112
+ export function verifyManifest(built: Manifest, committedRaw: string): void {
113
+ if (committedRaw !== serializeManifest(built)) {
114
+ throw new Error(
115
+ 'content manifest is stale: the committed file does not match the corpus. Regenerate it (npm run cairn:manifest) and commit the result.',
116
+ );
117
+ }
118
+ }
119
+
120
+ /** Replace the entry with the same concept and id, or add it. Order does not matter, since
121
+ * serializeManifest sorts. This is the save path's incremental patch. */
122
+ export function upsertEntry(manifest: Manifest, entry: ManifestEntry): Manifest {
123
+ const entries = manifest.entries.filter((e) => !(e.concept === entry.concept && e.id === entry.id));
124
+ entries.push(entry);
125
+ return { version: 1, entries };
126
+ }
127
+
128
+ /** Drop the entry with the given concept and id, if present. The delete path's patch. */
129
+ export function removeEntry(manifest: Manifest, concept: string, id: string): Manifest {
130
+ return { version: 1, entries: manifest.entries.filter((e) => !(e.concept === concept && e.id === id)) };
131
+ }
132
+
133
+ /** A resolver backed by manifest targets, for the admin preview. A miss returns undefined, so the
134
+ * render step marks the link broken rather than throwing. The build resolver throws instead. */
135
+ export function manifestLinkResolver(targets: { concept: string; id: string; permalink: string }[]): LinkResolve {
136
+ const byKey = new Map(targets.map((t) => [`${t.concept}/${t.id}`, t.permalink]));
137
+ return (ref) => byKey.get(`${ref.concept}/${ref.id}`);
138
+ }
@@ -11,6 +11,7 @@ import type { ComponentRegistry } from '../render/registry.js';
11
11
  import type { IconSet } from '../render/glyph.js';
12
12
  import type { DatePrefix } from './ids.js';
13
13
  import type { ConceptSchema } from './schema.js';
14
+ import type { LinkResolve } from './links.js';
14
15
 
15
16
  /** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
16
17
  interface FieldBase {
@@ -171,8 +172,13 @@ export interface CairnAdapter {
171
172
  };
172
173
  backend: BackendConfig;
173
174
  sender: SenderConfig;
174
- /** The site's one renderer: the editor preview and every public page call it (design decision 4). */
175
- render(md: string, opts?: { stagger?: boolean }): string | Promise<string>;
175
+ /** The site's one renderer: the editor preview and every public page call it (design decision 4).
176
+ * `resolve` rewrites cairn: links to live permalinks; the build passes a site-index resolver, the
177
+ * preview a manifest one. */
178
+ render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
179
+ /** Repo-relative path to the committed content manifest. Defaults to src/content/.cairn/index.json
180
+ * in composeRuntime. It sits outside any concept directory, so content enumeration never globs it. */
181
+ manifestPath?: string;
176
182
  /** Directive component registry; the renderer and the future palette derive from it (seam 3). */
177
183
  registry?: ComponentRegistry;
178
184
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
@@ -267,7 +273,8 @@ export interface CairnRuntime {
267
273
  backend: BackendConfig;
268
274
  sender: SenderConfig;
269
275
  /** The site's one renderer: the editor preview and every public page call it (design decision 4). */
270
- render(md: string, opts?: { stagger?: boolean }): string | Promise<string>;
276
+ render(md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }): string | Promise<string>;
277
+ manifestPath: string;
271
278
  registry?: ComponentRegistry;
272
279
  /** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
273
280
  icons?: IconSet;
@@ -84,18 +84,21 @@ export function createContentIndex<F = Record<string, unknown>>(
84
84
  descriptor: ConceptDescriptor,
85
85
  ): ContentIndex<F> {
86
86
  const problems: ContentProblem[] = [];
87
- const entries: ContentEntry<F>[] = files.map((file) => {
87
+ const entries: ContentEntry<F>[] = [];
88
+ for (const file of files) {
88
89
  const id = idFromFilename(basename(file.path));
89
90
  const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
90
91
  const { frontmatter: raw, body } = parseMarkdown(file.raw);
91
92
  const date = asDate(raw.date);
92
93
  const draft = raw.draft === true;
93
- // Validate once at build. The cheap summary stays raw-derived and robust; the typed detail
94
- // frontmatter carries the normalized data on success, the raw frontmatter on failure. A
95
- // failure is recorded, not thrown, so the query surface does not explode on construction.
94
+ // Validate once at build. A failure is recorded for the site gate and excluded from the typed
95
+ // read, so every readable entry's frontmatter is the validator's normalized output, never raw.
96
96
  const result = descriptor.validate(raw, body);
97
- if (!result.ok) problems.push({ id, draft, errors: result.errors });
98
- return {
97
+ if (!result.ok) {
98
+ problems.push({ id, draft, errors: result.errors });
99
+ continue;
100
+ }
101
+ entries.push({
99
102
  id,
100
103
  slug,
101
104
  permalink: permalink(descriptor, { id, slug, date }),
@@ -106,10 +109,10 @@ export function createContentIndex<F = Record<string, unknown>>(
106
109
  excerpt: deriveExcerpt(body, { description: asString(raw.description) }),
107
110
  wordCount: wordCount(body),
108
111
  draft,
109
- frontmatter: (result.ok ? result.data : raw) as F,
112
+ frontmatter: result.data as F,
110
113
  body,
111
- };
112
- });
114
+ });
115
+ }
113
116
 
114
117
  // Dated concepts sort newest-first; undated concepts (Pages) sort by title.
115
118
  const sorted = [...entries].sort((a, b) =>
@@ -17,7 +17,7 @@ export interface FeedChannel {
17
17
  export interface FeedItem {
18
18
  title: string;
19
19
  url: string;
20
- date: string;
20
+ date?: string;
21
21
  updated?: string;
22
22
  summary: string;
23
23
  contentHtml?: string;
@@ -37,14 +37,22 @@ function cdataSafe(value: string): string {
37
37
  return value.replace(/]]>/g, ']]]]><![CDATA[>');
38
38
  }
39
39
 
40
- /** Format a YYYY-MM-DD (or ISO) string as an RFC-822 date in UTC, as RSS wants. */
41
- function rfc822(date: string): string {
42
- return new Date(`${date.slice(0, 10)}T00:00:00.000Z`).toUTCString();
40
+ /** Parse a YYYY-MM-DD (or ISO) string as a UTC instant. Returns undefined for an absent or
41
+ * unparseable date, so a feed omits the date field rather than emit Invalid Date or throw. */
42
+ function parseFeedDate(date?: string): Date | undefined {
43
+ if (!date) return undefined;
44
+ const at = new Date(`${date.slice(0, 10)}T00:00:00.000Z`);
45
+ return Number.isNaN(at.getTime()) ? undefined : at;
43
46
  }
44
47
 
45
- /** Format a YYYY-MM-DD (or ISO) string as an ISO-8601 instant in UTC. */
46
- function iso(date: string): string {
47
- return new Date(`${date.slice(0, 10)}T00:00:00.000Z`).toISOString();
48
+ /** Format a date as an RFC-822 string in UTC, as RSS wants, or undefined when it cannot parse. */
49
+ function rfc822(date?: string): string | undefined {
50
+ return parseFeedDate(date)?.toUTCString();
51
+ }
52
+
53
+ /** Format a date as an ISO-8601 instant in UTC, or undefined when it cannot parse. */
54
+ function iso(date?: string): string | undefined {
55
+ return parseFeedDate(date)?.toISOString();
48
56
  }
49
57
 
50
58
  /** Build an RSS 2.0 document. */
@@ -52,17 +60,20 @@ export function buildRssFeed(channel: FeedChannel, items: FeedItem[]): string {
52
60
  const entries = items
53
61
  .map((item) => {
54
62
  const content = item.contentHtml ?? item.summary;
63
+ const pubDate = rfc822(item.date);
55
64
  return [
56
65
  ' <item>',
57
66
  ` <title>${escapeXml(item.title)}</title>`,
58
67
  ` <link>${escapeXml(item.url)}</link>`,
59
68
  ` <guid isPermaLink="true">${escapeXml(item.url)}</guid>`,
60
- ` <pubDate>${rfc822(item.date)}</pubDate>`,
69
+ pubDate ? ` <pubDate>${pubDate}</pubDate>` : '',
61
70
  ` <description>${escapeXml(item.summary)}</description>`,
62
71
  // CDATA cannot contain `]]>`, so split that one sequence rather than escape the body.
63
72
  ` <content:encoded><![CDATA[${cdataSafe(content)}]]></content:encoded>`,
64
73
  ' </item>',
65
- ].join('\n');
74
+ ]
75
+ .filter((line) => line !== '')
76
+ .join('\n');
66
77
  })
67
78
  .join('\n');
68
79
 
@@ -95,16 +106,20 @@ export function buildJsonFeed(channel: FeedChannel, items: FeedItem[]): string {
95
106
  feed_url: channel.feedUrl,
96
107
  ...(channel.language ? { language: channel.language } : {}),
97
108
  ...(channel.author ? { authors: [channel.author] } : {}),
98
- items: items.map((item) => ({
99
- id: item.url,
100
- url: item.url,
101
- title: item.title,
102
- summary: item.summary,
103
- date_published: iso(item.date),
104
- ...(item.updated ? { date_modified: iso(item.updated) } : {}),
105
- ...(item.contentHtml ? { content_html: item.contentHtml } : { content_text: item.summary }),
106
- ...(item.tags && item.tags.length ? { tags: item.tags } : {}),
107
- })),
109
+ items: items.map((item) => {
110
+ const datePublished = iso(item.date);
111
+ const dateModified = iso(item.updated);
112
+ return {
113
+ id: item.url,
114
+ url: item.url,
115
+ title: item.title,
116
+ summary: item.summary,
117
+ ...(datePublished ? { date_published: datePublished } : {}),
118
+ ...(dateModified ? { date_modified: dateModified } : {}),
119
+ ...(item.contentHtml ? { content_html: item.contentHtml } : { content_text: item.summary }),
120
+ ...(item.tags && item.tags.length ? { tags: item.tags } : {}),
121
+ };
122
+ }),
108
123
  },
109
124
  null,
110
125
  2,
@@ -25,6 +25,7 @@ export type { Page } from './paginate.js';
25
25
  export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
26
26
  export { jsonLdScript } from './json-ld.js';
27
27
  export { permalink } from '../content/permalink.js';
28
+ export { buildSiteManifest, buildLinkResolver } from './manifest.js';
28
29
  export { createPublicRoutes } from '../sveltekit/public-routes.js';
29
30
  export type {
30
31
  PublicRoutesDeps,
@@ -0,0 +1,38 @@
1
+ // cairn-cms: the build-side manifest builder and the build link resolver (content-graph design).
2
+ // buildSiteManifest mirrors createSiteIndexes: it maps the site descriptors over the per-concept
3
+ // globs and projects each file to a manifest row. buildLinkResolver reads the site index, which is
4
+ // fresh from the files at build, and throws on a missing target so a dangling cairn: token fails
5
+ // the build (the backstop). The admin preview uses manifestLinkResolver instead.
6
+ import { siteDescriptors } from './site-descriptors.js';
7
+ import { fromGlob } from './content-index.js';
8
+ import { emptyManifest, manifestEntryFromFile } from '../content/manifest.js';
9
+ import type { Manifest } from '../content/manifest.js';
10
+ import type { LinkResolve } from '../content/links.js';
11
+ import type { SiteIndex } from './site-index.js';
12
+ import type { SiteConfig } from '../nav/site-config.js';
13
+ import type { CairnAdapter } from '../content/types.js';
14
+ import type { SiteGlobs } from './site-indexes.js';
15
+
16
+ /** Build the whole-corpus manifest from a site's adapter, config, and per-concept globs. Drafts are
17
+ * included and flagged, so the admin picker and the guards see the full graph. */
18
+ export function buildSiteManifest<A extends CairnAdapter>(adapter: A, config: SiteConfig, globs: SiteGlobs<A>): Manifest {
19
+ const globRecord = globs as Record<string, Record<string, string> | undefined>;
20
+ const manifest = emptyManifest();
21
+ for (const descriptor of siteDescriptors(adapter, config)) {
22
+ const record = globRecord[descriptor.id] ?? {};
23
+ for (const file of fromGlob(record)) {
24
+ manifest.entries.push(manifestEntryFromFile(descriptor, file));
25
+ }
26
+ }
27
+ return manifest;
28
+ }
29
+
30
+ /** A resolver backed by the site index, for the build. A miss throws, so a dangling cairn: token
31
+ * fails the prerender (the build backstop). The preview uses manifestLinkResolver, which marks. */
32
+ export function buildLinkResolver(site: SiteIndex): LinkResolve {
33
+ return (ref) => {
34
+ const url = site.concept(ref.concept)?.byId(ref.id)?.permalink;
35
+ if (!url) throw new Error(`cairn link target not found: cairn:${ref.concept}/${ref.id}`);
36
+ return url;
37
+ };
38
+ }
@@ -39,10 +39,22 @@ export function createSiteIndexes<const A extends CairnAdapter>(
39
39
  opts: { validate?: boolean } = {},
40
40
  ): SiteIndexes<A> {
41
41
  const descriptors = siteDescriptors(adapter, config);
42
+ const globRecord = globs as Record<string, Record<string, string> | undefined>;
42
43
  const byConcept: Record<string, ContentIndex> = {};
43
44
  const conceptIndexes: ConceptIndex[] = [];
44
45
  for (const descriptor of descriptors) {
45
- const record = (globs as Record<string, Record<string, string> | undefined>)[descriptor.id] ?? {};
46
+ if (descriptor.id === 'site') {
47
+ throw new Error(
48
+ 'createSiteIndexes: a concept cannot be named "site", which is the reserved cross-concept resolver key',
49
+ );
50
+ }
51
+ if (!Object.prototype.hasOwnProperty.call(globRecord, descriptor.id)) {
52
+ const passed = Object.keys(globRecord);
53
+ throw new Error(
54
+ `createSiteIndexes: no glob passed for concept "${descriptor.id}"; pass its import.meta.glob (an empty {} for an intentionally empty concept). Globs passed: ${passed.length ? passed.join(', ') : '(none)'}`,
55
+ );
56
+ }
57
+ const record = globRecord[descriptor.id] ?? {};
46
58
  const index = createContentIndex(fromGlob(record), descriptor);
47
59
  byConcept[descriptor.id] = index;
48
60
  conceptIndexes.push({ descriptor, index });
package/src/lib/env.ts CHANGED
@@ -13,6 +13,19 @@ export function requireOrigin(env: { PUBLIC_ORIGIN?: string }): string {
13
13
  if (!origin) {
14
14
  throw new Error('PUBLIC_ORIGIN is not configured');
15
15
  }
16
+ let hostname: string;
17
+ try {
18
+ hostname = new URL(origin).hostname;
19
+ } catch {
20
+ throw new Error(`PUBLIC_ORIGIN is not a valid URL, got ${origin}`);
21
+ }
22
+ // The magic-link origin must be https in production so the link and the __Host- cookie are
23
+ // origin-bound. http is allowed only for local dev on localhost or 127.0.0.1, matched exactly so
24
+ // a lookalike host like localhost.example.com cannot skip the https requirement.
25
+ const isLocal = hostname === 'localhost' || hostname === '127.0.0.1';
26
+ if (!origin.startsWith('https://') && !isLocal) {
27
+ throw new Error(`PUBLIC_ORIGIN must be https in production, got ${origin}`);
28
+ }
16
29
  return origin;
17
30
  }
18
31
 
@@ -136,3 +136,106 @@ export async function commitFile(
136
136
  if (!res.ok) throw new Error(`GitHub commit ${path} failed: ${res.status} ${await res.text()}`);
137
137
  return ((await res.json()) as { commit: { sha: string } }).commit.sha;
138
138
  }
139
+
140
+ /** A path change for an atomic commit: write `content`, or delete the path when `content` is null. */
141
+ export interface FileChange {
142
+ path: string;
143
+ content: string | null;
144
+ }
145
+
146
+ /** A Git Trees API change entry: a blob written from raw content, or a `sha: null` delete. */
147
+ interface TreeChange {
148
+ path: string;
149
+ mode: '100644';
150
+ type: 'blob';
151
+ content?: string;
152
+ sha?: null;
153
+ }
154
+
155
+ /** A Git Data API URL under the repo's `git/` namespace. */
156
+ function gitUrl(repo: RepoRef, suffix: string): string {
157
+ return `${API}/repos/${repo.owner}/${repo.repo}/git/${suffix}`;
158
+ }
159
+
160
+ /** The branch head commit sha, through the Git Data API single-ref read. */
161
+ async function headCommitSha(repo: RepoRef, token: string): Promise<string> {
162
+ const res = await fetch(gitUrl(repo, `ref/heads/${encodeURIComponent(repo.branch)}`), {
163
+ headers: ghHeaders('application/vnd.github+json', token),
164
+ });
165
+ if (!res.ok) throw new Error(`GitHub ref ${repo.branch} failed: ${res.status}`);
166
+ return ((await res.json()) as { object: { sha: string } }).object.sha;
167
+ }
168
+
169
+ /** The base tree sha of a commit. */
170
+ async function commitTreeSha(repo: RepoRef, commitSha: string, token: string): Promise<string> {
171
+ const res = await fetch(gitUrl(repo, `commits/${commitSha}`), {
172
+ headers: ghHeaders('application/vnd.github+json', token),
173
+ });
174
+ if (!res.ok) throw new Error(`GitHub commit ${commitSha} failed: ${res.status}`);
175
+ return ((await res.json()) as { tree: { sha: string } }).tree.sha;
176
+ }
177
+
178
+ /** Map file changes to Git Trees API entries, encoding a null content as a delete. */
179
+ function treeChanges(changes: FileChange[]): TreeChange[] {
180
+ return changes.map((c) =>
181
+ c.content === null
182
+ ? { path: c.path, mode: '100644', type: 'blob', sha: null }
183
+ : { path: c.path, mode: '100644', type: 'blob', content: c.content },
184
+ );
185
+ }
186
+
187
+ /** Retries after the initial attempt when the branch moves under an atomic commit. */
188
+ const COMMIT_RETRIES = 3;
189
+
190
+ /**
191
+ * Commit several path changes in one commit over the Git Data API. The author is the editor; the
192
+ * committer is omitted, so GitHub attributes the commit to the App. Returns the new commit sha.
193
+ * Builds the new tree on the current head's tree, so paths not named here are preserved.
194
+ *
195
+ * Caller preconditions this layer cannot enforce (the save and lifecycle paths must): every
196
+ * `path` is confined to the site's content directories (the App token can write anywhere in the
197
+ * repo), and `author` is derived from the verified server-side session, never request input.
198
+ *
199
+ * An empty change set is rejected, since it would otherwise push an empty commit that triggers a
200
+ * site redeploy for no content change.
201
+ */
202
+ export async function commitFiles(
203
+ repo: RepoRef,
204
+ changes: FileChange[],
205
+ opts: { message: string; author: CommitAuthor },
206
+ token: string,
207
+ ): Promise<string> {
208
+ if (changes.length === 0) throw new Error('commitFiles: no changes to commit');
209
+ const tree = treeChanges(changes);
210
+ for (let attempt = 0; attempt <= COMMIT_RETRIES; attempt++) {
211
+ const parent = await headCommitSha(repo, token);
212
+ const baseTree = await commitTreeSha(repo, parent, token);
213
+
214
+ const treeRes = await fetch(gitUrl(repo, 'trees'), {
215
+ method: 'POST',
216
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
217
+ body: JSON.stringify({ base_tree: baseTree, tree }),
218
+ });
219
+ if (!treeRes.ok) throw new Error(`GitHub tree create failed: ${treeRes.status} ${await treeRes.text()}`);
220
+ const newTree = ((await treeRes.json()) as { sha: string }).sha;
221
+
222
+ const commitRes = await fetch(gitUrl(repo, 'commits'), {
223
+ method: 'POST',
224
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ message: opts.message, tree: newTree, parents: [parent], author: opts.author }),
226
+ });
227
+ if (!commitRes.ok) throw new Error(`GitHub commit create failed: ${commitRes.status} ${await commitRes.text()}`);
228
+ const newCommit = ((await commitRes.json()) as { sha: string }).sha;
229
+
230
+ const refRes = await fetch(gitUrl(repo, `refs/heads/${encodeURIComponent(repo.branch)}`), {
231
+ method: 'PATCH',
232
+ headers: { ...ghHeaders('application/vnd.github+json', token), 'Content-Type': 'application/json' },
233
+ body: JSON.stringify({ sha: newCommit, force: false }),
234
+ });
235
+ if (refRes.ok) return newCommit;
236
+ // A non-fast-forward means the branch moved; retry on the new head so a concurrent commit
237
+ // is preserved. Any other failure is not a race, so surface it.
238
+ if (refRes.status !== 422) throw new Error(`GitHub ref update failed: ${refRes.status} ${await refRes.text()}`);
239
+ }
240
+ throw new CommitConflictError(`${repo.branch} (atomic commit)`);
241
+ }
@@ -79,6 +79,38 @@ export async function installationToken(creds: AppCredentials): Promise<string>
79
79
  return ((await res.json()) as { token: string }).token;
80
80
  }
81
81
 
82
+ interface CachedToken {
83
+ token: string;
84
+ expiresAt: number;
85
+ }
86
+
87
+ /**
88
+ * Build an installation-token cache. A module-global instance memoizes the minted token per
89
+ * installation for most of its one-hour life, so a warm Worker isolate reuses it across requests
90
+ * instead of re-signing and re-calling GitHub on every list and commit. A cold isolate re-mints,
91
+ * which is always safe. This mirrors the default of @octokit/auth-app, which caches installation
92
+ * tokens in memory and returns them until expiry. The TTL stays under GitHub's documented one-hour
93
+ * lifetime, so a fixed margin avoids parsing the API expiry. `mint` and `now` are injected so the
94
+ * cache is testable with no network call and no real clock.
95
+ */
96
+ export function createInstallationTokenCache(
97
+ mint: (creds: AppCredentials) => Promise<string> = installationToken,
98
+ now: () => number = () => Date.now(),
99
+ ttlMs = 55 * 60 * 1000,
100
+ ): (creds: AppCredentials) => Promise<string> {
101
+ const cache = new Map<string, CachedToken>();
102
+ return async function get(creds: AppCredentials): Promise<string> {
103
+ const hit = cache.get(creds.installationId);
104
+ if (hit && hit.expiresAt > now()) return hit.token;
105
+ const token = await mint(creds);
106
+ cache.set(creds.installationId, { token, expiresAt: now() + ttlMs });
107
+ return token;
108
+ };
109
+ }
110
+
111
+ /** The shared installation-token cache, one instance per Worker isolate. */
112
+ export const cachedInstallationToken = createInstallationTokenCache();
113
+
82
114
  /**
83
115
  * Deploy-time self-test for the App signer: sign a dummy JWT with the configured key. It
84
116
  * exercises the brittle PKCS#1-to-PKCS#8 conversion and the Web Crypto import and sign with
package/src/lib/index.ts CHANGED
@@ -49,6 +49,22 @@ export {
49
49
  composeDatedId,
50
50
  } from './content/ids.js';
51
51
  export type { DatePrefix } from './content/ids.js';
52
+ // Internal-link token and the committed content manifest (content-graph design). The corpus
53
+ // builder and the request-time resolver ship from the delivery entry; this surface is the
54
+ // grammar, the manifest operations, and their types a migrating site adopts.
55
+ export { parseCairnToken, extractCairnLinks } from './content/links.js';
56
+ export type { CairnRef, LinkResolve } from './content/links.js';
57
+ export {
58
+ serializeManifest,
59
+ parseManifest,
60
+ emptyManifest,
61
+ verifyManifest,
62
+ upsertEntry,
63
+ removeEntry,
64
+ manifestEntryFromFile,
65
+ manifestLinkResolver,
66
+ } from './content/manifest.js';
67
+ export type { Manifest, ManifestEntry, LinkTarget } from './content/manifest.js';
52
68
  // Render engine (Plan 04): generic directive pipeline; sites own the component registry.
53
69
  export { defineRegistry, emptyValues } from './render/registry.js';
54
70
  export type {
@@ -6,23 +6,50 @@ import remarkRehype from 'remark-rehype';
6
6
  import rehypeRaw from 'rehype-raw';
7
7
  import rehypeSlug from 'rehype-slug';
8
8
  import rehypeStringify from 'rehype-stringify';
9
+ import rehypeSanitize from 'rehype-sanitize';
10
+ import type { Schema } from 'hast-util-sanitize';
11
+ import { VFile } from 'vfile';
12
+ import { buildSanitizeSchema, rehypeAnchorRel } from './sanitize-schema.js';
9
13
  import { remarkDirectiveStamp } from './remark-directives.js';
14
+ import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
10
15
  import { rehypeDispatch } from './rehype-dispatch.js';
11
16
  import type { ComponentRegistry } from './registry.js';
17
+ import type { LinkResolve } from '../content/links.js';
12
18
 
13
19
  export interface RendererOptions {
14
20
  /** Stamp a `data-rise` ordinal (0, 1, 2, …) on each top-level component so a site's
15
21
  * CSS can drive an entrance-cascade delay off it. Omit for no stagger. The ordinal
16
22
  * is inert, so a consumer's sanitize floor can keep `data-rise` and drop `style`. */
17
23
  stagger?: boolean;
24
+ /** Extend the sanitize allowlist. Receives cairn's default schema (defaultSchema plus the
25
+ * directive markers and the common benign tags) and returns the schema to use. Add to the
26
+ * allowlist for the benign HTML a site's content needs; start from the argument so the
27
+ * dangerous strip is preserved. */
28
+ sanitizeSchema?: (defaults: Schema) => Schema;
29
+ /** Developer-only escape hatch: disable the sanitize floor entirely. This reintroduces the XSS
30
+ * vector the floor closes, so it is only for a site whose content is fully developer-controlled.
31
+ * It is a code-level adapter decision, never an editor-facing setting. */
32
+ unsafeDisableSanitize?: boolean;
18
33
  }
19
34
 
20
35
  /** Compose a site's render pipeline from its component registry: directive syntax to
21
36
  * stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
22
37
  * rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
23
38
  export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
24
- const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
25
- const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.stagger], rehypeSlug];
39
+ const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry], remarkResolveCairnLinks];
40
+ // The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
41
+ // before the dispatch (so the site's trusted build() output and its inline SVG icons are never
42
+ // sanitized). The anchor-rel hardening runs last so it also covers component-built anchors.
43
+ const floor: PluggableList = options.unsafeDisableSanitize
44
+ ? []
45
+ : [[rehypeSanitize, buildSanitizeSchema(registry, options.sanitizeSchema)]];
46
+ const rehypePlugins: PluggableList = [
47
+ rehypeRaw,
48
+ ...floor,
49
+ [rehypeDispatch, registry, options.stagger],
50
+ rehypeSlug,
51
+ rehypeAnchorRel,
52
+ ];
26
53
  const processor = unified()
27
54
  .use(remarkParse)
28
55
  .use(remarkGfm)
@@ -33,6 +60,9 @@ export function createRenderer(registry: ComponentRegistry, options: RendererOpt
33
60
  return {
34
61
  remarkPlugins,
35
62
  rehypePlugins,
36
- renderMarkdown: async (content: string): Promise<string> => String(await processor.process(content)),
63
+ renderMarkdown: async (content: string, opts: { resolve?: LinkResolve } = {}): Promise<string> => {
64
+ const file = new VFile({ value: content, data: { [CAIRN_RESOLVE]: opts.resolve } });
65
+ return String(await processor.process(file));
66
+ },
37
67
  };
38
68
  }