@glw907/cairn-cms 0.7.0 → 0.9.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 (67) hide show
  1. package/dist/components/ConceptList.svelte +8 -4
  2. package/dist/components/ConceptList.svelte.d.ts.map +1 -1
  3. package/dist/components/EditPage.svelte +4 -6
  4. package/dist/components/EditPage.svelte.d.ts +1 -3
  5. package/dist/components/EditPage.svelte.d.ts.map +1 -1
  6. package/dist/components/EditorToolbar.svelte +61 -0
  7. package/dist/components/EditorToolbar.svelte.d.ts +15 -0
  8. package/dist/components/EditorToolbar.svelte.d.ts.map +1 -0
  9. package/dist/components/MarkdownEditor.svelte +96 -57
  10. package/dist/components/MarkdownEditor.svelte.d.ts +5 -6
  11. package/dist/components/MarkdownEditor.svelte.d.ts.map +1 -1
  12. package/dist/components/markdown-format.d.ts +13 -0
  13. package/dist/components/markdown-format.d.ts.map +1 -0
  14. package/dist/components/markdown-format.js +23 -0
  15. package/dist/content/compose.d.ts +2 -2
  16. package/dist/content/compose.d.ts.map +1 -1
  17. package/dist/content/compose.js +2 -2
  18. package/dist/content/concepts.d.ts +7 -6
  19. package/dist/content/concepts.d.ts.map +1 -1
  20. package/dist/content/concepts.js +9 -6
  21. package/dist/content/ids.d.ts +14 -0
  22. package/dist/content/ids.d.ts.map +1 -1
  23. package/dist/content/ids.js +40 -0
  24. package/dist/content/permalink.d.ts +1 -0
  25. package/dist/content/permalink.d.ts.map +1 -1
  26. package/dist/content/permalink.js +1 -1
  27. package/dist/content/types.d.ts +12 -6
  28. package/dist/content/types.d.ts.map +1 -1
  29. package/dist/delivery/content-index.d.ts +1 -0
  30. package/dist/delivery/content-index.d.ts.map +1 -1
  31. package/dist/delivery/content-index.js +4 -2
  32. package/dist/delivery/site-index.d.ts +28 -0
  33. package/dist/delivery/site-index.d.ts.map +1 -0
  34. package/dist/delivery/site-index.js +38 -0
  35. package/dist/index.d.ts +6 -3
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +3 -2
  38. package/dist/nav/site-config.d.ts +5 -0
  39. package/dist/nav/site-config.d.ts.map +1 -1
  40. package/dist/nav/site-config.js +4 -0
  41. package/dist/render/pipeline.d.ts +1 -1
  42. package/dist/render/pipeline.js +1 -1
  43. package/dist/render/sanitize.js +2 -2
  44. package/dist/sveltekit/content-routes.d.ts.map +1 -1
  45. package/dist/sveltekit/content-routes.js +18 -8
  46. package/dist/sveltekit/public-routes.d.ts +11 -12
  47. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  48. package/dist/sveltekit/public-routes.js +36 -35
  49. package/package.json +7 -3
  50. package/src/lib/components/ConceptList.svelte +8 -4
  51. package/src/lib/components/EditPage.svelte +4 -6
  52. package/src/lib/components/EditorToolbar.svelte +61 -0
  53. package/src/lib/components/MarkdownEditor.svelte +96 -57
  54. package/src/lib/components/markdown-format.ts +39 -0
  55. package/src/lib/content/compose.ts +3 -2
  56. package/src/lib/content/concepts.ts +10 -6
  57. package/src/lib/content/ids.ts +44 -0
  58. package/src/lib/content/permalink.ts +2 -2
  59. package/src/lib/content/types.ts +13 -6
  60. package/src/lib/delivery/content-index.ts +5 -2
  61. package/src/lib/delivery/site-index.ts +68 -0
  62. package/src/lib/index.ts +13 -1
  63. package/src/lib/nav/site-config.ts +8 -0
  64. package/src/lib/render/pipeline.ts +1 -1
  65. package/src/lib/render/sanitize.ts +2 -2
  66. package/src/lib/sveltekit/content-routes.ts +17 -7
  67. package/src/lib/sveltekit/public-routes.ts +38 -36
