@glw907/cairn-cms 0.17.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 (54) hide show
  1. package/dist/components/EditPage.svelte +9 -2
  2. package/dist/components/EditPage.svelte.d.ts +2 -0
  3. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  4. package/dist/content/compose.d.ts.map +1 -1
  5. package/dist/content/compose.js +1 -0
  6. package/dist/content/links.d.ts +14 -0
  7. package/dist/content/links.d.ts.map +1 -0
  8. package/dist/content/links.js +41 -0
  9. package/dist/content/manifest.d.ts +55 -0
  10. package/dist/content/manifest.d.ts.map +1 -0
  11. package/dist/content/manifest.js +98 -0
  12. package/dist/content/types.d.ts +10 -1
  13. package/dist/content/types.d.ts.map +1 -1
  14. package/dist/delivery/index.d.ts +1 -0
  15. package/dist/delivery/index.d.ts.map +1 -1
  16. package/dist/delivery/index.js +1 -0
  17. package/dist/delivery/manifest.d.ts +13 -0
  18. package/dist/delivery/manifest.d.ts.map +1 -0
  19. package/dist/delivery/manifest.js +31 -0
  20. package/dist/github/repo.d.ts +21 -0
  21. package/dist/github/repo.d.ts.map +1 -1
  22. package/dist/github/repo.js +79 -0
  23. package/dist/index.d.ts +4 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +5 -0
  26. package/dist/render/pipeline.d.ts +4 -1
  27. package/dist/render/pipeline.d.ts.map +1 -1
  28. package/dist/render/pipeline.js +7 -2
  29. package/dist/render/resolve-links.d.ts +8 -0
  30. package/dist/render/resolve-links.d.ts.map +1 -0
  31. package/dist/render/resolve-links.js +36 -0
  32. package/dist/render/sanitize-schema.d.ts.map +1 -1
  33. package/dist/render/sanitize-schema.js +9 -0
  34. package/dist/sveltekit/content-routes.d.ts +3 -0
  35. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  36. package/dist/sveltekit/content-routes.js +29 -2
  37. package/dist/sveltekit/public-routes.d.ts +2 -0
  38. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  39. package/dist/sveltekit/public-routes.js +2 -1
  40. package/package.json +1 -1
  41. package/src/lib/components/EditPage.svelte +9 -2
  42. package/src/lib/content/compose.ts +1 -0
  43. package/src/lib/content/links.ts +48 -0
  44. package/src/lib/content/manifest.ts +138 -0
  45. package/src/lib/content/types.ts +10 -3
  46. package/src/lib/delivery/index.ts +1 -0
  47. package/src/lib/delivery/manifest.ts +38 -0
  48. package/src/lib/github/repo.ts +103 -0
  49. package/src/lib/index.ts +16 -0
  50. package/src/lib/render/pipeline.ts +8 -2
  51. package/src/lib/render/resolve-links.ts +42 -0
  52. package/src/lib/render/sanitize-schema.ts +9 -0
  53. package/src/lib/sveltekit/content-routes.ts +36 -4
  54. package/src/lib/sveltekit/public-routes.ts +4 -2
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/lib/render/pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAStD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAIjD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEvD,MAAM,WAAW,eAAe;IAC9B;;0FAEsF;IACtF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;wCAGoC;IACpC,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9C;;+EAE2E;IAC3E,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;uFAEuF;AACvF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,OAAO,GAAE,eAAoB;;;8BAyBrD,MAAM,KAAG,OAAO,CAAC,MAAM,CAAC;EAE3D"}
1
+ {"version":3,"file":"pipeline.d.ts","sourceRoot":"","sources":["../../src/lib/render/pipeline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAW,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAStD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAMjD,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACvD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,eAAe;IAC9B;;0FAEsF;IACtF,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;wCAGoC;IACpC,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,CAAC;IAC9C;;+EAE2E;IAC3E,qBAAqB,CAAC,EAAE,OAAO,CAAC;CACjC;AAED;;uFAEuF;AACvF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,OAAO,GAAE,eAAoB;;;8BAyBrD,MAAM,SAAQ;QAAE,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,KAAQ,OAAO,CAAC,MAAM,CAAC;EAKjG"}
@@ -7,14 +7,16 @@ import rehypeRaw from 'rehype-raw';
7
7
  import rehypeSlug from 'rehype-slug';
8
8
  import rehypeStringify from 'rehype-stringify';
9
9
  import rehypeSanitize from 'rehype-sanitize';
10
+ import { VFile } from 'vfile';
10
11
  import { buildSanitizeSchema, rehypeAnchorRel } from './sanitize-schema.js';
11
12
  import { remarkDirectiveStamp } from './remark-directives.js';
13
+ import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
12
14
  import { rehypeDispatch } from './rehype-dispatch.js';
13
15
  /** Compose a site's render pipeline from its component registry: directive syntax to
14
16
  * stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
15
17
  * rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
16
18
  export function createRenderer(registry, options = {}) {
17
- const remarkPlugins = [remarkDirective, [remarkDirectiveStamp, registry]];
19
+ const remarkPlugins = [remarkDirective, [remarkDirectiveStamp, registry], remarkResolveCairnLinks];
18
20
  // The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
19
21
  // before the dispatch (so the site's trusted build() output and its inline SVG icons are never
20
22
  // sanitized). The anchor-rel hardening runs last so it also covers component-built anchors.
@@ -38,6 +40,9 @@ export function createRenderer(registry, options = {}) {
38
40
  return {
39
41
  remarkPlugins,
40
42
  rehypePlugins,
41
- renderMarkdown: async (content) => String(await processor.process(content)),
43
+ renderMarkdown: async (content, opts = {}) => {
44
+ const file = new VFile({ value: content, data: { [CAIRN_RESOLVE]: opts.resolve } });
45
+ return String(await processor.process(file));
46
+ },
42
47
  };
43
48
  }
@@ -0,0 +1,8 @@
1
+ import type { VFile } from 'vfile';
2
+ /** The VFile data key the renderer sets the per-call resolver under. */
3
+ export declare const CAIRN_RESOLVE = "cairnResolve";
4
+ /** Resolve cairn: link nodes against the VFile's resolver. A non-cairn href and a malformed token
5
+ * pass through. A missing target is marked with the cairn-broken-link class (the resolver returns
6
+ * undefined) or, when the resolver throws, the error propagates and fails the build. */
7
+ export declare function remarkResolveCairnLinks(): (tree: unknown, file: VFile) => void;
8
+ //# sourceMappingURL=resolve-links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-links.d.ts","sourceRoot":"","sources":["../../src/lib/render/resolve-links.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAGnC,wEAAwE;AACxE,eAAO,MAAM,aAAa,iBAAiB,CAAC;AAO5C;;yFAEyF;AACzF,wBAAgB,uBAAuB,KAC7B,MAAM,OAAO,EAAE,MAAM,KAAK,KAAG,IAAI,CAoB1C"}
@@ -0,0 +1,36 @@
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 { parseCairnToken } from '../content/links.js';
8
+ /** The VFile data key the renderer sets the per-call resolver under. */
9
+ export const CAIRN_RESOLVE = 'cairnResolve';
10
+ /** Resolve cairn: link nodes against the VFile's resolver. A non-cairn href and a malformed token
11
+ * pass through. A missing target is marked with the cairn-broken-link class (the resolver returns
12
+ * undefined) or, when the resolver throws, the error propagates and fails the build. */
13
+ export function remarkResolveCairnLinks() {
14
+ return (tree, file) => {
15
+ const resolve = file.data[CAIRN_RESOLVE];
16
+ if (!resolve)
17
+ return;
18
+ visit(tree, 'link', (node) => {
19
+ const ref = parseCairnToken(node.url);
20
+ if (!ref)
21
+ return;
22
+ const url = resolve(ref); // may throw (build backstop); propagates out of render
23
+ if (url) {
24
+ node.url = url;
25
+ return;
26
+ }
27
+ // Missing target in the preview: mark it broken and neutralize the href, keeping the text.
28
+ node.url = '#';
29
+ node.data = node.data ?? {};
30
+ const props = (node.data.hProperties = node.data.hProperties ?? {});
31
+ const existing = Array.isArray(props.className) ? props.className : [];
32
+ props.className = [...existing, 'cairn-broken-link'];
33
+ props.title = 'Broken internal link';
34
+ });
35
+ };
36
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"sanitize-schema.d.ts","sourceRoot":"","sources":["../../src/lib/render/sanitize-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,KAAK,EAAE,IAAI,EAAW,MAAM,MAAM,CAAC;AAE1C,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAMrE;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GACpC,MAAM,CAoBR;AAED;;;;GAIG;AACH,wBAAgB,eAAe,KACrB,MAAM,IAAI,UAOnB"}
1
+ {"version":3,"file":"sanitize-schema.d.ts","sourceRoot":"","sources":["../../src/lib/render/sanitize-schema.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAChE,OAAO,KAAK,EAAE,IAAI,EAAW,MAAM,MAAM,CAAC;AAE1C,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAMrE;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,iBAAiB,EAC3B,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GACpC,MAAM,CA6BR;AAED;;;;GAIG;AACH,wBAAgB,eAAe,KACrB,MAAM,IAAI,UAOnB"}
@@ -21,6 +21,11 @@ export function buildSanitizeSchema(registry, extend) {
21
21
  // that restricts a link's class to that one value. A per-tag tuple wins over a bare `*` entry, so
22
22
  // it would strip an author's link class. Drop that tuple before admitting a free-form `className`.
23
23
  const anchorAttrs = (attributes.a ?? []).filter((entry) => !(Array.isArray(entry) && entry[0] === 'className'));
24
+ // Admit the inert `cairn:` href scheme on top of the default protocol allowlist. The render
25
+ // resolver rewrites a `cairn:` link to a live permalink before delivery; an unresolved one
26
+ // survives the floor in its inert token form (a visible unresolved-link signal), never as an
27
+ // executable vector. The dangerous-protocol strip (javascript:, data:) is preserved.
28
+ const protocols = defaultSchema.protocols ?? {};
24
29
  const schema = {
25
30
  ...defaultSchema,
26
31
  tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
@@ -29,6 +34,10 @@ export function buildSanitizeSchema(registry, extend) {
29
34
  '*': [...(attributes['*'] ?? []), 'className', ...markers],
30
35
  a: [...anchorAttrs, 'className', 'target', 'rel'],
31
36
  },
37
+ protocols: {
38
+ ...protocols,
39
+ href: [...(protocols.href ?? []), 'cairn'],
40
+ },
32
41
  };
33
42
  return extend ? extend(schema) : schema;
34
43
  }
@@ -1,4 +1,5 @@
1
1
  import { type GithubKeyEnv } from '../github/credentials.js';
2
+ import { type LinkTarget } from '../content/manifest.js';
2
3
  import type { CairnRuntime, FrontmatterField } from '../content/types.js';
3
4
  import type { Editor, Role } from '../auth/types.js';
4
5
  /** A sidebar concept entry: just enough to render the nav without shipping validators to the client. */
@@ -50,6 +51,8 @@ export interface EditData {
50
51
  isNew: boolean;
51
52
  saved: boolean;
52
53
  error: string | null;
54
+ /** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
55
+ linkTargets: LinkTarget[];
53
56
  }
54
57
  /** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
55
58
  export interface ContentEvent {
@@ -1 +1 @@
1
- {"version":3,"file":"content-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/content-routes.ts"],"names":[],"mappings":"AAQA,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAI7E,OAAO,KAAK,EAAE,YAAY,EAAqB,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC7F,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAErD,wGAAwG;AACxG,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC;IAC1C,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,yGAAyG;IACzG,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,oCAAoC;AACpC,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,2FAA2F;IAC3F,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qDAAqD;IACrD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,6FAA6F;AAC7F,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB;AAED,gGAAgG;AAChG,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,GAAG,CAAC;IACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;CACnC;AAED,sFAAsF;AACtF,MAAM,WAAW,iBAAiB;IAChC,6FAA6F;IAC7F,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACpD;AAgBD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,YAAY,EAAE,IAAI,GAAE,iBAAsB;wBAK1D,YAAY,KAAG,UAAU;yBAa1B,KAAK;sBAqBA,YAAY,KAAG,OAAO,CAAC,QAAQ,CAAC;0BAqB5B,YAAY,KAAG,OAAO,CAAC,KAAK,CAAC;sBAyCjC,YAAY,KAAG,OAAO,CAAC,QAAQ,CAAC;wBAgC9B,YAAY,KAAG,OAAO,CAAC,KAAK,CAAC;qBAtJ5C,YAAY,KAAK,OAAO,CAAC,MAAM,CAAC;EA+LnD"}
1
+ {"version":3,"file":"content-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/content-routes.ts"],"names":[],"mappings":"AAQA,OAAO,EAAkB,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAG7E,OAAO,EAAuF,KAAK,UAAU,EAAE,MAAM,wBAAwB,CAAC;AAE9I,OAAO,KAAK,EAAE,YAAY,EAAqB,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAC7F,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAErD,wGAAwG;AACxG,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,MAAM,WAAW,UAAU;IACzB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,IAAI,CAAA;KAAE,CAAC;IAC1C,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,gBAAgB,EAAE,OAAO,CAAC;IAC1B,yGAAyG;IACzG,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED,wCAAwC;AACxC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,oCAAoC;AACpC,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,2FAA2F;IAC3F,KAAK,EAAE,OAAO,CAAC;IACf,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,gFAAgF;IAChF,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qDAAqD;IACrD,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,6FAA6F;AAC7F,MAAM,WAAW,QAAQ;IACvB,SAAS,EAAE,MAAM,CAAC;IAClB,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,OAAO,CAAC;IACf,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,0GAA0G;IAC1G,WAAW,EAAE,UAAU,EAAE,CAAC;CAC3B;AAED,gGAAgG;AAChG,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,GAAG,CAAC;IACT,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE;QAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IACnC,QAAQ,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,YAAY,CAAA;KAAE,CAAC;CACnC;AAED,sFAAsF;AACtF,MAAM,WAAW,iBAAiB;IAChC,6FAA6F;IAC7F,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,YAAY,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CACpD;AAgBD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,YAAY,EAAE,IAAI,GAAE,iBAAsB;wBAK1D,YAAY,KAAG,UAAU;yBAa1B,KAAK;sBAqBA,YAAY,KAAG,OAAO,CAAC,QAAQ,CAAC;0BAqB5B,YAAY,KAAG,OAAO,CAAC,KAAK,CAAC;sBAyCjC,YAAY,KAAG,OAAO,CAAC,QAAQ,CAAC;wBA+C9B,YAAY,KAAG,OAAO,CAAC,KAAK,CAAC;qBArK5C,YAAY,KAAK,OAAO,CAAC,MAAM,CAAC;EA4NnD"}
@@ -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 } from '../github/credentials.js';
10
- import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
10
+ import { listMarkdown, readRaw, commitFiles } from '../github/repo.js';
11
11
  import { cachedInstallationToken } from '../github/signing.js';
12
+ import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry } from '../content/manifest.js';
12
13
  import { CommitConflictError } from '../github/types.js';
13
14
  /** The signed-in editor the guard resolved, or a login redirect. Kept local to decouple event shapes. */
14
15
  function sessionOf(event) {
@@ -139,6 +140,18 @@ export function createContentRoutes(runtime, deps = {}) {
139
140
  throw error(404, 'Entry not found');
140
141
  const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
141
142
  const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
143
+ let linkTargets = [];
144
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
145
+ if (manifestRaw !== null) {
146
+ linkTargets = parseManifest(manifestRaw).entries.map((e) => ({
147
+ concept: e.concept,
148
+ id: e.id,
149
+ permalink: e.permalink,
150
+ title: e.title,
151
+ date: e.date,
152
+ draft: e.draft,
153
+ }));
154
+ }
142
155
  return {
143
156
  conceptId: concept.id,
144
157
  id,
@@ -150,6 +163,7 @@ export function createContentRoutes(runtime, deps = {}) {
150
163
  isNew,
151
164
  saved: event.url.searchParams.get('saved') === '1',
152
165
  error: event.url.searchParams.get('error'),
166
+ linkTargets,
153
167
  };
154
168
  }
155
169
  /** Match a commit conflict by class and by name (bundling can alias the class identity). */
@@ -177,8 +191,21 @@ export function createContentRoutes(runtime, deps = {}) {
177
191
  }
178
192
  const markdown = serializeMarkdown(result.data, body);
179
193
  const token = await mintToken(event.platform?.env ?? {});
194
+ // Read the committed manifest, upsert this entry's row, and commit content and manifest in one
195
+ // commit. A missing manifest starts empty (first save on a fresh repo). The build regenerates
196
+ // and verifies the manifest, so this incremental patch is the cheap request-time path. On a
197
+ // 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
198
+ // leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
199
+ // it with npm run cairn:manifest to recover.
200
+ const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
201
+ const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
202
+ const row = manifestEntryFromFile(concept, { path, raw: markdown });
203
+ const nextManifest = serializeManifest(upsertEntry(manifest, row));
180
204
  try {
181
- await commitFile(runtime.backend, path, markdown, { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
205
+ await commitFiles(runtime.backend, [
206
+ { path, content: markdown },
207
+ { path: runtime.manifestPath, content: nextManifest },
208
+ ], { message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } }, token);
182
209
  }
183
210
  catch (err) {
184
211
  if (isConflict(err)) {
@@ -1,11 +1,13 @@
1
1
  import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
2
2
  import type { SiteIndex } from '../delivery/site-index.js';
3
3
  import type { SeoMeta } from '../delivery/seo.js';
4
+ import type { LinkResolve } from '../content/links.js';
4
5
  /** Injected dependencies for the public loaders. */
5
6
  export interface PublicRoutesDeps {
6
7
  site: SiteIndex;
7
8
  render: (md: string, opts?: {
8
9
  stagger?: boolean;
10
+ resolve?: LinkResolve;
9
11
  }) => string | Promise<string>;
10
12
  origin: string;
11
13
  /** Site name for og:site_name and the SEO head. */
@@ -1 +1 @@
1
- {"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
1
+ {"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,WAAW,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACtG,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
@@ -6,6 +6,7 @@
6
6
  import { error } from '@sveltejs/kit';
7
7
  import { buildSeoMeta } from '../delivery/seo.js';
8
8
  import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
9
+ import { buildLinkResolver } from '../delivery/manifest.js';
9
10
  /** Build the public loaders for a site's unified index. */
10
11
  export function createPublicRoutes(deps) {
11
12
  const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
@@ -40,7 +41,7 @@ export function createPublicRoutes(deps) {
40
41
  ...(fields.author ? { author: fields.author } : {}),
41
42
  ...(entry.date ? { feeds } : {}),
42
43
  });
43
- return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
44
+ return { entry, html: await render(entry.body, { stagger: true, resolve: buildLinkResolver(site) }), canonicalUrl, seo, newer, older };
44
45
  }
45
46
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
46
47
  function archiveLoad(conceptId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -12,6 +12,8 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
12
12
  import type { IconSet } from '../render/glyph.js';
13
13
  import type { EditData } from '../sveltekit/content-routes.js';
14
14
  import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
15
+ import type { LinkResolve } from '../content/links.js';
16
+ import { manifestLinkResolver } from '../content/manifest.js';
15
17
 
16
18
  interface Props {
17
19
  /** The edit load's data, plus the site name for the heading. */
@@ -19,7 +21,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
19
21
  /** The site's component registry, for the insert palette. */
20
22
  registry?: ComponentRegistry;
21
23
  /** The site's design-accurate render pipeline; the preview pane renders its output, which the floored pipeline already sanitized. */
22
- render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
24
+ render?: (md: string, opts?: { stagger?: boolean; resolve?: LinkResolve }) => string | Promise<string>;
23
25
  /** The site's icon set, for the guided form's icon fields. */
24
26
  icons?: IconSet;
25
27
  }
@@ -33,6 +35,10 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
33
35
  let previewHtml = $state('');
34
36
  let insert = $state.raw<(text: string) => void>(() => {});
35
37
 
38
+ // The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
39
+ // returns undefined for a missing target so the render step marks it cairn-broken-link.
40
+ const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
41
+
36
42
  const PREVIEW_KEY = 'cairn-admin:preview';
37
43
 
38
44
  $effect(() => {
@@ -53,10 +59,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
53
59
  $effect(() => {
54
60
  if (!showPreview || !render) return;
55
61
  const md = body;
62
+ const resolve = resolveLink; // tracked read in the effect body
56
63
  const run = ++previewRun;
57
64
  const handle = setTimeout(async () => {
58
65
  try {
59
- const html = await render(md);
66
+ const html = await render(md, { resolve });
60
67
  if (run === previewRun) previewHtml = html;
61
68
  } catch {
62
69
  if (run === previewRun) previewHtml = '';
@@ -31,6 +31,7 @@ export function composeRuntime(
31
31
  backend: adapter.backend,
32
32
  sender: adapter.sender,
33
33
  render: adapter.render,
34
+ manifestPath: adapter.manifestPath ?? 'src/content/.cairn/index.json',
34
35
  registry: adapter.registry,
35
36
  icons: adapter.icons,
36
37
  navMenu: adapter.navMenu,
@@ -0,0 +1,48 @@
1
+ // cairn-cms: the cairn: internal-link token. An internal link is a standard CommonMark link
2
+ // whose href is `cairn:<concept>/<id>`, keyed to the target's permanent filename stem so it
3
+ // survives a slug, date, or permalink change (content-graph design). This module owns the
4
+ // grammar; the render resolver (resolve-links.ts) reuses parseCairnToken.
5
+ import { unified } from 'unified';
6
+ import remarkParse from 'remark-parse';
7
+ import remarkGfm from 'remark-gfm';
8
+ import { visit } from 'unist-util-visit';
9
+ import { isValidId } from './ids.js';
10
+
11
+ /** A resolved reference to a content entry by its concept and permanent id. */
12
+ export interface CairnRef {
13
+ concept: string;
14
+ id: string;
15
+ }
16
+
17
+ /** Resolve a reference to its live permalink. Returns undefined when the target is missing (the
18
+ * preview marks it); the build resolver throws instead, so a dangling token fails the build. */
19
+ export type LinkResolve = (ref: CairnRef) => string | undefined;
20
+
21
+ /** Parse a `cairn:<concept>/<id>` href, or null for any other href or a malformed token. */
22
+ export function parseCairnToken(href: string): CairnRef | null {
23
+ if (!href.startsWith('cairn:')) return null;
24
+ const rest = href.slice('cairn:'.length);
25
+ const slash = rest.indexOf('/');
26
+ if (slash <= 0) return null;
27
+ const concept = rest.slice(0, slash);
28
+ const id = rest.slice(slash + 1);
29
+ if (!concept || !isValidId(id)) return null;
30
+ return { concept, id };
31
+ }
32
+
33
+ /** The cairn links a markdown body points at, in first-occurrence order, deduped by concept/id.
34
+ * Parses the body as mdast, so a token inside a code span or fence is never matched. */
35
+ export function extractCairnLinks(body: string): CairnRef[] {
36
+ const tree = unified().use(remarkParse).use(remarkGfm).parse(body);
37
+ const seen = new Set<string>();
38
+ const refs: CairnRef[] = [];
39
+ visit(tree, 'link', (node: { url?: string }) => {
40
+ const ref = node.url ? parseCairnToken(node.url) : null;
41
+ if (!ref) return;
42
+ const key = `${ref.concept}/${ref.id}`;
43
+ if (seen.has(key)) return;
44
+ seen.add(key);
45
+ refs.push(ref);
46
+ });
47
+ return refs;
48
+ }
@@ -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;
@@ -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
+ }