@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.
- package/CHANGELOG.md +96 -0
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/EditPage.svelte +347 -7
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +283 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +901 -9
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +12 -0
- package/dist/components/markdown-directives.js +42 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +7 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/EditPage.svelte +347 -7
- package/src/lib/components/MarkdownEditor.svelte +283 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +46 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +5 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
package/src/lib/delivery/seo.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface SeoInput {
|
|
|
13
13
|
modified?: string;
|
|
14
14
|
feeds?: { rss?: string; json?: string };
|
|
15
15
|
image?: string;
|
|
16
|
+
/** The social image's alt text, emitted as twitter:image:alt. Used only when image is also set. */
|
|
17
|
+
imageAlt?: string;
|
|
16
18
|
/** A robots meta directive, e.g. "noindex, nofollow". Omitted from the head when absent. */
|
|
17
19
|
robots?: string;
|
|
18
20
|
/** Author name, emitted as article:author for the article type. */
|
|
@@ -42,6 +44,9 @@ export function buildSeoMeta(input: SeoInput): SeoMeta {
|
|
|
42
44
|
if (input.image) {
|
|
43
45
|
meta.push({ property: 'og:image', content: input.image });
|
|
44
46
|
meta.push({ name: 'twitter:image', content: input.image });
|
|
47
|
+
if (input.imageAlt) {
|
|
48
|
+
meta.push({ name: 'twitter:image:alt', content: input.imageAlt });
|
|
49
|
+
}
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
if (input.robots) {
|
|
@@ -27,6 +27,28 @@ export const configBindings: DoctorCheck = {
|
|
|
27
27
|
},
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
// The R2 media bucket is never added to the hard config.bindings check, so a no-media site never
|
|
31
|
+
// fails on a missing media binding (decision 9). This conditional runs only when the adapter
|
|
32
|
+
// declares assets, matching the adapter's bucketBinding against wrangler's r2_buckets. It reuses the
|
|
33
|
+
// config.bindings-missing condition rather than registering a new one, so the readiness count holds.
|
|
34
|
+
export const configMediaBucket: DoctorCheck = {
|
|
35
|
+
id: 'config.media-bucket',
|
|
36
|
+
conditionId: 'config.bindings-missing',
|
|
37
|
+
title: 'Media bucket binding',
|
|
38
|
+
async run(ctx: DoctorContext): Promise<CheckResult> {
|
|
39
|
+
const binding = ctx.mediaBucketBinding;
|
|
40
|
+
if (binding === undefined) return skip('no media assets configured');
|
|
41
|
+
const facts = await readWranglerConfig(ctx.readFile);
|
|
42
|
+
if (facts === null) return NO_WRANGLER;
|
|
43
|
+
if (!facts.r2Buckets.includes(binding)) {
|
|
44
|
+
return fail(
|
|
45
|
+
`adapter declares media bucket ${binding} but no matching r2_buckets binding is in wrangler`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return pass(`media bucket ${binding} is declared`);
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
30
52
|
export const configObservability: DoctorCheck = {
|
|
31
53
|
id: 'config.observability',
|
|
32
54
|
conditionId: 'config.observability-off',
|
package/src/lib/doctor/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import type { DoctorCheck, DoctorContext } from './types.js';
|
|
5
5
|
import {
|
|
6
6
|
configBindings,
|
|
7
|
+
configMediaBucket,
|
|
7
8
|
configObservability,
|
|
8
9
|
configCsrfDisable,
|
|
9
10
|
configSiteConfig,
|
|
@@ -93,8 +94,14 @@ export function contextFromEnv(
|
|
|
93
94
|
* Vite resolution and the wrangler config's account_id. Each runs only when an input it feeds
|
|
94
95
|
* is still missing, so a doctor run with full flags touches neither. */
|
|
95
96
|
export interface DerivationSources {
|
|
96
|
-
/** Returns { owner, repo, from } off the adapter, or null when nothing is
|
|
97
|
-
|
|
97
|
+
/** Returns { owner, repo, from, mediaBucketBinding } off the adapter, or null when nothing is
|
|
98
|
+
* derivable. */
|
|
99
|
+
adapterFacts: () => Promise<{
|
|
100
|
+
owner?: string;
|
|
101
|
+
repo?: string;
|
|
102
|
+
from?: string;
|
|
103
|
+
mediaBucketBinding?: string;
|
|
104
|
+
} | null>;
|
|
98
105
|
/** Returns the wrangler config's account_id, or undefined when none is declared. */
|
|
99
106
|
wranglerAccountId: () => Promise<string | undefined>;
|
|
100
107
|
}
|
|
@@ -112,7 +119,14 @@ export async function deriveMissingInputs(
|
|
|
112
119
|
sources: DerivationSources
|
|
113
120
|
): Promise<Omit<DoctorContext, 'fetch' | 'readFile'>> {
|
|
114
121
|
const out = { ...ctx };
|
|
115
|
-
|
|
122
|
+
// The adapter read also carries the media bucket binding, which has no env source, so it runs
|
|
123
|
+
// when from, repo, or the media binding is still missing. A failure leaves each input absent so
|
|
124
|
+
// its check skips with the usual remediation rather than the doctor crashing.
|
|
125
|
+
if (
|
|
126
|
+
out.from === undefined ||
|
|
127
|
+
out.repo === undefined ||
|
|
128
|
+
out.mediaBucketBinding === undefined
|
|
129
|
+
) {
|
|
116
130
|
const facts = await sources.adapterFacts().catch(() => null);
|
|
117
131
|
if (out.from === undefined && typeof facts?.from === 'string') {
|
|
118
132
|
out.from = facts.from;
|
|
@@ -124,6 +138,9 @@ export async function deriveMissingInputs(
|
|
|
124
138
|
) {
|
|
125
139
|
out.repo = `${facts.owner}/${facts.repo}`;
|
|
126
140
|
}
|
|
141
|
+
if (out.mediaBucketBinding === undefined && typeof facts?.mediaBucketBinding === 'string') {
|
|
142
|
+
out.mediaBucketBinding = facts.mediaBucketBinding;
|
|
143
|
+
}
|
|
127
144
|
}
|
|
128
145
|
if (out.cfAccountId === undefined) {
|
|
129
146
|
const accountId = await sources.wranglerAccountId().catch(() => undefined);
|
|
@@ -140,6 +157,7 @@ export async function deriveMissingInputs(
|
|
|
140
157
|
export function defaultChecks(): DoctorCheck[] {
|
|
141
158
|
return [
|
|
142
159
|
configBindings,
|
|
160
|
+
configMediaBucket,
|
|
143
161
|
configObservability,
|
|
144
162
|
configCsrfDisable,
|
|
145
163
|
configSiteConfig,
|
package/src/lib/doctor/types.ts
CHANGED
|
@@ -45,6 +45,9 @@ export interface DoctorContext {
|
|
|
45
45
|
cfAccountId?: string;
|
|
46
46
|
/** PUBLIC_ORIGIN, the env fallback when the wrangler vars carry none. */
|
|
47
47
|
publicOrigin?: string;
|
|
48
|
+
/** The adapter's media bucket binding (cairn.assets.bucketBinding), derived off the adapter.
|
|
49
|
+
* Undefined when the site declares no media assets; the media-bucket check skips in that case. */
|
|
50
|
+
mediaBucketBinding?: string;
|
|
48
51
|
/** GITHUB_APP_ID / GITHUB_APP_INSTALLATION_ID / GITHUB_APP_PRIVATE_KEY_B64. */
|
|
49
52
|
github?: { appId: string; installationId: string; privateKeyB64: string };
|
|
50
53
|
/** Injected fetch for tests; defaults to global fetch. */
|
|
@@ -16,6 +16,9 @@ export interface WranglerFacts {
|
|
|
16
16
|
publicOrigin?: string;
|
|
17
17
|
/** The top-level account_id, when declared; a fallback for CLOUDFLARE_ACCOUNT_ID. */
|
|
18
18
|
accountId?: string;
|
|
19
|
+
/** The declared r2_buckets binding names; the conditional media check matches the adapter's
|
|
20
|
+
* bucketBinding against this. Not part of the hard config.bindings check (decision 9). */
|
|
21
|
+
r2Buckets: string[];
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
export async function readWranglerConfig(
|
|
@@ -89,10 +92,19 @@ function factsFromJsonc(text: string): WranglerFacts {
|
|
|
89
92
|
typeof entry === 'object' && entry !== null && (entry as { binding?: unknown }).binding === 'AUTH_DB'
|
|
90
93
|
);
|
|
91
94
|
const observability = config.observability as { enabled?: unknown } | undefined;
|
|
95
|
+
const r2 = Array.isArray(config.r2_buckets) ? config.r2_buckets : [];
|
|
96
|
+
const r2Buckets = r2
|
|
97
|
+
.map((entry) =>
|
|
98
|
+
typeof entry === 'object' && entry !== null && typeof (entry as { binding?: unknown }).binding === 'string'
|
|
99
|
+
? (entry as { binding: string }).binding
|
|
100
|
+
: undefined
|
|
101
|
+
)
|
|
102
|
+
.filter((binding): binding is string => binding !== undefined);
|
|
92
103
|
const facts: WranglerFacts = {
|
|
93
104
|
hasEmailBinding,
|
|
94
105
|
hasAuthDb: authDb !== undefined,
|
|
95
106
|
observabilityEnabled: observability?.enabled === true,
|
|
107
|
+
r2Buckets,
|
|
96
108
|
};
|
|
97
109
|
if (typeof authDb?.database_id === 'string') facts.authDbId = authDb.database_id;
|
|
98
110
|
const vars = config.vars as { PUBLIC_ORIGIN?: unknown } | undefined;
|
|
@@ -110,10 +122,12 @@ function factsFromToml(text: string): WranglerFacts {
|
|
|
110
122
|
hasEmailBinding: false,
|
|
111
123
|
hasAuthDb: false,
|
|
112
124
|
observabilityEnabled: false,
|
|
125
|
+
r2Buckets: [],
|
|
113
126
|
};
|
|
114
127
|
let section = '';
|
|
115
128
|
let d1Binding: string | undefined;
|
|
116
129
|
let d1Id: string | undefined;
|
|
130
|
+
let r2Binding: string | undefined;
|
|
117
131
|
|
|
118
132
|
const flushD1 = () => {
|
|
119
133
|
if (d1Binding === 'AUTH_DB') {
|
|
@@ -124,10 +138,16 @@ function factsFromToml(text: string): WranglerFacts {
|
|
|
124
138
|
d1Id = undefined;
|
|
125
139
|
};
|
|
126
140
|
|
|
141
|
+
const flushR2 = () => {
|
|
142
|
+
if (r2Binding !== undefined) facts.r2Buckets.push(r2Binding);
|
|
143
|
+
r2Binding = undefined;
|
|
144
|
+
};
|
|
145
|
+
|
|
127
146
|
for (const line of text.split('\n')) {
|
|
128
147
|
const header = line.match(/^\s*(\[\[?[\w.]+\]?\])\s*(?:#.*)?$/);
|
|
129
148
|
if (header) {
|
|
130
149
|
flushD1();
|
|
150
|
+
flushR2();
|
|
131
151
|
section = header[1];
|
|
132
152
|
continue;
|
|
133
153
|
}
|
|
@@ -140,6 +160,8 @@ function factsFromToml(text: string): WranglerFacts {
|
|
|
140
160
|
} else if (section === '[[d1_databases]]') {
|
|
141
161
|
if (key === 'binding') d1Binding = str;
|
|
142
162
|
if (key === 'database_id') d1Id = str;
|
|
163
|
+
} else if (section === '[[r2_buckets]]') {
|
|
164
|
+
if (key === 'binding' && str !== undefined) r2Binding = str;
|
|
143
165
|
} else if (section === '[observability]' && key === 'enabled' && value.startsWith('true')) {
|
|
144
166
|
facts.observabilityEnabled = true;
|
|
145
167
|
} else if (section === '[vars]' && key === 'PUBLIC_ORIGIN' && str !== undefined) {
|
|
@@ -149,5 +171,6 @@ function factsFromToml(text: string): WranglerFacts {
|
|
|
149
171
|
}
|
|
150
172
|
}
|
|
151
173
|
flushD1();
|
|
174
|
+
flushR2();
|
|
152
175
|
return facts;
|
|
153
176
|
}
|
package/src/lib/env.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { D1Database } from '@cloudflare/workers-types';
|
|
2
2
|
import { CairnError } from './diagnostics/index.js';
|
|
3
|
+
import type { DeliveryBucket } from './media/delivery-bucket.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Returns the site's public origin from configuration.
|
|
@@ -49,3 +50,30 @@ export function requireDb(env: { AUTH_DB?: D1Database }): D1Database {
|
|
|
49
50
|
}
|
|
50
51
|
return env.AUTH_DB;
|
|
51
52
|
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Returns the media R2 bucket named by `bindingName`, or throws a clear error when a site has not
|
|
56
|
+
* wired it. The binding name is config-derived (the adapter's `bucketBinding`), so it is read off
|
|
57
|
+
* `env` dynamically rather than as a fixed key.
|
|
58
|
+
*
|
|
59
|
+
* The return type is the narrow structural `DeliveryBucket` seam, never the `@cloudflare/workers-types`
|
|
60
|
+
* `R2Bucket`, so no workers-types name reaches the public `.d.ts` (the delivery route is a public
|
|
61
|
+
* export and that package is only a devDependency). The cast through `unknown` is sound because the
|
|
62
|
+
* seam models a subset of the real R2 bucket API.
|
|
63
|
+
*
|
|
64
|
+
* The guard rejects a value wired to the wrong kind of binding too (a KV namespace, a string var),
|
|
65
|
+
* which is truthy but carries no callable `get`. Without that check the cast would succeed and the
|
|
66
|
+
* first `bucket.get(...)` would throw an uncaught 500 rather than the drained 503 a missing binding
|
|
67
|
+
* earns.
|
|
68
|
+
*
|
|
69
|
+
* @throws CairnError (`config.bindings-missing`) when the named binding is absent or not an R2 bucket.
|
|
70
|
+
*/
|
|
71
|
+
export function requireBucket(env: Record<string, unknown>, bindingName: string): DeliveryBucket {
|
|
72
|
+
const bucket = env[bindingName];
|
|
73
|
+
if (!bucket || typeof (bucket as { get?: unknown }).get !== 'function') {
|
|
74
|
+
throw new CairnError('config.bindings-missing', {
|
|
75
|
+
message: `${bindingName} binding is not configured`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
return bucket as unknown as DeliveryBucket;
|
|
79
|
+
}
|
package/src/lib/index.ts
CHANGED
package/src/lib/log/events.ts
CHANGED
|
@@ -15,4 +15,11 @@ export type CairnLogEvent =
|
|
|
15
15
|
| 'entry.discarded'
|
|
16
16
|
| 'publish.failed'
|
|
17
17
|
| 'github.unreachable'
|
|
18
|
-
| 'guard.rejected'
|
|
18
|
+
| 'guard.rejected'
|
|
19
|
+
| 'media.uploaded'
|
|
20
|
+
| 'media.upload_failed'
|
|
21
|
+
| 'media.delivery_failed'
|
|
22
|
+
| 'media.orphan_reconcile'
|
|
23
|
+
| 'media.resolve_missing'
|
|
24
|
+
| 'media.deleted'
|
|
25
|
+
| 'media.delete_blocked';
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// cairn-cms: media config normalization. A site declares its media setup as an AssetConfig on the
|
|
2
|
+
// adapter; this module validates that block and resolves it into the engine-internal
|
|
3
|
+
// ResolvedAssetConfig the upload, storage, and delivery paths read. An absent block means media is
|
|
4
|
+
// off, so the resolved value carries an `enabled` discriminant rather than throwing. The named
|
|
5
|
+
// variants merge over the built-in presets, so a caller preset of the same name wins. This module
|
|
6
|
+
// is engine-internal; later phases call normalizeAssets, but the contract surface stays AssetConfig.
|
|
7
|
+
import type { AssetConfig } from '../content/types.js';
|
|
8
|
+
import type { VariantSpec } from './transform-url.js';
|
|
9
|
+
|
|
10
|
+
/** The resolved media config the engine serves from. When a site declares no assets block, media is
|
|
11
|
+
* off and the value is `{ enabled: false }`; otherwise every field is filled from the AssetConfig
|
|
12
|
+
* or its default. */
|
|
13
|
+
export type ResolvedAssetConfig =
|
|
14
|
+
| { enabled: false }
|
|
15
|
+
| {
|
|
16
|
+
enabled: true;
|
|
17
|
+
bucketBinding: string;
|
|
18
|
+
publicBase: string;
|
|
19
|
+
urlForm: 'slug' | 'opaque';
|
|
20
|
+
maxUploadBytes: number;
|
|
21
|
+
allowedTypes: string[];
|
|
22
|
+
variants: Record<string, VariantSpec>;
|
|
23
|
+
/** Whether Cloudflare Image Transformations are enabled for the zone. With it false, the media
|
|
24
|
+
* resolver serves the bare full-size delivery path and ignores any preset. */
|
|
25
|
+
transformations: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The default delivery base path when the AssetConfig omits one. */
|
|
29
|
+
const DEFAULT_PUBLIC_BASE = '/media';
|
|
30
|
+
/** The default maximum upload size, 25 MB. */
|
|
31
|
+
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
32
|
+
/** The default accepted upload MIME types: the common web image formats. */
|
|
33
|
+
const DEFAULT_ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/avif'];
|
|
34
|
+
|
|
35
|
+
/** The built-in named transform presets. A site's `variants` merge over these, so a caller preset of
|
|
36
|
+
* the same name overrides the built-in. */
|
|
37
|
+
const BUILT_IN_PRESETS: Record<string, VariantSpec> = {
|
|
38
|
+
thumb: { width: 320, height: 320, fit: 'cover' },
|
|
39
|
+
inline: { width: 800 },
|
|
40
|
+
card: { width: 640, height: 400, fit: 'cover' },
|
|
41
|
+
hero: { width: 1600, height: 900, fit: 'cover' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** The fit values Cloudflare Images accepts. A variant whose fit is set to anything else is rejected. */
|
|
45
|
+
const FIT_VALUES: ReadonlySet<string> = new Set(['scale-down', 'contain', 'cover', 'crop', 'pad']);
|
|
46
|
+
/** The named gravity keywords Cloudflare Images accepts. A gravity is also valid as a coordinate
|
|
47
|
+
* string; everything else is rejected. */
|
|
48
|
+
const GRAVITY_KEYWORDS: ReadonlySet<string> = new Set([
|
|
49
|
+
'auto',
|
|
50
|
+
'face',
|
|
51
|
+
'left',
|
|
52
|
+
'right',
|
|
53
|
+
'top',
|
|
54
|
+
'bottom',
|
|
55
|
+
'center',
|
|
56
|
+
]);
|
|
57
|
+
/** A gravity coordinate string, e.g. "0.5x0.5". */
|
|
58
|
+
const GRAVITY_COORD_RE = /^\d+(\.\d+)?x\d+(\.\d+)?$/;
|
|
59
|
+
|
|
60
|
+
/** Validate one variant's fit and gravity, throwing a cairn:-prefixed error naming the offending
|
|
61
|
+
* preset and value. The type system collapses VariantSpec.gravity to string, so the gravity check
|
|
62
|
+
* is the only guard against a bogus value reaching the transform URL. */
|
|
63
|
+
function validateVariant(name: string, spec: VariantSpec): void {
|
|
64
|
+
if (spec.fit !== undefined && !FIT_VALUES.has(spec.fit)) {
|
|
65
|
+
throw new Error(`cairn: media variant "${name}" has an unknown fit "${spec.fit}"`);
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
spec.gravity !== undefined &&
|
|
69
|
+
!GRAVITY_KEYWORDS.has(spec.gravity) &&
|
|
70
|
+
!GRAVITY_COORD_RE.test(spec.gravity)
|
|
71
|
+
) {
|
|
72
|
+
throw new Error(`cairn: media variant "${name}" has an unknown gravity "${spec.gravity}"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Validate a site's AssetConfig and resolve it into a ResolvedAssetConfig. An undefined block leaves
|
|
77
|
+
* media off and returns `{ enabled: false }` rather than throwing. A declared block must name its R2
|
|
78
|
+
* bucket and carry a known urlForm and valid variant fit and gravity values; each failure throws a
|
|
79
|
+
* cairn:-prefixed error. The named variants merge over the built-in presets. */
|
|
80
|
+
export function normalizeAssets(assets: AssetConfig | undefined): ResolvedAssetConfig {
|
|
81
|
+
if (assets === undefined) return { enabled: false };
|
|
82
|
+
|
|
83
|
+
if (!assets.bucketBinding) {
|
|
84
|
+
throw new Error('cairn: a media assets block must name its R2 bucket binding');
|
|
85
|
+
}
|
|
86
|
+
if (assets.urlForm !== undefined && assets.urlForm !== 'slug' && assets.urlForm !== 'opaque') {
|
|
87
|
+
throw new Error(`cairn: media urlForm must be "slug" or "opaque", got "${assets.urlForm}"`);
|
|
88
|
+
}
|
|
89
|
+
for (const [name, spec] of Object.entries(assets.variants ?? {})) {
|
|
90
|
+
validateVariant(name, spec);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
enabled: true,
|
|
95
|
+
bucketBinding: assets.bucketBinding,
|
|
96
|
+
publicBase: assets.publicBase ?? DEFAULT_PUBLIC_BASE,
|
|
97
|
+
urlForm: assets.urlForm ?? 'slug',
|
|
98
|
+
maxUploadBytes: assets.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES,
|
|
99
|
+
allowedTypes: assets.allowedTypes ?? DEFAULT_ALLOWED_TYPES,
|
|
100
|
+
variants: { ...BUILT_IN_PRESETS, ...(assets.variants ?? {}) },
|
|
101
|
+
transformations: assets.transformations ?? false,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// A narrow structural seam over the R2 bucket, modelling only what the delivery route reads.
|
|
2
|
+
//
|
|
3
|
+
// The route needs conditional and ranged gets, which the narrow MediaStore seam cannot express, so
|
|
4
|
+
// it talks to the raw bucket. It must not name any `@cloudflare/workers-types` type (`R2Bucket`,
|
|
5
|
+
// `R2Object`, `R2ObjectBody`, `R2HTTPMetadata`): that package is a devDependency the engine builds
|
|
6
|
+
// against but a consumer does not have, so any such name in a public `.d.ts` would break a consumer
|
|
7
|
+
// build that lacks `skipLibCheck`. `Headers`, `ReadableStream`, and `Response` are web globals, not
|
|
8
|
+
// workers-types, so they are safe on the public surface. `requireBucket` casts the real R2 binding
|
|
9
|
+
// to `DeliveryBucket` through `unknown`; the shapes below are a structural subset of the real R2 API.
|
|
10
|
+
|
|
11
|
+
/** A stored object without its body: the shape an `If-None-Match` hit or a metadata read returns. */
|
|
12
|
+
export interface DeliveryObject {
|
|
13
|
+
/** Writes the stored HTTP metadata (Content-Type, Cache-Control, and so on) onto `headers`. */
|
|
14
|
+
writeHttpMetadata(headers: Headers): void;
|
|
15
|
+
/** The strong validator R2 stored for the bytes, set as the response `ETag`. */
|
|
16
|
+
httpEtag: string;
|
|
17
|
+
/** The full object size in bytes, the denominator of a `Content-Range`. */
|
|
18
|
+
size: number;
|
|
19
|
+
/** Present only on a ranged read: the served window, used to build the `Content-Range`. R2 fills
|
|
20
|
+
* both fields for a `bytes=start-end` request; each is typed optional so the route derives the
|
|
21
|
+
* range bounds defensively against `size`. */
|
|
22
|
+
range?: { offset?: number; length?: number };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stored object with its readable body, the shape a full or ranged read returns. */
|
|
26
|
+
export interface DeliveryObjectBody extends DeliveryObject {
|
|
27
|
+
body: ReadableStream;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The bucket surface the delivery route reads: a single conditional, optionally ranged, get. */
|
|
31
|
+
export interface DeliveryBucket {
|
|
32
|
+
get(
|
|
33
|
+
key: string,
|
|
34
|
+
opts?: {
|
|
35
|
+
/** R2 reads `If-None-Match`/`If-Match` from a passed `Headers`; the route forwards the request's. */
|
|
36
|
+
onlyIf?: { etagDoesNotMatch?: string } | Headers;
|
|
37
|
+
/** The byte window to serve; the route parses it from the request `Range` header. */
|
|
38
|
+
range?: { offset?: number; length?: number } | Headers;
|
|
39
|
+
},
|
|
40
|
+
): Promise<DeliveryObjectBody | DeliveryObject | null>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// cairn-cms: the node-safe `/media` public barrel. It re-exports only the pure media surface a site
|
|
2
|
+
// reaches outside the SvelteKit runtime: the config normalizer, the manifest functions, the naming
|
|
3
|
+
// and transform-URL helpers, the reference codec, and the render resolver. Nothing here pulls
|
|
4
|
+
// `@sveltejs/kit` or `@cloudflare/workers-types` into the module graph, so a plain-Node tool or a
|
|
5
|
+
// build step can import it. The R2-touching pieces (`store.ts`, `delivery-bucket.ts`) and the
|
|
6
|
+
// delivery-route factory and `requireBucket` stay on `/sveltekit`, off this surface, so the public
|
|
7
|
+
// `.d.ts` for `/media` names no kit or workers-types type.
|
|
8
|
+
export { normalizeAssets, type ResolvedAssetConfig } from './config.js';
|
|
9
|
+
export {
|
|
10
|
+
parseMediaManifest,
|
|
11
|
+
findByHash,
|
|
12
|
+
upsertMediaEntry,
|
|
13
|
+
removeMediaEntry,
|
|
14
|
+
serializeMediaManifest,
|
|
15
|
+
parseMediaEntries,
|
|
16
|
+
type MediaEntry,
|
|
17
|
+
type MediaManifest,
|
|
18
|
+
} from './manifest.js';
|
|
19
|
+
export { hashBytes, shortHash, slugifyFilename, r2Key, publicPath } from './naming.js';
|
|
20
|
+
export { presetUrl, variantUrl, type VariantSpec } from './transform-url.js';
|
|
21
|
+
export { parseMediaToken, mediaToken, type MediaRef } from './reference.js';
|
|
22
|
+
export { makeMediaResolver, manifestMediaResolver, type MediaResolve } from '../render/resolve-media.js';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// cairn-cms: the picker's human layer for one stored asset, the projection EditData carries on
|
|
2
|
+
// `mediaLibrary`. It is the media manifest's display facts (no sha256, no original filename) keyed by
|
|
3
|
+
// the 16-hex content hash, the shape the insert popover, the combobox picker, the editor's source
|
|
4
|
+
// decoration, and the Library screen all read.
|
|
5
|
+
//
|
|
6
|
+
// It lives in its own node-safe module (no @codemirror, no DOM, no @sveltejs/kit) so the consumers
|
|
7
|
+
// share one declaration: editLoad and mediaLibraryLoad both project it through `mediaLibraryEntry`,
|
|
8
|
+
// MediaPicker and the insert popover type their library prop with it, and the editor-media
|
|
9
|
+
// decoration resolves a token against it. The editor-boundary test bars a static import from
|
|
10
|
+
// editor-media.ts (it pulls @codemirror), so the shared type cannot be sourced from there; this
|
|
11
|
+
// module is its neutral home. It is internal, exported from no package subpath, so it carries no
|
|
12
|
+
// reference page.
|
|
13
|
+
import type { MediaEntry } from './manifest.js';
|
|
14
|
+
|
|
15
|
+
/** One stored asset in the picker's projected library, keyed elsewhere by the 16-hex content hash. */
|
|
16
|
+
export interface MediaLibraryEntry {
|
|
17
|
+
/** The 16-hex content-hash prefix that names the bytes. */
|
|
18
|
+
hash: string;
|
|
19
|
+
/** The cosmetic display slug in the media: token and the delivery path. */
|
|
20
|
+
slug: string;
|
|
21
|
+
/** The bare file extension (no dot), for example `webp`. */
|
|
22
|
+
ext: string;
|
|
23
|
+
/** The stored MIME type, for example `image/webp`; its top-level part drives the type facet. */
|
|
24
|
+
contentType: string;
|
|
25
|
+
/** The editable human name shown on the row. */
|
|
26
|
+
displayName: string;
|
|
27
|
+
/** The manifest alt, prefilled into a new placement; empty is the needs-alt signal. */
|
|
28
|
+
alt: string;
|
|
29
|
+
/** The pixel width, or null when the manifest carries none. */
|
|
30
|
+
width: number | null;
|
|
31
|
+
/** The pixel height, or null when the manifest carries none. */
|
|
32
|
+
height: number | null;
|
|
33
|
+
/** The stored byte size. */
|
|
34
|
+
bytes: number;
|
|
35
|
+
/** The ISO timestamp the bytes were first stored, the Library's sortable "Added" column. */
|
|
36
|
+
createdAt: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The projected library keyed by the 16-hex content hash, exactly EditData's `mediaLibrary`. */
|
|
40
|
+
export type MediaLibrary = Record<string, MediaLibraryEntry>;
|
|
41
|
+
|
|
42
|
+
/** Project a stored MediaEntry to the picker's MediaLibraryEntry, copying every display field and
|
|
43
|
+
* dropping the source-only sha256 and original filename. The single projection editLoad and
|
|
44
|
+
* mediaLibraryLoad both call, so the popover and the Library never diverge on the shared shape. */
|
|
45
|
+
export function mediaLibraryEntry(entry: MediaEntry): MediaLibraryEntry {
|
|
46
|
+
return {
|
|
47
|
+
hash: entry.hash,
|
|
48
|
+
slug: entry.slug,
|
|
49
|
+
ext: entry.ext,
|
|
50
|
+
contentType: entry.contentType,
|
|
51
|
+
displayName: entry.displayName,
|
|
52
|
+
alt: entry.alt,
|
|
53
|
+
width: entry.width,
|
|
54
|
+
height: entry.height,
|
|
55
|
+
bytes: entry.bytes,
|
|
56
|
+
createdAt: entry.createdAt,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// cairn-cms: the media manifest, a small git-committed record with one row per stored asset. It
|
|
2
|
+
// carries the human layer that the bytes cannot (display name, alt text, original filename) and is
|
|
3
|
+
// the dedup lookup: an ingest checks the content-hash prefix here before storing, so the same bytes
|
|
4
|
+
// are never stored twice. It mirrors the content manifest in ../content/manifest.ts, keyed by the
|
|
5
|
+
// 16-hex content-hash prefix rather than concept and id.
|
|
6
|
+
|
|
7
|
+
/** One stored asset's row: its content hash, its human layer, and its byte and pixel facts. The
|
|
8
|
+
* `contentType` is the stored MIME type, so the delivery route serves it verbatim rather than
|
|
9
|
+
* guessing from the extension. `width` and `height` are null when no dimensions are known (the
|
|
10
|
+
* client is the only dimension source and a Worker cannot re-derive them). */
|
|
11
|
+
export interface MediaEntry {
|
|
12
|
+
hash: string;
|
|
13
|
+
sha256: string;
|
|
14
|
+
slug: string;
|
|
15
|
+
displayName: string;
|
|
16
|
+
originalFilename: string;
|
|
17
|
+
alt: string;
|
|
18
|
+
ext: string;
|
|
19
|
+
contentType: string;
|
|
20
|
+
bytes: number;
|
|
21
|
+
width: number | null;
|
|
22
|
+
height: number | null;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The whole stored-asset record, keyed by the 16-hex content-hash prefix. */
|
|
27
|
+
export type MediaManifest = Record<string, MediaEntry>;
|
|
28
|
+
|
|
29
|
+
/** Parse a committed media manifest. Tolerant: an empty, missing, null, or non-object input yields
|
|
30
|
+
* an empty manifest, so a first ingest into a site with no manifest file reads a clean {}. A valid
|
|
31
|
+
* object is returned as the manifest. */
|
|
32
|
+
export function parseMediaManifest(json: unknown): MediaManifest {
|
|
33
|
+
if (!json || typeof json !== 'object' || Array.isArray(json)) return {};
|
|
34
|
+
return json as MediaManifest;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validate one posted value as a MediaEntry, returning it narrowed or undefined. The trust boundary
|
|
38
|
+
* for an optimistic record the client re-posts: the upload action server-owned each field at
|
|
39
|
+
* creation, but a re-post is untrusted, so every field is re-checked. A `hash` must be the 16-hex
|
|
40
|
+
* content-hash prefix; the string fields must be strings; `bytes` must be finite; `width`/`height`
|
|
41
|
+
* must each be a number or null; `createdAt` must be a string. */
|
|
42
|
+
function validateMediaEntry(value: unknown): MediaEntry | undefined {
|
|
43
|
+
if (!value || typeof value !== 'object') return undefined;
|
|
44
|
+
const e = value as Record<string, unknown>;
|
|
45
|
+
const isString = (v: unknown): v is string => typeof v === 'string';
|
|
46
|
+
const isNumOrNull = (v: unknown): v is number | null => v === null || typeof v === 'number';
|
|
47
|
+
if (typeof e.hash !== 'string' || !/^[0-9a-f]{16}$/.test(e.hash)) return undefined;
|
|
48
|
+
if (!isString(e.sha256)) return undefined;
|
|
49
|
+
if (!isString(e.slug) || !isString(e.displayName) || !isString(e.originalFilename)) return undefined;
|
|
50
|
+
if (!isString(e.alt) || !isString(e.ext) || !isString(e.contentType)) return undefined;
|
|
51
|
+
if (typeof e.bytes !== 'number' || !Number.isFinite(e.bytes)) return undefined;
|
|
52
|
+
if (!isNumOrNull(e.width) || !isNumOrNull(e.height)) return undefined;
|
|
53
|
+
if (!isString(e.createdAt)) return undefined;
|
|
54
|
+
return {
|
|
55
|
+
hash: e.hash,
|
|
56
|
+
sha256: e.sha256,
|
|
57
|
+
slug: e.slug,
|
|
58
|
+
displayName: e.displayName,
|
|
59
|
+
originalFilename: e.originalFilename,
|
|
60
|
+
alt: e.alt,
|
|
61
|
+
ext: e.ext,
|
|
62
|
+
contentType: e.contentType,
|
|
63
|
+
bytes: e.bytes,
|
|
64
|
+
width: e.width,
|
|
65
|
+
height: e.height,
|
|
66
|
+
createdAt: e.createdAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parse the posted `media` field into a validated list of MediaEntry rows. The field arrives as a
|
|
71
|
+
* JSON string (the usual form-post shape), an already-parsed array, or junk. A string is JSON-parsed
|
|
72
|
+
* inside a try/catch that yields `[]` on a parse failure; a non-string array is taken directly;
|
|
73
|
+
* anything else yields `[]`. Each element is validated and a failing element is dropped, so a partly
|
|
74
|
+
* malformed post still lands its good rows. This is the trust boundary for the client's optimistic
|
|
75
|
+
* records. */
|
|
76
|
+
export function parseMediaEntries(value: unknown): MediaEntry[] {
|
|
77
|
+
let raw: unknown = value;
|
|
78
|
+
if (typeof value === 'string') {
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(value);
|
|
81
|
+
} catch {
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!Array.isArray(raw)) return [];
|
|
86
|
+
const entries: MediaEntry[] = [];
|
|
87
|
+
for (const item of raw) {
|
|
88
|
+
const entry = validateMediaEntry(item);
|
|
89
|
+
if (entry) entries.push(entry);
|
|
90
|
+
}
|
|
91
|
+
return entries;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The dedup lookup: the entry stored under the content-hash prefix, or undefined when no bytes with
|
|
95
|
+
* that hash are stored yet. */
|
|
96
|
+
export function findByHash(manifest: MediaManifest, hash: string): MediaEntry | undefined {
|
|
97
|
+
return manifest[hash];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Set the entry under its own hash, replacing any same-hash row. Returns a new manifest and leaves
|
|
101
|
+
* the input untouched, so a caller's prior manifest reference stays valid. The ingest path's patch. */
|
|
102
|
+
export function upsertMediaEntry(manifest: MediaManifest, entry: MediaEntry): MediaManifest {
|
|
103
|
+
return { ...manifest, [entry.hash]: entry };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Drop the entry under the given hash, returning a new manifest and leaving the input untouched.
|
|
107
|
+
* Removing an absent hash is a no-op that still returns an equivalent new manifest. The safe-delete
|
|
108
|
+
* path's patch. */
|
|
109
|
+
export function removeMediaEntry(manifest: MediaManifest, hash: string): MediaManifest {
|
|
110
|
+
const { [hash]: _removed, ...rest } = manifest;
|
|
111
|
+
return rest;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Serialize canonically: the top-level hash keys sorted ascending, two-space pretty, and a trailing
|
|
115
|
+
* newline, so the committed file diffs cleanly in a PR and a re-serialization is byte-identical. */
|
|
116
|
+
export function serializeMediaManifest(manifest: MediaManifest): string {
|
|
117
|
+
const sorted: MediaManifest = {};
|
|
118
|
+
for (const hash of Object.keys(manifest).sort()) {
|
|
119
|
+
sorted[hash] = manifest[hash];
|
|
120
|
+
}
|
|
121
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
122
|
+
}
|