package/src/lib/index.ts CHANGED
@@ -23,6 +23,7 @@ export type {
23
23
  AssetConfig,
24
24
  RoutingRule,
25
25
  ConceptDescriptor,
26
+ ConceptUrlPolicy,
26
27
  CairnExtension,
27
28
  CairnRuntime,
28
29
  AdminPanel,
@@ -37,7 +38,15 @@ export {
37
38
  parseMarkdown,
38
39
  } from './content/frontmatter.js';
39
40
  export { validateFields } from './content/validate.js';
40
- export { isValidId, idFromFilename, filenameFromId, slugify } from './content/ids.js';
41
+ export {
42
+ isValidId,
43
+ idFromFilename,
44
+ filenameFromId,
45
+ slugify,
46
+ slugFromId,
47
+ composeDatedId,
48
+ } from './content/ids.js';
49
+ export type { DatePrefix } from './content/ids.js';
41
50
  // Render engine (Plan 04): generic directive pipeline; sites own the component registry.
42
51
  export { defineRegistry } from './render/registry.js';
43
52
  export type { ComponentDef, ComponentRegistry } from './render/registry.js';
@@ -76,6 +85,7 @@ export type { GithubKeyEnv } from './github/credentials.js';
76
85
  // Nav tree and site-config helpers (Plan 06).
77
86
  export {
78
87
  parseSiteConfig,
88
+ urlPolicyFrom,
79
89
  extractMenu,
80
90
  setMenu,
81
91
  validateNavTree,
@@ -96,6 +106,8 @@ export type {
96
106
  ContentEntry,
97
107
  ContentIndex,
98
108
  } from './delivery/content-index.js';
109
+ export { createSiteIndex } from './delivery/site-index.js';
110
+ export type { SiteIndex, ConceptIndex } from './delivery/site-index.js';
99
111
  export { deriveExcerpt, wordCount } from './delivery/excerpt.js';
100
112
  export { buildRssFeed, buildJsonFeed } from './delivery/feeds.js';
101
113
  export type { FeedChannel, FeedItem } from './delivery/feeds.js';
@@ -3,6 +3,7 @@
3
3
  // commits the file back through the GitHub-App pipeline. This module is pure: parse, validate, and
4
4
  // rewrite only. The engine returns data; each site renders the tree with its own markup.
5
5
  import { parse as parseYaml, parseDocument } from 'yaml';
6
+ import type { ConceptUrlPolicy } from '../content/types.js';
6
7
 
7
8
  /** One navigation node. An omitted or empty `url` is a label-only grouping header; no `children` is a leaf. */
8
9
  export interface NavNode {
@@ -78,6 +79,8 @@ export interface SiteConfig {
78
79
  locale?: string;
79
80
  /** Named navigation menus, each a NavNode[] (normalized by extractMenu). */
80
81
  menus?: Record<string, unknown>;
82
+ /** Per-concept URL policy: the permalink pattern and date-prefix granularity, keyed by concept id. */
83
+ content?: Record<string, ConceptUrlPolicy>;
81
84
  [key: string]: unknown;
82
85
  }
83
86
 
@@ -108,6 +111,11 @@ export function extractMenu(config: SiteConfig, name: string, maxDepth: number):
108
111
  return validateNavTree(menu, maxDepth);
109
112
  }
110
113
 
114
+ /** The per-concept URL policy from a parsed config, or an empty policy when the `content` key is absent. */
115
+ export function urlPolicyFrom(config: SiteConfig): Record<string, ConceptUrlPolicy> {
116
+ return config.content ?? {};
117
+ }
118
+
111
119
  /**
112
120
  * Replace one named menu in the YAML site-config text and reserialize, preserving every other
113
121
  * top-level key (siteName, other menus, settings). Parses into a Document so the rest of the file
@@ -19,7 +19,7 @@ export interface RendererOptions {
19
19
 
20
20
  /** Compose a site's render pipeline from its component registry: directive syntax to
21
21
  * stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
22
- * rehype plugin arrays (so the Carta editor preview can reuse the exact same set). */
22
+ * rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
23
23
  export function createRenderer(registry: ComponentRegistry, options: RendererOptions = {}) {
24
24
  const remarkPlugins: PluggableList = [remarkDirective, [remarkDirectiveStamp, registry]];
25
25
  const rehypePlugins: PluggableList = [rehypeRaw, [rehypeDispatch, registry, options.stagger], rehypeSlug];
@@ -1,5 +1,5 @@
1
- // The live preview's sanitize floor. Carta runs with `sanitizer: false` behind the MarkdownEditor
2
- // seam, so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
1
+ // The live preview's sanitize floor. The MarkdownEditor edits raw markdown and never sanitizes,
2
+ // so the admin preview pane is the one barrier between editor-authored markdown and the DOM.
3
3
  // DOMPurify needs a DOM, and the preview renders only in the browser after mount, so DOMPurify
4
4
  // loads through a dynamic import: the module never evaluates a DOM library on the Worker, and a
5
5
  // server import of this file pulls in nothing.
@@ -5,7 +5,7 @@
5
5
  import { redirect, error } from '@sveltejs/kit';
6
6
  import { findConcept } from '../content/concepts.js';
7
7
  import { frontmatterFromForm, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
8
- import { isValidId, slugify, filenameFromId } from '../content/ids.js';
8
+ import { isValidId, slugify, filenameFromId, composeDatedId } from '../content/ids.js';
9
9
  import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
10
10
  import { listMarkdown, readRaw, commitFile } from '../github/repo.js';
11
11
  import { installationToken } from '../github/signing.js';
@@ -153,22 +153,32 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
153
153
  }
154
154
  }
155
155
 
156
- /** Create a new entry: validate the slug, refuse to clobber, and redirect to the editor. */
156
+ /** Create a new entry: validate the slug, compose a dated id when the concept is dated, refuse to clobber. */
157
157
  async function createAction(event: ContentEvent): Promise<never> {
158
158
  sessionOf(event);
159
159
  const concept = conceptOf(runtime, event.params);
160
160
  const form = await event.request.formData();
161
- const raw = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
161
+ const slug = String(form.get('slug') ?? '').trim() || slugify(String(form.get('title') ?? ''));
162
+ const date = String(form.get('date') ?? '').trim();
162
163
  const bounce = (msg: string): never => {
163
164
  throw redirect(303, `/admin/${concept.id}?error=${encodeURIComponent(msg)}`);
164
165
  };
165
- if (!isValidId(raw)) bounce('Enter a valid slug: lowercase letters, numbers, and hyphens.');
166
+ if (!isValidId(slug)) return bounce('Enter a valid slug: lowercase letters, numbers, and hyphens.');
167
+
168
+ let id = slug;
169
+ if (concept.routing.dated) {
170
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return bounce('Pick a date for this entry.');
171
+ if (/^\d{4}-/.test(slug)) {
172
+ return bounce('Leave the date out of the slug; set it in the date field.');
173
+ }
174
+ id = composeDatedId(date, slug, concept.datePrefix);
175
+ }
166
176
 
167
177
  const token = await mintToken(event.platform?.env ?? {});
168
- const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(raw)}`, token);
169
- if (existing !== null) bounce('An entry with that slug already exists.');
178
+ const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
179
+ if (existing !== null) return bounce('An entry with that slug already exists.');
170
180
 
171
- throw redirect(303, `/admin/${concept.id}/${raw}?new=1`);
181
+ throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
172
182
  }
173
183
 
174
184
  /** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
@@ -1,13 +1,15 @@
1
- // cairn-cms: public route loaders (public-delivery design, decision 6). A factory closes over
2
- // a concept's index, the runtime render, and the origin, and returns thin load functions plus
3
- // entries() for prerender. A site route file stays a one-line shim. The index is built in site
4
- // code from a glob, so it stays in the prerender graph and out of the runtime Worker.
1
+ // cairn-cms: public route loaders (dated-slug design). The factory closes over the site-level
2
+ // index, the runtime render, and the origin. entryLoad and entries are site-wide: one catch-all
3
+ // `[...path]` route resolves any concept by request path through `byPermalink`. The archive, tag,
4
+ // and tag-index loaders stay concept-scoped, keyed by concept id. The index is built in site code
5
+ // from globs, so it stays in the prerender graph and out of the runtime Worker.
5
6
  import { error } from '@sveltejs/kit';
6
- import type { ContentIndex, ContentSummary, ContentEntry } from '../delivery/content-index.js';
7
+ import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
8
+ import type { SiteIndex } from '../delivery/site-index.js';
7
9
 
8
10
  /** Injected dependencies for the public loaders. */
9
11
  export interface PublicRoutesDeps {
10
- index: ContentIndex;
12
+ site: SiteIndex;
11
13
  render: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
12
14
  origin: string;
13
15
  }
@@ -36,46 +38,46 @@ export interface EntryData {
36
38
  older?: ContentSummary;
37
39
  }
38
40
 
39
- /** Build the public loaders for one concept's index. */
41
+ /** Build the public loaders for a site's unified index. */
40
42
  export function createPublicRoutes(deps: PublicRoutesDeps) {
41
- const { index, render, origin } = deps;
43
+ const { site, render, origin } = deps;
42
44
 
43
- /** The chronological archive: every non-draft summary, newest-first. */
44
- function archiveLoad(): ListData {
45
- return { entries: index.all() };
45
+ /** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
46
+ function indexOf(conceptId: string) {
47
+ const index = site.concept(conceptId);
48
+ if (!index) throw error(404, `Unknown content type: ${conceptId}`);
49
+ return index;
46
50
  }
47
51
 
48
- /** All tags with counts, for a tag index page. */
49
- function tagIndexLoad(): TagIndexData {
50
- return { tags: index.allTags() };
52
+ /** One entry by request path, rendered through the site renderer, or a 404. */
53
+ async function entryLoad(event: { url: URL }): Promise<EntryData> {
54
+ const entry = site.byPermalink(event.url.pathname);
55
+ if (!entry) throw error(404, `Not found: ${event.url.pathname}`);
56
+ const { newer, older } = site.adjacent(entry);
57
+ return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl: origin + entry.permalink, newer, older };
51
58
  }
52
59
 
53
- /** One tag's entries, or a 404 when the tag has none. */
54
- function tagLoad(event: { params: { tag: string } }): TagData {
55
- const tag = event.params.tag;
56
- const entries = index.byTag(tag);
57
- if (entries.length === 0) throw error(404, `No entries tagged "${tag}"`);
58
- return { tag, entries };
60
+ /** The chronological archive for one concept: every non-draft summary, newest-first. */
61
+ function archiveLoad(conceptId: string): ListData {
62
+ return { entries: indexOf(conceptId).all() };
59
63
  }
60
64
 
61
- /** One entry by slug, rendered through the site renderer, or a 404. */
62
- async function entryLoad(event: { params: { slug: string } }): Promise<EntryData> {
63
- const entry = index.byId(event.params.slug);
64
- if (!entry) throw error(404, `Not found: ${event.params.slug}`);
65
- const { newer, older } = index.adjacent(entry.id);
66
- return {
67
- entry,
68
- html: await render(entry.body, { stagger: true }),
69
- canonicalUrl: origin + entry.permalink,
70
- newer,
71
- older,
72
- };
65
+ /** All tags with counts for one concept, for a tag index page. */
66
+ function tagIndexLoad(conceptId: string): TagIndexData {
67
+ return { tags: indexOf(conceptId).allTags() };
73
68
  }
74
69
 
75
- /** Prerender enumeration: one `{ slug }` per non-draft entry. */
76
- function entries(): { slug: string }[] {
77
- return index.all().map((entry) => ({ slug: entry.id }));
70
+ /** One tag's entries for one concept, or a 404 when the tag has none. */
71
+ function tagLoad(conceptId: string, event: { params: { tag: string } }): TagData {
72
+ const entries = indexOf(conceptId).byTag(event.params.tag);
73
+ if (entries.length === 0) throw error(404, `No entries tagged "${event.params.tag}"`);
74
+ return { tag: event.params.tag, entries };
78
75
  }
79
76
 
80
- return { archiveLoad, tagIndexLoad, tagLoad, entryLoad, entries };
77
+ /** Prerender enumeration: one `{ path }` per entry across every concept. */
78
+ function entries(): { path: string }[] {
79
+ return site.entries();
80
+ }
81
+
82
+ return { entryLoad, archiveLoad, tagIndexLoad, tagLoad, entries };
81
83
  }