@glw907/cairn-cms 0.6.0-rc.1 → 0.7.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/EditPage.svelte +5 -5
- package/dist/components/EditPage.svelte.d.ts +3 -1
- package/dist/components/EditPage.svelte.d.ts.map +1 -1
- package/dist/content/compose.js +1 -1
- package/dist/content/concepts.d.ts.map +1 -1
- package/dist/content/concepts.js +5 -0
- package/dist/content/permalink.d.ts +11 -0
- package/dist/content/permalink.d.ts.map +1 -0
- package/dist/content/permalink.js +30 -0
- package/dist/content/types.d.ts +17 -3
- package/dist/content/types.d.ts.map +1 -1
- package/dist/delivery/content-index.d.ts +46 -0
- package/dist/delivery/content-index.d.ts.map +1 -0
- package/dist/delivery/content-index.js +84 -0
- package/dist/delivery/excerpt.d.ts +11 -0
- package/dist/delivery/excerpt.d.ts.map +1 -0
- package/dist/delivery/excerpt.js +38 -0
- package/dist/delivery/feeds.d.ts +27 -0
- package/dist/delivery/feeds.d.ts.map +1 -0
- package/dist/delivery/feeds.js +80 -0
- package/dist/delivery/paginate.d.ts +13 -0
- package/dist/delivery/paginate.d.ts.map +1 -0
- package/dist/delivery/paginate.js +20 -0
- package/dist/delivery/robots.d.ts +6 -0
- package/dist/delivery/robots.d.ts.map +1 -0
- package/dist/delivery/robots.js +10 -0
- package/dist/delivery/seo.d.ts +34 -0
- package/dist/delivery/seo.d.ts.map +1 -0
- package/dist/delivery/seo.js +46 -0
- package/dist/delivery/sitemap.d.ts +8 -0
- package/dist/delivery/sitemap.d.ts.map +1 -0
- package/dist/delivery/sitemap.js +21 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/sveltekit/index.d.ts +2 -0
- package/dist/sveltekit/index.d.ts.map +1 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/public-routes.d.ts +51 -0
- package/dist/sveltekit/public-routes.d.ts.map +1 -0
- package/dist/sveltekit/public-routes.js +44 -0
- package/package.json +1 -1
- package/src/lib/components/EditPage.svelte +5 -5
- package/src/lib/content/compose.ts +1 -1
- package/src/lib/content/concepts.ts +6 -0
- package/src/lib/content/permalink.ts +40 -0
- package/src/lib/content/types.ts +14 -4
- package/src/lib/delivery/content-index.ts +127 -0
- package/src/lib/delivery/excerpt.ts +41 -0
- package/src/lib/delivery/feeds.ts +112 -0
- package/src/lib/delivery/paginate.ts +32 -0
- package/src/lib/delivery/robots.ts +10 -0
- package/src/lib/delivery/seo.ts +72 -0
- package/src/lib/delivery/sitemap.ts +29 -0
- package/src/lib/index.ts +22 -0
- package/src/lib/sveltekit/index.ts +8 -0
- package/src/lib/sveltekit/public-routes.ts +81 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// cairn-cms: the SEO head builder (public-delivery design, decision 6). Returns plain data so
|
|
2
|
+
// the template renders it inside <svelte:head>. It covers the universal, mechanical tags;
|
|
3
|
+
// og:image art and richer JSON-LD types stay a template or plugin concern.
|
|
4
|
+
/** Build the head data for a page. */
|
|
5
|
+
export function buildSeoMeta(input) {
|
|
6
|
+
const type = input.type ?? 'website';
|
|
7
|
+
const meta = [
|
|
8
|
+
{ name: 'description', content: input.description },
|
|
9
|
+
{ property: 'og:title', content: input.title },
|
|
10
|
+
{ property: 'og:description', content: input.description },
|
|
11
|
+
{ property: 'og:type', content: type },
|
|
12
|
+
{ property: 'og:url', content: input.canonicalUrl },
|
|
13
|
+
{ property: 'og:site_name', content: input.siteName },
|
|
14
|
+
{ name: 'twitter:card', content: input.image ? 'summary_large_image' : 'summary' },
|
|
15
|
+
];
|
|
16
|
+
if (input.image) {
|
|
17
|
+
meta.push({ property: 'og:image', content: input.image });
|
|
18
|
+
meta.push({ name: 'twitter:image', content: input.image });
|
|
19
|
+
}
|
|
20
|
+
const links = [{ rel: 'canonical', href: input.canonicalUrl }];
|
|
21
|
+
if (input.feeds?.rss) {
|
|
22
|
+
links.push({ rel: 'alternate', type: 'application/rss+xml', href: input.feeds.rss, title: input.siteName });
|
|
23
|
+
}
|
|
24
|
+
if (input.feeds?.json) {
|
|
25
|
+
links.push({ rel: 'alternate', type: 'application/feed+json', href: input.feeds.json, title: input.siteName });
|
|
26
|
+
}
|
|
27
|
+
const jsonLd = type === 'article'
|
|
28
|
+
? {
|
|
29
|
+
'@context': 'https://schema.org',
|
|
30
|
+
'@type': 'Article',
|
|
31
|
+
headline: input.title,
|
|
32
|
+
description: input.description,
|
|
33
|
+
url: input.canonicalUrl,
|
|
34
|
+
...(input.published ? { datePublished: input.published } : {}),
|
|
35
|
+
...(input.modified ? { dateModified: input.modified } : {}),
|
|
36
|
+
...(input.image ? { image: input.image } : {}),
|
|
37
|
+
}
|
|
38
|
+
: {
|
|
39
|
+
'@context': 'https://schema.org',
|
|
40
|
+
'@type': 'WebSite',
|
|
41
|
+
name: input.siteName,
|
|
42
|
+
description: input.description,
|
|
43
|
+
url: input.canonicalUrl,
|
|
44
|
+
};
|
|
45
|
+
return { title: input.title, meta, links, jsonLd };
|
|
46
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** One sitemap URL. `lastmod` is a YYYY-MM-DD date. */
|
|
2
|
+
export interface SitemapUrl {
|
|
3
|
+
loc: string;
|
|
4
|
+
lastmod?: string;
|
|
5
|
+
}
|
|
6
|
+
/** Build a sitemap XML document from a list of URLs. */
|
|
7
|
+
export declare function buildSitemap(urls: SitemapUrl[]): string;
|
|
8
|
+
//# sourceMappingURL=sitemap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sitemap.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/sitemap.ts"],"names":[],"mappings":"AAGA,uDAAuD;AACvD,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAMD,wDAAwD;AACxD,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,MAAM,CAcvD"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// cairn-cms: sitemap builder (public-delivery design). Pure over a URL list; the caller
|
|
2
|
+
// derives the list from the content index and the routable concepts.
|
|
3
|
+
function escapeXml(value) {
|
|
4
|
+
return value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
5
|
+
}
|
|
6
|
+
/** Build a sitemap XML document from a list of URLs. */
|
|
7
|
+
export function buildSitemap(urls) {
|
|
8
|
+
const entries = urls
|
|
9
|
+
.map((url) => {
|
|
10
|
+
const lastmod = url.lastmod ? `\n <lastmod>${escapeXml(url.lastmod)}</lastmod>` : '';
|
|
11
|
+
return ` <url>\n <loc>${escapeXml(url.loc)}</loc>${lastmod}\n </url>`;
|
|
12
|
+
})
|
|
13
|
+
.join('\n');
|
|
14
|
+
return [
|
|
15
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
16
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
17
|
+
entries,
|
|
18
|
+
'</urlset>',
|
|
19
|
+
'',
|
|
20
|
+
].join('\n');
|
|
21
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -25,4 +25,17 @@ export { appCredentials } from './github/credentials.js';
|
|
|
25
25
|
export type { GithubKeyEnv } from './github/credentials.js';
|
|
26
26
|
export { parseSiteConfig, extractMenu, setMenu, validateNavTree, MAX_NAV_NODES, NavValidationError, SiteConfigError, } from './nav/site-config.js';
|
|
27
27
|
export type { NavNode, SiteConfig } from './nav/site-config.js';
|
|
28
|
+
export { permalink } from './content/permalink.js';
|
|
29
|
+
export { createContentIndex, fromGlob } from './delivery/content-index.js';
|
|
30
|
+
export type { RawFile, ContentSummary, ContentEntry, ContentIndex, } from './delivery/content-index.js';
|
|
31
|
+
export { deriveExcerpt, wordCount } from './delivery/excerpt.js';
|
|
32
|
+
export { buildRssFeed, buildJsonFeed } from './delivery/feeds.js';
|
|
33
|
+
export type { FeedChannel, FeedItem } from './delivery/feeds.js';
|
|
34
|
+
export { buildSitemap } from './delivery/sitemap.js';
|
|
35
|
+
export type { SitemapUrl } from './delivery/sitemap.js';
|
|
36
|
+
export { buildRobots } from './delivery/robots.js';
|
|
37
|
+
export { buildSeoMeta } from './delivery/seo.js';
|
|
38
|
+
export type { SeoInput, SeoMeta } from './delivery/seo.js';
|
|
39
|
+
export { paginate } from './delivery/paginate.js';
|
|
40
|
+
export type { Page } from './delivery/paginate.js';
|
|
28
41
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AACzC,YAAY,EAAE,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC7D,YAAY,EAAE,YAAY,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAChF,OAAO,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAGnE,YAAY,EACV,YAAY,EACZ,aAAa,EACb,gBAAgB,EAChB,SAAS,EACT,aAAa,EACb,SAAS,EACT,YAAY,EACZ,SAAS,EACT,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,YAAY,EACZ,aAAa,EACb,WAAW,EACX,WAAW,EACX,iBAAiB,EACjB,cAAc,EACd,YAAY,EACZ,UAAU,EACV,YAAY,GACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,WAAW,EAAE,MAAM,uBAAuB,CAAC;AACxF,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EACL,mBAAmB,EACnB,cAAc,EACd,iBAAiB,EACjB,aAAa,GACd,MAAM,0BAA0B,CAAC;AAClC,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,SAAS,EAAE,cAAc,EAAE,cAAc,EAAE,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAEtF,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AAC5E,OAAO,EAAE,KAAK,EAAE,MAAM,mBAAmB,CAAC;AAC1C,YAAY,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AACjD,OAAO,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EACL,cAAc,EACd,SAAS,EACT,OAAO,EACP,QAAQ,EACR,SAAS,EACT,SAAS,EACT,aAAa,GACd,MAAM,6BAA6B,CAAC;AACrC,YAAY,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAC;AAC5D,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG5D,YAAY,EAAE,OAAO,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACzF,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC;AACxD,OAAO,EAAE,MAAM,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AACjF,OAAO,EACL,OAAO,EACP,eAAe,EACf,YAAY,EACZ,WAAW,EACX,OAAO,EACP,OAAO,EACP,UAAU,GACX,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AACzD,YAAY,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAG5D,OAAO,EACL,eAAe,EACf,WAAW,EACX,OAAO,EACP,eAAe,EACf,aAAa,EACb,kBAAkB,EAClB,eAAe,GAChB,MAAM,sBAAsB,CAAC;AAC9B,YAAY,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -19,3 +19,14 @@ export { treeUrl, markdownFilesIn, listMarkdown, contentsUrl, readRaw, fileSha,
|
|
|
19
19
|
export { appCredentials } from './github/credentials.js';
|
|
20
20
|
// Nav tree and site-config helpers (Plan 06).
|
|
21
21
|
export { parseSiteConfig, extractMenu, setMenu, validateNavTree, MAX_NAV_NODES, NavValidationError, SiteConfigError, } from './nav/site-config.js';
|
|
22
|
+
// Public content delivery (public-delivery design): the query index, syndication, and
|
|
23
|
+
// discovery surface that sites read. Pure builders plus the one permalink resolver; the
|
|
24
|
+
// SvelteKit loaders live under the /sveltekit subpath.
|
|
25
|
+
export { permalink } from './content/permalink.js';
|
|
26
|
+
export { createContentIndex, fromGlob } from './delivery/content-index.js';
|
|
27
|
+
export { deriveExcerpt, wordCount } from './delivery/excerpt.js';
|
|
28
|
+
export { buildRssFeed, buildJsonFeed } from './delivery/feeds.js';
|
|
29
|
+
export { buildSitemap } from './delivery/sitemap.js';
|
|
30
|
+
export { buildRobots } from './delivery/robots.js';
|
|
31
|
+
export { buildSeoMeta } from './delivery/seo.js';
|
|
32
|
+
export { paginate } from './delivery/paginate.js';
|
|
@@ -7,4 +7,6 @@ export { createNavRoutes } from './nav-routes.js';
|
|
|
7
7
|
export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
|
|
8
8
|
export { healthLoad, type HealthData } from './health.js';
|
|
9
9
|
export type { RequestContext, CookieJar, HandleInput } from './types.js';
|
|
10
|
+
export { createPublicRoutes } from './public-routes.js';
|
|
11
|
+
export type { PublicRoutesDeps, ListData as PublicListData, TagData, TagIndexData, EntryData, } from './public-routes.js';
|
|
10
12
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC3E,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAC3E,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EACV,UAAU,EACV,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,QAAQ,EACR,YAAY,EACZ,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAC3E,OAAO,EAAE,gBAAgB,EAAE,KAAK,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAC3E,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AACzD,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,YAAY,EACV,UAAU,EACV,UAAU,EACV,YAAY,EACZ,QAAQ,EACR,QAAQ,EACR,YAAY,EACZ,iBAAiB,GAClB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,YAAY,EAAE,WAAW,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AACjF,OAAO,EAAE,UAAU,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EAAE,cAAc,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,YAAY,CAAC;AACzE,OAAO,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AACxD,YAAY,EACV,gBAAgB,EAChB,QAAQ,IAAI,cAAc,EAC1B,OAAO,EACP,YAAY,EACZ,SAAS,GACV,MAAM,oBAAoB,CAAC"}
|
package/dist/sveltekit/index.js
CHANGED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ContentIndex, ContentSummary, ContentEntry } from '../delivery/content-index.js';
|
|
2
|
+
/** Injected dependencies for the public loaders. */
|
|
3
|
+
export interface PublicRoutesDeps {
|
|
4
|
+
index: ContentIndex;
|
|
5
|
+
render: (md: string, opts?: {
|
|
6
|
+
stagger?: boolean;
|
|
7
|
+
}) => string | Promise<string>;
|
|
8
|
+
origin: string;
|
|
9
|
+
}
|
|
10
|
+
/** The archive and tag list data: summaries the template renders. */
|
|
11
|
+
export interface ListData {
|
|
12
|
+
entries: ContentSummary[];
|
|
13
|
+
}
|
|
14
|
+
/** A single tag's data plus the tag it filtered on. */
|
|
15
|
+
export interface TagData extends ListData {
|
|
16
|
+
tag: string;
|
|
17
|
+
}
|
|
18
|
+
/** The tag-index data: every tag with its count. */
|
|
19
|
+
export interface TagIndexData {
|
|
20
|
+
tags: {
|
|
21
|
+
tag: string;
|
|
22
|
+
count: number;
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
25
|
+
/** One entry's data: the detail entry, its rendered html, and its canonical URL. */
|
|
26
|
+
export interface EntryData {
|
|
27
|
+
entry: ContentEntry;
|
|
28
|
+
html: string;
|
|
29
|
+
canonicalUrl: string;
|
|
30
|
+
newer?: ContentSummary;
|
|
31
|
+
older?: ContentSummary;
|
|
32
|
+
}
|
|
33
|
+
/** Build the public loaders for one concept's index. */
|
|
34
|
+
export declare function createPublicRoutes(deps: PublicRoutesDeps): {
|
|
35
|
+
archiveLoad: () => ListData;
|
|
36
|
+
tagIndexLoad: () => TagIndexData;
|
|
37
|
+
tagLoad: (event: {
|
|
38
|
+
params: {
|
|
39
|
+
tag: string;
|
|
40
|
+
};
|
|
41
|
+
}) => TagData;
|
|
42
|
+
entryLoad: (event: {
|
|
43
|
+
params: {
|
|
44
|
+
slug: string;
|
|
45
|
+
};
|
|
46
|
+
}) => Promise<EntryData>;
|
|
47
|
+
entries: () => {
|
|
48
|
+
slug: string;
|
|
49
|
+
}[];
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=public-routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public-routes.d.ts","sourceRoot":"","sources":["../../src/lib/sveltekit/public-routes.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAE/F,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,YAAY,CAAC;IACpB,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,wDAAwD;AACxD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB;uBAI/B,QAAQ;wBAKP,YAAY;qBAKb;QAAE,MAAM,EAAE;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO;uBAQ7B;QAAE,MAAM,EAAE;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,KAAG,OAAO,CAAC,SAAS,CAAC;mBAc7D;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE;EAKvC"}
|
|
@@ -0,0 +1,44 @@
|
|
|
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.
|
|
5
|
+
import { error } from '@sveltejs/kit';
|
|
6
|
+
/** Build the public loaders for one concept's index. */
|
|
7
|
+
export function createPublicRoutes(deps) {
|
|
8
|
+
const { index, render, origin } = deps;
|
|
9
|
+
/** The chronological archive: every non-draft summary, newest-first. */
|
|
10
|
+
function archiveLoad() {
|
|
11
|
+
return { entries: index.all() };
|
|
12
|
+
}
|
|
13
|
+
/** All tags with counts, for a tag index page. */
|
|
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. */
|
|
26
|
+
async function entryLoad(event) {
|
|
27
|
+
const entry = index.byId(event.params.slug);
|
|
28
|
+
if (!entry)
|
|
29
|
+
throw error(404, `Not found: ${event.params.slug}`);
|
|
30
|
+
const { newer, older } = index.adjacent(entry.id);
|
|
31
|
+
return {
|
|
32
|
+
entry,
|
|
33
|
+
html: await render(entry.body, { stagger: true }),
|
|
34
|
+
canonicalUrl: origin + entry.permalink,
|
|
35
|
+
newer,
|
|
36
|
+
older,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Prerender enumeration: one `{ slug }` per non-draft entry. */
|
|
40
|
+
function entries() {
|
|
41
|
+
return index.all().map((entry) => ({ slug: entry.id }));
|
|
42
|
+
}
|
|
43
|
+
return { archiveLoad, tagIndexLoad, tagLoad, entryLoad, entries };
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -21,10 +21,10 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
21
21
|
/** Carta preview plugins from the adapter, for the design-accurate preview. */
|
|
22
22
|
preview?: unknown[];
|
|
23
23
|
/** The site's design-accurate render pipeline; the preview pane sanitizes its output. */
|
|
24
|
-
|
|
24
|
+
render?: (md: string, opts?: { stagger?: boolean }) => string | Promise<string>;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
let { data, registry, preview = [],
|
|
27
|
+
let { data, registry, preview = [], render }: Props = $props();
|
|
28
28
|
|
|
29
29
|
// `body` is local editor state seeded once from the prop; it diverges as the user types.
|
|
30
30
|
// untrack() captures the initial value without subscribing to future prop changes.
|
|
@@ -48,15 +48,15 @@ markdown editor and a live, design-accurate preview. The whole surface is one fo
|
|
|
48
48
|
// Render the design-accurate preview as the body changes, debounced, and sanitize before the DOM.
|
|
49
49
|
// The sanitize is the one barrier between editor-authored markdown and the page (Carta is unsanitized).
|
|
50
50
|
// previewRun is a plain counter (not reactive state) used as a latest-wins guard: if a slow earlier
|
|
51
|
-
// async
|
|
51
|
+
// async render call resolves after a newer one has started, the stale result is discarded.
|
|
52
52
|
let previewRun = 0;
|
|
53
53
|
$effect(() => {
|
|
54
|
-
if (!showPreview || !
|
|
54
|
+
if (!showPreview || !render) return;
|
|
55
55
|
const md = body;
|
|
56
56
|
const run = ++previewRun;
|
|
57
57
|
const handle = setTimeout(async () => {
|
|
58
58
|
try {
|
|
59
|
-
const html = await
|
|
59
|
+
const html = await render(md);
|
|
60
60
|
const safe = await sanitizePreviewHtml(html);
|
|
61
61
|
if (run === previewRun) previewHtml = safe;
|
|
62
62
|
} catch {
|
|
@@ -29,7 +29,7 @@ export function composeRuntime(
|
|
|
29
29
|
concepts: normalizeConcepts(content),
|
|
30
30
|
backend: adapter.backend,
|
|
31
31
|
sender: adapter.sender,
|
|
32
|
-
|
|
32
|
+
render: adapter.render,
|
|
33
33
|
registry: adapter.registry,
|
|
34
34
|
navMenu: adapter.navMenu,
|
|
35
35
|
assets: adapter.assets,
|
|
@@ -23,6 +23,11 @@ function defaultLabel(id: string): string {
|
|
|
23
23
|
return id.charAt(0).toUpperCase() + id.slice(1);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** The default permalink pattern: Pages live at the root, other concepts under their id. */
|
|
27
|
+
function defaultPermalink(id: string): string {
|
|
28
|
+
return id === 'pages' ? '/:slug' : `/${id}/:slug`;
|
|
29
|
+
}
|
|
30
|
+
|
|
26
31
|
/**
|
|
27
32
|
* Normalize an adapter's declared concepts into uniform descriptors (seam 1). Each declared
|
|
28
33
|
* key under `content` becomes one descriptor; an undeclared (`undefined`) concept is
|
|
@@ -41,6 +46,7 @@ export function normalizeConcepts(
|
|
|
41
46
|
label: config.label ?? defaultLabel(id),
|
|
42
47
|
dir: config.dir,
|
|
43
48
|
routing: routing[id] ?? DEFAULT_ROUTING,
|
|
49
|
+
permalink: config.permalink ?? defaultPermalink(id),
|
|
44
50
|
fields: config.fields,
|
|
45
51
|
validate: config.validate,
|
|
46
52
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// cairn-cms: the one permalink resolver (public-delivery design, decision 3). Feeds, the
|
|
2
|
+
// sitemap, canonical links, list links, and the prerender entries() all call this, so an
|
|
3
|
+
// entry has exactly one canonical URL. The date is read straight from the YYYY-MM-DD string,
|
|
4
|
+
// so a permalink never shifts across a timezone.
|
|
5
|
+
import type { ConceptDescriptor } from './types.js';
|
|
6
|
+
|
|
7
|
+
function pad(n: number): string {
|
|
8
|
+
return String(n).padStart(2, '0');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function dateParts(date?: string): { year: string; month: string; day: string } | null {
|
|
12
|
+
const match = date?.match(/^(\d{4})-(\d{2})-(\d{2})/);
|
|
13
|
+
return match ? { year: match[1], month: match[2], day: match[3] } : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve an entry's canonical path from its concept's permalink pattern. Throws when the
|
|
18
|
+
* pattern uses a date token and the entry has no valid date, or when a token is unknown, so
|
|
19
|
+
* a misconfiguration fails at build rather than emitting a broken path.
|
|
20
|
+
*/
|
|
21
|
+
export function permalink(
|
|
22
|
+
descriptor: ConceptDescriptor,
|
|
23
|
+
entry: { id: string; date?: string },
|
|
24
|
+
): string {
|
|
25
|
+
return descriptor.permalink.replace(/:(\w+)/g, (_match, token: string) => {
|
|
26
|
+
if (token === 'slug') return entry.id;
|
|
27
|
+
if (token === 'year' || token === 'month' || token === 'day') {
|
|
28
|
+
const parts = dateParts(entry.date);
|
|
29
|
+
if (!parts) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`permalink: concept "${descriptor.id}" pattern uses :${token}, but entry "${entry.id}" has no valid date`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (token === 'year') return parts.year;
|
|
35
|
+
if (token === 'month') return pad(Number(parts.month));
|
|
36
|
+
return pad(Number(parts.day));
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`permalink: unknown token :${token} in pattern "${descriptor.permalink}"`);
|
|
39
|
+
});
|
|
40
|
+
}
|
package/src/lib/content/types.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// The adapter is the single seam the engine consumes (spec §8). A site supplies a
|
|
5
5
|
// `CairnAdapter` at `src/lib/cairn.config.ts` declaring its backend repo, the content
|
|
6
|
-
// concepts it enables, its magic-link sender, and a design-accurate `
|
|
6
|
+
// concepts it enables, its magic-link sender, and a design-accurate `render`. The
|
|
7
7
|
// engine never hard-codes a concept, directory, or field; it reads them here. Field
|
|
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.
|
|
@@ -84,6 +84,13 @@ export interface ConceptConfig {
|
|
|
84
84
|
fields: FrontmatterField[];
|
|
85
85
|
/** Validate submitted frontmatter before any commit. */
|
|
86
86
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
87
|
+
/**
|
|
88
|
+
* Public URL pattern for this concept, a `/`-prefixed string of literal segments and the
|
|
89
|
+
* tokens `:slug`, `:year`, `:month`, `:day`. `normalizeConcepts` fills a per-concept
|
|
90
|
+
* default when omitted (`/:slug` for Pages, `/<conceptId>/:slug` otherwise). The pattern
|
|
91
|
+
* must agree with the site's filesystem route directory.
|
|
92
|
+
*/
|
|
93
|
+
permalink?: string;
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
/** The GitHub App backend a site reads from and commits to (spec §8). Plain data the GitHub engine (Plan 03) consumes. */
|
|
@@ -135,8 +142,8 @@ export interface CairnAdapter {
|
|
|
135
142
|
};
|
|
136
143
|
backend: BackendConfig;
|
|
137
144
|
sender: SenderConfig;
|
|
138
|
-
/**
|
|
139
|
-
|
|
145
|
+
/** The site's one renderer: the editor preview and every public page call it (design decision 4). */
|
|
146
|
+
render(md: string, opts?: { stagger?: boolean }): string | Promise<string>;
|
|
140
147
|
/** Directive component registry; the renderer and the future palette derive from it (seam 3). */
|
|
141
148
|
registry?: ComponentRegistry;
|
|
142
149
|
navMenu?: NavMenuConfig;
|
|
@@ -166,6 +173,8 @@ export interface ConceptDescriptor {
|
|
|
166
173
|
label: string;
|
|
167
174
|
dir: string;
|
|
168
175
|
routing: RoutingRule;
|
|
176
|
+
/** The resolved permalink pattern, defaulted by `normalizeConcepts`. */
|
|
177
|
+
permalink: string;
|
|
169
178
|
fields: FrontmatterField[];
|
|
170
179
|
validate(frontmatter: Record<string, unknown>, body: string): ValidationResult;
|
|
171
180
|
}
|
|
@@ -224,7 +233,8 @@ export interface CairnRuntime {
|
|
|
224
233
|
concepts: ConceptDescriptor[];
|
|
225
234
|
backend: BackendConfig;
|
|
226
235
|
sender: SenderConfig;
|
|
227
|
-
|
|
236
|
+
/** The site's one renderer: the editor preview and every public page call it (design decision 4). */
|
|
237
|
+
render(md: string, opts?: { stagger?: boolean }): string | Promise<string>;
|
|
228
238
|
registry?: ComponentRegistry;
|
|
229
239
|
navMenu?: NavMenuConfig;
|
|
230
240
|
assets?: AssetConfig;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// cairn-cms: the per-concept content index (public-delivery design, decisions 1 and 5). It
|
|
2
|
+
// takes raw files from a site's glob, parses them with the engine's own parseMarkdown, and
|
|
3
|
+
// returns cheap plain-data summaries plus an on-demand detail lookup. It is concept-generic:
|
|
4
|
+
// every operation reads the descriptor and its routing rule, never a hardcoded concept id.
|
|
5
|
+
import { parseMarkdown } from '../content/frontmatter.js';
|
|
6
|
+
import { idFromFilename } from '../content/ids.js';
|
|
7
|
+
import { permalink } from '../content/permalink.js';
|
|
8
|
+
import { deriveExcerpt, wordCount } from './excerpt.js';
|
|
9
|
+
import type { ConceptDescriptor } from '../content/types.js';
|
|
10
|
+
|
|
11
|
+
/** A raw content file before parsing: the glob key and the file's full markdown text. */
|
|
12
|
+
export interface RawFile {
|
|
13
|
+
path: string;
|
|
14
|
+
raw: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The cheap, plain-data view of one entry, for lists, feeds, and the sitemap. */
|
|
18
|
+
export interface ContentSummary {
|
|
19
|
+
id: string;
|
|
20
|
+
permalink: string;
|
|
21
|
+
title: string;
|
|
22
|
+
date?: string;
|
|
23
|
+
updated?: string;
|
|
24
|
+
tags: string[];
|
|
25
|
+
excerpt: string;
|
|
26
|
+
wordCount: number;
|
|
27
|
+
draft: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The detail view: a summary plus the frontmatter and the body to render. */
|
|
31
|
+
export interface ContentEntry extends ContentSummary {
|
|
32
|
+
frontmatter: Record<string, unknown>;
|
|
33
|
+
body: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The per-concept query surface. */
|
|
37
|
+
export interface ContentIndex {
|
|
38
|
+
all(opts?: { includeDrafts?: boolean }): ContentSummary[];
|
|
39
|
+
byId(id: string): ContentEntry | undefined;
|
|
40
|
+
byTag(tag: string, opts?: { includeDrafts?: boolean }): ContentSummary[];
|
|
41
|
+
allTags(): { tag: string; count: number }[];
|
|
42
|
+
adjacent(id: string): { newer?: ContentSummary; older?: ContentSummary };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Map a Vite eager `?raw` glob record (`{ path: raw }`) to `RawFile[]`. */
|
|
46
|
+
export function fromGlob(record: Record<string, string>): RawFile[] {
|
|
47
|
+
return Object.entries(record).map(([path, raw]) => ({ path, raw }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function basename(path: string): string {
|
|
51
|
+
const slash = path.lastIndexOf('/');
|
|
52
|
+
return slash >= 0 ? path.slice(slash + 1) : path;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function asString(value: unknown): string | undefined {
|
|
56
|
+
return typeof value === 'string' && value.trim() ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function asDate(value: unknown): string | undefined {
|
|
60
|
+
if (value instanceof Date) return Number.isNaN(value.getTime()) ? undefined : value.toISOString().slice(0, 10);
|
|
61
|
+
if (typeof value === 'string') return value.match(/^\d{4}-\d{2}-\d{2}/)?.[0];
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function asTags(value: unknown): string[] {
|
|
66
|
+
return Array.isArray(value) ? value.map(String) : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build a concept's index from its raw files and normalized descriptor. */
|
|
70
|
+
export function createContentIndex(files: RawFile[], descriptor: ConceptDescriptor): ContentIndex {
|
|
71
|
+
const entries: ContentEntry[] = files.map((file) => {
|
|
72
|
+
const id = idFromFilename(basename(file.path));
|
|
73
|
+
const { frontmatter, body } = parseMarkdown(file.raw);
|
|
74
|
+
const date = asDate(frontmatter.date);
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
permalink: permalink(descriptor, { id, date }),
|
|
78
|
+
title: asString(frontmatter.title) ?? id,
|
|
79
|
+
date,
|
|
80
|
+
updated: asDate(frontmatter.updated),
|
|
81
|
+
tags: asTags(frontmatter.tags),
|
|
82
|
+
excerpt: deriveExcerpt(body, { description: asString(frontmatter.description) }),
|
|
83
|
+
wordCount: wordCount(body),
|
|
84
|
+
draft: frontmatter.draft === true,
|
|
85
|
+
frontmatter,
|
|
86
|
+
body,
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Dated concepts sort newest-first; undated concepts (Pages) sort by title.
|
|
91
|
+
const sorted = [...entries].sort((a, b) =>
|
|
92
|
+
descriptor.routing.dated ? (b.date ?? '').localeCompare(a.date ?? '') : a.title.localeCompare(b.title),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const summarize = (entry: ContentEntry): ContentSummary => {
|
|
96
|
+
const { frontmatter: _frontmatter, body: _body, ...summary } = entry;
|
|
97
|
+
return summary;
|
|
98
|
+
};
|
|
99
|
+
const visible = (list: ContentEntry[], includeDrafts?: boolean): ContentEntry[] =>
|
|
100
|
+
includeDrafts ? list : list.filter((entry) => !entry.draft);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
all: (opts = {}) => visible(sorted, opts.includeDrafts).map(summarize),
|
|
104
|
+
byId: (id) => entries.find((entry) => entry.id === id),
|
|
105
|
+
byTag: (tag, opts = {}) =>
|
|
106
|
+
visible(sorted, opts.includeDrafts)
|
|
107
|
+
.filter((entry) => entry.tags.includes(tag))
|
|
108
|
+
.map(summarize),
|
|
109
|
+
allTags: () => {
|
|
110
|
+
const counts = new Map<string, number>();
|
|
111
|
+
for (const entry of sorted) {
|
|
112
|
+
if (entry.draft) continue;
|
|
113
|
+
for (const tag of entry.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
return [...counts].map(([tag, count]) => ({ tag, count })).sort((a, b) => a.tag.localeCompare(b.tag));
|
|
116
|
+
},
|
|
117
|
+
adjacent: (id) => {
|
|
118
|
+
const list = visible(sorted, false);
|
|
119
|
+
const i = list.findIndex((entry) => entry.id === id);
|
|
120
|
+
if (i < 0) return {};
|
|
121
|
+
return {
|
|
122
|
+
newer: i > 0 ? summarize(list[i - 1]) : undefined,
|
|
123
|
+
older: i < list.length - 1 ? summarize(list[i + 1]) : undefined,
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// cairn-cms: excerpt and word count for content summaries (public-delivery design, decision
|
|
2
|
+
// 5). A light markdown strip keeps summaries cheap, so a list card, an og:description, and a
|
|
3
|
+
// summary-mode feed read one derived excerpt without a full render.
|
|
4
|
+
|
|
5
|
+
/** Reduce markdown to readable plain text: drop fenced code, images, and markup; unwrap inline
|
|
6
|
+
* code and links to their text; collapse whitespace. */
|
|
7
|
+
function toPlainText(md: string): string {
|
|
8
|
+
return md
|
|
9
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
10
|
+
.replace(/`([^`]*)`/g, '$1')
|
|
11
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
|
12
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
13
|
+
.replace(/^\s{0,3}[#>]+\s*/gm, ' ')
|
|
14
|
+
.replace(/^\s{0,3}[-*+]\s+/gm, ' ')
|
|
15
|
+
.replace(/[*_~]/g, '')
|
|
16
|
+
.replace(/\s+/g, ' ')
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A plain-text excerpt. Returns a trimmed frontmatter `description` when present, else the
|
|
22
|
+
* stripped body cut at a word boundary near `maxChars` (default 200) with an ellipsis.
|
|
23
|
+
*/
|
|
24
|
+
export function deriveExcerpt(body: string, opts: { description?: string; maxChars?: number } = {}): string {
|
|
25
|
+
const description = opts.description?.trim();
|
|
26
|
+
if (description) return description;
|
|
27
|
+
|
|
28
|
+
const max = opts.maxChars ?? 200;
|
|
29
|
+
const text = toPlainText(body);
|
|
30
|
+
if (text.length <= max) return text;
|
|
31
|
+
|
|
32
|
+
const cut = text.slice(0, max);
|
|
33
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
34
|
+
return `${(lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Count words in the stripped body. */
|
|
38
|
+
export function wordCount(body: string): number {
|
|
39
|
+
const text = toPlainText(body);
|
|
40
|
+
return text ? text.split(/\s+/).length : 0;
|
|
41
|
+
}
|