@glw907/cairn-cms 0.56.1 → 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 +148 -0
- package/README.md +10 -4
- 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/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +477 -15
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +358 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +51 -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 +1045 -28
- 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 +19 -0
- package/dist/components/markdown-directives.js +52 -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/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +15 -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 +8 -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/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +477 -15
- package/src/lib/components/MarkdownEditor.svelte +358 -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 +57 -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/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +38 -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
|
@@ -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
|
+
}
|
|
@@ -7,6 +7,26 @@ export declare function parseComponent(markdown: string, def: ComponentDef): Pro
|
|
|
7
7
|
/** The raw attribute keys present on the component's opening directive, read from the parsed tree
|
|
8
8
|
* (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
|
|
9
9
|
export declare function parseRawAttributeKeys(markdown: string, def: ComponentDef): string[];
|
|
10
|
+
/** The result of {@link componentRoundTripSafety}: whether re-opening a placed block into the
|
|
11
|
+
* guided form and re-serializing it is provably lossless. */
|
|
12
|
+
export type RoundTripSafety = {
|
|
13
|
+
safe: true;
|
|
14
|
+
} | {
|
|
15
|
+
safe: false;
|
|
16
|
+
reason: 'unknown-attribute' | 'undeclared-child' | 'not-idempotent' | 'not-a-component';
|
|
17
|
+
};
|
|
18
|
+
/** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
|
|
19
|
+
* hand can carry more than the schema models (an attribute the def does not list, a child container
|
|
20
|
+
* the def does not declare, slot content the form cannot represent stably), and parsing such a block
|
|
21
|
+
* into the form then re-serializing would silently drop it. The edit affordance is offered only when
|
|
22
|
+
* this returns `{ safe: true }`. Checks run in order and return the first failure:
|
|
23
|
+
*
|
|
24
|
+
* 1. `not-a-component`: the def's opening container is not present.
|
|
25
|
+
* 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
|
|
26
|
+
* 3. `undeclared-child`: the root has a direct child container directive that is not a declared
|
|
27
|
+
* nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
|
|
28
|
+
* 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
|
|
29
|
+
export declare function componentRoundTripSafety(markdown: string, def: ComponentDef): Promise<RoundTripSafety>;
|
|
10
30
|
/** Parse the component once and derive both the guided-form values and the raw attribute keys.
|
|
11
31
|
* Validation needs both, so this seam spares it the double parse that calling
|
|
12
32
|
* {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
|
|
@@ -58,7 +58,7 @@ function isContainer(node) {
|
|
|
58
58
|
}
|
|
59
59
|
// Pin the bullet to `-` so a markdown body or slot that uses dash bullets round-trips unchanged
|
|
60
60
|
// rather than drifting to remark-stringify's default `*`, which would silently mutate author content.
|
|
61
|
-
const toMd = unified().use(remarkStringify, { bullet: '-' });
|
|
61
|
+
const toMd = unified().use(remarkDirective).use(remarkStringify, { bullet: '-' });
|
|
62
62
|
/** Render mdast children back to trimmed markdown text. */
|
|
63
63
|
function childrenToText(children) {
|
|
64
64
|
const root = { type: 'root', children };
|
|
@@ -120,6 +120,40 @@ export async function parseComponent(markdown, def) {
|
|
|
120
120
|
export function parseRawAttributeKeys(markdown, def) {
|
|
121
121
|
return rawKeysFromRoot(findComponentRoot(markdown, def));
|
|
122
122
|
}
|
|
123
|
+
/** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
|
|
124
|
+
* hand can carry more than the schema models (an attribute the def does not list, a child container
|
|
125
|
+
* the def does not declare, slot content the form cannot represent stably), and parsing such a block
|
|
126
|
+
* into the form then re-serializing would silently drop it. The edit affordance is offered only when
|
|
127
|
+
* this returns `{ safe: true }`. Checks run in order and return the first failure:
|
|
128
|
+
*
|
|
129
|
+
* 1. `not-a-component`: the def's opening container is not present.
|
|
130
|
+
* 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
|
|
131
|
+
* 3. `undeclared-child`: the root has a direct child container directive that is not a declared
|
|
132
|
+
* nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
|
|
133
|
+
* 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
|
|
134
|
+
export async function componentRoundTripSafety(markdown, def) {
|
|
135
|
+
const root = findComponentRoot(markdown, def);
|
|
136
|
+
if (!root)
|
|
137
|
+
return { safe: false, reason: 'not-a-component' };
|
|
138
|
+
const declaredKeys = new Set((def.attributes ?? []).map((f) => f.key));
|
|
139
|
+
for (const key of parseRawAttributeKeys(markdown, def)) {
|
|
140
|
+
if (!declaredKeys.has(key))
|
|
141
|
+
return { safe: false, reason: 'unknown-attribute' };
|
|
142
|
+
}
|
|
143
|
+
const slotNames = new Set(nestedSlots(def).map((s) => s.name));
|
|
144
|
+
for (const child of root.children) {
|
|
145
|
+
if (isContainer(child) && !slotNames.has(child.name)) {
|
|
146
|
+
return { safe: false, reason: 'undeclared-child' };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// The values are plain strings, booleans, and string arrays in declared (object-key) order, so a
|
|
150
|
+
// stable JSON.stringify is a sufficient deep-equal.
|
|
151
|
+
const v1 = await parseComponent(markdown, def);
|
|
152
|
+
const v2 = await parseComponent(serializeComponent(def, v1), def);
|
|
153
|
+
if (JSON.stringify(v1) !== JSON.stringify(v2))
|
|
154
|
+
return { safe: false, reason: 'not-idempotent' };
|
|
155
|
+
return { safe: true };
|
|
156
|
+
}
|
|
123
157
|
/** Parse the component once and derive both the guided-form values and the raw attribute keys.
|
|
124
158
|
* Validation needs both, so this seam spares it the double parse that calling
|
|
125
159
|
* {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
|
|
@@ -146,8 +180,18 @@ function isDirectiveLabel(node) {
|
|
|
146
180
|
function readLabel(root) {
|
|
147
181
|
for (const child of root.children) {
|
|
148
182
|
const p = child;
|
|
149
|
-
if (p.type
|
|
150
|
-
|
|
183
|
+
if (p.type !== 'paragraph' || !p.data?.directiveLabel)
|
|
184
|
+
continue;
|
|
185
|
+
const kids = p.children ?? [];
|
|
186
|
+
// When every label child is a plain text node, join the raw `.value`s. That keeps the pure-text
|
|
187
|
+
// path identical to before, so a literal `[` or `]` in the title is not re-escaped by the
|
|
188
|
+
// stringifier (serializeComponent already escapes brackets, and remark un-escapes them on parse).
|
|
189
|
+
// When the label carries inline markdown (a link, bold, emphasis), the text-only join would drop
|
|
190
|
+
// the markup, so stringify the children to recover the full inline source losslessly.
|
|
191
|
+
if (kids.every((c) => c.type === 'text')) {
|
|
192
|
+
return kids.map((c) => c.value ?? '').join('');
|
|
193
|
+
}
|
|
194
|
+
return childrenToText(kids);
|
|
151
195
|
}
|
|
152
196
|
return undefined;
|
|
153
197
|
}
|
|
@@ -12,6 +12,16 @@ export async function validateComponent(markdown, def) {
|
|
|
12
12
|
}
|
|
13
13
|
if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
|
|
14
14
|
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
|
|
18
|
+
errors[field.key] = field.pattern.message;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (field.validate) {
|
|
22
|
+
const message = runFieldValidator(def, field.key, () => field.validate(v, values));
|
|
23
|
+
if (typeof message === 'string')
|
|
24
|
+
errors[field.key] = message;
|
|
15
25
|
}
|
|
16
26
|
}
|
|
17
27
|
for (const key of rawKeys) {
|
|
@@ -28,3 +38,15 @@ export async function validateComponent(markdown, def) {
|
|
|
28
38
|
}
|
|
29
39
|
return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
|
|
30
40
|
}
|
|
41
|
+
// Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
|
|
42
|
+
// the field is treated as valid and a dev-time warning names the component and field so the author
|
|
43
|
+
// can find the bug. A returned string is the field error; anything else (null) is clean.
|
|
44
|
+
function runFieldValidator(def, key, call) {
|
|
45
|
+
try {
|
|
46
|
+
return call();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type PluggableList } from 'unified';
|
|
2
2
|
import type { Schema } from 'hast-util-sanitize';
|
|
3
|
+
import { type MediaResolve } from './resolve-media.js';
|
|
3
4
|
import { type ComponentRegistry } from './registry.js';
|
|
4
5
|
import type { LinkResolve } from '../content/links.js';
|
|
5
6
|
export interface RendererOptions {
|
|
@@ -29,5 +30,6 @@ export declare function createRenderer(registry?: ComponentRegistry, options?: R
|
|
|
29
30
|
rehypePlugins: PluggableList;
|
|
30
31
|
renderMarkdown: (content: string, opts?: {
|
|
31
32
|
resolve?: LinkResolve;
|
|
33
|
+
resolveMedia?: MediaResolve;
|
|
32
34
|
}) => Promise<string>;
|
|
33
35
|
};
|
package/dist/render/pipeline.js
CHANGED
|
@@ -10,14 +10,22 @@ import rehypeSanitize from 'rehype-sanitize';
|
|
|
10
10
|
import { VFile } from 'vfile';
|
|
11
11
|
import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard } from './sanitize-schema.js';
|
|
12
12
|
import { remarkDirectiveStamp } from './remark-directives.js';
|
|
13
|
+
import { remarkFigure } from './remark-figure.js';
|
|
13
14
|
import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
|
|
15
|
+
import { remarkResolveMedia, MEDIA_RESOLVE } from './resolve-media.js';
|
|
14
16
|
import { rehypeDispatch } from './rehype-dispatch.js';
|
|
15
17
|
import { defineRegistry } from './registry.js';
|
|
16
18
|
/** Compose a site's render pipeline from its component registry: directive syntax to
|
|
17
19
|
* stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
|
|
18
20
|
* rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
|
|
19
21
|
export function createRenderer(registry = defineRegistry({ components: [] }), options = {}) {
|
|
20
|
-
const remarkPlugins = [
|
|
22
|
+
const remarkPlugins = [
|
|
23
|
+
remarkDirective,
|
|
24
|
+
[remarkDirectiveStamp, registry],
|
|
25
|
+
remarkResolveCairnLinks,
|
|
26
|
+
remarkFigure,
|
|
27
|
+
remarkResolveMedia,
|
|
28
|
+
];
|
|
21
29
|
// The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
|
|
22
30
|
// before the dispatch (so the site's trusted build() output and its inline SVG icons are never
|
|
23
31
|
// sanitized). The anchor-rel hardening runs last so it also covers component-built anchors.
|
|
@@ -48,7 +56,10 @@ export function createRenderer(registry = defineRegistry({ components: [] }), op
|
|
|
48
56
|
remarkPlugins,
|
|
49
57
|
rehypePlugins,
|
|
50
58
|
renderMarkdown: async (content, opts = {}) => {
|
|
51
|
-
const file = new VFile({
|
|
59
|
+
const file = new VFile({
|
|
60
|
+
value: content,
|
|
61
|
+
data: { [CAIRN_RESOLVE]: opts.resolve, [MEDIA_RESOLVE]: opts.resolveMedia },
|
|
62
|
+
});
|
|
52
63
|
return String(await processor.process(file));
|
|
53
64
|
},
|
|
54
65
|
};
|
|
@@ -15,6 +15,15 @@ export interface AttributeField {
|
|
|
15
15
|
options?: readonly string[];
|
|
16
16
|
/** Helper text shown under the field. */
|
|
17
17
|
help?: string;
|
|
18
|
+
/** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
|
|
19
|
+
pattern?: {
|
|
20
|
+
source: string;
|
|
21
|
+
message: string;
|
|
22
|
+
};
|
|
23
|
+
/** A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
|
|
24
|
+
* Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
|
|
25
|
+
* fields. The picker wraps the call in try/catch so an author's throw never crashes the form. */
|
|
26
|
+
validate?: (value: string | boolean, all: ComponentValues) => string | null;
|
|
18
27
|
}
|
|
19
28
|
export type SlotKind = 'markdown' | 'inline' | 'repeatable';
|
|
20
29
|
/** One named content region of a component. The slots named `title` and `body` are special: `title`
|
|
@@ -27,6 +36,9 @@ export interface SlotDef {
|
|
|
27
36
|
help?: string;
|
|
28
37
|
/** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
|
|
29
38
|
itemFields?: AttributeField[];
|
|
39
|
+
/** For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
|
|
40
|
+
* When it returns nothing, the picker falls back to `${label} ${index + 1}`. */
|
|
41
|
+
itemLabel?: (item: Record<string, string | boolean>, index: number) => string;
|
|
30
42
|
}
|
|
31
43
|
/** The structured input a component's `build` receives. The engine stamps the component's
|
|
32
44
|
* attributes and partitions its slots from the rendered hast, so `build` arranges hast and
|
|
@@ -64,6 +76,18 @@ export interface ComponentDef {
|
|
|
64
76
|
attributes?: AttributeField[];
|
|
65
77
|
/** The named content regions this component accepts. */
|
|
66
78
|
slots?: SlotDef[];
|
|
79
|
+
/** A glyph key from the site IconSet, shown beside the label in the picker. */
|
|
80
|
+
icon?: string;
|
|
81
|
+
/** A category heading for the picker. Components order by declaration within a group. */
|
|
82
|
+
group?: string;
|
|
83
|
+
/** Omit from the top-level picker (for a nested or round-trip-only component). */
|
|
84
|
+
hidden?: boolean;
|
|
85
|
+
/** A structured sample the picker seeds the form with and renders through the same path a real
|
|
86
|
+
* insert takes. Declaring `preview` is what opts the component into the two-pane configure layout. */
|
|
87
|
+
preview?: {
|
|
88
|
+
attributes?: Record<string, string | boolean>;
|
|
89
|
+
slots?: Record<string, string | string[]>;
|
|
90
|
+
};
|
|
67
91
|
}
|
|
68
92
|
export interface ComponentRegistry {
|
|
69
93
|
defs: ComponentDef[];
|
|
@@ -93,3 +117,7 @@ export interface ComponentValues {
|
|
|
93
117
|
/** Seed an empty {@link ComponentValues} from a component's schema: attribute defaults (or '' / false)
|
|
94
118
|
* and empty slot values ([] for repeatable, '' otherwise). */
|
|
95
119
|
export declare function emptyValues(def: ComponentDef): ComponentValues;
|
|
120
|
+
/** Seed {@link ComponentValues} from a component's `preview` sample: the {@link emptyValues} base
|
|
121
|
+
* with `def.preview.attributes` and `def.preview.slots` overlaid (a shallow merge per side). When
|
|
122
|
+
* the def declares no `preview`, returns exactly the {@link emptyValues} output. */
|
|
123
|
+
export declare function previewValues(def: ComponentDef): ComponentValues;
|
package/dist/render/registry.js
CHANGED
|
@@ -16,6 +16,9 @@ function findIconField(def) {
|
|
|
16
16
|
*/
|
|
17
17
|
export function defineRegistry({ components }) {
|
|
18
18
|
for (const c of components) {
|
|
19
|
+
if (c.name === 'figure') {
|
|
20
|
+
throw new Error('cairn: "figure" is a reserved directive name handled by the engine render step; a component cannot use it');
|
|
21
|
+
}
|
|
19
22
|
if (c.defaultIconByRole && Object.keys(c.defaultIconByRole).length > 0 && !findIconField(c)) {
|
|
20
23
|
throw new Error(`cairn: component "${c.name}" sets defaultIconByRole but declares no type:'icon' attribute, so the default icon can never render`);
|
|
21
24
|
}
|
|
@@ -45,3 +48,15 @@ export function emptyValues(def) {
|
|
|
45
48
|
}
|
|
46
49
|
return { attributes, slots };
|
|
47
50
|
}
|
|
51
|
+
/** Seed {@link ComponentValues} from a component's `preview` sample: the {@link emptyValues} base
|
|
52
|
+
* with `def.preview.attributes` and `def.preview.slots` overlaid (a shallow merge per side). When
|
|
53
|
+
* the def declares no `preview`, returns exactly the {@link emptyValues} output. */
|
|
54
|
+
export function previewValues(def) {
|
|
55
|
+
const base = emptyValues(def);
|
|
56
|
+
if (!def.preview)
|
|
57
|
+
return base;
|
|
58
|
+
return {
|
|
59
|
+
attributes: { ...base.attributes, ...def.preview.attributes },
|
|
60
|
+
slots: { ...base.slots, ...def.preview.slots },
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Root } from 'mdast';
|
|
2
|
+
/** Rewrite the reserved `figure` container directive into a placed <figure>. Every other directive
|
|
3
|
+
* is left to remarkDirectiveStamp, which already skips unregistered names. */
|
|
4
|
+
export declare function remarkFigure(): (tree: Root) => void;
|