@glw907/cairn-cms 0.11.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/content-index.d.ts +8 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +17 -8
- package/dist/delivery/index.d.ts +5 -1
- package/dist/delivery/index.d.ts.map +1 -1
- package/dist/delivery/index.js +2 -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/site-index.d.ts +2 -2
- package/dist/delivery/site-index.d.ts.map +1 -1
- package/dist/delivery/site-index.js +16 -18
- 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 +3 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +9 -2
- package/package.json +1 -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/content-index.ts +25 -8
- package/src/lib/delivery/index.ts +5 -1
- package/src/lib/delivery/seo-fields.ts +43 -0
- package/src/lib/delivery/site-index.ts +15 -16
- 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 +12 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Root, Element, ElementContent } from 'hast';
|
|
2
2
|
import { h } from 'hastscript';
|
|
3
|
-
import type
|
|
3
|
+
import { dataAttrProp, type ComponentContext, type ComponentDef, type ComponentRegistry } from './registry.js';
|
|
4
4
|
|
|
5
5
|
export function isElement(node: ElementContent | undefined): node is Element {
|
|
6
6
|
return !!node && node.type === 'element';
|
|
@@ -23,24 +23,6 @@ export function iconSpan(glyphEl: Element, role?: string): Element {
|
|
|
23
23
|
/** A site's icon factory: turn a stamped icon name + role into a hast element. */
|
|
24
24
|
export type MakeIcon = (name: string, role?: string) => Element;
|
|
25
25
|
|
|
26
|
-
// Pull the section's <h2> out, retag it .card-title, and build the .ec-head row
|
|
27
|
-
// (optional icon + heading). Returns the head plus the remaining body children.
|
|
28
|
-
// `makeIcon` (site-supplied) turns the stamped data-icon into an element; omit it
|
|
29
|
-
// for a head with no icon.
|
|
30
|
-
export function splitHead(node: Element, makeIcon?: MakeIcon): { head: Element; rest: ElementContent[] } {
|
|
31
|
-
const children = node.children as ElementContent[];
|
|
32
|
-
const i = children.findIndex((c) => isElement(c) && c.tagName === 'h2');
|
|
33
|
-
const h2 = children[i] as Element;
|
|
34
|
-
h2.properties = { ...h2.properties, className: ['card-title'] };
|
|
35
|
-
const rest = children.filter((_, j) => j !== i);
|
|
36
|
-
const icon = strProp(node, 'dataIcon');
|
|
37
|
-
const role = strProp(node, 'dataRole');
|
|
38
|
-
const headKids: ElementContent[] = [];
|
|
39
|
-
if (makeIcon && icon) headKids.push(makeIcon(icon, role));
|
|
40
|
-
headKids.push(h2);
|
|
41
|
-
return { head: h('div', { className: ['ec-head'] }, headKids), rest };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
26
|
/** Section wrapper: `<section class=…><div class="card-body">…</div></section>`. */
|
|
45
27
|
export function cardShell(classes: string[], body: ElementContent[]): Element {
|
|
46
28
|
return h('section', { className: classes }, [h('div', { className: ['card-body'] }, body)]);
|
|
@@ -70,11 +52,76 @@ function transformChildren(children: ElementContent[], registry: ComponentRegist
|
|
|
70
52
|
});
|
|
71
53
|
}
|
|
72
54
|
|
|
55
|
+
// Read a stamped attribute back into its typed value. Booleans arrive as the strings
|
|
56
|
+
// 'true'/'false'; everything else is the literal string the author wrote.
|
|
57
|
+
function readAttributes(node: Element, def: ComponentDef): Record<string, string | boolean> {
|
|
58
|
+
const out: Record<string, string | boolean> = {};
|
|
59
|
+
for (const field of def.attributes ?? []) {
|
|
60
|
+
const value = strProp(node, dataAttrProp(field.key));
|
|
61
|
+
if (value == null) continue;
|
|
62
|
+
out[field.key] = field.type === 'boolean' ? value === 'true' : value;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The title label paragraph carries data-slot="title"; build() wants its inline children, not
|
|
68
|
+
// the marked paragraph. Return the paragraph's children.
|
|
69
|
+
function stripSlotMarker(child: ElementContent): ElementContent[] {
|
|
70
|
+
return isElement(child) ? (child.children as ElementContent[]) : [child];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Split a component's stamped children into named slots and the default body. A child marked
|
|
74
|
+
// data-slot="title"/<name> routes to that slot; an unmarked child is body. A repeatable slot
|
|
75
|
+
// wraps a <ul>, so its items are that list's <li> children, one child-list per item.
|
|
76
|
+
function partitionSlots(node: Element): {
|
|
77
|
+
slot(name: string): ElementContent[];
|
|
78
|
+
items(name: string): ElementContent[][];
|
|
79
|
+
} {
|
|
80
|
+
const named = new Map<string, ElementContent[]>();
|
|
81
|
+
const body: ElementContent[] = [];
|
|
82
|
+
for (const child of node.children as ElementContent[]) {
|
|
83
|
+
const slotName = isElement(child) ? strProp(child, 'dataSlot') : undefined;
|
|
84
|
+
if (slotName === 'title') named.set('title', stripSlotMarker(child));
|
|
85
|
+
else if (slotName) named.set(slotName, [child]);
|
|
86
|
+
else body.push(child);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
slot(name: string): ElementContent[] {
|
|
90
|
+
if (name === 'body') return body;
|
|
91
|
+
const wrap = named.get(name);
|
|
92
|
+
if (!wrap) return [];
|
|
93
|
+
// For title we stored the label's own children, so return them as-is. For a markdown or
|
|
94
|
+
// inline named slot the wrapper <div> holds the rendered children; unwrap it.
|
|
95
|
+
if (name === 'title') return wrap;
|
|
96
|
+
const div = wrap[0];
|
|
97
|
+
return isElement(div) ? (div.children as ElementContent[]) : wrap;
|
|
98
|
+
},
|
|
99
|
+
items(name: string): ElementContent[][] {
|
|
100
|
+
const wrap = named.get(name);
|
|
101
|
+
const div = wrap?.[0];
|
|
102
|
+
if (!div || !isElement(div)) return [];
|
|
103
|
+
const ul = (div.children as ElementContent[]).find((c) => isElement(c) && c.tagName === 'ul');
|
|
104
|
+
if (!ul || !isElement(ul)) return [];
|
|
105
|
+
return (ul.children as ElementContent[])
|
|
106
|
+
.filter((li) => isElement(li) && li.tagName === 'li')
|
|
107
|
+
.map((li) => (li as Element).children as ElementContent[]);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
73
112
|
function transformNode(node: Element, registry: ComponentRegistry): Element {
|
|
74
113
|
node.children = transformChildren(node.children as ElementContent[], registry);
|
|
75
114
|
const name = strProp(node, 'dataPrimitive');
|
|
76
115
|
const def = name ? registry.get(name) : undefined;
|
|
77
|
-
|
|
116
|
+
if (!def) return node;
|
|
117
|
+
const parts = partitionSlots(node);
|
|
118
|
+
const ctx: ComponentContext = {
|
|
119
|
+
attributes: readAttributes(node, def),
|
|
120
|
+
slot: parts.slot,
|
|
121
|
+
items: parts.items,
|
|
122
|
+
node,
|
|
123
|
+
};
|
|
124
|
+
return def.build(ctx);
|
|
78
125
|
}
|
|
79
126
|
|
|
80
127
|
/** Rehype transformer: dispatch each stamped element through its registry `build`
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import type { Paragraph, PhrasingContent, Root, Text } from 'mdast';
|
|
2
2
|
import type { ContainerDirective, LeafDirective, TextDirective } from 'mdast-util-directive';
|
|
3
3
|
import { visit } from 'unist-util-visit';
|
|
4
|
-
import type
|
|
4
|
+
import { dataAttrProp, type ComponentRegistry } from './registry.js';
|
|
5
|
+
|
|
6
|
+
// mdast-util-directive carries the `[label]` as a paragraph whose `data.directiveLabel` is set.
|
|
7
|
+
function isDirectiveLabel(node: unknown): boolean {
|
|
8
|
+
return Boolean((node as { data?: { directiveLabel?: boolean } }).data?.directiveLabel);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Stamp data-slot on a child so the rehype dispatch partitioner can route it. For a nested
|
|
12
|
+
// container directive we also set hName so it renders as a <div> wrapper rather than being
|
|
13
|
+
// dropped as an unknown directive.
|
|
14
|
+
function markSlot(node: unknown, name: string): void {
|
|
15
|
+
const n = node as { type?: string; data?: { hName?: string; hProperties?: Record<string, string> } };
|
|
16
|
+
const data = n.data ?? (n.data = {});
|
|
17
|
+
if (n.type === 'containerDirective') data.hName = 'div';
|
|
18
|
+
data.hProperties = { ...(data.hProperties ?? {}), dataSlot: name };
|
|
19
|
+
}
|
|
5
20
|
|
|
6
21
|
// Reconstruct a directive's authored attribute block (`{#id .class key="value"}`).
|
|
7
22
|
// Accidental prose directives carry none, so this is almost always empty.
|
|
@@ -41,6 +56,7 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
|
|
|
41
56
|
return (tree: Root) => {
|
|
42
57
|
visit(tree, 'containerDirective', (node: ContainerDirective) => {
|
|
43
58
|
if (!known.has(node.name)) return;
|
|
59
|
+
const def = registry.get(node.name);
|
|
44
60
|
const attrs = node.attributes ?? {};
|
|
45
61
|
const role = attrs.role || undefined;
|
|
46
62
|
let icon = attrs.icon || undefined;
|
|
@@ -49,10 +65,32 @@ export function remarkDirectiveStamp(registry: ComponentRegistry) {
|
|
|
49
65
|
const properties: Record<string, string> = { dataPrimitive: node.name };
|
|
50
66
|
if (icon) properties.dataIcon = icon;
|
|
51
67
|
if (role) properties.dataRole = role;
|
|
68
|
+
// Carry every declared attribute to hast so the dispatch partitioner can build the
|
|
69
|
+
// component context. data-attr-<key> survives to the element; build() consumes it and
|
|
70
|
+
// returns a fresh element, so the marker never reaches the published DOM.
|
|
71
|
+
for (const field of def?.attributes ?? []) {
|
|
72
|
+
const raw = attrs[field.key];
|
|
73
|
+
if (raw != null) properties[dataAttrProp(field.key)] = raw;
|
|
74
|
+
}
|
|
52
75
|
|
|
53
76
|
const data = node.data ?? (node.data = {});
|
|
54
77
|
data.hName = 'div';
|
|
55
78
|
data.hProperties = properties;
|
|
79
|
+
|
|
80
|
+
// Mark the title label paragraph and the nested slot directives so they survive to hast
|
|
81
|
+
// and the partitioner can find them. A slot named in the component schema (other than the
|
|
82
|
+
// default body) is a nested container directive; the title is the directive [label].
|
|
83
|
+
const slotNames = new Set((def?.slots ?? []).map((s) => s.name));
|
|
84
|
+
for (const child of node.children) {
|
|
85
|
+
if (isDirectiveLabel(child) && slotNames.has('title')) {
|
|
86
|
+
markSlot(child, 'title');
|
|
87
|
+
} else if (
|
|
88
|
+
(child as { type?: string }).type === 'containerDirective' &&
|
|
89
|
+
slotNames.has((child as { name: string }).name)
|
|
90
|
+
) {
|
|
91
|
+
markSlot(child, (child as { name: string }).name);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
56
94
|
});
|
|
57
95
|
|
|
58
96
|
visit(tree, ['textDirective', 'leafDirective'], (node, index, parent) => {
|
|
@@ -8,6 +8,7 @@ import type { ContentSummary, ContentEntry } from '../delivery/content-index.js'
|
|
|
8
8
|
import type { SiteIndex } from '../delivery/site-index.js';
|
|
9
9
|
import { buildSeoMeta } from '../delivery/seo.js';
|
|
10
10
|
import type { SeoMeta } from '../delivery/seo.js';
|
|
11
|
+
import { readSeoFields, resolveImageUrl } from '../delivery/seo-fields.js';
|
|
11
12
|
|
|
12
13
|
/** Injected dependencies for the public loaders. */
|
|
13
14
|
export interface PublicRoutesDeps {
|
|
@@ -20,6 +21,9 @@ export interface PublicRoutesDeps {
|
|
|
20
21
|
description: string;
|
|
21
22
|
/** Absolute feed URLs for the head's autodiscovery links. */
|
|
22
23
|
feeds?: { rss?: string; json?: string };
|
|
24
|
+
/** A site-wide default OG image, used when an entry declares none. Resolved to absolute like the
|
|
25
|
+
* canonical URL, so a relative path such as "/og/default.png" works. */
|
|
26
|
+
defaultImage?: string;
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
/** The archive and tag list data: summaries the template renders. */
|
|
@@ -49,7 +53,7 @@ export interface EntryData {
|
|
|
49
53
|
|
|
50
54
|
/** Build the public loaders for a site's unified index. */
|
|
51
55
|
export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
52
|
-
const { site, render, origin, siteName, description, feeds } = deps;
|
|
56
|
+
const { site, render, origin, siteName, description, feeds, defaultImage } = deps;
|
|
53
57
|
|
|
54
58
|
/** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
|
|
55
59
|
function indexOf(conceptId: string) {
|
|
@@ -64,15 +68,21 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
|
|
|
64
68
|
if (!entry) throw error(404, `Not found: ${event.url.pathname}`);
|
|
65
69
|
const { newer, older } = site.adjacent(entry);
|
|
66
70
|
const canonicalUrl = origin + entry.permalink;
|
|
71
|
+
const fields = readSeoFields(entry.frontmatter);
|
|
72
|
+
const rawImage = fields.image ?? defaultImage;
|
|
73
|
+
const image = rawImage ? resolveImageUrl(rawImage, origin) : undefined;
|
|
67
74
|
// A dated entry is an article; an undated one (a page) is a website.
|
|
68
75
|
const seo = buildSeoMeta({
|
|
69
76
|
title: entry.title,
|
|
70
|
-
description:
|
|
77
|
+
description: fields.description || entry.excerpt || description,
|
|
71
78
|
canonicalUrl,
|
|
72
79
|
siteName,
|
|
73
80
|
type: entry.date ? 'article' : 'website',
|
|
74
81
|
...(entry.date ? { published: entry.date } : {}),
|
|
75
82
|
...(entry.updated ? { modified: entry.updated } : {}),
|
|
83
|
+
...(image ? { image } : {}),
|
|
84
|
+
...(fields.robots ? { robots: fields.robots } : {}),
|
|
85
|
+
...(fields.author ? { author: fields.author } : {}),
|
|
76
86
|
feeds,
|
|
77
87
|
});
|
|
78
88
|
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
|