@glw907/cairn-cms 0.10.0 → 0.14.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/dist/components/ComponentForm.svelte +33 -10
- package/dist/components/ComponentForm.svelte.d.ts.map +1 -1
- package/dist/components/IconPicker.svelte +53 -7
- package/dist/components/IconPicker.svelte.d.ts +7 -3
- package/dist/components/IconPicker.svelte.d.ts.map +1 -1
- package/dist/content/adapter.d.ts +4 -0
- package/dist/content/adapter.d.ts.map +1 -0
- package/dist/content/adapter.js +4 -0
- package/dist/content/concepts.js +2 -2
- package/dist/content/schema.d.ts +75 -0
- package/dist/content/schema.d.ts.map +1 -0
- package/dist/content/schema.js +72 -0
- package/dist/content/types.d.ts +30 -7
- package/dist/content/types.d.ts.map +1 -1
- package/dist/content/validate.d.ts +5 -3
- package/dist/content/validate.d.ts.map +1 -1
- package/dist/content/validate.js +14 -7
- package/dist/delivery/CairnHead.svelte +36 -0
- package/dist/delivery/CairnHead.svelte.d.ts +15 -0
- package/dist/delivery/CairnHead.svelte.d.ts.map +1 -0
- package/dist/delivery/content-index.d.ts +16 -6
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +17 -8
- package/dist/delivery/index.d.ts +26 -0
- package/dist/delivery/index.d.ts.map +1 -0
- package/dist/delivery/index.js +21 -0
- package/dist/delivery/json-ld.d.ts +2 -0
- package/dist/delivery/json-ld.d.ts.map +1 -0
- package/dist/delivery/json-ld.js +16 -0
- package/dist/delivery/responses.d.ts +14 -0
- package/dist/delivery/responses.d.ts.map +1 -0
- package/dist/delivery/responses.js +30 -0
- package/dist/delivery/seo-fields.d.ts +22 -0
- package/dist/delivery/seo-fields.d.ts.map +1 -0
- package/dist/delivery/seo-fields.js +32 -0
- package/dist/delivery/seo.d.ts +4 -0
- package/dist/delivery/seo.d.ts.map +1 -1
- package/dist/delivery/seo.js +11 -0
- package/dist/delivery/site-descriptors.d.ts +5 -0
- package/dist/delivery/site-descriptors.d.ts.map +1 -0
- package/dist/delivery/site-descriptors.js +9 -0
- package/dist/delivery/site-index.d.ts +8 -2
- package/dist/delivery/site-index.d.ts.map +1 -1
- package/dist/delivery/site-index.js +26 -2
- package/dist/delivery/site-indexes.d.ts +26 -0
- package/dist/delivery/site-indexes.d.ts.map +1 -0
- package/dist/delivery/site-indexes.js +22 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/render/component-grammar.d.ts +7 -0
- package/dist/render/component-grammar.d.ts.map +1 -1
- package/dist/render/component-grammar.js +27 -8
- package/dist/render/component-validate.js +3 -3
- package/dist/render/glyph.d.ts +4 -1
- package/dist/render/glyph.d.ts.map +1 -1
- package/dist/render/glyph.js +6 -2
- package/dist/render/registry.d.ts +23 -5
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +6 -0
- package/dist/render/rehype-dispatch.d.ts +1 -5
- package/dist/render/rehype-dispatch.d.ts.map +1 -1
- package/dist/render/rehype-dispatch.js +71 -19
- package/dist/render/remark-directives.d.ts +1 -1
- package/dist/render/remark-directives.d.ts.map +1 -1
- package/dist/render/remark-directives.js +37 -0
- package/dist/sveltekit/public-routes.d.ts +14 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +22 -2
- package/package.json +6 -1
- package/src/lib/components/ComponentForm.svelte +33 -10
- package/src/lib/components/IconPicker.svelte +53 -7
- package/src/lib/content/adapter.ts +10 -0
- package/src/lib/content/concepts.ts +2 -2
- package/src/lib/content/schema.ts +133 -0
- package/src/lib/content/types.ts +30 -7
- package/src/lib/content/validate.ts +10 -7
- package/src/lib/delivery/CairnHead.svelte +36 -0
- package/src/lib/delivery/content-index.ts +39 -17
- package/src/lib/delivery/index.ts +36 -0
- package/src/lib/delivery/json-ld.ts +16 -0
- package/src/lib/delivery/responses.ts +34 -0
- package/src/lib/delivery/seo-fields.ts +43 -0
- package/src/lib/delivery/seo.ts +13 -0
- package/src/lib/delivery/site-descriptors.ts +12 -0
- package/src/lib/delivery/site-index.ts +26 -2
- package/src/lib/delivery/site-indexes.ts +52 -0
- package/src/lib/index.ts +8 -2
- package/src/lib/render/component-grammar.ts +34 -10
- package/src/lib/render/component-validate.ts +3 -3
- package/src/lib/render/glyph.ts +6 -2
- package/src/lib/render/registry.ts +27 -5
- package/src/lib/render/rehype-dispatch.ts +67 -20
- package/src/lib/render/remark-directives.ts +39 -1
- package/src/lib/sveltekit/public-routes.ts +33 -2
|
@@ -64,12 +64,16 @@ function childrenToText(children) {
|
|
|
64
64
|
const root = { type: 'root', children };
|
|
65
65
|
return String(toMd.stringify(root)).trim();
|
|
66
66
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
// Parse the markdown and find the component's opening container directive. The single seam both
|
|
68
|
+
// parseComponent and parseRawAttributeKeys (and the combined validator helper) build on, so one
|
|
69
|
+
// parse derives both the form values and the raw attribute keys.
|
|
70
|
+
function findComponentRoot(markdown, def) {
|
|
71
71
|
const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown);
|
|
72
|
-
|
|
72
|
+
return tree.children.find((c) => isContainer(c) && c.name === def.name);
|
|
73
|
+
}
|
|
74
|
+
// Build guided-form values from an already-found component root. Returns the empty base when the
|
|
75
|
+
// root is absent.
|
|
76
|
+
function valuesFromRoot(root, def) {
|
|
73
77
|
const values = emptyComponentValues(def);
|
|
74
78
|
if (!root)
|
|
75
79
|
return values;
|
|
@@ -101,12 +105,27 @@ export async function parseComponent(markdown, def) {
|
|
|
101
105
|
}
|
|
102
106
|
return values;
|
|
103
107
|
}
|
|
108
|
+
// The raw attribute keys on an already-found component root.
|
|
109
|
+
function rawKeysFromRoot(root) {
|
|
110
|
+
return Object.keys(root?.attributes ?? {});
|
|
111
|
+
}
|
|
112
|
+
/** Parse a serialized component directive back into guided-form values, the inverse of
|
|
113
|
+
* {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
|
|
114
|
+
* saved directive through the form. */
|
|
115
|
+
export async function parseComponent(markdown, def) {
|
|
116
|
+
return valuesFromRoot(findComponentRoot(markdown, def), def);
|
|
117
|
+
}
|
|
104
118
|
/** The raw attribute keys present on the component's opening directive, read from the parsed tree
|
|
105
119
|
* (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
|
|
106
120
|
export function parseRawAttributeKeys(markdown, def) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
121
|
+
return rawKeysFromRoot(findComponentRoot(markdown, def));
|
|
122
|
+
}
|
|
123
|
+
/** Parse the component once and derive both the guided-form values and the raw attribute keys.
|
|
124
|
+
* Validation needs both, so this seam spares it the double parse that calling
|
|
125
|
+
* {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
|
|
126
|
+
export async function parseComponentWithRawKeys(markdown, def) {
|
|
127
|
+
const root = findComponentRoot(markdown, def);
|
|
128
|
+
return { values: valuesFromRoot(root, def), rawKeys: rawKeysFromRoot(root) };
|
|
110
129
|
}
|
|
111
130
|
// A bare parse base: empty strings, false, and empty lists, with no attribute defaults applied. The
|
|
112
131
|
// `emptyValues` helper in registry.ts seeds form defaults instead, so it is deliberately not reused
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseComponentWithRawKeys } from './component-grammar.js';
|
|
2
2
|
export async function validateComponent(markdown, def) {
|
|
3
|
-
const values = await
|
|
3
|
+
const { values, rawKeys } = await parseComponentWithRawKeys(markdown, def);
|
|
4
4
|
const errors = {};
|
|
5
5
|
const declared = new Set((def.attributes ?? []).map((f) => f.key));
|
|
6
6
|
for (const field of def.attributes ?? []) {
|
|
@@ -14,7 +14,7 @@ export async function validateComponent(markdown, def) {
|
|
|
14
14
|
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
15
15
|
}
|
|
16
16
|
}
|
|
17
|
-
for (const key of
|
|
17
|
+
for (const key of rawKeys) {
|
|
18
18
|
if (!declared.has(key))
|
|
19
19
|
errors[key] = `Unknown attribute "${key}".`;
|
|
20
20
|
}
|
package/dist/render/glyph.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { Element } from 'hast';
|
|
2
2
|
/** A glyph name to SVG path-data map (the site owns the icon set). */
|
|
3
3
|
export type IconSet = Record<string, string>;
|
|
4
|
-
/** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill.
|
|
4
|
+
/** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill.
|
|
5
|
+
* An unknown icon name yields the bare svg shell with no path child, so it never serializes
|
|
6
|
+
* a stray empty (or undefined) path. Callers always wrap the returned element, so the shell
|
|
7
|
+
* keeps them safe. */
|
|
5
8
|
export declare function glyph(name: string, icons: IconSet): Element;
|
|
6
9
|
//# sourceMappingURL=glyph.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"glyph.d.ts","sourceRoot":"","sources":["../../src/lib/render/glyph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,sEAAsE;AACtE,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE7C
|
|
1
|
+
{"version":3,"file":"glyph.d.ts","sourceRoot":"","sources":["../../src/lib/render/glyph.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;AAEpC,sEAAsE;AACtE,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;AAE7C;;;uBAGuB;AACvB,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAO3D"}
|
package/dist/render/glyph.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { s } from 'hastscript';
|
|
2
|
-
/** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill.
|
|
2
|
+
/** Inline SVG glyph as a real hast node: class ec-glyph, 256 viewBox, currentColor fill.
|
|
3
|
+
* An unknown icon name yields the bare svg shell with no path child, so it never serializes
|
|
4
|
+
* a stray empty (or undefined) path. Callers always wrap the returned element, so the shell
|
|
5
|
+
* keeps them safe. */
|
|
3
6
|
export function glyph(name, icons) {
|
|
4
|
-
|
|
7
|
+
const d = icons[name];
|
|
8
|
+
return s('svg', { className: ['ec-glyph'], viewBox: '0 0 256 256', fill: 'currentColor', ariaHidden: 'true' }, d == null ? [] : [s('path', { d })]);
|
|
5
9
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Element } from 'hast';
|
|
1
|
+
import type { Element, ElementContent } from 'hast';
|
|
2
2
|
/** The input types a component attribute or repeatable item field can take. */
|
|
3
3
|
export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
|
|
4
4
|
/** One `{key="value"}` attribute on a component directive, or one field of a repeatable item. */
|
|
@@ -28,6 +28,20 @@ export interface SlotDef {
|
|
|
28
28
|
/** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
|
|
29
29
|
itemFields?: AttributeField[];
|
|
30
30
|
}
|
|
31
|
+
/** The structured input a component's `build` receives. The engine stamps the component's
|
|
32
|
+
* attributes and partitions its slots from the rendered hast, so `build` arranges hast and
|
|
33
|
+
* never walks the tree. `slot(name)` returns a slot's rendered children (title, body, or any
|
|
34
|
+
* named slot); `items(name)` returns a repeatable slot's items, one child list per item. */
|
|
35
|
+
export interface ComponentContext {
|
|
36
|
+
/** Declared attribute values, keyed by attribute key. Booleans are real booleans. */
|
|
37
|
+
attributes: Record<string, string | boolean>;
|
|
38
|
+
/** A named slot's rendered children. Returns `[]` for an absent or empty slot. */
|
|
39
|
+
slot(name: string): ElementContent[];
|
|
40
|
+
/** A repeatable slot's items, each item its own list of rendered children. `[]` when absent. */
|
|
41
|
+
items(name: string): ElementContent[][];
|
|
42
|
+
/** The stamped component element, for an escape hatch. Most builds never need it. */
|
|
43
|
+
node: Element;
|
|
44
|
+
}
|
|
31
45
|
/** A site component: how it inserts (editor) and how it renders (rehype). */
|
|
32
46
|
export interface ComponentDef {
|
|
33
47
|
/** Directive name, e.g. 'card' (matches `:::card`). */
|
|
@@ -38,10 +52,10 @@ export interface ComponentDef {
|
|
|
38
52
|
description: string;
|
|
39
53
|
/** Markdown scaffold inserted at the cursor by the editor palette. */
|
|
40
54
|
insertTemplate?: string;
|
|
41
|
-
/** Build the final hast element from the
|
|
42
|
-
* stamps the entrance-stagger ordinal (`data-rise`) on the top-level
|
|
43
|
-
* build fn stays free of any motion concern. */
|
|
44
|
-
build: (
|
|
55
|
+
/** Build the final hast element from the component context (attributes plus partitioned
|
|
56
|
+
* slots). The engine stamps the entrance-stagger ordinal (`data-rise`) on the top-level
|
|
57
|
+
* result, so a build fn stays free of any motion concern. */
|
|
58
|
+
build: (ctx: ComponentContext) => Element;
|
|
45
59
|
/** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
|
|
46
60
|
defaultIconByRole?: Record<string, string>;
|
|
47
61
|
/** One line on when to reach for this component; feeds the picker and the reference file. */
|
|
@@ -57,6 +71,10 @@ export interface ComponentRegistry {
|
|
|
57
71
|
get(name: string): ComponentDef | undefined;
|
|
58
72
|
defaultIcon(name: string, role?: string): string | undefined;
|
|
59
73
|
}
|
|
74
|
+
/** The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
|
|
75
|
+
* becomes `dataAttrTone`. The directive stamp writes it and the rehype dispatch reads it, so both
|
|
76
|
+
* sides derive the name from this one helper rather than spelling the capitalize twice. */
|
|
77
|
+
export declare function dataAttrProp(key: string): string;
|
|
60
78
|
/**
|
|
61
79
|
* Build a registry from a site's component definitions. The single source the render
|
|
62
80
|
* pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/lib/render/registry.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/lib/render/registry.ts"],"names":[],"mappings":"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"}
|
package/dist/render/registry.js
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** The hast property name carrying one declared attribute from stamp to dispatch, e.g. `tone`
|
|
2
|
+
* becomes `dataAttrTone`. The directive stamp writes it and the rehype dispatch reads it, so both
|
|
3
|
+
* sides derive the name from this one helper rather than spelling the capitalize twice. */
|
|
4
|
+
export function dataAttrProp(key) {
|
|
5
|
+
return `dataAttr${key.charAt(0).toUpperCase()}${key.slice(1)}`;
|
|
6
|
+
}
|
|
1
7
|
/**
|
|
2
8
|
* Build a registry from a site's component definitions. The single source the render
|
|
3
9
|
* pipeline (directive stamp plus rehype dispatch) and the editor palette both read.
|
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
import type { Root, Element, ElementContent } from 'hast';
|
|
2
|
-
import type
|
|
2
|
+
import { type ComponentRegistry } from './registry.js';
|
|
3
3
|
export declare function isElement(node: ElementContent | undefined): node is Element;
|
|
4
4
|
export declare function strProp(node: Element, name: string): string | undefined;
|
|
5
5
|
/** Wrap a pre-built glyph in an ec-icon span; secondary role adds the modifier. */
|
|
6
6
|
export declare function iconSpan(glyphEl: Element, role?: string): Element;
|
|
7
7
|
/** A site's icon factory: turn a stamped icon name + role into a hast element. */
|
|
8
8
|
export type MakeIcon = (name: string, role?: string) => Element;
|
|
9
|
-
export declare function splitHead(node: Element, makeIcon?: MakeIcon): {
|
|
10
|
-
head: Element;
|
|
11
|
-
rest: ElementContent[];
|
|
12
|
-
};
|
|
13
9
|
/** Section wrapper: `<section class=…><div class="card-body">…</div></section>`. */
|
|
14
10
|
export declare function cardShell(classes: string[], body: ElementContent[]): Element;
|
|
15
11
|
/** Tag the first <ul> among children with `ec-grid` and strip its whitespace-only
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rehype-dispatch.d.ts","sourceRoot":"","sources":["../../src/lib/render/rehype-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAE1D,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"rehype-dispatch.d.ts","sourceRoot":"","sources":["../../src/lib/render/rehype-dispatch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,OAAO,EAAE,cAAc,EAAE,MAAM,MAAM,CAAC;AAE1D,OAAO,EAA0D,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAE/G,wBAAgB,SAAS,CAAC,IAAI,EAAE,cAAc,GAAG,SAAS,GAAG,IAAI,IAAI,OAAO,CAE3E;AAKD,wBAAgB,OAAO,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED,mFAAmF;AACnF,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAGjE;AAED,kFAAkF;AAClF,MAAM,MAAM,QAAQ,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC;AAEhE,oFAAoF;AACpF,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,cAAc,EAAE,GAAG,OAAO,CAE5E;AAED;kFACkF;AAClF,wBAAgB,aAAa,CAAC,QAAQ,EAAE,cAAc,EAAE,GAAG,OAAO,GAAG,SAAS,CAS7E;AAqFD;;;;;mFAKmF;AACnF,wBAAgB,cAAc,CAAC,QAAQ,EAAE,iBAAiB,EAAE,OAAO,CAAC,EAAE,OAAO,IACnE,MAAM,IAAI,UAYnB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { h } from 'hastscript';
|
|
2
|
+
import { dataAttrProp } from './registry.js';
|
|
2
3
|
export function isElement(node) {
|
|
3
4
|
return !!node && node.type === 'element';
|
|
4
5
|
}
|
|
@@ -14,24 +15,6 @@ export function iconSpan(glyphEl, role) {
|
|
|
14
15
|
const className = role === 'secondary' ? ['ec-icon', 'ec-icon-secondary'] : ['ec-icon'];
|
|
15
16
|
return h('span', { className }, [glyphEl]);
|
|
16
17
|
}
|
|
17
|
-
// Pull the section's <h2> out, retag it .card-title, and build the .ec-head row
|
|
18
|
-
// (optional icon + heading). Returns the head plus the remaining body children.
|
|
19
|
-
// `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
|
|
20
|
-
// for a head with no icon.
|
|
21
|
-
export function splitHead(node, makeIcon) {
|
|
22
|
-
const children = node.children;
|
|
23
|
-
const i = children.findIndex((c) => isElement(c) && c.tagName === 'h2');
|
|
24
|
-
const h2 = children[i];
|
|
25
|
-
h2.properties = { ...h2.properties, className: ['card-title'] };
|
|
26
|
-
const rest = children.filter((_, j) => j !== i);
|
|
27
|
-
const icon = strProp(node, 'dataIcon');
|
|
28
|
-
const role = strProp(node, 'dataRole');
|
|
29
|
-
const headKids = [];
|
|
30
|
-
if (makeIcon && icon)
|
|
31
|
-
headKids.push(makeIcon(icon, role));
|
|
32
|
-
headKids.push(h2);
|
|
33
|
-
return { head: h('div', { className: ['ec-head'] }, headKids), rest };
|
|
34
|
-
}
|
|
35
18
|
/** Section wrapper: `<section class=…><div class="card-body">…</div></section>`. */
|
|
36
19
|
export function cardShell(classes, body) {
|
|
37
20
|
return h('section', { className: classes }, [h('div', { className: ['card-body'] }, body)]);
|
|
@@ -58,11 +41,80 @@ function transformChildren(children, registry) {
|
|
|
58
41
|
return c;
|
|
59
42
|
});
|
|
60
43
|
}
|
|
44
|
+
// Read a stamped attribute back into its typed value. Booleans arrive as the strings
|
|
45
|
+
// 'true'/'false'; everything else is the literal string the author wrote.
|
|
46
|
+
function readAttributes(node, def) {
|
|
47
|
+
const out = {};
|
|
48
|
+
for (const field of def.attributes ?? []) {
|
|
49
|
+
const value = strProp(node, dataAttrProp(field.key));
|
|
50
|
+
if (value == null)
|
|
51
|
+
continue;
|
|
52
|
+
out[field.key] = field.type === 'boolean' ? value === 'true' : value;
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
// The title label paragraph carries data-slot="title"; build() wants its inline children, not
|
|
57
|
+
// the marked paragraph. Return the paragraph's children.
|
|
58
|
+
function stripSlotMarker(child) {
|
|
59
|
+
return isElement(child) ? child.children : [child];
|
|
60
|
+
}
|
|
61
|
+
// Split a component's stamped children into named slots and the default body. A child marked
|
|
62
|
+
// data-slot="title"/<name> routes to that slot; an unmarked child is body. A repeatable slot
|
|
63
|
+
// wraps a <ul>, so its items are that list's <li> children, one child-list per item.
|
|
64
|
+
function partitionSlots(node) {
|
|
65
|
+
const named = new Map();
|
|
66
|
+
const body = [];
|
|
67
|
+
for (const child of node.children) {
|
|
68
|
+
const slotName = isElement(child) ? strProp(child, 'dataSlot') : undefined;
|
|
69
|
+
if (slotName === 'title')
|
|
70
|
+
named.set('title', stripSlotMarker(child));
|
|
71
|
+
else if (slotName)
|
|
72
|
+
named.set(slotName, [child]);
|
|
73
|
+
else
|
|
74
|
+
body.push(child);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
slot(name) {
|
|
78
|
+
if (name === 'body')
|
|
79
|
+
return body;
|
|
80
|
+
const wrap = named.get(name);
|
|
81
|
+
if (!wrap)
|
|
82
|
+
return [];
|
|
83
|
+
// For title we stored the label's own children, so return them as-is. For a markdown or
|
|
84
|
+
// inline named slot the wrapper <div> holds the rendered children; unwrap it.
|
|
85
|
+
if (name === 'title')
|
|
86
|
+
return wrap;
|
|
87
|
+
const div = wrap[0];
|
|
88
|
+
return isElement(div) ? div.children : wrap;
|
|
89
|
+
},
|
|
90
|
+
items(name) {
|
|
91
|
+
const wrap = named.get(name);
|
|
92
|
+
const div = wrap?.[0];
|
|
93
|
+
if (!div || !isElement(div))
|
|
94
|
+
return [];
|
|
95
|
+
const ul = div.children.find((c) => isElement(c) && c.tagName === 'ul');
|
|
96
|
+
if (!ul || !isElement(ul))
|
|
97
|
+
return [];
|
|
98
|
+
return ul.children
|
|
99
|
+
.filter((li) => isElement(li) && li.tagName === 'li')
|
|
100
|
+
.map((li) => li.children);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
61
104
|
function transformNode(node, registry) {
|
|
62
105
|
node.children = transformChildren(node.children, registry);
|
|
63
106
|
const name = strProp(node, 'dataPrimitive');
|
|
64
107
|
const def = name ? registry.get(name) : undefined;
|
|
65
|
-
|
|
108
|
+
if (!def)
|
|
109
|
+
return node;
|
|
110
|
+
const parts = partitionSlots(node);
|
|
111
|
+
const ctx = {
|
|
112
|
+
attributes: readAttributes(node, def),
|
|
113
|
+
slot: parts.slot,
|
|
114
|
+
items: parts.items,
|
|
115
|
+
node,
|
|
116
|
+
};
|
|
117
|
+
return def.build(ctx);
|
|
66
118
|
}
|
|
67
119
|
/** Rehype transformer: dispatch each stamped element through its registry `build`
|
|
68
120
|
* fn. When `stagger` is on, each top-level primitive gets a `data-rise` attribute
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Root } from 'mdast';
|
|
2
|
-
import type
|
|
2
|
+
import { type ComponentRegistry } from './registry.js';
|
|
3
3
|
export declare function remarkDirectiveStamp(registry: ComponentRegistry): (tree: Root) => void;
|
|
4
4
|
//# sourceMappingURL=remark-directives.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remark-directives.d.ts","sourceRoot":"","sources":["../../src/lib/render/remark-directives.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA8B,IAAI,EAAQ,MAAM,OAAO,CAAC;AAGpE,OAAO,KAAK,
|
|
1
|
+
{"version":3,"file":"remark-directives.d.ts","sourceRoot":"","sources":["../../src/lib/render/remark-directives.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA8B,IAAI,EAAQ,MAAM,OAAO,CAAC;AAGpE,OAAO,EAAgB,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAkDrE,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,iBAAiB,IAEtD,MAAM,IAAI,UAqDnB"}
|
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import { visit } from 'unist-util-visit';
|
|
2
|
+
import { dataAttrProp } from './registry.js';
|
|
3
|
+
// mdast-util-directive carries the `[label]` as a paragraph whose `data.directiveLabel` is set.
|
|
4
|
+
function isDirectiveLabel(node) {
|
|
5
|
+
return Boolean(node.data?.directiveLabel);
|
|
6
|
+
}
|
|
7
|
+
// Stamp data-slot on a child so the rehype dispatch partitioner can route it. For a nested
|
|
8
|
+
// container directive we also set hName so it renders as a <div> wrapper rather than being
|
|
9
|
+
// dropped as an unknown directive.
|
|
10
|
+
function markSlot(node, name) {
|
|
11
|
+
const n = node;
|
|
12
|
+
const data = n.data ?? (n.data = {});
|
|
13
|
+
if (n.type === 'containerDirective')
|
|
14
|
+
data.hName = 'div';
|
|
15
|
+
data.hProperties = { ...(data.hProperties ?? {}), dataSlot: name };
|
|
16
|
+
}
|
|
2
17
|
// Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
|
|
3
18
|
// Accidental prose directives carry none, so this is almost always empty.
|
|
4
19
|
function serializeAttributes(attributes) {
|
|
@@ -42,6 +57,7 @@ export function remarkDirectiveStamp(registry) {
|
|
|
42
57
|
visit(tree, 'containerDirective', (node) => {
|
|
43
58
|
if (!known.has(node.name))
|
|
44
59
|
return;
|
|
60
|
+
const def = registry.get(node.name);
|
|
45
61
|
const attrs = node.attributes ?? {};
|
|
46
62
|
const role = attrs.role || undefined;
|
|
47
63
|
let icon = attrs.icon || undefined;
|
|
@@ -52,9 +68,30 @@ export function remarkDirectiveStamp(registry) {
|
|
|
52
68
|
properties.dataIcon = icon;
|
|
53
69
|
if (role)
|
|
54
70
|
properties.dataRole = role;
|
|
71
|
+
// Carry every declared attribute to hast so the dispatch partitioner can build the
|
|
72
|
+
// component context. data-attr-<key> survives to the element; build() consumes it and
|
|
73
|
+
// returns a fresh element, so the marker never reaches the published DOM.
|
|
74
|
+
for (const field of def?.attributes ?? []) {
|
|
75
|
+
const raw = attrs[field.key];
|
|
76
|
+
if (raw != null)
|
|
77
|
+
properties[dataAttrProp(field.key)] = raw;
|
|
78
|
+
}
|
|
55
79
|
const data = node.data ?? (node.data = {});
|
|
56
80
|
data.hName = 'div';
|
|
57
81
|
data.hProperties = properties;
|
|
82
|
+
// Mark the title label paragraph and the nested slot directives so they survive to hast
|
|
83
|
+
// and the partitioner can find them. A slot named in the component schema (other than the
|
|
84
|
+
// default body) is a nested container directive; the title is the directive [label].
|
|
85
|
+
const slotNames = new Set((def?.slots ?? []).map((s) => s.name));
|
|
86
|
+
for (const child of node.children) {
|
|
87
|
+
if (isDirectiveLabel(child) && slotNames.has('title')) {
|
|
88
|
+
markSlot(child, 'title');
|
|
89
|
+
}
|
|
90
|
+
else if (child.type === 'containerDirective' &&
|
|
91
|
+
slotNames.has(child.name)) {
|
|
92
|
+
markSlot(child, child.name);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
58
95
|
});
|
|
59
96
|
visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
|
|
60
97
|
if (!parent || index == null)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
|
|
2
2
|
import type { SiteIndex } from '../delivery/site-index.js';
|
|
3
|
+
import type { SeoMeta } from '../delivery/seo.js';
|
|
3
4
|
/** Injected dependencies for the public loaders. */
|
|
4
5
|
export interface PublicRoutesDeps {
|
|
5
6
|
site: SiteIndex;
|
|
@@ -7,6 +8,18 @@ export interface PublicRoutesDeps {
|
|
|
7
8
|
stagger?: boolean;
|
|
8
9
|
}) => string | Promise<string>;
|
|
9
10
|
origin: string;
|
|
11
|
+
/** Site name for og:site_name and the SEO head. */
|
|
12
|
+
siteName: string;
|
|
13
|
+
/** Default description used when an entry has none. */
|
|
14
|
+
description: string;
|
|
15
|
+
/** Absolute feed URLs for the head's autodiscovery links. */
|
|
16
|
+
feeds?: {
|
|
17
|
+
rss?: string;
|
|
18
|
+
json?: string;
|
|
19
|
+
};
|
|
20
|
+
/** A site-wide default OG image, used when an entry declares none. Resolved to absolute like the
|
|
21
|
+
* canonical URL, so a relative path such as "/og/default.png" works. */
|
|
22
|
+
defaultImage?: string;
|
|
10
23
|
}
|
|
11
24
|
/** The archive and tag list data: summaries the template renders. */
|
|
12
25
|
export interface ListData {
|
|
@@ -28,6 +41,7 @@ export interface EntryData {
|
|
|
28
41
|
entry: ContentEntry;
|
|
29
42
|
html: string;
|
|
30
43
|
canonicalUrl: string;
|
|
44
|
+
seo: SeoMeta;
|
|
31
45
|
newer?: ContentSummary;
|
|
32
46
|
older?: ContentSummary;
|
|
33
47
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/E,MAAM,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAGlD,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;6EACyE;IACzE,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,GAAG,EAAE,OAAO,CAAC;IACb,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BA0BjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
// and tag-index loaders stay concept-scoped, keyed by concept id. The index is built in site code
|
|
5
5
|
// from globs, so it stays in the prerender graph and out of the runtime Worker.
|
|
6
6
|
import { error } from '@sveltejs/kit';
|
|
7
|
+
import { buildSeoMeta } from '../delivery/seo.js';
|
|
8
|
+
import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
|
|
7
9
|
/** Build the public loaders for a site's unified index. */
|
|
8
10
|
export function createPublicRoutes(deps) {
|
|
9
|
-
const { site, render, origin } = deps;
|
|
11
|
+
const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
|
|
10
12
|
/** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
|
|
11
13
|
function indexOf(conceptId) {
|
|
12
14
|
const index = site.concept(conceptId);
|
|
@@ -20,7 +22,25 @@ export function createPublicRoutes(deps) {
|
|
|
20
22
|
if (!entry)
|
|
21
23
|
throw error(404, `Not found: ${event.url.pathname}`);
|
|
22
24
|
const { newer, older } = site.adjacent(entry);
|
|
23
|
-
|
|
25
|
+
const canonicalUrl = origin + entry.permalink;
|
|
26
|
+
const fields = readSeoFields(entry.frontmatter);
|
|
27
|
+
const rawImage = fields.image ?? defaultImage;
|
|
28
|
+
const image = rawImage ? resolveImageUrl(rawImage, origin) : undefined;
|
|
29
|
+
// A dated entry is an article; an undated one (a page) is a website.
|
|
30
|
+
const seo = buildSeoMeta({
|
|
31
|
+
title: entry.title,
|
|
32
|
+
description: fields.description || entry.excerpt || description,
|
|
33
|
+
canonicalUrl,
|
|
34
|
+
siteName,
|
|
35
|
+
type: entry.date ? 'article' : 'website',
|
|
36
|
+
...(entry.date ? { published: entry.date } : {}),
|
|
37
|
+
...(entry.updated ? { modified: entry.updated } : {}),
|
|
38
|
+
...(image ? { image } : {}),
|
|
39
|
+
...(fields.robots ? { robots: fields.robots } : {}),
|
|
40
|
+
...(fields.author ? { author: fields.author } : {}),
|
|
41
|
+
feeds,
|
|
42
|
+
});
|
|
43
|
+
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
|
|
24
44
|
}
|
|
25
45
|
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|
|
26
46
|
function archiveLoad(conceptId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -48,6 +48,11 @@
|
|
|
48
48
|
"svelte": "./dist/components/index.js",
|
|
49
49
|
"default": "./dist/components/index.js"
|
|
50
50
|
},
|
|
51
|
+
"./delivery": {
|
|
52
|
+
"types": "./dist/delivery/index.d.ts",
|
|
53
|
+
"svelte": "./dist/delivery/index.js",
|
|
54
|
+
"default": "./dist/delivery/index.js"
|
|
55
|
+
},
|
|
51
56
|
"./package.json": "./package.json"
|
|
52
57
|
},
|
|
53
58
|
"files": [
|
|
@@ -38,6 +38,32 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
38
38
|
return Array.isArray(v) ? v : [];
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Stable per-item ids run parallel to each repeatable slot's value array, so the {#each} keys by
|
|
42
|
+
// identity instead of index. A mid-list removal then drops the right DOM node and the focused
|
|
43
|
+
// item follows the data. Ids come from a monotonic module-local counter, never Math.random or
|
|
44
|
+
// Date.now. The value arrays in values.slots stay the canonical string lists serializeComponent
|
|
45
|
+
// reads, so the emitted markdown is unchanged. emptyValues seeds every repeatable slot to [], so
|
|
46
|
+
// the id lists start empty and stay in lockstep with the values through addItem/removeItem.
|
|
47
|
+
let nextId = 0;
|
|
48
|
+
const itemIds = $state<Record<string, number[]>>(
|
|
49
|
+
untrack(() => Object.fromEntries((def.slots ?? []).filter((s) => s.kind === 'repeatable').map((s) => [s.name, []]))),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// emptyValues and the itemIds seed both cover every repeatable slot, so this read always hits.
|
|
53
|
+
function slotIds(name: string): number[] {
|
|
54
|
+
return itemIds[name] ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addItem(name: string): void {
|
|
58
|
+
slotItems(name).push('');
|
|
59
|
+
slotIds(name).push(nextId++);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function removeItem(name: string, index: number): void {
|
|
63
|
+
slotItems(name).splice(index, 1);
|
|
64
|
+
slotIds(name).splice(index, 1);
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
// Typed accessors over the unions so explicit value targets stay sound.
|
|
42
68
|
function asString(key: string): string {
|
|
43
69
|
const v = values.attributes[key];
|
|
@@ -79,7 +105,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
79
105
|
<input
|
|
80
106
|
class="checkbox checkbox-sm"
|
|
81
107
|
type="checkbox"
|
|
82
|
-
aria-label={field.label}
|
|
83
108
|
aria-invalid={Boolean(errors[field.key])}
|
|
84
109
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
85
110
|
checked={asBool(field.key)}
|
|
@@ -92,7 +117,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
92
117
|
<span class="text-sm font-medium">{field.label}</span>
|
|
93
118
|
<select
|
|
94
119
|
class="select"
|
|
95
|
-
aria-label={field.label}
|
|
96
120
|
aria-invalid={Boolean(errors[field.key])}
|
|
97
121
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
98
122
|
value={asString(field.key)}
|
|
@@ -107,6 +131,7 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
107
131
|
<span class="text-sm font-medium">{field.label}</span>
|
|
108
132
|
<IconPicker
|
|
109
133
|
{icons}
|
|
134
|
+
label={field.label}
|
|
110
135
|
value={asString(field.key)}
|
|
111
136
|
required={field.required ?? false}
|
|
112
137
|
onChange={(name) => (values.attributes[field.key] = name)}
|
|
@@ -117,7 +142,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
117
142
|
<span class="text-sm font-medium">{field.label}</span>
|
|
118
143
|
<input
|
|
119
144
|
class="input"
|
|
120
|
-
aria-label={field.label}
|
|
121
145
|
aria-invalid={Boolean(errors[field.key])}
|
|
122
146
|
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
123
147
|
value={asString(field.key)}
|
|
@@ -134,7 +158,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
134
158
|
<span class="text-sm font-medium">{slot.label}</span>
|
|
135
159
|
<textarea
|
|
136
160
|
class="textarea"
|
|
137
|
-
aria-label={slot.label}
|
|
138
161
|
aria-invalid={Boolean(errors[slot.name])}
|
|
139
162
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
140
163
|
rows={3}
|
|
@@ -147,7 +170,6 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
147
170
|
<span class="text-sm font-medium">{slot.label}</span>
|
|
148
171
|
<input
|
|
149
172
|
class="input"
|
|
150
|
-
aria-label={slot.label}
|
|
151
173
|
aria-invalid={Boolean(errors[slot.name])}
|
|
152
174
|
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
153
175
|
value={slotString(slot.name)}
|
|
@@ -160,16 +182,17 @@ markdown. Back returns to the picker. This is not a nested HTML form; Insert cal
|
|
|
160
182
|
|
|
161
183
|
{#each repeatableSlots as slot (slot.name)}
|
|
162
184
|
{@const items = slotItems(slot.name)}
|
|
185
|
+
{@const ids = slotIds(slot.name)}
|
|
163
186
|
<fieldset class="rounded-box border border-base-300 flex flex-col gap-2 p-2">
|
|
164
187
|
<legend class="text-sm font-medium">{slot.label}</legend>
|
|
165
|
-
<!--
|
|
166
|
-
{#each
|
|
188
|
+
<!-- Keyed by the parallel stable id so a mid-list removal drops the right node and focus follows the data; the value still binds to the canonical items[i] string the serializer reads. -->
|
|
189
|
+
{#each ids as id, i (id)}
|
|
167
190
|
<div class="flex items-center gap-2">
|
|
168
|
-
<input class="input input-sm flex-1" aria-label={`${slot.label}
|
|
169
|
-
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() =>
|
|
191
|
+
<input class="input input-sm flex-1" aria-label={`${slot.label} ${i + 1}`} bind:value={items[i]} />
|
|
192
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => removeItem(slot.name, i)}>✕</button>
|
|
170
193
|
</div>
|
|
171
194
|
{/each}
|
|
172
|
-
<button type="button" class="btn btn-sm self-start" onclick={() =>
|
|
195
|
+
<button type="button" class="btn btn-sm self-start" onclick={() => addItem(slot.name)}>Add item</button>
|
|
173
196
|
{#if errors[slot.name]}<span id={`err-${slot.name}`} role="alert" class="text-error text-xs">{errors[slot.name]}</span>{/if}
|
|
174
197
|
</fieldset>
|
|
175
198
|
{/each}
|