@glw907/cairn-cms 0.7.0 → 0.8.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/ConceptList.svelte +8 -4
- package/dist/components/ConceptList.svelte.d.ts.map +1 -1
- package/dist/content/compose.d.ts +2 -2
- package/dist/content/compose.d.ts.map +1 -1
- package/dist/content/compose.js +2 -2
- package/dist/content/concepts.d.ts +7 -6
- package/dist/content/concepts.d.ts.map +1 -1
- package/dist/content/concepts.js +9 -6
- package/dist/content/ids.d.ts +14 -0
- package/dist/content/ids.d.ts.map +1 -1
- package/dist/content/ids.js +40 -0
- package/dist/content/permalink.d.ts +1 -0
- package/dist/content/permalink.d.ts.map +1 -1
- package/dist/content/permalink.js +1 -1
- package/dist/content/types.d.ts +12 -6
- package/dist/content/types.d.ts.map +1 -1
- package/dist/delivery/content-index.d.ts +1 -0
- package/dist/delivery/content-index.d.ts.map +1 -1
- package/dist/delivery/content-index.js +4 -2
- package/dist/delivery/site-index.d.ts +28 -0
- package/dist/delivery/site-index.d.ts.map +1 -0
- package/dist/delivery/site-index.js +38 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/nav/site-config.d.ts +5 -0
- package/dist/nav/site-config.d.ts.map +1 -1
- package/dist/nav/site-config.js +4 -0
- package/dist/sveltekit/content-routes.d.ts.map +1 -1
- package/dist/sveltekit/content-routes.js +18 -8
- package/dist/sveltekit/public-routes.d.ts +11 -12
- package/dist/sveltekit/public-routes.d.ts.map +1 -1
- package/dist/sveltekit/public-routes.js +36 -35
- package/package.json +1 -1
- package/src/lib/components/ConceptList.svelte +8 -4
- package/src/lib/content/compose.ts +3 -2
- package/src/lib/content/concepts.ts +10 -6
- package/src/lib/content/ids.ts +44 -0
- package/src/lib/content/permalink.ts +2 -2
- package/src/lib/content/types.ts +13 -6
- package/src/lib/delivery/content-index.ts +5 -2
- package/src/lib/delivery/site-index.ts +68 -0
- package/src/lib/index.ts +13 -1
- package/src/lib/nav/site-config.ts +8 -0
- package/src/lib/sveltekit/content-routes.ts +17 -7
- package/src/lib/sveltekit/public-routes.ts +38 -36
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
|
|
2
|
+
import type { SiteIndex } from '../delivery/site-index.js';
|
|
2
3
|
/** Injected dependencies for the public loaders. */
|
|
3
4
|
export interface PublicRoutesDeps {
|
|
4
|
-
|
|
5
|
+
site: SiteIndex;
|
|
5
6
|
render: (md: string, opts?: {
|
|
6
7
|
stagger?: boolean;
|
|
7
8
|
}) => string | Promise<string>;
|
|
@@ -30,22 +31,20 @@ export interface EntryData {
|
|
|
30
31
|
newer?: ContentSummary;
|
|
31
32
|
older?: ContentSummary;
|
|
32
33
|
}
|
|
33
|
-
/** Build the public loaders for
|
|
34
|
+
/** Build the public loaders for a site's unified index. */
|
|
34
35
|
export declare function createPublicRoutes(deps: PublicRoutesDeps): {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
entryLoad: (event: {
|
|
37
|
+
url: URL;
|
|
38
|
+
}) => Promise<EntryData>;
|
|
39
|
+
archiveLoad: (conceptId: string) => ListData;
|
|
40
|
+
tagIndexLoad: (conceptId: string) => TagIndexData;
|
|
41
|
+
tagLoad: (conceptId: string, event: {
|
|
38
42
|
params: {
|
|
39
43
|
tag: string;
|
|
40
44
|
};
|
|
41
45
|
}) => TagData;
|
|
42
|
-
entryLoad: (event: {
|
|
43
|
-
params: {
|
|
44
|
-
slug: string;
|
|
45
|
-
};
|
|
46
|
-
}) => Promise<EntryData>;
|
|
47
46
|
entries: () => {
|
|
48
|
-
|
|
47
|
+
path: string;
|
|
49
48
|
}[];
|
|
50
49
|
};
|
|
51
50
|
//# sourceMappingURL=public-routes.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjF,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,2BAA2B,CAAC;AAE3D,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,OAAO,CAAA;KAAE,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC/E,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,qEAAqE;AACrE,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,uDAAuD;AACvD,MAAM,WAAW,OAAQ,SAAQ,QAAQ;IACvC,GAAG,EAAE,MAAM,CAAC;CACb;AAED,oDAAoD;AACpD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CACxC;AAED,oFAAoF;AACpF,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,YAAY,CAAC;IACpB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,cAAc,CAAC;IACvB,KAAK,CAAC,EAAE,cAAc,CAAC;CACxB;AAED,2DAA2D;AAC3D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAWvB;QAAE,GAAG,EAAE,GAAG,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;6BAQjC,MAAM,KAAG,QAAQ;8BAKhB,MAAM,KAAG,YAAY;yBAK1B,MAAM,SAAS;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;mBAO5D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
|
|
@@ -1,44 +1,45 @@
|
|
|
1
|
-
// cairn-cms: public route loaders (
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
/** Build the public loaders for
|
|
7
|
+
/** Build the public loaders for a site's unified index. */
|
|
7
8
|
export function createPublicRoutes(deps) {
|
|
8
|
-
const {
|
|
9
|
-
/**
|
|
10
|
-
function
|
|
11
|
-
|
|
9
|
+
const { site, render, origin } = deps;
|
|
10
|
+
/** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
|
|
11
|
+
function indexOf(conceptId) {
|
|
12
|
+
const index = site.concept(conceptId);
|
|
13
|
+
if (!index)
|
|
14
|
+
throw error(404, `Unknown content type: ${conceptId}`);
|
|
15
|
+
return index;
|
|
12
16
|
}
|
|
13
|
-
/**
|
|
14
|
-
function tagIndexLoad() {
|
|
15
|
-
return { tags: index.allTags() };
|
|
16
|
-
}
|
|
17
|
-
/** One tag's entries, or a 404 when the tag has none. */
|
|
18
|
-
function tagLoad(event) {
|
|
19
|
-
const tag = event.params.tag;
|
|
20
|
-
const entries = index.byTag(tag);
|
|
21
|
-
if (entries.length === 0)
|
|
22
|
-
throw error(404, `No entries tagged "${tag}"`);
|
|
23
|
-
return { tag, entries };
|
|
24
|
-
}
|
|
25
|
-
/** One entry by slug, rendered through the site renderer, or a 404. */
|
|
17
|
+
/** One entry by request path, rendered through the site renderer, or a 404. */
|
|
26
18
|
async function entryLoad(event) {
|
|
27
|
-
const entry =
|
|
19
|
+
const entry = site.byPermalink(event.url.pathname);
|
|
28
20
|
if (!entry)
|
|
29
|
-
throw error(404, `Not found: ${event.
|
|
30
|
-
const { newer, older } =
|
|
31
|
-
return {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
21
|
+
throw error(404, `Not found: ${event.url.pathname}`);
|
|
22
|
+
const { newer, older } = site.adjacent(entry);
|
|
23
|
+
return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl: origin + entry.permalink, newer, older };
|
|
24
|
+
}
|
|
25
|
+
/** The chronological archive for one concept: every non-draft summary, newest-first. */
|
|
26
|
+
function archiveLoad(conceptId) {
|
|
27
|
+
return { entries: indexOf(conceptId).all() };
|
|
28
|
+
}
|
|
29
|
+
/** All tags with counts for one concept, for a tag index page. */
|
|
30
|
+
function tagIndexLoad(conceptId) {
|
|
31
|
+
return { tags: indexOf(conceptId).allTags() };
|
|
32
|
+
}
|
|
33
|
+
/** One tag's entries for one concept, or a 404 when the tag has none. */
|
|
34
|
+
function tagLoad(conceptId, event) {
|
|
35
|
+
const entries = indexOf(conceptId).byTag(event.params.tag);
|
|
36
|
+
if (entries.length === 0)
|
|
37
|
+
throw error(404, `No entries tagged "${event.params.tag}"`);
|
|
38
|
+
return { tag: event.params.tag, entries };
|
|
38
39
|
}
|
|
39
|
-
/** Prerender enumeration: one `{
|
|
40
|
+
/** Prerender enumeration: one `{ path }` per entry across every concept. */
|
|
40
41
|
function entries() {
|
|
41
|
-
return
|
|
42
|
+
return site.entries();
|
|
42
43
|
}
|
|
43
|
-
return { archiveLoad, tagIndexLoad, tagLoad,
|
|
44
|
+
return { entryLoad, archiveLoad, tagIndexLoad, tagLoad, entries };
|
|
44
45
|
}
|
package/package.json
CHANGED
|
@@ -17,9 +17,14 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
|
|
|
17
17
|
let title = $state('');
|
|
18
18
|
let slug = $state('');
|
|
19
19
|
let slugEdited = $state(false);
|
|
20
|
+
// Default the date client-side so the SSR pass and hydration agree across UTC midnight.
|
|
21
|
+
let dateDefault = $state('');
|
|
22
|
+
$effect(() => {
|
|
23
|
+
dateDefault = new Date().toISOString().slice(0, 10);
|
|
24
|
+
});
|
|
20
25
|
|
|
21
26
|
const derivedSlug = $derived(slugEdited ? slug : slugify(title));
|
|
22
|
-
const slugPlaceholder = $derived(data.dated ? '
|
|
27
|
+
const slugPlaceholder = $derived(data.dated ? 'my-entry' : 'about-us');
|
|
23
28
|
</script>
|
|
24
29
|
|
|
25
30
|
<header class="mb-4 flex items-center justify-between">
|
|
@@ -58,14 +63,13 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
|
|
|
58
63
|
<h2 class="text-sm font-semibold">New entry</h2>
|
|
59
64
|
<label class="flex flex-col gap-1">
|
|
60
65
|
<span class="text-sm font-medium">Title</span>
|
|
61
|
-
<input class="input" name="title"
|
|
66
|
+
<input class="input" name="title" bind:value={title} required />
|
|
62
67
|
</label>
|
|
63
68
|
<label class="flex flex-col gap-1">
|
|
64
69
|
<span class="text-sm font-medium">Slug</span>
|
|
65
70
|
<input
|
|
66
71
|
class="input"
|
|
67
72
|
name="slug"
|
|
68
|
-
aria-label="Slug"
|
|
69
73
|
placeholder={slugPlaceholder}
|
|
70
74
|
value={derivedSlug}
|
|
71
75
|
oninput={(e) => { slugEdited = true; slug = e.currentTarget.value; }}
|
|
@@ -74,7 +78,7 @@ plus a new-entry form. The slug auto-derives from the title until the author edi
|
|
|
74
78
|
{#if data.dated}
|
|
75
79
|
<label class="flex flex-col gap-1">
|
|
76
80
|
<span class="text-sm font-medium">Date</span>
|
|
77
|
-
<input class="input" type="date" name="date"
|
|
81
|
+
<input class="input" type="date" name="date" value={dateDefault} />
|
|
78
82
|
</label>
|
|
79
83
|
{/if}
|
|
80
84
|
<button type="submit" class="btn btn-primary self-start">Create</button>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the same way and contributes the same kinds of things: nav entries, route logic,
|
|
4
4
|
// concepts, components, field types, and save hooks. Shaped now so the extension contract
|
|
5
5
|
// is additive later.
|
|
6
|
-
import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, FieldTypeDef } from './types.js';
|
|
6
|
+
import type { AdminPanel, CairnAdapter, CairnExtension, CairnRuntime, ConceptConfig, ConceptUrlPolicy, FieldTypeDef } from './types.js';
|
|
7
7
|
import { normalizeConcepts } from './concepts.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
@@ -13,6 +13,7 @@ import { normalizeConcepts } from './concepts.js';
|
|
|
13
13
|
export function composeRuntime(
|
|
14
14
|
adapter: CairnAdapter,
|
|
15
15
|
extensions: CairnExtension[] = [],
|
|
16
|
+
urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
|
|
16
17
|
): CairnRuntime {
|
|
17
18
|
const content: Record<string, ConceptConfig | undefined> = { ...adapter.content };
|
|
18
19
|
const adminPanels: AdminPanel[] = [];
|
|
@@ -26,7 +27,7 @@ export function composeRuntime(
|
|
|
26
27
|
}
|
|
27
28
|
return {
|
|
28
29
|
siteName: adapter.siteName,
|
|
29
|
-
concepts: normalizeConcepts(content),
|
|
30
|
+
concepts: normalizeConcepts(content, urlPolicy),
|
|
30
31
|
backend: adapter.backend,
|
|
31
32
|
sender: adapter.sender,
|
|
32
33
|
render: adapter.render,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (id, label, directory, concept-fixed routing, fields, validator) the admin reads. A
|
|
4
4
|
// future Fragments concept attaches by adding one key under `content` and one routing
|
|
5
5
|
// entry, with no reshape here.
|
|
6
|
-
import type { ConceptConfig, ConceptDescriptor, RoutingRule } from './types.js';
|
|
6
|
+
import type { ConceptConfig, ConceptDescriptor, ConceptUrlPolicy, RoutingRule } from './types.js';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Concept-fixed routing, keyed by concept id (spec §7.2). Posts are dated feed entries;
|
|
@@ -29,24 +29,28 @@ function defaultPermalink(id: string): string {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Normalize an adapter's declared concepts into uniform descriptors (seam 1).
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
32
|
+
* Normalize an adapter's declared concepts into uniform descriptors (seam 1). URL policy
|
|
33
|
+
* (`permalink`, `datePrefix`) comes from the YAML site-config, passed here as `urlPolicy` keyed by
|
|
34
|
+
* concept id; each value defaults when the YAML omits it (`/:slug` for Pages, `/<id>/:slug`
|
|
35
|
+
* otherwise; `datePrefix` defaults to `day`). `routing` is injectable so a contract test can prove
|
|
36
|
+
* a new concept attaches additively; production passes the default `CONCEPT_ROUTING`.
|
|
36
37
|
*/
|
|
37
38
|
export function normalizeConcepts(
|
|
38
39
|
content: Record<string, ConceptConfig | undefined>,
|
|
40
|
+
urlPolicy: Record<string, ConceptUrlPolicy | undefined> = {},
|
|
39
41
|
routing: Readonly<Record<string, RoutingRule>> = CONCEPT_ROUTING,
|
|
40
42
|
): ConceptDescriptor[] {
|
|
41
43
|
const descriptors: ConceptDescriptor[] = [];
|
|
42
44
|
for (const [id, config] of Object.entries(content)) {
|
|
43
45
|
if (!config) continue;
|
|
46
|
+
const policy = urlPolicy[id] ?? {};
|
|
44
47
|
descriptors.push({
|
|
45
48
|
id,
|
|
46
49
|
label: config.label ?? defaultLabel(id),
|
|
47
50
|
dir: config.dir,
|
|
48
51
|
routing: routing[id] ?? DEFAULT_ROUTING,
|
|
49
|
-
permalink:
|
|
52
|
+
permalink: policy.permalink ?? defaultPermalink(id),
|
|
53
|
+
datePrefix: policy.datePrefix ?? 'day',
|
|
50
54
|
fields: config.fields,
|
|
51
55
|
validate: config.validate,
|
|
52
56
|
});
|
package/src/lib/content/ids.ts
CHANGED
|
@@ -36,3 +36,47 @@ export function slugify(title: string): string {
|
|
|
36
36
|
.replace(/[^a-z0-9]+/g, '-')
|
|
37
37
|
.replace(/^-+|-+$/g, '');
|
|
38
38
|
}
|
|
39
|
+
|
|
40
|
+
/** Filename date-prefix granularity for a dated concept: the leading `YYYY[-MM[-DD]]-` on the stem. */
|
|
41
|
+
export type DatePrefix = 'year' | 'month' | 'day';
|
|
42
|
+
|
|
43
|
+
/** The leading date-prefix shape for each granularity. */
|
|
44
|
+
const DATE_PREFIX_RE: Record<DatePrefix, RegExp> = {
|
|
45
|
+
year: /^\d{4}-/,
|
|
46
|
+
month: /^\d{4}-\d{2}-/,
|
|
47
|
+
day: /^\d{4}-\d{2}-\d{2}-/,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The URL slug for an id. A dated concept passes its `datePrefix` and the leading date prefix is
|
|
52
|
+
* stripped when present; a non-dated concept passes `null` and the id is returned verbatim. Only
|
|
53
|
+
* the leading prefix is removed, so a year-like tail (a post titled "2024 Recap") stays in the slug.
|
|
54
|
+
*/
|
|
55
|
+
export function slugFromId(id: string, datePrefix: DatePrefix | null): string {
|
|
56
|
+
if (!datePrefix) return id;
|
|
57
|
+
return id.replace(DATE_PREFIX_RE[datePrefix], '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Compose a dated entry's id from a `YYYY-MM-DD` date, a date-free slug, and the concept's
|
|
62
|
+
* granularity: the date truncated to the granularity, a hyphen, then the slug. Throws on a
|
|
63
|
+
* malformed date so a bad create fails before touching git.
|
|
64
|
+
*/
|
|
65
|
+
export function composeDatedId(date: string, slug: string, datePrefix: DatePrefix): string {
|
|
66
|
+
const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
67
|
+
if (!m) throw new Error(`composeDatedId: malformed date "${date}"`);
|
|
68
|
+
const [, year, month, day] = m;
|
|
69
|
+
let prefix: string;
|
|
70
|
+
switch (datePrefix) {
|
|
71
|
+
case 'year':
|
|
72
|
+
prefix = year;
|
|
73
|
+
break;
|
|
74
|
+
case 'month':
|
|
75
|
+
prefix = `${year}-${month}`;
|
|
76
|
+
break;
|
|
77
|
+
case 'day':
|
|
78
|
+
prefix = `${year}-${month}-${day}`;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
return `${prefix}-${slug}`;
|
|
82
|
+
}
|
|
@@ -20,10 +20,10 @@ function dateParts(date?: string): { year: string; month: string; day: string }
|
|
|
20
20
|
*/
|
|
21
21
|
export function permalink(
|
|
22
22
|
descriptor: ConceptDescriptor,
|
|
23
|
-
entry: { id: string; date?: string },
|
|
23
|
+
entry: { id: string; slug: string; date?: string },
|
|
24
24
|
): string {
|
|
25
25
|
return descriptor.permalink.replace(/:(\w+)/g, (_match, token: string) => {
|
|
26
|
-
if (token === 'slug') return entry.
|
|
26
|
+
if (token === 'slug') return entry.slug;
|
|
27
27
|
if (token === 'year' || token === 'month' || token === 'day') {
|
|
28
28
|
const parts = dateParts(entry.date);
|
|
29
29
|
if (!parts) {
|
package/src/lib/content/types.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// descriptors are plain data so a `load` function can hand them across the server-to-client
|
|
9
9
|
// boundary to the editor form.
|
|
10
10
|
import type { ComponentRegistry } from '../render/registry.js';
|
|
11
|
+
import type { DatePrefix } from './ids.js';
|
|
11
12
|
|
|
12
13
|
/** Common to every frontmatter field: the frontmatter key, the form label, and whether it is required. */
|
|
13
14
|
interface FieldBase {
|
|
@@ -84,13 +85,17 @@ export interface ConceptConfig {
|
|
|
84
85
|
fields: FrontmatterField[];
|
|
85
86
|
/** Validate submitted frontmatter before any commit. */
|
|
86
87
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A concept's URL policy, set per concept in the YAML site-config (not the adapter). `permalink` is
|
|
92
|
+
* a `/`-prefixed pattern of literal segments and the tokens `:slug`, `:year`, `:month`, `:day`.
|
|
93
|
+
* `datePrefix` is the filename date-prefix granularity for a dated concept. Both default in
|
|
94
|
+
* `normalizeConcepts` when omitted.
|
|
95
|
+
*/
|
|
96
|
+
export interface ConceptUrlPolicy {
|
|
93
97
|
permalink?: string;
|
|
98
|
+
datePrefix?: DatePrefix;
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
|
|
@@ -175,6 +180,8 @@ export interface ConceptDescriptor {
|
|
|
175
180
|
routing: RoutingRule;
|
|
176
181
|
/** The resolved permalink pattern, defaulted by `normalizeConcepts`. */
|
|
177
182
|
permalink: string;
|
|
183
|
+
/** Filename date-prefix granularity for a dated concept; resolved by `normalizeConcepts`. */
|
|
184
|
+
datePrefix: DatePrefix;
|
|
178
185
|
fields: FrontmatterField[];
|
|
179
186
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
180
187
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// returns cheap plain-data summaries plus an on-demand detail lookup. It is concept-generic:
|
|
4
4
|
// every operation reads the descriptor and its routing rule, never a hardcoded concept id.
|
|
5
5
|
import { parseMarkdown } from '../content/frontmatter.js';
|
|
6
|
-
import { idFromFilename } from '../content/ids.js';
|
|
6
|
+
import { idFromFilename, slugFromId } from '../content/ids.js';
|
|
7
7
|
import { permalink } from '../content/permalink.js';
|
|
8
8
|
import { deriveExcerpt, wordCount } from './excerpt.js';
|
|
9
9
|
import type { ConceptDescriptor } from '../content/types.js';
|
|
@@ -17,6 +17,7 @@ export interface RawFile {
|
|
|
17
17
|
/** The cheap, plain-data view of one entry, for lists, feeds, and the sitemap. */
|
|
18
18
|
export interface ContentSummary {
|
|
19
19
|
id: string;
|
|
20
|
+
slug: string;
|
|
20
21
|
permalink: string;
|
|
21
22
|
title: string;
|
|
22
23
|
date?: string;
|
|
@@ -70,11 +71,13 @@ function asTags(value: unknown): string[] {
|
|
|
70
71
|
export function createContentIndex(files: RawFile[], descriptor: ConceptDescriptor): ContentIndex {
|
|
71
72
|
const entries: ContentEntry[] = files.map((file) => {
|
|
72
73
|
const id = idFromFilename(basename(file.path));
|
|
74
|
+
const slug = slugFromId(id, descriptor.routing.dated ? descriptor.datePrefix : null);
|
|
73
75
|
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
74
76
|
const date = asDate(frontmatter.date);
|
|
75
77
|
return {
|
|
76
78
|
id,
|
|
77
|
-
|
|
79
|
+
slug,
|
|
80
|
+
permalink: permalink(descriptor, { id, slug, date }),
|
|
78
81
|
title: asString(frontmatter.title) ?? id,
|
|
79
82
|
date,
|
|
80
83
|
updated: asDate(frontmatter.updated),
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// cairn-cms: the site-level content index (dated-slug design). It unions every concept's
|
|
2
|
+
// per-concept index into one cross-concept resolver: a single byPermalink map a catch-all route
|
|
3
|
+
// matches a request path against, one entries() list the prerenderer walks, and the per-concept
|
|
4
|
+
// indexes for concept-scoped archive, tag, and feed loaders. A duplicate permalink throws at build.
|
|
5
|
+
import type { ConceptDescriptor } from '../content/types.js';
|
|
6
|
+
import type { ContentEntry, ContentIndex, ContentSummary } from './content-index.js';
|
|
7
|
+
|
|
8
|
+
/** One concept's descriptor paired with its built index. */
|
|
9
|
+
export interface ConceptIndex {
|
|
10
|
+
descriptor: ConceptDescriptor;
|
|
11
|
+
index: ContentIndex;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The cross-concept query surface a catch-all route and the sitemap read. */
|
|
15
|
+
export interface SiteIndex {
|
|
16
|
+
/** Resolve a request path (with or without a trailing slash) to its entry, or undefined. */
|
|
17
|
+
byPermalink(path: string): ContentEntry | undefined;
|
|
18
|
+
/** Newer/older neighbors within the entry's own concept, for prev/next links. */
|
|
19
|
+
adjacent(entry: ContentSummary): { newer?: ContentSummary; older?: ContentSummary };
|
|
20
|
+
/** Every entry's path across concepts, leading slash stripped, for SvelteKit `[...path]` prerender. */
|
|
21
|
+
entries(): { path: string }[];
|
|
22
|
+
/** One concept's index, for its archive, tag, and feed loaders. */
|
|
23
|
+
concept(id: string): ContentIndex | undefined;
|
|
24
|
+
/** Every non-draft summary across concepts, for the site-wide sitemap. */
|
|
25
|
+
all(): ContentSummary[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Strip a trailing slash from a path, keeping the root "/" intact. */
|
|
29
|
+
function normalizePath(path: string): string {
|
|
30
|
+
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Union per-concept indexes into a site-level resolver; throw on a duplicate permalink. */
|
|
34
|
+
export function createSiteIndex(concepts: ConceptIndex[]): SiteIndex {
|
|
35
|
+
const byPath = new Map<string, { index: ContentIndex; id: string }>();
|
|
36
|
+
const byId = new Map<string, ContentIndex>();
|
|
37
|
+
for (const { descriptor, index } of concepts) {
|
|
38
|
+
byId.set(descriptor.id, index);
|
|
39
|
+
for (const summary of index.all()) {
|
|
40
|
+
const existing = byPath.get(summary.permalink);
|
|
41
|
+
if (existing) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
`site index: "${existing.id}" and "${summary.id}" both resolve to "${summary.permalink}"`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
byPath.set(summary.permalink, { index, id: summary.id });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
byPermalink(path) {
|
|
51
|
+
const hit = byPath.get(normalizePath(path));
|
|
52
|
+
return hit ? hit.index.byId(hit.id) : undefined;
|
|
53
|
+
},
|
|
54
|
+
adjacent(entry) {
|
|
55
|
+
const hit = byPath.get(entry.permalink);
|
|
56
|
+
return hit ? hit.index.adjacent(entry.id) : {};
|
|
57
|
+
},
|
|
58
|
+
entries() {
|
|
59
|
+
return [...byPath.keys()].map((p) => ({ path: p.replace(/^\//, '') }));
|
|
60
|
+
},
|
|
61
|
+
concept(id) {
|
|
62
|
+
return byId.get(id);
|
|
63
|
+
},
|
|
64
|
+
all() {
|
|
65
|
+
return concepts.flatMap(({ index }) => index.all());
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
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 {
|
|
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
|
|
@@ -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,
|
|
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
|
|
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(
|
|
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(
|
|
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}/${
|
|
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. */
|