@glw907/cairn-cms 0.9.0 → 0.11.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.
Files changed (91) hide show
  1. package/dist/components/ComponentForm.svelte +178 -0
  2. package/dist/components/ComponentForm.svelte.d.ts +20 -0
  3. package/dist/components/ComponentForm.svelte.d.ts.map +1 -0
  4. package/dist/components/ComponentInsertDialog.svelte +92 -0
  5. package/dist/components/ComponentInsertDialog.svelte.d.ts +20 -0
  6. package/dist/components/ComponentInsertDialog.svelte.d.ts.map +1 -0
  7. package/dist/components/EditPage.svelte +6 -3
  8. package/dist/components/EditPage.svelte.d.ts +3 -0
  9. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  10. package/dist/components/IconPicker.svelte +51 -0
  11. package/dist/components/IconPicker.svelte.d.ts +20 -0
  12. package/dist/components/IconPicker.svelte.d.ts.map +1 -0
  13. package/dist/components/index.d.ts +3 -1
  14. package/dist/components/index.d.ts.map +1 -1
  15. package/dist/components/index.js +3 -1
  16. package/dist/content/compose.d.ts.map +1 -1
  17. package/dist/content/compose.js +1 -0
  18. package/dist/content/types.d.ts +5 -0
  19. package/dist/content/types.d.ts.map +1 -1
  20. package/dist/delivery/CairnHead.svelte +36 -0
  21. package/dist/delivery/CairnHead.svelte.d.ts +15 -0
  22. package/dist/delivery/CairnHead.svelte.d.ts.map +1 -0
  23. package/dist/delivery/content-index.d.ts +8 -6
  24. package/dist/delivery/content-index.d.ts.map +1 -1
  25. package/dist/delivery/content-index.js +1 -1
  26. package/dist/delivery/index.d.ts +22 -0
  27. package/dist/delivery/index.d.ts.map +1 -0
  28. package/dist/delivery/index.js +19 -0
  29. package/dist/delivery/json-ld.d.ts +2 -0
  30. package/dist/delivery/json-ld.d.ts.map +1 -0
  31. package/dist/delivery/json-ld.js +16 -0
  32. package/dist/delivery/responses.d.ts +14 -0
  33. package/dist/delivery/responses.d.ts.map +1 -0
  34. package/dist/delivery/responses.js +30 -0
  35. package/dist/delivery/seo.d.ts +4 -0
  36. package/dist/delivery/seo.d.ts.map +1 -1
  37. package/dist/delivery/seo.js +11 -0
  38. package/dist/delivery/site-descriptors.d.ts +5 -0
  39. package/dist/delivery/site-descriptors.d.ts.map +1 -0
  40. package/dist/delivery/site-descriptors.js +9 -0
  41. package/dist/delivery/site-index.d.ts +8 -2
  42. package/dist/delivery/site-index.d.ts.map +1 -1
  43. package/dist/delivery/site-index.js +28 -2
  44. package/dist/index.d.ts +8 -2
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +5 -1
  47. package/dist/render/component-grammar.d.ts +10 -0
  48. package/dist/render/component-grammar.d.ts.map +1 -0
  49. package/dist/render/component-grammar.js +140 -0
  50. package/dist/render/component-insert.d.ts +14 -0
  51. package/dist/render/component-insert.d.ts.map +1 -0
  52. package/dist/render/component-insert.js +9 -0
  53. package/dist/render/component-reference.d.ts +11 -0
  54. package/dist/render/component-reference.d.ts.map +1 -0
  55. package/dist/render/component-reference.js +34 -0
  56. package/dist/render/component-validate.d.ts +10 -0
  57. package/dist/render/component-validate.d.ts.map +1 -0
  58. package/dist/render/component-validate.js +30 -0
  59. package/dist/render/registry.d.ts +45 -1
  60. package/dist/render/registry.d.ts.map +1 -1
  61. package/dist/render/registry.js +13 -0
  62. package/dist/sveltekit/public-routes.d.ts +11 -0
  63. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  64. package/dist/sveltekit/public-routes.js +15 -2
  65. package/package.json +7 -1
  66. package/src/lib/components/ComponentForm.svelte +178 -0
  67. package/src/lib/components/ComponentInsertDialog.svelte +92 -0
  68. package/src/lib/components/EditPage.svelte +6 -3
  69. package/src/lib/components/IconPicker.svelte +51 -0
  70. package/src/lib/components/index.ts +3 -1
  71. package/src/lib/content/compose.ts +1 -0
  72. package/src/lib/content/types.ts +5 -0
  73. package/src/lib/delivery/CairnHead.svelte +36 -0
  74. package/src/lib/delivery/content-index.ts +15 -10
  75. package/src/lib/delivery/index.ts +32 -0
  76. package/src/lib/delivery/json-ld.ts +16 -0
  77. package/src/lib/delivery/responses.ts +34 -0
  78. package/src/lib/delivery/seo.ts +13 -0
  79. package/src/lib/delivery/site-descriptors.ts +12 -0
  80. package/src/lib/delivery/site-index.ts +27 -2
  81. package/src/lib/index.ts +16 -2
  82. package/src/lib/render/component-grammar.ts +167 -0
  83. package/src/lib/render/component-insert.ts +15 -0
  84. package/src/lib/render/component-reference.ts +38 -0
  85. package/src/lib/render/component-validate.ts +36 -0
  86. package/src/lib/render/registry.ts +61 -1
  87. package/src/lib/sveltekit/public-routes.ts +23 -2
  88. package/dist/components/ComponentPalette.svelte +0 -50
  89. package/dist/components/ComponentPalette.svelte.d.ts +0 -16
  90. package/dist/components/ComponentPalette.svelte.d.ts.map +0 -1
  91. package/src/lib/components/ComponentPalette.svelte +0 -50
