@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { unified } from 'unified';
|
|
2
|
+
import remarkParse from 'remark-parse';
|
|
3
|
+
import remarkDirective from 'remark-directive';
|
|
4
|
+
import remarkStringify from 'remark-stringify';
|
|
5
|
+
const COLON = ':';
|
|
6
|
+
function attrBlock(def, values) {
|
|
7
|
+
const parts = [];
|
|
8
|
+
for (const field of def.attributes ?? []) {
|
|
9
|
+
const v = values.attributes[field.key];
|
|
10
|
+
if (field.type === 'boolean') {
|
|
11
|
+
if (v === true)
|
|
12
|
+
parts.push(`${field.key}="true"`);
|
|
13
|
+
}
|
|
14
|
+
else if (typeof v === 'string' && v !== '') {
|
|
15
|
+
// The directive attribute grammar (mdast-util-directive) treats a literal `"` as the value
|
|
16
|
+
// terminator and decodes HTML entities, so a backslash escape does not survive a round-trip.
|
|
17
|
+
// Encode `&` first (so existing entities are not double-decoded) then `"`; the parser decodes
|
|
18
|
+
// both back. A backslash is literal in this grammar and needs no escaping.
|
|
19
|
+
const escaped = v.replace(/&/g, '&').replace(/"/g, '"');
|
|
20
|
+
parts.push(`${field.key}="${escaped}"`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return parts.length ? `{${parts.join(' ')}}` : '';
|
|
24
|
+
}
|
|
25
|
+
function slotByName(def, name) {
|
|
26
|
+
return (def.slots ?? []).find((s) => s.name === name);
|
|
27
|
+
}
|
|
28
|
+
function nestedSlots(def) {
|
|
29
|
+
return (def.slots ?? []).filter((s) => s.name !== 'title' && s.name !== 'body');
|
|
30
|
+
}
|
|
31
|
+
export function serializeComponent(def, values) {
|
|
32
|
+
const fence = COLON.repeat(nestedSlots(def).length > 0 ? 4 : 3);
|
|
33
|
+
const title = slotByName(def, 'title') ? values.slots.title ?? '' : '';
|
|
34
|
+
// Escape brackets in the label so a `[` or `]` in the title does not break the directive label
|
|
35
|
+
// grammar; remark un-escapes them back to literal text on parse, so readLabel recovers them.
|
|
36
|
+
const label = title ? `[${title.replace(/\[/g, '\\[').replace(/\]/g, '\\]')}]` : '';
|
|
37
|
+
const open = `${fence}${def.name}${label}${attrBlock(def, values)}`;
|
|
38
|
+
const lines = [open];
|
|
39
|
+
const body = slotByName(def, 'body') ? values.slots.body ?? '' : '';
|
|
40
|
+
if (body)
|
|
41
|
+
lines.push(body);
|
|
42
|
+
for (const slot of nestedSlots(def)) {
|
|
43
|
+
const raw = values.slots[slot.name];
|
|
44
|
+
const content = slot.kind === 'repeatable'
|
|
45
|
+
? (Array.isArray(raw) ? raw : []).filter((i) => i !== '').map((i) => `- ${i}`).join('\n')
|
|
46
|
+
: (raw ?? '');
|
|
47
|
+
if (!content)
|
|
48
|
+
continue;
|
|
49
|
+
if (lines.length > 1)
|
|
50
|
+
lines.push(''); // blank line before this block
|
|
51
|
+
lines.push(`${COLON.repeat(3)}${slot.name}`, content, COLON.repeat(3));
|
|
52
|
+
}
|
|
53
|
+
lines.push(fence);
|
|
54
|
+
return lines.join('\n');
|
|
55
|
+
}
|
|
56
|
+
function isContainer(node) {
|
|
57
|
+
return node.type === 'containerDirective';
|
|
58
|
+
}
|
|
59
|
+
// Pin the bullet to `-` so a markdown body or slot that uses dash bullets round-trips unchanged
|
|
60
|
+
// rather than drifting to remark-stringify's default `*`, which would silently mutate author content.
|
|
61
|
+
const toMd = unified().use(remarkStringify, { bullet: '-' });
|
|
62
|
+
/** Render mdast children back to trimmed markdown text. */
|
|
63
|
+
function childrenToText(children) {
|
|
64
|
+
const root = { type: 'root', children };
|
|
65
|
+
return String(toMd.stringify(root)).trim();
|
|
66
|
+
}
|
|
67
|
+
/** Parse a serialized component directive back into guided-form values, the inverse of
|
|
68
|
+
* {@link serializeComponent}. The grammar is reversible, so the editor can round-trip a
|
|
69
|
+
* saved directive through the form. */
|
|
70
|
+
export async function parseComponent(markdown, def) {
|
|
71
|
+
const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown);
|
|
72
|
+
const root = tree.children.find((c) => isContainer(c) && c.name === def.name);
|
|
73
|
+
const values = emptyComponentValues(def);
|
|
74
|
+
if (!root)
|
|
75
|
+
return values;
|
|
76
|
+
for (const field of def.attributes ?? []) {
|
|
77
|
+
const raw = root.attributes?.[field.key];
|
|
78
|
+
if (field.type === 'boolean')
|
|
79
|
+
values.attributes[field.key] = raw === 'true';
|
|
80
|
+
else if (typeof raw === 'string')
|
|
81
|
+
values.attributes[field.key] = raw;
|
|
82
|
+
}
|
|
83
|
+
const titleSlot = slotByName(def, 'title');
|
|
84
|
+
const bodySlot = slotByName(def, 'body');
|
|
85
|
+
const nested = nestedSlots(def);
|
|
86
|
+
const nestedNames = new Set(nested.map((s) => s.name));
|
|
87
|
+
const directChildren = root.children.filter((c) => !(isContainer(c) && nestedNames.has(c.name)) && !isDirectiveLabel(c));
|
|
88
|
+
const nestedChildren = root.children.filter((c) => isContainer(c) && nestedNames.has(c.name));
|
|
89
|
+
if (titleSlot)
|
|
90
|
+
values.slots.title = readLabel(root) ?? '';
|
|
91
|
+
if (bodySlot)
|
|
92
|
+
values.slots.body = childrenToText(directChildren);
|
|
93
|
+
for (const slot of nested) {
|
|
94
|
+
const node = nestedChildren.find((c) => c.name === slot.name);
|
|
95
|
+
if (!node)
|
|
96
|
+
continue;
|
|
97
|
+
if (slot.kind === 'repeatable')
|
|
98
|
+
values.slots[slot.name] = readListItems(node.children);
|
|
99
|
+
else
|
|
100
|
+
values.slots[slot.name] = childrenToText(node.children);
|
|
101
|
+
}
|
|
102
|
+
return values;
|
|
103
|
+
}
|
|
104
|
+
/** The raw attribute keys present on the component's opening directive, read from the parsed tree
|
|
105
|
+
* (quote-aware, unlike a regex over the source). Used by validation to flag unknown keys. */
|
|
106
|
+
export function parseRawAttributeKeys(markdown, def) {
|
|
107
|
+
const tree = unified().use(remarkParse).use(remarkDirective).parse(markdown);
|
|
108
|
+
const root = tree.children.find((c) => isContainer(c) && c.name === def.name);
|
|
109
|
+
return Object.keys(root?.attributes ?? {});
|
|
110
|
+
}
|
|
111
|
+
// A bare parse base: empty strings, false, and empty lists, with no attribute defaults applied. The
|
|
112
|
+
// `emptyValues` helper in registry.ts seeds form defaults instead, so it is deliberately not reused
|
|
113
|
+
// here; the parse must overwrite only the fields actually present in the markdown.
|
|
114
|
+
function emptyComponentValues(def) {
|
|
115
|
+
const attributes = {};
|
|
116
|
+
for (const f of def.attributes ?? [])
|
|
117
|
+
attributes[f.key] = f.type === 'boolean' ? false : '';
|
|
118
|
+
const slots = {};
|
|
119
|
+
for (const s of def.slots ?? [])
|
|
120
|
+
slots[s.name] = s.kind === 'repeatable' ? [] : '';
|
|
121
|
+
return { attributes, slots };
|
|
122
|
+
}
|
|
123
|
+
// mdast-util-directive carries the `[label]` as a paragraph whose `data.directiveLabel` is set.
|
|
124
|
+
function isDirectiveLabel(node) {
|
|
125
|
+
return Boolean(node.data?.directiveLabel);
|
|
126
|
+
}
|
|
127
|
+
function readLabel(root) {
|
|
128
|
+
for (const child of root.children) {
|
|
129
|
+
const p = child;
|
|
130
|
+
if (p.type === 'paragraph' && p.data?.directiveLabel)
|
|
131
|
+
return (p.children ?? []).map((c) => c.value ?? '').join('');
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
function readListItems(children) {
|
|
136
|
+
const list = children.find((c) => c.type === 'list');
|
|
137
|
+
if (!list?.children)
|
|
138
|
+
return [];
|
|
139
|
+
return list.children.map((li) => childrenToText(li.children ?? []));
|
|
140
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ComponentDef, ComponentValues } from './registry.js';
|
|
2
|
+
/** The outcome of preparing a guided-form component for insertion: the markdown to insert, or the
|
|
3
|
+
* field-keyed errors to show on the form. */
|
|
4
|
+
export type ComponentInsert = {
|
|
5
|
+
ok: true;
|
|
6
|
+
markdown: string;
|
|
7
|
+
} | {
|
|
8
|
+
ok: false;
|
|
9
|
+
errors: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
/** Serialize a component's form values, then validate the result against its schema. Returns the
|
|
12
|
+
* markdown to insert at the cursor, or the field errors keyed by attribute key or slot name. */
|
|
13
|
+
export declare function buildComponentInsert(def: ComponentDef, values: ComponentValues): Promise<ComponentInsert>;
|
|
14
|
+
//# sourceMappingURL=component-insert.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"component-insert.d.ts","sourceRoot":"","sources":["../../src/lib/render/component-insert.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAEnE;8CAC8C;AAC9C,MAAM,MAAM,eAAe,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAE7G;iGACiG;AACjG,wBAAsB,oBAAoB,CAAC,GAAG,EAAE,YAAY,EAAE,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CAI/G"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { serializeComponent } from './component-grammar.js';
|
|
2
|
+
import { validateComponent } from './component-validate.js';
|
|
3
|
+
/** Serialize a component's form values, then validate the result against its schema. Returns the
|
|
4
|
+
* markdown to insert at the cursor, or the field errors keyed by attribute key or slot name. */
|
|
5
|
+
export async function buildComponentInsert(def, values) {
|
|
6
|
+
const markdown = serializeComponent(def, values);
|
|
7
|
+
const verdict = await validateComponent(markdown, def);
|
|
8
|
+
return verdict.ok ? { ok: true, markdown } : { ok: false, errors: verdict.errors };
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ComponentRegistry } from './registry.js';
|
|
2
|
+
export interface ReferenceOptions {
|
|
3
|
+
/** The H1 title of the reference document. */
|
|
4
|
+
title: string;
|
|
5
|
+
/** The one-line blockquote summary under the title. */
|
|
6
|
+
summary: string;
|
|
7
|
+
}
|
|
8
|
+
/** Build a self-contained markdown reference (the llms-full.txt shape) for a component registry, for
|
|
9
|
+
* authors and for pointing an LLM at one curated file. */
|
|
10
|
+
export declare function generateComponentReference(registry: ComponentRegistry, opts: ReferenceOptions): string;
|
|
11
|
+
//# sourceMappingURL=component-reference.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"component-reference.d.ts","sourceRoot":"","sources":["../../src/lib/render/component-reference.ts"],"names":[],"mappings":"AACA,OAAO,EAAkC,KAAK,iBAAiB,EAAwB,MAAM,eAAe,CAAC;AAE7G,MAAM,WAAW,gBAAgB;IAC/B,8CAA8C;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;2DAC2D;AAC3D,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,iBAAiB,EAAE,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAGtG"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { serializeComponent } from './component-grammar.js';
|
|
2
|
+
import { emptyValues } from './registry.js';
|
|
3
|
+
/** Build a self-contained markdown reference (the llms-full.txt shape) for a component registry, for
|
|
4
|
+
* authors and for pointing an LLM at one curated file. */
|
|
5
|
+
export function generateComponentReference(registry, opts) {
|
|
6
|
+
const sections = registry.defs.map((def) => componentSection(def));
|
|
7
|
+
return `# ${opts.title}\n\n> ${opts.summary}\n\n${sections.join('\n\n')}\n`;
|
|
8
|
+
}
|
|
9
|
+
function componentSection(def) {
|
|
10
|
+
const lines = [`## ${def.label} (\`:::${def.name}\`)`, '', def.description ?? ''];
|
|
11
|
+
if (def.use)
|
|
12
|
+
lines.push('', `**When to use:** ${def.use}`);
|
|
13
|
+
lines.push('', '```', serializeComponent(def, exampleValues(def)), '```');
|
|
14
|
+
return lines.join('\n');
|
|
15
|
+
}
|
|
16
|
+
/** Seed example values that show every declared field: an ellipsis for strings, one sample list item. */
|
|
17
|
+
function exampleValues(def) {
|
|
18
|
+
const values = emptyValues(def);
|
|
19
|
+
for (const field of def.attributes ?? []) {
|
|
20
|
+
if (field.type === 'boolean')
|
|
21
|
+
values.attributes[field.key] = false;
|
|
22
|
+
else
|
|
23
|
+
values.attributes[field.key] = field.options?.[0] ?? '…';
|
|
24
|
+
}
|
|
25
|
+
for (const slot of def.slots ?? []) {
|
|
26
|
+
if (slot.kind === 'repeatable')
|
|
27
|
+
values.slots[slot.name] = ['…'];
|
|
28
|
+
else if (slot.name === 'title')
|
|
29
|
+
values.slots[slot.name] = 'Title';
|
|
30
|
+
else
|
|
31
|
+
values.slots[slot.name] = '…';
|
|
32
|
+
}
|
|
33
|
+
return values;
|
|
34
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ComponentDef } from './registry.js';
|
|
2
|
+
/** A validation verdict: ok, or field-keyed error messages. */
|
|
3
|
+
export type ComponentValidation = {
|
|
4
|
+
ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
errors: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
export declare function validateComponent(markdown: string, def: ComponentDef): Promise<ComponentValidation>;
|
|
10
|
+
//# sourceMappingURL=component-validate.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"component-validate.d.ts","sourceRoot":"","sources":["../../src/lib/render/component-validate.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,eAAe,CAAC;AAElD,+DAA+D;AAC/D,MAAM,MAAM,mBAAmB,GAAG;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAE/F,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA6BzG"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { parseComponent, parseRawAttributeKeys } from './component-grammar.js';
|
|
2
|
+
export async function validateComponent(markdown, def) {
|
|
3
|
+
const values = await parseComponent(markdown, def);
|
|
4
|
+
const errors = {};
|
|
5
|
+
const declared = new Set((def.attributes ?? []).map((f) => f.key));
|
|
6
|
+
for (const field of def.attributes ?? []) {
|
|
7
|
+
const v = values.attributes[field.key];
|
|
8
|
+
const filled = field.type === 'boolean' ? true : typeof v === 'string' && v !== '';
|
|
9
|
+
if (field.required && !filled) {
|
|
10
|
+
errors[field.key] = `${field.label} is required.`;
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
|
|
14
|
+
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const key of parseRawAttributeKeys(markdown, def)) {
|
|
18
|
+
if (!declared.has(key))
|
|
19
|
+
errors[key] = `Unknown attribute "${key}".`;
|
|
20
|
+
}
|
|
21
|
+
for (const slot of def.slots ?? []) {
|
|
22
|
+
if (!slot.required)
|
|
23
|
+
continue;
|
|
24
|
+
const v = values.slots[slot.name];
|
|
25
|
+
const filled = Array.isArray(v) ? v.length > 0 : typeof v === 'string' && v !== '';
|
|
26
|
+
if (!filled)
|
|
27
|
+
errors[slot.name] = `${slot.label} is required.`;
|
|
28
|
+
}
|
|
29
|
+
return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
|
|
30
|
+
}
|
|
@@ -1,4 +1,33 @@
|
|
|
1
1
|
import type { Element } from 'hast';
|
|
2
|
+
/** The input types a component attribute or repeatable item field can take. */
|
|
3
|
+
export type FieldType = 'text' | 'select' | 'icon' | 'boolean';
|
|
4
|
+
/** One `{key="value"}` attribute on a component directive, or one field of a repeatable item. */
|
|
5
|
+
export interface AttributeField {
|
|
6
|
+
/** The attribute name as it appears in the directive, e.g. `icon`. */
|
|
7
|
+
key: string;
|
|
8
|
+
/** The form label. */
|
|
9
|
+
label: string;
|
|
10
|
+
type: FieldType;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
/** Initial value; a string for text/select/icon, a boolean for boolean. */
|
|
13
|
+
default?: string | boolean;
|
|
14
|
+
/** Allowed values for `type: 'select'`. */
|
|
15
|
+
options?: string[];
|
|
16
|
+
/** Helper text shown under the field. */
|
|
17
|
+
help?: string;
|
|
18
|
+
}
|
|
19
|
+
export type SlotKind = 'markdown' | 'inline' | 'repeatable';
|
|
20
|
+
/** One named content region of a component. The slots named `title` and `body` are special: `title`
|
|
21
|
+
* serializes to the directive `[label]` and `body` to the unmarked content (see the canonical grammar). */
|
|
22
|
+
export interface SlotDef {
|
|
23
|
+
name: string;
|
|
24
|
+
label: string;
|
|
25
|
+
kind: SlotKind;
|
|
26
|
+
required?: boolean;
|
|
27
|
+
help?: string;
|
|
28
|
+
/** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
|
|
29
|
+
itemFields?: AttributeField[];
|
|
30
|
+
}
|
|
2
31
|
/** A site component: how it inserts (editor) and how it renders (rehype). */
|
|
3
32
|
export interface ComponentDef {
|
|
4
33
|
/** Directive name, e.g. 'card' (matches `:::card`). */
|
|
@@ -8,13 +37,19 @@ export interface ComponentDef {
|
|
|
8
37
|
/** Palette description. */
|
|
9
38
|
description: string;
|
|
10
39
|
/** Markdown scaffold inserted at the cursor by the editor palette. */
|
|
11
|
-
insertTemplate
|
|
40
|
+
insertTemplate?: string;
|
|
12
41
|
/** Build the final hast element from the stamped directive element. The engine
|
|
13
42
|
* stamps the entrance-stagger ordinal (`data-rise`) on the top-level result, so a
|
|
14
43
|
* build fn stays free of any motion concern. */
|
|
15
44
|
build: (node: Element) => Element;
|
|
16
45
|
/** Optional role-to-default-icon, e.g. `{ caution: 'warning' }`. */
|
|
17
46
|
defaultIconByRole?: Record<string, string>;
|
|
47
|
+
/** One line on when to reach for this component; feeds the picker and the reference file. */
|
|
48
|
+
use?: string;
|
|
49
|
+
/** The `{key="value"}` attributes this component accepts. */
|
|
50
|
+
attributes?: AttributeField[];
|
|
51
|
+
/** The named content regions this component accepts. */
|
|
52
|
+
slots?: SlotDef[];
|
|
18
53
|
}
|
|
19
54
|
export interface ComponentRegistry {
|
|
20
55
|
defs: ComponentDef[];
|
|
@@ -29,4 +64,13 @@ export interface ComponentRegistry {
|
|
|
29
64
|
export declare function defineRegistry({ components }: {
|
|
30
65
|
components: ComponentDef[];
|
|
31
66
|
}): ComponentRegistry;
|
|
67
|
+
/** Guided-form values for one component: attribute values keyed by attribute key, slot values keyed
|
|
68
|
+
* by slot name (a string, or a string list for a repeatable slot). */
|
|
69
|
+
export interface ComponentValues {
|
|
70
|
+
attributes: Record<string, string | boolean>;
|
|
71
|
+
slots: Record<string, string | string[]>;
|
|
72
|
+
}
|
|
73
|
+
/** Seed an empty {@link ComponentValues} from a component's schema: attribute defaults (or '' / false)
|
|
74
|
+
* and empty slot values ([] for repeatable, '' otherwise). */
|
|
75
|
+
export declare function emptyValues(def: ComponentDef): ComponentValues;
|
|
32
76
|
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -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;AAEpC,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,cAAc,EAAE,MAAM,CAAC;
|
|
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;AAEpC,+EAA+E;AAC/E,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,GAAG,SAAS,CAAC;AAE/D,iGAAiG;AACjG,MAAM,WAAW,cAAc;IAC7B,sEAAsE;IACtE,GAAG,EAAE,MAAM,CAAC;IACZ,sBAAsB;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,SAAS,CAAC;IAChB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAC3B,2CAA2C;IAC3C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,yCAAyC;IACzC,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,MAAM,MAAM,QAAQ,GAAG,UAAU,GAAG,QAAQ,GAAG,YAAY,CAAC;AAE5D;4GAC4G;AAC5G,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,+FAA+F;IAC/F,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;CAC/B;AAED,6EAA6E;AAC7E,MAAM,WAAW,YAAY;IAC3B,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,2BAA2B;IAC3B,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;qDAEiD;IACjD,KAAK,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;IAClC,oEAAoE;IACpE,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC3C,6FAA6F;IAC7F,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,6DAA6D;IAC7D,UAAU,CAAC,EAAE,cAAc,EAAE,CAAC;IAC9B,wDAAwD;IACxD,KAAK,CAAC,EAAE,OAAO,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,YAAY,EAAE,CAAC;IACrB,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IAC5C,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CAC9D;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,EAAE,UAAU,EAAE,EAAE;IAAE,UAAU,EAAE,YAAY,EAAE,CAAA;CAAE,GAAG,iBAAiB,CAQhG;AAED;uEACuE;AACvE,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAC;IAC7C,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;CAC1C;AAED;+DAC+D;AAC/D,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,eAAe,CAU9D"}
|
package/dist/render/registry.js
CHANGED
|
@@ -11,3 +11,16 @@ export function defineRegistry({ components }) {
|
|
|
11
11
|
defaultIcon: (name, role) => (role ? byName.get(name)?.defaultIconByRole?.[role] : undefined),
|
|
12
12
|
};
|
|
13
13
|
}
|
|
14
|
+
/** Seed an empty {@link ComponentValues} from a component's schema: attribute defaults (or '' / false)
|
|
15
|
+
* and empty slot values ([] for repeatable, '' otherwise). */
|
|
16
|
+
export function emptyValues(def) {
|
|
17
|
+
const attributes = {};
|
|
18
|
+
for (const field of def.attributes ?? []) {
|
|
19
|
+
attributes[field.key] = field.default ?? (field.type === 'boolean' ? false : '');
|
|
20
|
+
}
|
|
21
|
+
const slots = {};
|
|
22
|
+
for (const slot of def.slots ?? []) {
|
|
23
|
+
slots[slot.name] = slot.kind === 'repeatable' ? [] : '';
|
|
24
|
+
}
|
|
25
|
+
return { attributes, slots };
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glw907/cairn-cms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -79,6 +79,7 @@
|
|
|
79
79
|
"remark-gfm": "^4",
|
|
80
80
|
"remark-parse": "^11.0.0",
|
|
81
81
|
"remark-rehype": "^11.1.2",
|
|
82
|
+
"remark-stringify": "^11.0.0",
|
|
82
83
|
"unified": "^11.0.5",
|
|
83
84
|
"unist-util-visit": "^5.1.0",
|
|
84
85
|
"yaml": "^2"
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The schema-driven fill form for one component. It holds the working ComponentValues, seeded from
|
|
4
|
+
emptyValues(def), and renders attribute fields and the title/body and other non-repeatable slots.
|
|
5
|
+
Submit (Task 6) serializes and validates through buildComponentInsert and calls onInsert with the
|
|
6
|
+
markdown. Back returns to the picker. This is not a nested HTML form; Insert calls a callback.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import { untrack } from 'svelte';
|
|
10
|
+
import { emptyValues, type ComponentDef } from '../render/registry.js';
|
|
11
|
+
import { buildComponentInsert } from '../render/component-insert.js';
|
|
12
|
+
import type { IconSet } from '../render/glyph.js';
|
|
13
|
+
import IconPicker from './IconPicker.svelte';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
def: ComponentDef;
|
|
17
|
+
icons?: IconSet;
|
|
18
|
+
/** Called with the serialized markdown when the form validates. */
|
|
19
|
+
onInsert: (markdown: string) => void;
|
|
20
|
+
/** Return to the picker. */
|
|
21
|
+
onBack: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let { def, icons, onInsert, onBack }: Props = $props();
|
|
25
|
+
|
|
26
|
+
// Working values, seeded once from the schema. $state makes the nested records deeply reactive.
|
|
27
|
+
// untrack marks the seed as a deliberate one-time read of the initial def, not a reactive miss.
|
|
28
|
+
let values = $state(untrack(() => emptyValues(def)));
|
|
29
|
+
|
|
30
|
+
const attributes = $derived(def.attributes ?? []);
|
|
31
|
+
// Non-repeatable slots render here; the repeatable list is handled separately.
|
|
32
|
+
const flatSlots = $derived((def.slots ?? []).filter((s) => s.kind !== 'repeatable'));
|
|
33
|
+
const repeatableSlots = $derived((def.slots ?? []).filter((s) => s.kind === 'repeatable'));
|
|
34
|
+
|
|
35
|
+
// The live $state proxy array for a repeatable slot, so push/splice stay reactive.
|
|
36
|
+
function slotItems(name: string): string[] {
|
|
37
|
+
const v = values.slots[name];
|
|
38
|
+
return Array.isArray(v) ? v : [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Typed accessors over the unions so explicit value targets stay sound.
|
|
42
|
+
function asString(key: string): string {
|
|
43
|
+
const v = values.attributes[key];
|
|
44
|
+
return typeof v === 'string' ? v : '';
|
|
45
|
+
}
|
|
46
|
+
function asBool(key: string): boolean {
|
|
47
|
+
return values.attributes[key] === true;
|
|
48
|
+
}
|
|
49
|
+
function slotString(name: string): string {
|
|
50
|
+
const v = values.slots[name];
|
|
51
|
+
return typeof v === 'string' ? v : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Field-keyed validation errors from the last submit, keyed by attribute key or slot name.
|
|
55
|
+
let errors = $state<Record<string, string>>({});
|
|
56
|
+
|
|
57
|
+
// Serialize and validate through the pure helper. On success clear errors and emit the markdown;
|
|
58
|
+
// on failure keep the field-keyed errors so each field can show its message and insert nothing.
|
|
59
|
+
async function submit() {
|
|
60
|
+
const result = await buildComponentInsert(def, values);
|
|
61
|
+
if (result.ok) {
|
|
62
|
+
errors = {};
|
|
63
|
+
onInsert(result.markdown);
|
|
64
|
+
} else {
|
|
65
|
+
errors = result.errors;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</script>
|
|
69
|
+
|
|
70
|
+
<div class="flex flex-col gap-3">
|
|
71
|
+
<div class="flex items-center justify-between">
|
|
72
|
+
<h3 class="text-sm font-semibold">{def.label}</h3>
|
|
73
|
+
<button type="button" class="btn btn-ghost btn-xs" onclick={onBack}>Back</button>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
{#each attributes as field (field.key)}
|
|
77
|
+
{#if field.type === 'boolean'}
|
|
78
|
+
<label class="label cursor-pointer justify-start gap-2">
|
|
79
|
+
<input
|
|
80
|
+
class="checkbox checkbox-sm"
|
|
81
|
+
type="checkbox"
|
|
82
|
+
aria-label={field.label}
|
|
83
|
+
aria-invalid={Boolean(errors[field.key])}
|
|
84
|
+
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
85
|
+
checked={asBool(field.key)}
|
|
86
|
+
onchange={(e) => (values.attributes[field.key] = e.currentTarget.checked)}
|
|
87
|
+
/>
|
|
88
|
+
<span class="text-sm">{field.label}</span>
|
|
89
|
+
</label>
|
|
90
|
+
{:else if field.type === 'select'}
|
|
91
|
+
<label class="flex flex-col gap-1">
|
|
92
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
93
|
+
<select
|
|
94
|
+
class="select"
|
|
95
|
+
aria-label={field.label}
|
|
96
|
+
aria-invalid={Boolean(errors[field.key])}
|
|
97
|
+
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
98
|
+
value={asString(field.key)}
|
|
99
|
+
onchange={(e) => (values.attributes[field.key] = e.currentTarget.value)}
|
|
100
|
+
>
|
|
101
|
+
{#if !field.required}<option value="">—</option>{/if}
|
|
102
|
+
{#each field.options ?? [] as opt (opt)}<option value={opt}>{opt}</option>{/each}
|
|
103
|
+
</select>
|
|
104
|
+
</label>
|
|
105
|
+
{:else if field.type === 'icon' && icons}
|
|
106
|
+
<div class="flex flex-col gap-1">
|
|
107
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
108
|
+
<IconPicker
|
|
109
|
+
{icons}
|
|
110
|
+
value={asString(field.key)}
|
|
111
|
+
required={field.required ?? false}
|
|
112
|
+
onChange={(name) => (values.attributes[field.key] = name)}
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
{:else}
|
|
116
|
+
<label class="flex flex-col gap-1">
|
|
117
|
+
<span class="text-sm font-medium">{field.label}</span>
|
|
118
|
+
<input
|
|
119
|
+
class="input"
|
|
120
|
+
aria-label={field.label}
|
|
121
|
+
aria-invalid={Boolean(errors[field.key])}
|
|
122
|
+
aria-describedby={errors[field.key] ? `err-${field.key}` : undefined}
|
|
123
|
+
value={asString(field.key)}
|
|
124
|
+
oninput={(e) => (values.attributes[field.key] = e.currentTarget.value)}
|
|
125
|
+
/>
|
|
126
|
+
</label>
|
|
127
|
+
{/if}
|
|
128
|
+
{#if errors[field.key]}<span id={`err-${field.key}`} role="alert" class="text-error text-xs">{errors[field.key]}</span>{/if}
|
|
129
|
+
{/each}
|
|
130
|
+
|
|
131
|
+
{#each flatSlots as slot (slot.name)}
|
|
132
|
+
{#if slot.kind === 'markdown'}
|
|
133
|
+
<label class="flex flex-col gap-1">
|
|
134
|
+
<span class="text-sm font-medium">{slot.label}</span>
|
|
135
|
+
<textarea
|
|
136
|
+
class="textarea"
|
|
137
|
+
aria-label={slot.label}
|
|
138
|
+
aria-invalid={Boolean(errors[slot.name])}
|
|
139
|
+
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
140
|
+
rows={3}
|
|
141
|
+
value={slotString(slot.name)}
|
|
142
|
+
oninput={(e) => (values.slots[slot.name] = e.currentTarget.value)}
|
|
143
|
+
></textarea>
|
|
144
|
+
</label>
|
|
145
|
+
{:else}
|
|
146
|
+
<label class="flex flex-col gap-1">
|
|
147
|
+
<span class="text-sm font-medium">{slot.label}</span>
|
|
148
|
+
<input
|
|
149
|
+
class="input"
|
|
150
|
+
aria-label={slot.label}
|
|
151
|
+
aria-invalid={Boolean(errors[slot.name])}
|
|
152
|
+
aria-describedby={errors[slot.name] ? `err-${slot.name}` : undefined}
|
|
153
|
+
value={slotString(slot.name)}
|
|
154
|
+
oninput={(e) => (values.slots[slot.name] = e.currentTarget.value)}
|
|
155
|
+
/>
|
|
156
|
+
</label>
|
|
157
|
+
{/if}
|
|
158
|
+
{#if errors[slot.name]}<span id={`err-${slot.name}`} role="alert" class="text-error text-xs">{errors[slot.name]}</span>{/if}
|
|
159
|
+
{/each}
|
|
160
|
+
|
|
161
|
+
{#each repeatableSlots as slot (slot.name)}
|
|
162
|
+
{@const items = slotItems(slot.name)}
|
|
163
|
+
<fieldset class="rounded-box border border-base-300 flex flex-col gap-2 p-2">
|
|
164
|
+
<legend class="text-sm font-medium">{slot.label}</legend>
|
|
165
|
+
<!-- Index key is deliberate: items are bare strings with no stable id, so the value bindings and splice/push are index-based by design. -->
|
|
166
|
+
{#each items as _, i (i)}
|
|
167
|
+
<div class="flex items-center gap-2">
|
|
168
|
+
<input class="input input-sm flex-1" aria-label={`${slot.label} item`} bind:value={items[i]} />
|
|
169
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label={`Remove item ${i + 1}`} onclick={() => items.splice(i, 1)}>✕</button>
|
|
170
|
+
</div>
|
|
171
|
+
{/each}
|
|
172
|
+
<button type="button" class="btn btn-sm self-start" onclick={() => items.push('')}>Add item</button>
|
|
173
|
+
{#if errors[slot.name]}<span id={`err-${slot.name}`} role="alert" class="text-error text-xs">{errors[slot.name]}</span>{/if}
|
|
174
|
+
</fieldset>
|
|
175
|
+
{/each}
|
|
176
|
+
|
|
177
|
+
<button type="button" class="btn btn-primary btn-sm mt-2" onclick={submit}>Insert</button>
|
|
178
|
+
</div>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component
|
|
3
|
+
The Insert control and its modal. The picker lists each actionable component with its description and
|
|
4
|
+
intended use. A component with a schema opens the guided ComponentForm; a template-only component
|
|
5
|
+
inserts directly; a component with neither is not listed. Built on a native <dialog> for focus
|
|
6
|
+
trapping and Escape, following the dropdown's a11y conventions used elsewhere in the admin.
|
|
7
|
+
-->
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
import type { ComponentRegistry, ComponentDef } from '../render/registry.js';
|
|
10
|
+
import type { IconSet } from '../render/glyph.js';
|
|
11
|
+
import ComponentForm from './ComponentForm.svelte';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** The site's component registry. */
|
|
15
|
+
registry?: ComponentRegistry;
|
|
16
|
+
/** Insert markdown at the editor cursor. */
|
|
17
|
+
insert: (text: string) => void;
|
|
18
|
+
/** The site's icon set, for icon fields. */
|
|
19
|
+
icons?: IconSet;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let { registry, insert, icons }: Props = $props();
|
|
23
|
+
|
|
24
|
+
let dialog = $state<HTMLDialogElement | null>(null);
|
|
25
|
+
let picked = $state<ComponentDef | null>(null);
|
|
26
|
+
|
|
27
|
+
function hasSchema(def: ComponentDef): boolean {
|
|
28
|
+
return (def.attributes?.length ?? 0) > 0 || (def.slots?.length ?? 0) > 0;
|
|
29
|
+
}
|
|
30
|
+
function actionable(def: ComponentDef): boolean {
|
|
31
|
+
return hasSchema(def) || Boolean(def.insertTemplate);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const defs = $derived((registry?.defs ?? []).filter(actionable));
|
|
35
|
+
|
|
36
|
+
function open() {
|
|
37
|
+
picked = null;
|
|
38
|
+
dialog?.showModal();
|
|
39
|
+
}
|
|
40
|
+
function close() {
|
|
41
|
+
picked = null;
|
|
42
|
+
dialog?.close();
|
|
43
|
+
}
|
|
44
|
+
function choose(def: ComponentDef) {
|
|
45
|
+
if (hasSchema(def)) {
|
|
46
|
+
picked = def;
|
|
47
|
+
} else {
|
|
48
|
+
insert(def.insertTemplate ?? '');
|
|
49
|
+
close();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function onInsert(markdown: string) {
|
|
53
|
+
insert(markdown);
|
|
54
|
+
close();
|
|
55
|
+
}
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
{#if defs.length > 0}
|
|
59
|
+
<button type="button" class="btn btn-sm btn-ghost" aria-haspopup="dialog" aria-label="Insert component" onclick={open}>Insert</button>
|
|
60
|
+
|
|
61
|
+
<dialog class="modal" aria-labelledby="cairn-insert-dialog-title" bind:this={dialog} onclose={() => (picked = null)}>
|
|
62
|
+
<div class="modal-box">
|
|
63
|
+
<div class="mb-3 flex items-center justify-between">
|
|
64
|
+
<h2 id="cairn-insert-dialog-title" class="text-base font-semibold">Insert component</h2>
|
|
65
|
+
<button type="button" class="btn btn-ghost btn-sm" aria-label="Close" onclick={close}>✕</button>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{#if picked}
|
|
69
|
+
{#key picked}
|
|
70
|
+
<ComponentForm def={picked} {icons} {onInsert} onBack={() => (picked = null)} />
|
|
71
|
+
{/key}
|
|
72
|
+
{:else}
|
|
73
|
+
<ul class="menu w-full">
|
|
74
|
+
{#each defs as def (def.name)}
|
|
75
|
+
<li>
|
|
76
|
+
<button type="button" onclick={() => choose(def)}>
|
|
77
|
+
<span class="flex flex-col items-start">
|
|
78
|
+
<span class="font-medium">{def.label}</span>
|
|
79
|
+
{#if def.description}<span class="text-xs text-[var(--color-muted)]">{def.description}</span>{/if}
|
|
80
|
+
{#if def.use}<span class="text-xs text-[var(--color-muted)]">{def.use}</span>{/if}
|
|
81
|
+
</span>
|
|
82
|
+
</button>
|
|
83
|
+
</li>
|
|
84
|
+
{/each}
|
|
85
|
+
</ul>
|
|
86
|
+
{/if}
|
|
87
|
+
</div>
|
|
88
|
+
<form method="dialog" class="modal-backdrop">
|
|
89
|
+
<button tabindex="-1" aria-label="Close">close</button>
|
|
90
|
+
</form>
|
|
91
|
+
</dialog>
|
|
92
|
+
{/if}
|