@glw907/cairn-cms 0.56.2 → 0.57.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 (173) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +929 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +347 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +569 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +67 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +17 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +62 -11
  47. package/dist/content/validate.js +27 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +68 -2
  102. package/dist/sveltekit/content-routes.js +461 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +929 -0
  116. package/src/lib/components/EditPage.svelte +347 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +569 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +16 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +78 -13
  138. package/src/lib/content/validate.ts +26 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +573 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,18 @@
1
+ /** The full lowercase hex sha256 of the bytes, via Web Crypto, hand-formatted to 64 hex chars. */
2
+ export declare function hashBytes(bytes: Uint8Array): Promise<string>;
3
+ /** The first 16 characters of a full hex digest, the content-hash prefix media references commit to. */
4
+ export declare function shortHash(full: string): string;
5
+ /** The strict ingest transform from a raw filename to a slug that satisfies the media: slug grammar,
6
+ * or the literal `file`. Drops the extension, lowercases, transliterates accents, collapses non-alphanumeric runs
7
+ * to a single hyphen, trims, caps at 80 chars, screens Windows reserved names, and falls back to
8
+ * `file` when nothing usable is left. */
9
+ export declare function slugifyFilename(name: string): string;
10
+ /** The content-addressed R2 object key `media/<aa>/<shortHash>.<ext>`, fanned out on the first two
11
+ * hex chars of the short hash. No leading slash: this is an object key, not a URL. `ext` is bare
12
+ * (no dot), for example `webp`. */
13
+ export declare function r2Key(shortHash: string, ext: string): string;
14
+ /** The public delivery URL path, with a leading slash, under the delivery base (`publicBase`,
15
+ * default `/media`). The `slug` form is human-readable (`<base>/<slug>.<shortHash>.<ext>`, or
16
+ * `<base>/<shortHash>.<ext>` when the slug is null); the `opaque` form mirrors the R2 fan-out
17
+ * (`<base>/<aa>/<shortHash>.<ext>`) and ignores the slug. */
18
+ export declare function publicPath(slug: string | null, shortHash: string, ext: string, urlForm: 'slug' | 'opaque', publicBase?: string): string;
@@ -0,0 +1,112 @@
1
+ // cairn-cms: media naming. Media is content-addressed: the sha256 of the bytes names the object, so
2
+ // the same bytes always land at the same key no matter the original filename. This module owns the
3
+ // hash, the ingest slug transform, the R2 object key, and the public delivery path. The slug grammar
4
+ // here matches the one parseMediaToken validates in ./reference.ts, so an ingested filename round
5
+ // trips through the media: token unchanged.
6
+ // slugifyFilename output always satisfies parseMediaToken's grammar (lowercase alphanumerics joined
7
+ // by single internal hyphens, no leading or trailing hyphen), or is the literal `file`.
8
+ /** Combining marks (Unicode block U+0300 to U+036F), left over after an NFD decompose, stripped to
9
+ * fold an accented letter down to its ASCII base. Written as escapes because the literal marks are
10
+ * invisible in source. */
11
+ const COMBINING_MARKS = /[\u0300-\u036f]/g;
12
+ /** Windows reserved device names. A bare match (case-insensitive) cannot survive as the slug, since
13
+ * it names a device rather than a file on that platform. */
14
+ const RESERVED = new Set([
15
+ 'con',
16
+ 'prn',
17
+ 'aux',
18
+ 'nul',
19
+ 'com1',
20
+ 'com2',
21
+ 'com3',
22
+ 'com4',
23
+ 'com5',
24
+ 'com6',
25
+ 'com7',
26
+ 'com8',
27
+ 'com9',
28
+ 'lpt1',
29
+ 'lpt2',
30
+ 'lpt3',
31
+ 'lpt4',
32
+ 'lpt5',
33
+ 'lpt6',
34
+ 'lpt7',
35
+ 'lpt8',
36
+ 'lpt9',
37
+ ]);
38
+ /** The maximum slug length, applied before the reserved-name and empty fallbacks. */
39
+ const MAX_SLUG = 80;
40
+ /** A 16-character lowercase hex content-hash prefix, the bare-hash reference form. A slug that
41
+ * matches this shape would collide with `media:<hash>`, so slugifyFilename screens it. */
42
+ const HASH_RE = /^[0-9a-f]{16}$/;
43
+ /** A short alphanumeric extension (no dot), the only shape r2Key accepts, for example `webp`. */
44
+ const R2_EXT_RE = /^[a-z0-9]{1,5}$/;
45
+ // A Uint8Array's generic buffer type no longer satisfies Web Crypto's BufferSource under strict lib
46
+ // types, since the backing buffer may be a SharedArrayBuffer; slice the bytes into a plain
47
+ // ArrayBuffer to hand digest. Mirrors the buf helper in ../github/signing.ts.
48
+ function asArrayBuffer(bytes) {
49
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
50
+ }
51
+ /** The full lowercase hex sha256 of the bytes, via Web Crypto, hand-formatted to 64 hex chars. */
52
+ export async function hashBytes(bytes) {
53
+ const digest = await crypto.subtle.digest('SHA-256', asArrayBuffer(bytes));
54
+ return Array.from(new Uint8Array(digest))
55
+ .map((b) => b.toString(16).padStart(2, '0'))
56
+ .join('');
57
+ }
58
+ /** The first 16 characters of a full hex digest, the content-hash prefix media references commit to. */
59
+ export function shortHash(full) {
60
+ return full.slice(0, 16);
61
+ }
62
+ /** The strict ingest transform from a raw filename to a slug that satisfies the media: slug grammar,
63
+ * or the literal `file`. Drops the extension, lowercases, transliterates accents, collapses non-alphanumeric runs
64
+ * to a single hyphen, trims, caps at 80 chars, screens Windows reserved names, and falls back to
65
+ * `file` when nothing usable is left. */
66
+ export function slugifyFilename(name) {
67
+ const dot = name.lastIndexOf('.');
68
+ const stem = dot === -1 ? name : name.slice(0, dot);
69
+ let slug = stem
70
+ .toLowerCase()
71
+ .normalize('NFD')
72
+ .replace(COMBINING_MARKS, '')
73
+ .replace(/[^a-z0-9]+/g, '-')
74
+ .replace(/^-+|-+$/g, '');
75
+ if (slug.length > MAX_SLUG) {
76
+ slug = slug.slice(0, MAX_SLUG).replace(/-+$/, '');
77
+ }
78
+ if (RESERVED.has(slug))
79
+ return `${slug}-file`;
80
+ if (slug === '')
81
+ return 'file';
82
+ // A slug shaped like a bare 16-hex hash would collide with the `media:<hash>` reference form, so
83
+ // append -img (mirroring the reserved-name -file fallback) to keep the slug and bare-hash forms
84
+ // disjoint.
85
+ if (HASH_RE.test(slug))
86
+ return `${slug}-img`;
87
+ return slug;
88
+ }
89
+ /** The content-addressed R2 object key `media/<aa>/<shortHash>.<ext>`, fanned out on the first two
90
+ * hex chars of the short hash. No leading slash: this is an object key, not a URL. `ext` is bare
91
+ * (no dot), for example `webp`. */
92
+ export function r2Key(shortHash, ext) {
93
+ if (!HASH_RE.test(shortHash)) {
94
+ throw new Error(`r2Key: hash must be 16 lowercase hex chars, got "${shortHash}"`);
95
+ }
96
+ if (!R2_EXT_RE.test(ext)) {
97
+ throw new Error(`r2Key: ext must be 1 to 5 lowercase alphanumerics, got "${ext}"`);
98
+ }
99
+ return `media/${shortHash.slice(0, 2)}/${shortHash}.${ext}`;
100
+ }
101
+ /** The public delivery URL path, with a leading slash, under the delivery base (`publicBase`,
102
+ * default `/media`). The `slug` form is human-readable (`<base>/<slug>.<shortHash>.<ext>`, or
103
+ * `<base>/<shortHash>.<ext>` when the slug is null); the `opaque` form mirrors the R2 fan-out
104
+ * (`<base>/<aa>/<shortHash>.<ext>`) and ignores the slug. */
105
+ export function publicPath(slug, shortHash, ext, urlForm, publicBase = '/media') {
106
+ if (urlForm === 'opaque') {
107
+ return `${publicBase}/${shortHash.slice(0, 2)}/${shortHash}.${ext}`;
108
+ }
109
+ return slug === null
110
+ ? `${publicBase}/${shortHash}.${ext}`
111
+ : `${publicBase}/${slug}.${shortHash}.${ext}`;
112
+ }
@@ -0,0 +1,36 @@
1
+ import type { MediaManifest } from './manifest.js';
2
+ /** What a reconcile read found in either direction. `orphanedObjects` are stored R2 keys whose hash
3
+ * has no manifest row; `missingObjects` are manifest hashes with no stored object. */
4
+ export interface ReconcileResult {
5
+ /** Stored keys (full R2 keys) whose content hash is absent from the manifest. */
6
+ orphanedObjects: string[];
7
+ /** Manifest content-hash keys with no matching stored object. */
8
+ missingObjects: string[];
9
+ }
10
+ /** The pure core: compare the stored R2 keys against the manifest's content-hash keys and report
11
+ * both orphan directions. A stored key that does not match the media-key grammar is ignored, since
12
+ * it is not a content-addressed media object this reconcile owns. */
13
+ export declare function reconcileMedia(storedKeys: string[], manifest: MediaManifest): ReconcileResult;
14
+ /** One page of an R2 list, the narrow subset the reconcile read consumes. */
15
+ interface ReconcileListPage {
16
+ objects: {
17
+ key: string;
18
+ }[];
19
+ truncated: boolean;
20
+ cursor?: string;
21
+ }
22
+ /** The R2 bucket surface the reconcile read needs: a single prefixed, paginated list. A local
23
+ * structural interface so no @cloudflare/workers-types name is imported (the module is internal and
24
+ * on no public subpath, but the narrow seam keeps the build self-contained either way). */
25
+ export interface ReconcileBucket {
26
+ list(opts?: {
27
+ prefix?: string;
28
+ cursor?: string;
29
+ }): Promise<ReconcileListPage>;
30
+ }
31
+ /** The glue runner: list every stored key under the media/ prefix (paginating through R2's
32
+ * cursor/truncated), reconcile against the manifest, log the count summary, and return the result.
33
+ * The log record carries counts only, never bytes or a key list; the keys are content hashes and so
34
+ * carry no PII, but the count summary is all an operator needs to size the orphan state. */
35
+ export declare function runReconcile(bucket: ReconcileBucket, manifest: MediaManifest): Promise<ReconcileResult>;
36
+ export {};
@@ -0,0 +1,45 @@
1
+ import { log } from '../log/index.js';
2
+ /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. */
3
+ const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
4
+ /** The pure core: compare the stored R2 keys against the manifest's content-hash keys and report
5
+ * both orphan directions. A stored key that does not match the media-key grammar is ignored, since
6
+ * it is not a content-addressed media object this reconcile owns. */
7
+ export function reconcileMedia(storedKeys, manifest) {
8
+ const manifestHashes = new Set(Object.keys(manifest));
9
+ const storedHashes = new Set();
10
+ const orphanedObjects = [];
11
+ for (const key of storedKeys) {
12
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
13
+ if (hash === undefined)
14
+ continue;
15
+ storedHashes.add(hash);
16
+ if (!manifestHashes.has(hash))
17
+ orphanedObjects.push(key);
18
+ }
19
+ const missingObjects = [];
20
+ for (const hash of manifestHashes) {
21
+ if (!storedHashes.has(hash))
22
+ missingObjects.push(hash);
23
+ }
24
+ return { orphanedObjects, missingObjects };
25
+ }
26
+ /** The glue runner: list every stored key under the media/ prefix (paginating through R2's
27
+ * cursor/truncated), reconcile against the manifest, log the count summary, and return the result.
28
+ * The log record carries counts only, never bytes or a key list; the keys are content hashes and so
29
+ * carry no PII, but the count summary is all an operator needs to size the orphan state. */
30
+ export async function runReconcile(bucket, manifest) {
31
+ const storedKeys = [];
32
+ let cursor;
33
+ do {
34
+ const page = await bucket.list({ prefix: 'media/', cursor });
35
+ for (const object of page.objects)
36
+ storedKeys.push(object.key);
37
+ cursor = page.truncated ? page.cursor : undefined;
38
+ } while (cursor !== undefined);
39
+ const result = reconcileMedia(storedKeys, manifest);
40
+ log.info('media.orphan_reconcile', {
41
+ orphaned: result.orphanedObjects.length,
42
+ missing: result.missingObjects.length,
43
+ });
44
+ return result;
45
+ }
@@ -0,0 +1,12 @@
1
+ /** A resolved reference to a media asset by its content-hash prefix, with an optional display slug. */
2
+ export interface MediaRef {
3
+ slug: string | null;
4
+ hash: string;
5
+ }
6
+ /** Parse a `media:<slug>.<hash>` href (or the bare `media:<hash>` form), or null for any other
7
+ * href or a malformed token. Splits on the last dot, so a slug that illegally contains a dot fails
8
+ * the slug grammar and returns null. */
9
+ export declare function parseMediaToken(href: string): MediaRef | null;
10
+ /** Write the canonical media: token for a ref. The inverse of parseMediaToken, so a parse then
11
+ * write round trip is stable: `media:<slug>.<hash>` when the slug is present, else `media:<hash>`. */
12
+ export declare function mediaToken(ref: MediaRef): string;
@@ -0,0 +1,33 @@
1
+ // cairn-cms: the media: reference token. A media reference is the logical handle that content
2
+ // commits to git, keyed to a content-hash prefix so the same bytes resolve no matter where they
3
+ // are stored or what they are named. The canonical form is `media:<slug>.<hash>`: the hash is a
4
+ // 16-character lowercase hex content-hash prefix that identifies the bytes, and the slug is a
5
+ // cosmetic display name. The bare `media:<hash>` form (no slug) is also valid. This module owns
6
+ // the grammar; it mirrors the cairn: link codec in ../content/links.ts.
7
+ /** A 16-character lowercase hex content-hash prefix. */
8
+ const HASH_RE = /^[0-9a-f]{16}$/;
9
+ /** The slug grammar from the Task 2 slugify transform: lowercase alphanumerics joined by single
10
+ * internal hyphens, with no leading or trailing hyphen and no dot (the dot is the slug/hash
11
+ * separator). */
12
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ /** Parse a `media:<slug>.<hash>` href (or the bare `media:<hash>` form), or null for any other
14
+ * href or a malformed token. Splits on the last dot, so a slug that illegally contains a dot fails
15
+ * the slug grammar and returns null. */
16
+ export function parseMediaToken(href) {
17
+ if (!href.startsWith('media:'))
18
+ return null;
19
+ const rest = href.slice('media:'.length);
20
+ const dot = rest.lastIndexOf('.');
21
+ if (dot === -1)
22
+ return HASH_RE.test(rest) ? { slug: null, hash: rest } : null;
23
+ const slug = rest.slice(0, dot);
24
+ const hash = rest.slice(dot + 1);
25
+ if (!HASH_RE.test(hash) || !SLUG_RE.test(slug))
26
+ return null;
27
+ return { slug, hash };
28
+ }
29
+ /** Write the canonical media: token for a ref. The inverse of parseMediaToken, so a parse then
30
+ * write round trip is stable: `media:<slug>.<hash>` when the slug is present, else `media:<hash>`. */
31
+ export function mediaToken(ref) {
32
+ return ref.slug === null ? `media:${ref.hash}` : `media:${ref.slug}.${ref.hash}`;
33
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Detect the MIME type of an image from its leading magic bytes. Reads only the first ~32 bytes and
3
+ * returns the recognized type, or null for an unrecognized magic or an input too short for a given
4
+ * check. This is the server's source of truth for an upload's type; the client-declared type is
5
+ * advisory. Recognizes JPEG, PNG, GIF, WebP, and the AVIF/HEIC ISO-BMFF brands.
6
+ */
7
+ export declare function sniffMediaType(bytes: Uint8Array): string | null;
8
+ /** The storage extension for a sniffed media type, or null for a type the upload path does not store
9
+ * (HEIC, an unknown type). Driven by the sniffed type, so the key's ext is server-owned. */
10
+ export declare function extForMediaType(type: string): string | null;
11
+ /**
12
+ * The engine-level upload deny predicate. Returns true (reject) when the upload is markup a site can
13
+ * never override: a declared type of image/svg+xml, image/svg, text/html, or application/xml, OR a
14
+ * payload whose first non-whitespace byte is `<` (an 0x3C after skipping leading ASCII whitespace).
15
+ * This runs ahead of and independent of any site `allowedTypes`, since SVG and HTML carry active
16
+ * content. The byte check catches a markup payload sent under a permitted declared type.
17
+ */
18
+ export declare function isDeniedUpload(bytes: Uint8Array, declaredType?: string): boolean;
@@ -0,0 +1,106 @@
1
+ // cairn-cms: content-type sniffing and the engine-level upload deny-list. The upload action trusts no
2
+ // client-declared type: it sniffs the real type from the leading bytes and screens the payload against a
3
+ // deny-list a site cannot override. Both functions are pure and Worker-clean (a plain Uint8Array, no
4
+ // Node Buffer and no stream), so they run unchanged on Cloudflare Workers and under vitest.
5
+ //
6
+ // The sniff is necessary but not sufficient. A polyglot can carry a valid image magic and an HTML tail,
7
+ // and this byte check sees only the magic. The delivery route's response headers (X-Content-Type-Options:
8
+ // nosniff, Content-Disposition: inline, a restrictive Content-Security-Policy) are the real XSS control
9
+ // for the served bytes; sniffing here is the ingest gate, not the served-bytes defense.
10
+ /** The leading ASCII whitespace bytes skipped before the deny-list's first-byte-is-`<` check:
11
+ * tab (0x09), newline (0x0A), carriage return (0x0D), and space (0x20). */
12
+ const WHITESPACE = new Set([0x09, 0x0a, 0x0d, 0x20]);
13
+ /** The single byte `<` (0x3C). A payload whose first non-whitespace byte is `<` is markup (SVG, HTML,
14
+ * XML) and is denied regardless of its declared type or any site `allowedTypes`. */
15
+ const LT = 0x3c;
16
+ /** Declared content types denied at the engine level, independent of any site `allowedTypes`. SVG and
17
+ * the markup types carry active content (script, foreignObject), so they never ingest as media. */
18
+ const DENIED_TYPES = new Set(['image/svg+xml', 'image/svg', 'text/html', 'application/xml']);
19
+ /** The ISO-BMFF major-brand codes (at bytes 8..11 of an `ftyp` box) that mean an AVIF image. */
20
+ const AVIF_BRANDS = new Set(['avif', 'avis']);
21
+ /** The ISO-BMFF major-brand codes that mean a HEIF/HEIC image. */
22
+ const HEIC_BRANDS = new Set(['heic', 'heix', 'heif', 'hevc', 'hevx', 'mif1', 'msf1']);
23
+ /** True when every byte of `magic` matches `bytes` starting at `offset`. False if `bytes` is too
24
+ * short to hold the whole magic. */
25
+ function matches(bytes, offset, magic) {
26
+ if (bytes.length < offset + magic.length)
27
+ return false;
28
+ for (let i = 0; i < magic.length; i++) {
29
+ if (bytes[offset + i] !== magic[i])
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ /** The four ASCII characters at bytes `offset..offset+3`, or null when the input is too short. Used to
35
+ * read an ISO-BMFF brand code as a string. */
36
+ function ascii4(bytes, offset) {
37
+ if (bytes.length < offset + 4)
38
+ return null;
39
+ return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
40
+ }
41
+ /**
42
+ * Detect the MIME type of an image from its leading magic bytes. Reads only the first ~32 bytes and
43
+ * returns the recognized type, or null for an unrecognized magic or an input too short for a given
44
+ * check. This is the server's source of truth for an upload's type; the client-declared type is
45
+ * advisory. Recognizes JPEG, PNG, GIF, WebP, and the AVIF/HEIC ISO-BMFF brands.
46
+ */
47
+ export function sniffMediaType(bytes) {
48
+ // JPEG: starts FF D8 FF.
49
+ if (matches(bytes, 0, [0xff, 0xd8, 0xff]))
50
+ return 'image/jpeg';
51
+ // PNG: the 8-byte signature 89 50 4E 47 0D 0A 1A 0A; the leading 89 50 4E 47 ('.PNG') is enough.
52
+ if (matches(bytes, 0, [0x89, 0x50, 0x4e, 0x47]))
53
+ return 'image/png';
54
+ // GIF: 'GIF8' (the 87a and 89a versions share this prefix).
55
+ if (matches(bytes, 0, [0x47, 0x49, 0x46, 0x38]))
56
+ return 'image/gif';
57
+ // WebP: a RIFF container ('RIFF' at 0..3) whose form type is 'WEBP' at 8..11.
58
+ if (matches(bytes, 0, [0x52, 0x49, 0x46, 0x46]) && matches(bytes, 8, [0x57, 0x45, 0x42, 0x50])) {
59
+ return 'image/webp';
60
+ }
61
+ // AVIF and HEIC are ISO base media format files: an 'ftyp' box tag at bytes 4..7, then the 4-byte
62
+ // major brand at bytes 8..11. A truncated box (no brand bytes) or an unknown brand returns null.
63
+ if (matches(bytes, 4, [0x66, 0x74, 0x79, 0x70])) {
64
+ const brand = ascii4(bytes, 8);
65
+ if (brand !== null) {
66
+ if (AVIF_BRANDS.has(brand))
67
+ return 'image/avif';
68
+ if (HEIC_BRANDS.has(brand))
69
+ return 'image/heic';
70
+ }
71
+ }
72
+ return null;
73
+ }
74
+ /** The bare file extension (no dot) for each sniffed media type the upload path stores. The ext is
75
+ * derived from the server-sniffed type, never the client filename, so the stored key and the
76
+ * delivery extension allow-list always agree. An unmappable type returns null (the upload 415s). */
77
+ const EXT_BY_TYPE = {
78
+ 'image/jpeg': 'jpg',
79
+ 'image/png': 'png',
80
+ 'image/gif': 'gif',
81
+ 'image/webp': 'webp',
82
+ 'image/avif': 'avif',
83
+ };
84
+ /** The storage extension for a sniffed media type, or null for a type the upload path does not store
85
+ * (HEIC, an unknown type). Driven by the sniffed type, so the key's ext is server-owned. */
86
+ export function extForMediaType(type) {
87
+ return EXT_BY_TYPE[type] ?? null;
88
+ }
89
+ /**
90
+ * The engine-level upload deny predicate. Returns true (reject) when the upload is markup a site can
91
+ * never override: a declared type of image/svg+xml, image/svg, text/html, or application/xml, OR a
92
+ * payload whose first non-whitespace byte is `<` (an 0x3C after skipping leading ASCII whitespace).
93
+ * This runs ahead of and independent of any site `allowedTypes`, since SVG and HTML carry active
94
+ * content. The byte check catches a markup payload sent under a permitted declared type.
95
+ */
96
+ export function isDeniedUpload(bytes, declaredType) {
97
+ if (declaredType !== undefined && DENIED_TYPES.has(declaredType.toLowerCase()))
98
+ return true;
99
+ for (let i = 0; i < bytes.length; i++) {
100
+ if (WHITESPACE.has(bytes[i]))
101
+ continue;
102
+ return bytes[i] === LT;
103
+ }
104
+ // An empty or all-whitespace payload has no opening byte to deny here; the type and size gates own it.
105
+ return false;
106
+ }
@@ -0,0 +1,25 @@
1
+ import type { R2Bucket, R2Conditional, R2HTTPMetadata, R2Object, R2ObjectBody, R2Range } from '@cloudflare/workers-types';
2
+ /** The narrow R2 surface the media pipeline uses. The engine depends on this, not on R2Bucket, so the
3
+ * multipart, list, and conditional-read surface R2 also carries never leaks into the media code. */
4
+ export interface MediaStore {
5
+ /** Store bytes under a content-addressed key, with the response HTTP metadata (the content type)
6
+ * and optional custom metadata (the upload stores the full sha256 here, so a short-hash collision
7
+ * is detectable on a later dedup probe). */
8
+ put(key: string, bytes: ArrayBuffer | Uint8Array, httpMetadata?: R2HTTPMetadata, customMetadata?: Record<string, string>): Promise<void>;
9
+ /** The object's metadata, or null when no object lives at the key (the dedup probe). */
10
+ head(key: string): Promise<R2Object | null>;
11
+ /** The object body for streaming to a delivery response, or null when the key is absent. The
12
+ * delivery route passes `onlyIf` and `range` through for conditional and partial reads: an
13
+ * `onlyIf` etag match returns a body-less R2Object (the 304 shape), so the return widens to
14
+ * `R2Object` alongside `R2ObjectBody`. */
15
+ get(key: string, opts?: {
16
+ range?: R2Range;
17
+ onlyIf?: R2Conditional;
18
+ }): Promise<R2ObjectBody | R2Object | null>;
19
+ /** Remove the object at the key. A delete of an absent key is a no-op, the R2 contract. */
20
+ delete(key: string): Promise<void>;
21
+ }
22
+ /** Wrap an R2 bucket binding as a MediaStore. Each method delegates to the binding; put folds the
23
+ * HTTP and custom metadata into R2's options shape and drops the returned R2Object the pipeline does
24
+ * not read. */
25
+ export declare function r2Store(bucket: R2Bucket): MediaStore;
@@ -0,0 +1,16 @@
1
+ /** Wrap an R2 bucket binding as a MediaStore. Each method delegates to the binding; put folds the
2
+ * HTTP and custom metadata into R2's options shape and drops the returned R2Object the pipeline does
3
+ * not read. */
4
+ export function r2Store(bucket) {
5
+ return {
6
+ async put(key, bytes, httpMetadata, customMetadata) {
7
+ const options = httpMetadata || customMetadata
8
+ ? { ...(httpMetadata ? { httpMetadata } : {}), ...(customMetadata ? { customMetadata } : {}) }
9
+ : undefined;
10
+ await bucket.put(key, bytes, options);
11
+ },
12
+ head: (key) => bucket.head(key),
13
+ get: (key, opts) => bucket.get(key, opts),
14
+ delete: (key) => bucket.delete(key),
15
+ };
16
+ }
@@ -0,0 +1,26 @@
1
+ /** A single image variant: the resize and format directives Cloudflare Images applies to the
2
+ * original bytes. Every field is optional. width, height, quality, and fit are emitted only when
3
+ * set; format and gravity always appear, defaulting to auto. */
4
+ export interface VariantSpec {
5
+ /** Target width in pixels. */
6
+ width?: number;
7
+ /** Target height in pixels. */
8
+ height?: number;
9
+ /** Output quality, 1 to 100. */
10
+ quality?: number;
11
+ /** How the image fits the target box. */
12
+ fit?: 'scale-down' | 'contain' | 'cover' | 'crop' | 'pad';
13
+ /** Crop focus, `auto` or `face` or a coordinate string. */
14
+ gravity?: 'auto' | 'face' | string;
15
+ /** Output format, `auto` to let Cloudflare negotiate, or a forced codec. */
16
+ format?: 'auto' | 'webp' | 'avif' | string;
17
+ }
18
+ /** Build the on-demand Cloudflare Images transform URL for a delivery path. The options are
19
+ * comma-joined in the stable order width, height, quality, fit, format, gravity, with width through
20
+ * fit emitted only when the spec sets them and format and gravity always present (defaulting to
21
+ * auto). The publicPath is appended unaltered, so the result is `/cdn-cgi/image/<options><publicPath>`. */
22
+ export declare function variantUrl(publicPath: string, spec: VariantSpec): string;
23
+ /** Build a variant URL from a named preset. Looks up presetName in variants and builds its spec with
24
+ * variantUrl. Throws a cairn:-prefixed error naming the unknown preset when the name is absent, so a
25
+ * typo in a preset name fails loudly rather than silently rendering an unsized image. */
26
+ export declare function presetUrl(publicPath: string, presetName: string, variants: Record<string, VariantSpec>): string;
@@ -0,0 +1,38 @@
1
+ // cairn-cms: the Cloudflare Images transform URL. A delivery path names the original bytes; an
2
+ // on-demand variant is that path prefixed with `/cdn-cgi/image/<options>/`, where the options are a
3
+ // comma-joined list of resize and format directives Cloudflare reads at the edge. This module owns
4
+ // the option encoding and the stable option order, so the same spec always builds the same URL and
5
+ // a CDN cache keys on it cleanly. The delivery path is appended unaltered, since it already carries
6
+ // its own leading slash.
7
+ /** Build the on-demand Cloudflare Images transform URL for a delivery path. The options are
8
+ * comma-joined in the stable order width, height, quality, fit, format, gravity, with width through
9
+ * fit emitted only when the spec sets them and format and gravity always present (defaulting to
10
+ * auto). The publicPath is appended unaltered, so the result is `/cdn-cgi/image/<options><publicPath>`. */
11
+ export function variantUrl(publicPath, spec) {
12
+ const options = [];
13
+ if (spec.width !== undefined)
14
+ options.push(`width=${spec.width}`);
15
+ if (spec.height !== undefined)
16
+ options.push(`height=${spec.height}`);
17
+ if (spec.quality !== undefined)
18
+ options.push(`quality=${spec.quality}`);
19
+ if (spec.fit !== undefined)
20
+ options.push(`fit=${spec.fit}`);
21
+ options.push(`format=${spec.format ?? 'auto'}`);
22
+ options.push(`gravity=${spec.gravity ?? 'auto'}`);
23
+ // The source must be its own path segment after the options, so it needs a leading slash;
24
+ // Cloudflare reads a slashless join as a malformed options list. publicPath carries one, but this
25
+ // guards a caller that passes a relative path from fusing the options and the source.
26
+ const source = publicPath.startsWith('/') ? publicPath : `/${publicPath}`;
27
+ return `/cdn-cgi/image/${options.join(',')}${source}`;
28
+ }
29
+ /** Build a variant URL from a named preset. Looks up presetName in variants and builds its spec with
30
+ * variantUrl. Throws a cairn:-prefixed error naming the unknown preset when the name is absent, so a
31
+ * typo in a preset name fails loudly rather than silently rendering an unsized image. */
32
+ export function presetUrl(publicPath, presetName, variants) {
33
+ const spec = variants[presetName];
34
+ if (spec === undefined) {
35
+ throw new Error(`cairn: unknown image variant preset "${presetName}"`);
36
+ }
37
+ return variantUrl(publicPath, spec);
38
+ }
@@ -0,0 +1,48 @@
1
+ import type { ConceptDescriptor } from '../content/types.js';
2
+ import type { RepoRef } from '../github/types.js';
3
+ import type { Manifest } from '../content/manifest.js';
4
+ /** Where a reference lives: the published corpus on main, or a named open edit branch. */
5
+ export type UsageOrigin = {
6
+ kind: 'published';
7
+ } | {
8
+ kind: 'branch';
9
+ branch: string;
10
+ };
11
+ /** One entry that references an asset, in a shape the screen links and groups by. */
12
+ export interface UsageEntry {
13
+ /** The concept id, e.g. "posts". */
14
+ concept: string;
15
+ /** The entry id (its filename stem). */
16
+ id: string;
17
+ /** The entry title for display, from the manifest (published) or frontmatter (branch). */
18
+ title: string;
19
+ /** The public permalink, present for a published entry (carried from the manifest). */
20
+ permalink?: string;
21
+ /** Published vs the cairn/* branch the edit lives on. */
22
+ origin: UsageOrigin;
23
+ }
24
+ /** Content hash to the distinct entries that reference it. A hash with no row is "no references
25
+ * found" (see the raw-HTML caveat above), never a proven orphan. */
26
+ export type UsageIndex = Map<string, UsageEntry[]>;
27
+ /** Build options. `branches` lets a caller that already listed the open cairn/* branches pass them
28
+ * in so the index does not list them a second time (the load path lists once for the media-union).
29
+ * `strict` flips the per-branch read from degrade-and-skip to fail-closed: a delete gate must not
30
+ * treat a transient branch-read failure as an absent reference, so it rethrows instead. */
31
+ export interface BuildUsageOptions {
32
+ /** The open cairn/* branch names, already listed. When present the index skips its own listing. */
33
+ branches?: string[];
34
+ /** When true a branch read that throws rejects the whole build, so the caller can fail closed. */
35
+ strict?: boolean;
36
+ }
37
+ /**
38
+ * Build the hash-keyed usage index over main (from the manifest's per-entry mediaRefs) plus every
39
+ * open cairn/* branch (parsed from its edited markdown).
40
+ *
41
+ * By default a single branch read that throws degrades that one branch and is skipped, the way the
42
+ * admin loaders degrade a failed read, rather than sinking the whole screen. That tolerance is right
43
+ * for the Library DISPLAY, but wrong for the delete gate: a transient branch-read failure would make
44
+ * a still-referenced asset look orphaned. Pass `strict: true` (the delete path) to rethrow a branch
45
+ * failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
46
+ * (the load path lists once for the media-union) rather than listing them a second time.
47
+ */
48
+ export declare function buildUsageIndex(repo: RepoRef, token: string, concepts: ConceptDescriptor[], manifest: Manifest, opts?: BuildUsageOptions): Promise<UsageIndex>;
@@ -0,0 +1,90 @@
1
+ import { listBranches } from '../github/branches.js';
2
+ import { readRaw } from '../github/repo.js';
3
+ import { PENDING_PREFIX, parsePendingBranch } from '../content/pending.js';
4
+ import { findConcept } from '../content/concepts.js';
5
+ import { isValidId, filenameFromId } from '../content/ids.js';
6
+ import { parseMarkdown } from '../content/frontmatter.js';
7
+ import { extractMediaRefs } from '../content/media-refs.js';
8
+ /** Append a row under its hash, creating the bucket on first use. */
9
+ function push(index, hash, entry) {
10
+ const rows = index.get(hash);
11
+ if (rows)
12
+ rows.push(entry);
13
+ else
14
+ index.set(hash, [entry]);
15
+ }
16
+ /**
17
+ * Build the hash-keyed usage index over main (from the manifest's per-entry mediaRefs) plus every
18
+ * open cairn/* branch (parsed from its edited markdown).
19
+ *
20
+ * By default a single branch read that throws degrades that one branch and is skipped, the way the
21
+ * admin loaders degrade a failed read, rather than sinking the whole screen. That tolerance is right
22
+ * for the Library DISPLAY, but wrong for the delete gate: a transient branch-read failure would make
23
+ * a still-referenced asset look orphaned. Pass `strict: true` (the delete path) to rethrow a branch
24
+ * failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
25
+ * (the load path lists once for the media-union) rather than listing them a second time.
26
+ */
27
+ export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}) {
28
+ const index = new Map();
29
+ // The main arm: the manifest already carries each entry's mediaRefs, so this is a pure reverse
30
+ // map with no per-file read.
31
+ for (const entry of manifest.entries) {
32
+ for (const hash of entry.mediaRefs ?? []) {
33
+ push(index, hash, {
34
+ concept: entry.concept,
35
+ id: entry.id,
36
+ title: entry.title,
37
+ permalink: entry.permalink,
38
+ origin: { kind: 'published' },
39
+ });
40
+ }
41
+ }
42
+ // The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
43
+ // branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
44
+ const names = opts.branches ?? (await listBranches(repo, PENDING_PREFIX, token));
45
+ // Read the branches in parallel rather than one at a time, so the latency floor is one round trip
46
+ // instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
47
+ // the load path's media-union batch each stay under the limit; do NOT merge the two into one
48
+ // wider Promise.all, since the combined fan-out would queue behind that throttle.
49
+ const perBranch = await Promise.all(names.map(async (name) => {
50
+ // Resolve the branch name to a configured entry with the same guard the branch tooling uses: a
51
+ // malformed name, an id that fails the slug rule (entry paths are built from it, so this is the
52
+ // path confinement), or a concept this site does not configure is skipped, no read attempted.
53
+ const ref = parsePendingBranch(name);
54
+ if (!ref || !isValidId(ref.id))
55
+ return [];
56
+ const concept = findConcept(concepts, ref.concept);
57
+ if (!concept)
58
+ return [];
59
+ const path = `${concept.dir}/${filenameFromId(ref.id)}`;
60
+ try {
61
+ const raw = await readRaw({ ...repo, branch: name }, path, token);
62
+ if (raw === null)
63
+ return []; // The file is absent on the branch: nothing to extract.
64
+ const { frontmatter, body } = parseMarkdown(raw);
65
+ const fmTitle = frontmatter.title;
66
+ const title = typeof fmTitle === 'string' && fmTitle.trim() ? fmTitle : ref.id;
67
+ const rows = [];
68
+ for (const hash of extractMediaRefs(frontmatter, body, concept.fields)) {
69
+ rows.push({
70
+ hash,
71
+ entry: { concept: concept.id, id: ref.id, title, origin: { kind: 'branch', branch: name } },
72
+ });
73
+ }
74
+ return rows;
75
+ }
76
+ catch (err) {
77
+ // In strict mode a branch failure fails the whole build so the delete gate can fail closed;
78
+ // otherwise degrade this one branch rather than sinking the screen.
79
+ if (opts.strict)
80
+ throw err;
81
+ return [];
82
+ }
83
+ }));
84
+ // Fold the per-branch rows back in, preserving the branch order so the index reads stably.
85
+ for (const rows of perBranch) {
86
+ for (const { hash, entry } of rows)
87
+ push(index, hash, entry);
88
+ }
89
+ return index;
90
+ }