@@ -28,16 +28,18 @@ export interface ContentSummary {
28
28
  draft: boolean;
29
29
  }
30
30
 
31
- /** The detail view: a summary plus the frontmatter and the body to render. */
32
- export interface ContentEntry extends ContentSummary {
33
- frontmatter: Record<string, unknown>;
31
+ /** The detail view: a summary plus the frontmatter and the body to render. The frontmatter
32
+ * type defaults to `Record<string, unknown>`; the typed-reads pass infers it from the concept
33
+ * fields. Generic now so that change does not break this signature. */
34
+ export interface ContentEntry<F = Record<string, unknown>> extends ContentSummary {
35
+ frontmatter: F;
34
36
  body: string;
35
37
  }
36
38
 
37
39
  /** The per-concept query surface. */
38
- export interface ContentIndex {
40
+ export interface ContentIndex<F = Record<string, unknown>> {
39
41
  all(opts?: { includeDrafts?: boolean }): ContentSummary[];
40
- byId(id: string): ContentEntry | undefined;
42
+ byId(id: string): ContentEntry<F> | undefined;
41
43
  byTag(tag: string, opts?: { includeDrafts?: boolean }): ContentSummary[];
42
44
  allTags(): { tag: string; count: number }[];
43
45
  adjacent(id: string): { newer?: ContentSummary; older?: ContentSummary };
@@ -68,8 +70,11 @@ function asTags(value: unknown): string[] {
68
70
  }
69
71
 
70
72
  /** Build a concept's index from its raw files and normalized descriptor. */
71
- export function createContentIndex(files: RawFile[], descriptor: ConceptDescriptor): ContentIndex {
72
- const entries: ContentEntry[] = files.map((file) => {
73
+ export function createContentIndex<F = Record<string, unknown>>(
74
+ files: RawFile[],
75
+ descriptor: ConceptDescriptor,
76
+ ): ContentIndex<F> {
77
+ const entries: ContentEntry<F>[] = files.map((file) => {
73
78
  const id = idFromFilename(basename(file.path));
74
79
  const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
75
80
  const { frontmatter, body } = parseMarkdown(file.raw);
@@ -85,7 +90,7 @@ export function createContentIndex(files: RawFile[], descriptor: ConceptDescript
85
90
  excerpt: deriveExcerpt(body, { description: asString(frontmatter.description) }),
86
91
  wordCount: wordCount(body),
87
92
  draft: frontmatter.draft === true,
88
- frontmatter,
93
+ frontmatter: frontmatter as F,
89
94
  body,
90
95
  };
91
96
  });
@@ -95,11 +100,11 @@ export function createContentIndex(files: RawFile[], descriptor: ConceptDescript
95
100
  descriptor.routing.dated ? (b.date ?? '').localeCompare(a.date ?? '') : a.title.localeCompare(b.title),
96
101
  );
97
102
 
98
- const summarize = (entry: ContentEntry): ContentSummary => {
103
+ const summarize = (entry: ContentEntry<F>): ContentSummary => {
99
104
  const { frontmatter: _frontmatter, body: _body, ...summary } = entry;
100
105
  return summary;
101
106
  };
102
- const visible = (list: ContentEntry[], includeDrafts?: boolean): ContentEntry[] =>
107
+ const visible = (list: ContentEntry<F>[], includeDrafts?: boolean): ContentEntry<F>[] =>
103
108
  includeDrafts ? list : list.filter((entry) => !entry.draft);
104
109
 
105
110
  return {
@@ -0,0 +1,32 @@
1
+ // cairn-cms: the public delivery entry (@glw907/cairn-cms/delivery). The complete, canonical,
2
+ // backend-free toolkit a SvelteKit site wires its public pages with: the content index and the
3
+ // site resolver, the descriptor helper, the syndication and SEO builders, the endpoint response
4
+ // helpers, the catch-all route loaders, and the head component. It imports nothing from auth,
5
+ // github, or email, so importing it does not pull the server backend into a public bundle.
6
+ export { createContentIndex, fromGlob } from './content-index.js';
7
+ export type { RawFile, ContentSummary, ContentEntry, ContentIndex } from './content-index.js';
8
+ export { createSiteIndex } from './site-index.js';
9
+ export type { SiteIndex, ConceptIndex } from './site-index.js';
10
+ export { siteDescriptors } from './site-descriptors.js';
11
+ export { deriveExcerpt, wordCount } from './excerpt.js';
12
+ export { buildRssFeed, buildJsonFeed } from './feeds.js';
13
+ export type { FeedChannel, FeedItem } from './feeds.js';
14
+ export { buildSitemap } from './sitemap.js';
15
+ export type { SitemapUrl } from './sitemap.js';
16
+ export { buildRobots } from './robots.js';
17
+ export { buildSeoMeta } from './seo.js';
18
+ export type { SeoInput, SeoMeta } from './seo.js';
19
+ export { paginate } from './paginate.js';
20
+ export type { Page } from './paginate.js';
21
+ export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
22
+ export { jsonLdScript } from './json-ld.js';
23
+ export { permalink } from '../content/permalink.js';
24
+ export { createPublicRoutes } from '../sveltekit/public-routes.js';
25
+ export type {
26
+ PublicRoutesDeps,
27
+ ListData,
28
+ TagData,
29
+ TagIndexData,
30
+ EntryData,
31
+ } from '../sveltekit/public-routes.js';
32
+ export { default as CairnHead } from './CairnHead.svelte';
@@ -0,0 +1,16 @@
1
+ // cairn-cms: serialize a JSON-LD object into a safe inline script string. JSON.stringify does
2
+ // not escape <, >, or &, so a value containing "</script>" would close the element and inject
3
+ // markup. Escaping the three characters to their JSON unicode forms keeps the structured data
4
+ // identical for a parser while making the bytes unable to break out of the script element.
5
+ // The line separator U+2028 and paragraph separator U+2029 get the same treatment: they are
6
+ // legal inside a JSON string but unsafe in inline script text, where some parsers read them as
7
+ // line terminators, so an author pasting one into frontmatter would corrupt the JSON-LD block.
8
+ export function jsonLdScript(data: Record<string, unknown>): string {
9
+ const json = JSON.stringify(data)
10
+ .replace(/</g, '\\u003c')
11
+ .replace(/>/g, '\\u003e')
12
+ .replace(/&/g, '\\u0026')
13
+ .replace(/\u2028/g, '\\u2028')
14
+ .replace(/\u2029/g, '\\u2029');
15
+ return `<script type="application/ld+json">${json}</script>`;
16
+ }
@@ -0,0 +1,34 @@
1
+ // cairn-cms: response helpers for the public delivery endpoints. Each wraps a builder in a
2
+ // Response with the correct Content-Type, so a site's +server.ts GET is a single call. The
3
+ // content type is the one detail every site otherwise copies and occasionally gets wrong.
4
+ import { buildRssFeed, buildJsonFeed, type FeedChannel, type FeedItem } from './feeds.js';
5
+ import { buildSitemap, type SitemapUrl } from './sitemap.js';
6
+ import { buildRobots } from './robots.js';
7
+
8
+ /** An RSS 2.0 feed response. */
9
+ export function rssResponse(channel: FeedChannel, items: FeedItem[]): Response {
10
+ return new Response(buildRssFeed(channel, items), {
11
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
12
+ });
13
+ }
14
+
15
+ /** A JSON Feed 1.1 response. */
16
+ export function jsonFeedResponse(channel: FeedChannel, items: FeedItem[]): Response {
17
+ return new Response(buildJsonFeed(channel, items), {
18
+ headers: { 'Content-Type': 'application/feed+json; charset=utf-8' },
19
+ });
20
+ }
21
+
22
+ /** A sitemap response. */
23
+ export function sitemapResponse(urls: SitemapUrl[]): Response {
24
+ return new Response(buildSitemap(urls), {
25
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
26
+ });
27
+ }
28
+
29
+ /** A robots.txt response. */
30
+ export function robotsResponse(opts: { sitemapUrl: string; disallow?: string[] }): Response {
31
+ return new Response(buildRobots(opts), {
32
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
33
+ });
34
+ }
@@ -13,6 +13,10 @@ export interface SeoInput {
13
13
  modified?: string;
14
14
  feeds?: { rss?: string; json?: string };
15
15
  image?: string;
16
+ /** A robots meta directive, e.g. "noindex, nofollow". Omitted from the head when absent. */
17
+ robots?: string;
18
+ /** Author name, emitted as article:author for the article type. */
19
+ author?: string;
16
20
  }
17
21
 
18
22
  /** Plain-data head: a title, meta tags, link tags, and one JSON-LD object. */
@@ -40,6 +44,15 @@ export function buildSeoMeta(input: SeoInput): SeoMeta {
40
44
  meta.push({ name: 'twitter:image', content: input.image });
41
45
  }
42
46
 
47
+ if (input.robots) {
48
+ meta.push({ name: 'robots', content: input.robots });
49
+ }
50
+ if (type === 'article') {
51
+ if (input.published) meta.push({ property: 'article:published_time', content: input.published });
52
+ if (input.modified) meta.push({ property: 'article:modified_time', content: input.modified });
53
+ if (input.author) meta.push({ property: 'article:author', content: input.author });
54
+ }
55
+
43
56
  const links: SeoMeta['links'] = [{ rel: 'canonical', href: input.canonicalUrl }];
44
57
  if (input.feeds?.rss) {
45
58
  links.push({ rel: 'alternate', type: 'application/rss+xml', href: input.feeds.rss, title: input.siteName });
@@ -0,0 +1,12 @@
1
+ // cairn-cms: the one-call descriptor helper. A delivery site needs the same per-concept
2
+ // descriptors the admin runtime uses; this wraps the two calls that derive them so the
3
+ // pairing is not tribal knowledge. The YAML URL policy stays the single source of truth.
4
+ import { normalizeConcepts } from '../content/concepts.js';
5
+ import { urlPolicyFrom } from '../nav/site-config.js';
6
+ import type { CairnAdapter, ConceptDescriptor } from '../content/types.js';
7
+ import type { SiteConfig } from '../nav/site-config.js';
8
+
9
+ /** Per-concept descriptors for a site, from its adapter content and its parsed site config. */
10
+ export function siteDescriptors(adapter: CairnAdapter, siteConfig: SiteConfig): ConceptDescriptor[] {
11
+ return normalizeConcepts(adapter.content, urlPolicyFrom(siteConfig));
12
+ }
@@ -30,8 +30,33 @@ function normalizePath(path: string): string {
30
30
  return path.length > 1 ? path.replace(/\/+$/, '') : path;
31
31
  }
32
32
 
33
- /** Union per-concept indexes into a site-level resolver; throw on a duplicate permalink. */
34
- export function createSiteIndex(concepts: ConceptIndex[]): SiteIndex {
33
+ /** Validate every entry (drafts included) against its concept, aggregating failures. */
34
+ function validateAll(concepts: ConceptIndex[]): void {
35
+ const problems: string[] = [];
36
+ for (const { descriptor, index } of concepts) {
37
+ for (const summary of index.all({ includeDrafts: true })) {
38
+ const entry = index.byId(summary.id);
39
+ if (!entry) continue;
40
+ const result = descriptor.validate(entry.frontmatter, entry.body);
41
+ if (!result.ok) {
42
+ for (const [field, message] of Object.entries(result.errors)) {
43
+ problems.push(`${descriptor.dir}/${summary.id}: ${field}: ${message}`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+ if (problems.length > 0) {
49
+ throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Union per-concept indexes into a site-level resolver. Throws on a duplicate permalink and,
55
+ * unless `validate` is `false`, on any entry whose frontmatter fails its concept's validator,
56
+ * so malformed content fails the build instead of shipping.
57
+ */
58
+ export function createSiteIndex(concepts: ConceptIndex[], opts: { validate?: boolean } = {}): SiteIndex {
59
+ if (opts.validate !== false) validateAll(concepts);
35
60
  const byPath = new Map<string, { index: ContentIndex; id: string }>();
36
61
  const byId = new Map<string, ContentIndex>();
37
62
  for (const { descriptor, index } of concepts) {
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 { ComponentDef, ComponentRegistry } from './render/registry.js';
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, '&amp;').replace(/"/g, '&quot;');
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: string;
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
+ }
@@ -6,12 +6,20 @@
6
6
  import { error } from '@sveltejs/kit';
7
7
  import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
8
8
  import type { SiteIndex } from '../delivery/site-index.js';
9
+ import { buildSeoMeta } from '../delivery/seo.js';
10
+ import type { SeoMeta } from '../delivery/seo.js';
9
11
 
10
12
  /** Injected dependencies for the public loaders. */
11
13
  export interface PublicRoutesDeps {
12
14
  site: SiteIndex;
13
15
  render: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
14
16
  origin: string;
17
+ /** Site name for og:site_name and the SEO head. */
18
+ siteName: string;
19
+ /** Default description used when an entry has none. */
20
+ description: string;
21
+ /** Absolute feed URLs for the head's autodiscovery links. */
22
+ feeds?: { rss?: string; json?: string };
15
23
  }
16
24
 
17
25
  /** The archive and tag list data: summaries the template renders. */
@@ -34,13 +42,14 @@ export interface EntryData {
34
42
  entry: ContentEntry;
35
43
  html: string;
36
44
  canonicalUrl: string;
45
+ seo: SeoMeta;
37
46
  newer?: ContentSummary;
38
47
  older?: ContentSummary;
39
48
  }
40
49
 
41
50
  /** Build the public loaders for a site's unified index. */
42
51
  export function createPublicRoutes(deps: PublicRoutesDeps) {
43
- const { site, render, origin } = deps;
52
+ const { site, render, origin, siteName, description, feeds } = deps;
44
53
 
45
54
  /** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
46
55
  function indexOf(conceptId: string) {
@@ -54,7 +63,19 @@ export function createPublicRoutes(deps: PublicRoutesDeps) {
54
63
  const entry = site.byPermalink(event.url.pathname);
55
64
  if (!entry) throw error(404, `Not found: ${event.url.pathname}`);
56
65
  const { newer, older } = site.adjacent(entry);
57
- return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl: origin + entry.permalink, newer, older };
66
+ const canonicalUrl = origin + entry.permalink;
67
+ // A dated entry is an article; an undated one (a page) is a website.
68
+ const seo = buildSeoMeta({
69
+ title: entry.title,
70
+ description: (entry.frontmatter.description as string) || entry.excerpt || description,
71
+ canonicalUrl,
72
+ siteName,
73
+ type: entry.date ? 'article' : 'website',
74
+ ...(entry.date ? { published: entry.date } : {}),
75
+ ...(entry.updated ? { modified: entry.updated } : {}),
76
+ feeds,
77
+ });
78
+ return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
58
79
  }
59
80
 
60
81
  /** The chronological archive for one concept: every non-draft summary, newest-first. */