@glw907/cairn-cms 0.9.0 → 0.10.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 +178 -0
- package/dist/components/ComponentForm.svelte.d.ts +20 -0
- package/dist/components/ComponentForm.svelte.d.ts.map +1 -0
- package/dist/components/ComponentInsertDialog.svelte +92 -0
- package/dist/components/ComponentInsertDialog.svelte.d.ts +20 -0
- package/dist/components/ComponentInsertDialog.svelte.d.ts.map +1 -0
- package/dist/components/EditPage.svelte +6 -3
- package/dist/components/EditPage.svelte.d.ts +3 -0
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/components/IconPicker.svelte +51 -0
- package/dist/components/IconPicker.svelte.d.ts +20 -0
- package/dist/components/IconPicker.svelte.d.ts.map +1 -0
- package/dist/components/index.d.ts +3 -1
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +3 -1
- package/dist/content/compose.d.ts.map +1 -1
- package/dist/content/compose.js +1 -0
- package/dist/content/types.d.ts +5 -0
- package/dist/content/types.d.ts.map +1 -1
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/render/component-grammar.d.ts +10 -0
- package/dist/render/component-grammar.d.ts.map +1 -0
- package/dist/render/component-grammar.js +140 -0
- package/dist/render/component-insert.d.ts +14 -0
- package/dist/render/component-insert.d.ts.map +1 -0
- package/dist/render/component-insert.js +9 -0
- package/dist/render/component-reference.d.ts +11 -0
- package/dist/render/component-reference.d.ts.map +1 -0
- package/dist/render/component-reference.js +34 -0
- package/dist/render/component-validate.d.ts +10 -0
- package/dist/render/component-validate.d.ts.map +1 -0
- package/dist/render/component-validate.js +30 -0
- package/dist/render/registry.d.ts +45 -1
- package/dist/render/registry.d.ts.map +1 -1
- package/dist/render/registry.js +13 -0
- package/package.json +2 -1
- package/src/lib/components/ComponentForm.svelte +178 -0
- package/src/lib/components/ComponentInsertDialog.svelte +92 -0
- package/src/lib/components/EditPage.svelte +6 -3
- package/src/lib/components/IconPicker.svelte +51 -0
- package/src/lib/components/index.ts +3 -1
- package/src/lib/content/compose.ts +1 -0
- package/src/lib/content/types.ts +5 -0
- package/src/lib/index.ts +16 -2
- package/src/lib/render/component-grammar.ts +167 -0
- package/src/lib/render/component-insert.ts +15 -0
- package/src/lib/render/component-reference.ts +38 -0
- package/src/lib/render/component-validate.ts +36 -0
- package/src/lib/render/registry.ts +61 -1
- package/dist/components/ComponentPalette.svelte +0 -50
- package/dist/components/ComponentPalette.svelte.d.ts +0 -16
- package/dist/components/ComponentPalette.svelte.d.ts.map +0 -1
- package/src/lib/components/ComponentPalette.svelte +0 -50
|
@@ -7,8 +7,9 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
7
7
|
<script lang="ts">
|
|
8
8
|
import { untrack } from 'svelte';
|
|
9
9
|
import MarkdownEditor from './MarkdownEditor.svelte';
|
|
10
|
-
import
|
|
10
|
+
import ComponentInsertDialog from './ComponentInsertDialog.svelte';
|
|
11
11
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
12
|
+
import type { IconSet } from '../render/glyph.js';
|
|
12
13
|
import type { EditData } from '../sveltekit/content-routes.js';
|
|
13
14
|
import type { TextareaField, TagsField, FreeTagsField } from '../content/types.js';
|
|
14
15
|
import { sanitizePreviewHtml } from '../render/sanitize.js';
|
|
@@ -20,9 +21,11 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
20
21
|
registry?: ComponentRegistry;
|
|
21
22
|
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
22
23
|
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
24
|
+
/** The site's icon set, for the guided form's icon fields. */
|
|
25
|
+
icons?: IconSet;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
|
-
let { data, registry, render }: Props = $props();
|
|
28
|
+
let { data, registry, render, icons }: Props = $props();
|
|
26
29
|
|
|
27
30
|
// `body` is local editor state seeded once from the prop; it diverges as the user types.
|
|
28
31
|
// untrack() captures the initial value without subscribing to future prop changes.
|
|
@@ -76,7 +79,7 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
76
79
|
<p class="text-xs text-[var(--color-muted)]">{data.label}: {data.id}</p>
|
|
77
80
|
</div>
|
|
78
81
|
<div class="flex items-center gap-2">
|
|
79
|
-
<
|
|
82
|
+
<ComponentInsertDialog {registry} {insert} {icons} />
|
|
80
83
|
<button
|
|
81
84
|
type="button"
|
|
82
85
|
class="btn btn-sm btn-ghost"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
A visual icon choice over the site's IconSet. Each glyph is a toggle button; the selected one carries
|
|
4
|
+
aria-pressed. When the field is optional, a None button clears the value. The glyph renders inline from
|
|
5
|
+
the IconSet path data, matching the renderer's 256-unit viewBox.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import type { IconSet } from '../render/glyph.js';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The site's glyph name to SVG path-data map. */
|
|
12
|
+
icons: IconSet;
|
|
13
|
+
/** The currently selected glyph name, or '' for none. */
|
|
14
|
+
value: string;
|
|
15
|
+
/** When false, a None choice is offered. */
|
|
16
|
+
required: boolean;
|
|
17
|
+
/** Called with the new glyph name (or '' for none). */
|
|
18
|
+
onChange: (name: string) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let { icons, value, required, onChange }: Props = $props();
|
|
22
|
+
|
|
23
|
+
const names = $derived(Object.keys(icons));
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
<div class="flex flex-wrap gap-2" role="group" aria-label="Icon">
|
|
27
|
+
{#if !required}
|
|
28
|
+
<button
|
|
29
|
+
type="button"
|
|
30
|
+
class="btn btn-sm"
|
|
31
|
+
class:btn-primary={value === ''}
|
|
32
|
+
aria-pressed={value === ''}
|
|
33
|
+
onclick={() => onChange('')}
|
|
34
|
+
>None</button>
|
|
35
|
+
{/if}
|
|
36
|
+
{#each names as name (name)}
|
|
37
|
+
<button
|
|
38
|
+
type="button"
|
|
39
|
+
class="btn btn-sm gap-1"
|
|
40
|
+
class:btn-primary={value === name}
|
|
41
|
+
aria-pressed={value === name}
|
|
42
|
+
aria-label={name}
|
|
43
|
+
onclick={() => onChange(name)}
|
|
44
|
+
>
|
|
45
|
+
<svg class="ec-glyph" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" width="16" height="16">
|
|
46
|
+
<path d={icons[name]} />
|
|
47
|
+
</svg>
|
|
48
|
+
<span class="text-xs">{name}</span>
|
|
49
|
+
</button>
|
|
50
|
+
{/each}
|
|
51
|
+
</div>
|
|
@@ -7,5 +7,7 @@ export { default as ConceptList } from './ConceptList.svelte';
|
|
|
7
7
|
export { default as EditPage } from './EditPage.svelte';
|
|
8
8
|
export { default as ManageEditors } from './ManageEditors.svelte';
|
|
9
9
|
export { default as MarkdownEditor } from './MarkdownEditor.svelte';
|
|
10
|
-
export { default as
|
|
10
|
+
export { default as ComponentInsertDialog } from './ComponentInsertDialog.svelte';
|
|
11
|
+
export { default as ComponentForm } from './ComponentForm.svelte';
|
|
12
|
+
export { default as IconPicker } from './IconPicker.svelte';
|
|
11
13
|
export { default as NavTree } from './NavTree.svelte';
|
package/src/lib/content/types.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// descriptors are plain data so a `load` function can hand them across the server-to-client
|
|
9
9
|
// boundary to the editor form.
|
|
10
10
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
11
|
+
import type { IconSet } from '../render/glyph.js';
|
|
11
12
|
import type { DatePrefix } from './ids.js';
|
|
12
13
|
|
|
13
14
|
/** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
|
|
@@ -151,6 +152,8 @@ export interface CairnAdapter {
|
|
|
151
152
|
render(md: string, opts?: { stagger?: boolean }): string | Promise<string>;
|
|
152
153
|
/** Directive component registry; the renderer and the future palette derive from it (seam 3). */
|
|
153
154
|
registry?: ComponentRegistry;
|
|
155
|
+
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
156
|
+
icons?: IconSet;
|
|
154
157
|
navMenu?: NavMenuConfig;
|
|
155
158
|
assets?: AssetConfig;
|
|
156
159
|
}
|
|
@@ -243,6 +246,8 @@ export interface CairnRuntime {
|
|
|
243
246
|
/** The site's one renderer: the editor preview and every public page call it (design decision 4). */
|
|
244
247
|
render(md: string, opts?: { stagger?: boolean }): string | Promise<string>;
|
|
245
248
|
registry?: ComponentRegistry;
|
|
249
|
+
/** The site's glyph name to SVG path-data map, for the admin icon picker and the renderer. */
|
|
250
|
+
icons?: IconSet;
|
|
246
251
|
navMenu?: NavMenuConfig;
|
|
247
252
|
assets?: AssetConfig;
|
|
248
253
|
/** Admin panels contributed by extensions (Mode 2). Empty until Plan 09 wires the dispatch route. */
|
package/src/lib/index.ts
CHANGED
|
@@ -48,8 +48,22 @@ export {
|
|
|
48
48
|
} from './content/ids.js';
|
|
49
49
|
export type { DatePrefix } from './content/ids.js';
|
|
50
50
|
// Render engine (Plan 04): generic directive pipeline; sites own the component registry.
|
|
51
|
-
export { defineRegistry } from './render/registry.js';
|
|
52
|
-
export type {
|
|
51
|
+
export { defineRegistry, emptyValues } from './render/registry.js';
|
|
52
|
+
export type {
|
|
53
|
+
ComponentDef,
|
|
54
|
+
ComponentRegistry,
|
|
55
|
+
FieldType,
|
|
56
|
+
AttributeField,
|
|
57
|
+
SlotKind,
|
|
58
|
+
SlotDef,
|
|
59
|
+
ComponentValues,
|
|
60
|
+
} from './render/registry.js';
|
|
61
|
+
export { serializeComponent, parseComponent } from './render/component-grammar.js';
|
|
62
|
+
export { validateComponent } from './render/component-validate.js';
|
|
63
|
+
export type { ComponentValidation } from './render/component-validate.js';
|
|
64
|
+
export { buildComponentInsert, type ComponentInsert } from './render/component-insert.js';
|
|
65
|
+
export { generateComponentReference } from './render/component-reference.js';
|
|
66
|
+
export type { ReferenceOptions } from './render/component-reference.js';
|
|
53
67
|
export { glyph } from './render/glyph.js';
|
|
54
68
|
export type { IconSet } from './render/glyph.js';
|
|
55
69
|
export { remarkDirectiveStamp } from './render/remark-directives.js';
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkDirective from 'remark-directive';
|
|
4
|
+
import remarkStringify from 'remark-stringify';
|
|
5
|
+
import type { Root, RootContent } from 'mdast';
|
|
6
|
+
import type { ComponentDef, ComponentValues, SlotDef } from './registry.js';
|
|
7
|
+
|
|
8
|
+
const COLON = ':';
|
|
9
|
+
|
|
10
|
+
function attrBlock(def: ComponentDef, values: ComponentValues): string {
|
|
11
|
+
const parts: string[] = [];
|
|
12
|
+
for (const field of def.attributes ?? []) {
|
|
13
|
+
const v = values.attributes[field.key];
|
|
14
|
+
if (field.type === 'boolean') {
|
|
15
|
+
if (v === true) parts.push(`${field.key}="true"`);
|
|
16
|
+
} else if (typeof v === 'string' && v !== '') {
|
|
17
|
+
// The directive attribute grammar (mdast-util-directive) treats a literal `"` as the value
|
|
18
|
+
// terminator and decodes HTML entities, so a backslash escape does not survive a round-trip.
|
|
19
|
+
// Encode `&` first (so existing entities are not double-decoded) then `"`; the parser decodes
|
|
20
|
+
// both back. A backslash is literal in this grammar and needs no escaping.
|
|
21
|
+
const escaped = v.replace(/&/g, '&').replace(/"/g, '"');
|
|
22
|
+
parts.push(`${field.key}="${escaped}"`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return parts.length ? `{${parts.join(' ')}}` : '';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function slotByName(def: ComponentDef, name: string): SlotDef | undefined {
|
|
29
|
+
return (def.slots ?? []).find((s) => s.name === name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nestedSlots(def: ComponentDef): SlotDef[] {
|
|
33
|
+
return (def.slots ?? []).filter((s) => s.name !== 'title' && s.name !== 'body');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function serializeComponent(def: ComponentDef, values: ComponentValues): string {
|
|
37
|
+
const fence = COLON.repeat(nestedSlots(def).length > 0 ? 4 : 3);
|
|
38
|
+
|
|
39
|
+
const title = slotByName(def, 'title') ? (values.slots.title as string) ?? '' : '';
|
|
40
|
+
// Escape brackets in the label so a `[` or `]` in the title does not break the directive label
|
|
41
|
+
// grammar; remark un-escapes them back to literal text on parse, so readLabel recovers them.
|
|
42
|
+
const label = title ? `[${title.replace(/\[/g, '\\[').replace(/\]/g, '\\]')}]` : '';
|
|
43
|
+
|
|
44
|
+
const open = `${fence}${def.name}${label}${attrBlock(def, values)}`;
|
|
45
|
+
|
|
46
|
+
const lines: string[] = [open];
|
|
47
|
+
const body = slotByName(def, 'body') ? (values.slots.body as string) ?? '' : '';
|
|
48
|
+
if (body) lines.push(body);
|
|
49
|
+
|
|
50
|
+
for (const slot of nestedSlots(def)) {
|
|
51
|
+
const raw = values.slots[slot.name];
|
|
52
|
+
const content =
|
|
53
|
+
slot.kind === 'repeatable'
|
|
54
|
+
? (Array.isArray(raw) ? raw : []).filter((i) => i !== '').map((i) => `- ${i}`).join('\n')
|
|
55
|
+
: ((raw as string | undefined) ?? '');
|
|
56
|
+
if (!content) continue;
|
|
57
|
+
if (lines.length > 1) lines.push(''); // blank line before this block
|
|
58
|
+
lines.push(`${COLON.repeat(3)}${slot.name}`, content, COLON.repeat(3));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
lines.push(fence);
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A minimal structural view of a mdast containerDirective node (mdast-util-directive shape).
|
|
66
|
+
interface DirectiveNode {
|
|
67
|
+
type: 'containerDirective' | 'leafDirective' | 'textDirective';
|
|
68
|
+
name: string;
|
|
69
|
+
attributes?: Record<string, string | null> | null;
|
|
70
|
+
children: RootContent[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function isContainer(node: RootContent): node is RootContent & DirectiveNode {
|
|
74
|
+
return (node as DirectiveNode).type === 'containerDirective';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Pin the bullet to `-` so a markdown body or slot that uses dash bullets round-trips unchanged
|
|
78
|
+
// rather than drifting to remark-stringify's default `*`, which would silently mutate author content.
|
|
79
|
+
const toMd = unified().use(remarkStringify, { bullet: '-' });
|
|
80
|
+
|
|
81
|
+
/** Render mdast children back to trimmed markdown text. */
|
|
82
|
+
function childrenToText(children: RootContent[]): string {
|
|
83
|
+
const root: Root = { type: 'root', children };
|
|
84
|
+
return String(toMd.stringify(root)).trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Parse a serialized component directive back into guided-form values, the inverse of
|
|
88
|
+
* {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
|
|
89
|
+
* saved directive through the form. */
|
|
90
|
+
export async function parseComponent(markdown: string, def: ComponentDef): Promise<ComponentValues> {
|
|
91
|
+
const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown) as Root;
|
|
92
|
+
const root = tree.children.find(
|
|
93
|
+
(c): c is RootContent & DirectiveNode => isContainer(c) && (c as DirectiveNode).name === def.name,
|
|
94
|
+
);
|
|
95
|
+
const values = emptyComponentValues(def);
|
|
96
|
+
if (!root) return values;
|
|
97
|
+
|
|
98
|
+
for (const field of def.attributes ?? []) {
|
|
99
|
+
const raw = root.attributes?.[field.key];
|
|
100
|
+
if (field.type === 'boolean') values.attributes[field.key] = raw === 'true';
|
|
101
|
+
else if (typeof raw === 'string') values.attributes[field.key] = raw;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const titleSlot = slotByName(def, 'title');
|
|
105
|
+
const bodySlot = slotByName(def, 'body');
|
|
106
|
+
const nested = nestedSlots(def);
|
|
107
|
+
const nestedNames = new Set(nested.map((s) => s.name));
|
|
108
|
+
|
|
109
|
+
const directChildren = root.children.filter(
|
|
110
|
+
(c) => !(isContainer(c) && nestedNames.has((c as DirectiveNode).name)) && !isDirectiveLabel(c),
|
|
111
|
+
);
|
|
112
|
+
const nestedChildren = root.children.filter(
|
|
113
|
+
(c): c is RootContent & DirectiveNode => isContainer(c) && nestedNames.has((c as DirectiveNode).name),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (titleSlot) values.slots.title = readLabel(root) ?? '';
|
|
117
|
+
if (bodySlot) values.slots.body = childrenToText(directChildren);
|
|
118
|
+
|
|
119
|
+
for (const slot of nested) {
|
|
120
|
+
const node = nestedChildren.find((c) => c.name === slot.name);
|
|
121
|
+
if (!node) continue;
|
|
122
|
+
if (slot.kind === 'repeatable') values.slots[slot.name] = readListItems(node.children);
|
|
123
|
+
else values.slots[slot.name] = childrenToText(node.children);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return values;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The raw attribute keys present on the component's opening directive, read from the parsed tree
|
|
130
|
+
* (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
|
|
131
|
+
export function parseRawAttributeKeys(markdown: string, def: ComponentDef): string[] {
|
|
132
|
+
const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown) as Root;
|
|
133
|
+
const root = tree.children.find(
|
|
134
|
+
(c): c is RootContent & DirectiveNode => isContainer(c) && (c as DirectiveNode).name === def.name,
|
|
135
|
+
);
|
|
136
|
+
return Object.keys(root?.attributes ?? {});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A bare parse base: empty strings, false, and empty lists, with no attribute defaults applied. The
|
|
140
|
+
// `emptyValues` helper in registry.ts seeds form defaults instead, so it is deliberately not reused
|
|
141
|
+
// here; the parse must overwrite only the fields actually present in the markdown.
|
|
142
|
+
function emptyComponentValues(def: ComponentDef): ComponentValues {
|
|
143
|
+
const attributes: Record<string, string | boolean> = {};
|
|
144
|
+
for (const f of def.attributes ?? []) attributes[f.key] = f.type === 'boolean' ? false : '';
|
|
145
|
+
const slots: Record<string, string | string[]> = {};
|
|
146
|
+
for (const s of def.slots ?? []) slots[s.name] = s.kind === 'repeatable' ? [] : '';
|
|
147
|
+
return { attributes, slots };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// mdast-util-directive carries the `[label]` as a paragraph whose `data.directiveLabel` is set.
|
|
151
|
+
function isDirectiveLabel(node: RootContent): boolean {
|
|
152
|
+
return Boolean((node as { data?: { directiveLabel?: boolean } }).data?.directiveLabel);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function readLabel(root: DirectiveNode): string | undefined {
|
|
156
|
+
for (const child of root.children) {
|
|
157
|
+
const p = child as { type: string; data?: { directiveLabel?: boolean }; children?: { value?: string }[] };
|
|
158
|
+
if (p.type === 'paragraph' && p.data?.directiveLabel) return (p.children ?? []).map((c) => c.value ?? '').join('');
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function readListItems(children: RootContent[]): string[] {
|
|
164
|
+
const list = children.find((c) => (c as { type: string }).type === 'list') as { children?: RootContent[] } | undefined;
|
|
165
|
+
if (!list?.children) return [];
|
|
166
|
+
return list.children.map((li) => childrenToText((li as { children?: RootContent[] }).children ?? []));
|
|
167
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { serializeComponent } from './component-grammar.js';
|
|
2
|
+
import { validateComponent } from './component-validate.js';
|
|
3
|
+
import type { ComponentDef, ComponentValues } from './registry.js';
|
|
4
|
+
|
|
5
|
+
/** The outcome of preparing a guided-form component for insertion: the markdown to insert, or the
|
|
6
|
+
* field-keyed errors to show on the form. */
|
|
7
|
+
export type ComponentInsert = { ok: true; markdown: string } | { ok: false; errors: Record<string, string> };
|
|
8
|
+
|
|
9
|
+
/** Serialize a component's form values, then validate the result against its schema. Returns the
|
|
10
|
+
* markdown to insert at the cursor, or the field errors keyed by attribute key or slot name. */
|
|
11
|
+
export async function buildComponentInsert(def: ComponentDef, values: ComponentValues): Promise<ComponentInsert> {
|
|
12
|
+
const markdown = serializeComponent(def, values);
|
|
13
|
+
const verdict = await validateComponent(markdown, def);
|
|
14
|
+
return verdict.ok ? { ok: true, markdown } : { ok: false, errors: verdict.errors };
|
|
15
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { serializeComponent } from './component-grammar.js';
|
|
2
|
+
import { emptyValues, type ComponentDef, type ComponentRegistry, type ComponentValues } from './registry.js';
|
|
3
|
+
|
|
4
|
+
export interface ReferenceOptions {
|
|
5
|
+
/** The H1 title of the reference document. */
|
|
6
|
+
title: string;
|
|
7
|
+
/** The one-line blockquote summary under the title. */
|
|
8
|
+
summary: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build a self-contained markdown reference (the llms-full.txt shape) for a component registry, for
|
|
12
|
+
* authors and for pointing an LLM at one curated file. */
|
|
13
|
+
export function generateComponentReference(registry: ComponentRegistry, opts: ReferenceOptions): string {
|
|
14
|
+
const sections = registry.defs.map((def) => componentSection(def));
|
|
15
|
+
return `# ${opts.title}\n\n> ${opts.summary}\n\n${sections.join('\n\n')}\n`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function componentSection(def: ComponentDef): string {
|
|
19
|
+
const lines = [`## ${def.label} (\`:::${def.name}\`)`, '', def.description ?? ''];
|
|
20
|
+
if (def.use) lines.push('', `**When to use:** ${def.use}`);
|
|
21
|
+
lines.push('', '```', serializeComponent(def, exampleValues(def)), '```');
|
|
22
|
+
return lines.join('\n');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Seed example values that show every declared field: an ellipsis for strings, one sample list item. */
|
|
26
|
+
function exampleValues(def: ComponentDef): ComponentValues {
|
|
27
|
+
const values = emptyValues(def);
|
|
28
|
+
for (const field of def.attributes ?? []) {
|
|
29
|
+
if (field.type === 'boolean') values.attributes[field.key] = false;
|
|
30
|
+
else values.attributes[field.key] = field.options?.[0] ?? '…';
|
|
31
|
+
}
|
|
32
|
+
for (const slot of def.slots ?? []) {
|
|
33
|
+
if (slot.kind === 'repeatable') values.slots[slot.name] = ['…'];
|
|
34
|
+
else if (slot.name === 'title') values.slots[slot.name] = 'Title';
|
|
35
|
+
else values.slots[slot.name] = '…';
|
|
36
|
+
}
|
|
37
|
+
return values;
|
|
38
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { parseComponent, parseRawAttributeKeys } from './component-grammar.js';
|
|
2
|
+
import type { ComponentDef } from './registry.js';
|
|
3
|
+
|
|
4
|
+
/** A validation verdict: ok, or field-keyed error messages. */
|
|
5
|
+
export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
|
|
6
|
+
|
|
7
|
+
export async function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation> {
|
|
8
|
+
const values = await parseComponent(markdown, def);
|
|
9
|
+
const errors: Record<string, string> = {};
|
|
10
|
+
const declared = new Set((def.attributes ?? []).map((f) => f.key));
|
|
11
|
+
|
|
12
|
+
for (const field of def.attributes ?? []) {
|
|
13
|
+
const v = values.attributes[field.key];
|
|
14
|
+
const filled = field.type === 'boolean' ? true : typeof v === 'string' && v !== '';
|
|
15
|
+
if (field.required && !filled) {
|
|
16
|
+
errors[field.key] = `${field.label} is required.`;
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
|
|
20
|
+
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
for (const key of parseRawAttributeKeys(markdown, def)) {
|
|
25
|
+
if (!declared.has(key)) errors[key] = `Unknown attribute "${key}".`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for (const slot of def.slots ?? []) {
|
|
29
|
+
if (!slot.required) continue;
|
|
30
|
+
const v = values.slots[slot.name];
|
|
31
|
+
const filled = Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v !== '';
|
|
32
|
+
if (!filled) errors[slot.name] = `${slot.label} is required.`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
|
|
36
|
+
}
|
|
@@ -5,6 +5,39 @@
|
|
|
5
5
|
// `ComponentRegistry` from here.
|
|
6
6
|
import type { Element } from 'hast';
|
|
7
7
|
|
|
8
|
+
/** The input types a component attribute or repeatable item field can take. */
|
|
9
|
+
export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
|
|
10
|
+
|
|
11
|
+
/** One `{key="value"}` attribute on a component directive, or one field of a repeatable item. */
|
|
12
|
+
export interface AttributeField {
|
|
13
|
+
/** The attribute name as it appears in the directive, e.g. `icon`. */
|
|
14
|
+
key: string;
|
|
15
|
+
/** The form label. */
|
|
16
|
+
label: string;
|
|
17
|
+
type: FieldType;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
/** Initial value; a string for text/select/icon, a boolean for boolean. */
|
|
20
|
+
default?: string | boolean;
|
|
21
|
+
/** Allowed values for `type: 'select'`. */
|
|
22
|
+
options?: string[];
|
|
23
|
+
/** Helper text shown under the field. */
|
|
24
|
+
help?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SlotKind = 'markdown' | 'inline' | 'repeatable';
|
|
28
|
+
|
|
29
|
+
/** One named content region of a component. The slots named `title` and `body` are special: `title`
|
|
30
|
+
* serializes to the directive `[label]` and `body` to the unmarked content (see the canonical grammar). */
|
|
31
|
+
export interface SlotDef {
|
|
32
|
+
name: string;
|
|
33
|
+
label: string;
|
|
34
|
+
kind: SlotKind;
|
|
35
|
+
required?: boolean;
|
|
36
|
+
help?: string;
|
|
37
|
+
/** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
|
|
38
|
+
itemFields?: AttributeField[];
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
/** A site component: how it inserts (editor) and how it renders (rehype). */
|
|
9
42
|
export interface ComponentDef {
|
|
10
43
|
/** Directive name, e.g. 'card' (matches `:::card`). */
|
|
@@ -14,13 +47,19 @@ export interface ComponentDef {
|
|
|
14
47
|
/** Palette description. */
|
|
15
48
|
description: string;
|
|
16
49
|
/** Markdown scaffold inserted at the cursor by the editor palette. */
|
|
17
|
-
insertTemplate
|
|
50
|
+
insertTemplate?: string;
|
|
18
51
|
/** Build the final hast element from the stamped directive element. The engine
|
|
19
52
|
* stamps the entrance-stagger ordinal (`data-rise`) on the top-level result, so a
|
|
20
53
|
* build fn stays free of any motion concern. */
|
|
21
54
|
build: (node: Element) => Element;
|
|
22
55
|
/** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
|
|
23
56
|
defaultIconByRole?: Record<string, string>;
|
|
57
|
+
/** One line on when to reach for this component; feeds the picker and the reference file. */
|
|
58
|
+
use?: string;
|
|
59
|
+
/** The `{key="value"}` attributes this component accepts. */
|
|
60
|
+
attributes?: AttributeField[];
|
|
61
|
+
/** The named content regions this component accepts. */
|
|
62
|
+
slots?: SlotDef[];
|
|
24
63
|
}
|
|
25
64
|
|
|
26
65
|
export interface ComponentRegistry {
|
|
@@ -43,3 +82,24 @@ export function defineRegistry({ components }: { components: ComponentDef[] }):
|
|
|
43
82
|
defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
|
|
44
83
|
};
|
|
45
84
|
}
|
|
85
|
+
|
|
86
|
+
/** Guided-form values for one component: attribute values keyed by attribute key, slot values keyed
|
|
87
|
+
* by slot name (a string, or a string list for a repeatable slot). */
|
|
88
|
+
export interface ComponentValues {
|
|
89
|
+
attributes: Record<string, string | boolean>;
|
|
90
|
+
slots: Record<string, string | string[]>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Seed an empty {@link ComponentValues} from a component's schema: attribute defaults (or '' / false)
|
|
94
|
+
* and empty slot values ([] for repeatable, '' otherwise). */
|
|
95
|
+
export function emptyValues(def: ComponentDef): ComponentValues {
|
|
96
|
+
const attributes: Record<string, string | boolean> = {};
|
|
97
|
+
for (const field of def.attributes ?? []) {
|
|
98
|
+
attributes[field.key] = field.default ?? (field.type === 'boolean' ? false : '');
|
|
99
|
+
}
|
|
100
|
+
const slots: Record<string, string | string[]> = {};
|
|
101
|
+
for (const slot of def.slots ?? []) {
|
|
102
|
+
slots[slot.name] = slot.kind === 'repeatable' ? [] : '';
|
|
103
|
+
}
|
|
104
|
+
return { attributes, slots };
|
|
105
|
+
}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
@component
|
|
3
|
-
The insert-component palette: a dropdown listing the site's registered directive components
|
|
4
|
-
(seam 3). Picking one inserts its template at the cursor through the editor's insert callback.
|
|
5
|
-
Renders nothing when the site configures no registry.
|
|
6
|
-
-->
|
|
7
|
-
<script lang="ts">
|
|
8
|
-
import type { ComponentRegistry } from '../render/registry.js';
|
|
9
|
-
|
|
10
|
-
interface Props {
|
|
11
|
-
/** The site's component registry; the palette derives its catalog from it. */
|
|
12
|
-
registry?: ComponentRegistry;
|
|
13
|
-
/** Insert a template at the editor's cursor. */
|
|
14
|
-
insert: (template: string) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let { registry, insert }: Props = $props();
|
|
18
|
-
|
|
19
|
-
const defs = $derived(registry?.defs ?? []);
|
|
20
|
-
let open = $state(false);
|
|
21
|
-
</script>
|
|
22
|
-
|
|
23
|
-
{#if defs.length > 0}
|
|
24
|
-
<div
|
|
25
|
-
class="dropdown"
|
|
26
|
-
class:dropdown-open={open}
|
|
27
|
-
role="presentation"
|
|
28
|
-
onkeydown={(e) => { if (e.key === 'Escape') open = false; }}
|
|
29
|
-
>
|
|
30
|
-
<button
|
|
31
|
-
type="button"
|
|
32
|
-
class="btn btn-sm btn-ghost"
|
|
33
|
-
aria-haspopup="listbox"
|
|
34
|
-
aria-expanded={open}
|
|
35
|
-
onclick={() => (open = !open)}
|
|
36
|
-
>Insert</button>
|
|
37
|
-
<ul class="dropdown-content menu rounded-box border border-base-300 bg-base-100 z-10 w-56 shadow" role="listbox">
|
|
38
|
-
{#each defs as def (def.name)}
|
|
39
|
-
<li role="option" aria-selected={false}>
|
|
40
|
-
<button type="button" onclick={() => { insert(def.insertTemplate); open = false; }}>
|
|
41
|
-
<span class="flex flex-col items-start">
|
|
42
|
-
<span class="font-medium">{def.label}</span>
|
|
43
|
-
<span class="text-xs text-[var(--color-muted)]">{def.description}</span>
|
|
44
|
-
</span>
|
|
45
|
-
</button>
|
|
46
|
-
</li>
|
|
47
|
-
{/each}
|
|
48
|
-
</ul>
|
|
49
|
-
</div>
|
|
50
|
-
{/if}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import type { ComponentRegistry } from '../render/registry.js';
|
|
2
|
-
interface Props {
|
|
3
|
-
/** The site's component registry; the palette derives its catalog from it. */
|
|
4
|
-
registry?: ComponentRegistry;
|
|
5
|
-
/** Insert a template at the editor's cursor. */
|
|
6
|
-
insert: (template: string) => void;
|
|
7
|
-
}
|
|
8
|
-
/**
|
|
9
|
-
* The insert-component palette: a dropdown listing the site's registered directive components
|
|
10
|
-
* (seam 3). Picking one inserts its template at the cursor through the editor's insert callback.
|
|
11
|
-
* Renders nothing when the site configures no registry.
|
|
12
|
-
*/
|
|
13
|
-
declare const ComponentPalette: import("svelte").Component<Props, {}, "">;
|
|
14
|
-
type ComponentPalette = ReturnType<typeof ComponentPalette>;
|
|
15
|
-
export default ComponentPalette;
|
|
16
|
-
//# sourceMappingURL=ComponentPalette.svelte.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"ComponentPalette.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/components/ComponentPalette.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAG7D,UAAU,KAAK;IACb,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,iBAAiB,CAAC;IAC7B,gDAAgD;IAChD,MAAM,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACpC;AAgCH;;;;GAIG;AACH,QAAA,MAAM,gBAAgB,2CAAwC,CAAC;AAC/D,KAAK,gBAAgB,GAAG,UAAU,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAC5D,eAAe,gBAAgB,CAAC"}
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
<!--
|
|
2
|
-
@component
|
|
3
|
-
The insert-component palette: a dropdown listing the site's registered directive components
|
|
4
|
-
(seam 3). Picking one inserts its template at the cursor through the editor's insert callback.
|
|
5
|
-
Renders nothing when the site configures no registry.
|
|
6
|
-
-->
|
|
7
|
-
<script lang="ts">
|
|
8
|
-
import type { ComponentRegistry } from '../render/registry.js';
|
|
9
|
-
|
|
10
|
-
interface Props {
|
|
11
|
-
/** The site's component registry; the palette derives its catalog from it. */
|
|
12
|
-
registry?: ComponentRegistry;
|
|
13
|
-
/** Insert a template at the editor's cursor. */
|
|
14
|
-
insert: (template: string) => void;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
let { registry, insert }: Props = $props();
|
|
18
|
-
|
|
19
|
-
const defs = $derived(registry?.defs ?? []);
|
|
20
|
-
let open = $state(false);
|
|
21
|
-
</script>
|
|
22
|
-
|
|
23
|
-
{#if defs.length > 0}
|
|
24
|
-
<div
|
|
25
|
-
class="dropdown"
|
|
26
|
-
class:dropdown-open={open}
|
|
27
|
-
role="presentation"
|
|
28
|
-
onkeydown={(e) => { if (e.key === 'Escape') open = false; }}
|
|
29
|
-
>
|
|
30
|
-
<button
|
|
31
|
-
type="button"
|
|
32
|
-
class="btn btn-sm btn-ghost"
|
|
33
|
-
aria-haspopup="listbox"
|
|
34
|
-
aria-expanded={open}
|
|
35
|
-
onclick={() => (open = !open)}
|
|
36
|
-
>Insert</button>
|
|
37
|
-
<ul class="dropdown-content menu rounded-box border border-base-300 bg-base-100 z-10 w-56 shadow" role="listbox">
|
|
38
|
-
{#each defs as def (def.name)}
|
|
39
|
-
<li role="option" aria-selected={false}>
|
|
40
|
-
<button type="button" onclick={() => { insert(def.insertTemplate); open = false; }}>
|
|
41
|
-
<span class="flex flex-col items-start">
|
|
42
|
-
<span class="font-medium">{def.label}</span>
|
|
43
|
-
<span class="text-xs text-[var(--color-muted)]">{def.description}</span>
|
|
44
|
-
</span>
|
|
45
|
-
</button>
|
|
46
|
-
</li>
|
|
47
|
-
{/each}
|
|
48
|
-
</ul>
|
|
49
|
-
</div>
|
|
50
|
-
{/if}
|