@glw907/cairn-cms 0.10.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 (37) hide show
  1. package/dist/delivery/CairnHead.svelte +36 -0
  2. package/dist/delivery/CairnHead.svelte.d.ts +15 -0
  3. package/dist/delivery/CairnHead.svelte.d.ts.map +1 -0
  4. package/dist/delivery/content-index.d.ts +8 -6
  5. package/dist/delivery/content-index.d.ts.map +1 -1
  6. package/dist/delivery/content-index.js +1 -1
  7. package/dist/delivery/index.d.ts +22 -0
  8. package/dist/delivery/index.d.ts.map +1 -0
  9. package/dist/delivery/index.js +19 -0
  10. package/dist/delivery/json-ld.d.ts +2 -0
  11. package/dist/delivery/json-ld.d.ts.map +1 -0
  12. package/dist/delivery/json-ld.js +16 -0
  13. package/dist/delivery/responses.d.ts +14 -0
  14. package/dist/delivery/responses.d.ts.map +1 -0
  15. package/dist/delivery/responses.js +30 -0
  16. package/dist/delivery/seo.d.ts +4 -0
  17. package/dist/delivery/seo.d.ts.map +1 -1
  18. package/dist/delivery/seo.js +11 -0
  19. package/dist/delivery/site-descriptors.d.ts +5 -0
  20. package/dist/delivery/site-descriptors.d.ts.map +1 -0
  21. package/dist/delivery/site-descriptors.js +9 -0
  22. package/dist/delivery/site-index.d.ts +8 -2
  23. package/dist/delivery/site-index.d.ts.map +1 -1
  24. package/dist/delivery/site-index.js +28 -2
  25. package/dist/sveltekit/public-routes.d.ts +11 -0
  26. package/dist/sveltekit/public-routes.d.ts.map +1 -1
  27. package/dist/sveltekit/public-routes.js +15 -2
  28. package/package.json +6 -1
  29. package/src/lib/delivery/CairnHead.svelte +36 -0
  30. package/src/lib/delivery/content-index.ts +15 -10
  31. package/src/lib/delivery/index.ts +32 -0
  32. package/src/lib/delivery/json-ld.ts +16 -0
  33. package/src/lib/delivery/responses.ts +34 -0
  34. package/src/lib/delivery/seo.ts +13 -0
  35. package/src/lib/delivery/site-descriptors.ts +12 -0
  36. package/src/lib/delivery/site-index.ts +27 -2
  37. package/src/lib/sveltekit/public-routes.ts +23 -2
@@ -0,0 +1,36 @@
1
+ <!--
2
+ @component
3
+ Renders a page's SEO head from a SeoMeta object into <svelte:head>: a title, meta tags, link
4
+ tags, and one escaped JSON-LD script. The title renders from seo.title by default; title={false}
5
+ lets the site own the <title>, and a string overrides it. It carries no CSS, so it pulls in no
6
+ admin styles.
7
+ -->
8
+ <script lang="ts">
9
+ import type { SeoMeta } from './seo.js';
10
+ import { jsonLdScript } from './json-ld.js';
11
+
12
+ let {
13
+ /** The plain-data head to render. */
14
+ seo,
15
+ /** Title override: a string replaces seo.title, false lets the site own <title>. */
16
+ title,
17
+ }: { seo: SeoMeta; title?: string | false } = $props();
18
+ const titleText = $derived(title === undefined ? seo.title : title);
19
+ </script>
20
+
21
+ <svelte:head>
22
+ {#if titleText !== false}
23
+ <title>{titleText}</title>
24
+ {/if}
25
+ {#each seo.meta as m}
26
+ {#if m.name}
27
+ <meta name={m.name} content={m.content} />
28
+ {:else if m.property}
29
+ <meta property={m.property} content={m.content} />
30
+ {/if}
31
+ {/each}
32
+ {#each seo.links as l}
33
+ <link rel={l.rel} type={l.type} href={l.href} title={l.title} />
34
+ {/each}
35
+ {@html jsonLdScript(seo.jsonLd)}
36
+ </svelte:head>
@@ -0,0 +1,15 @@
1
+ import type { SeoMeta } from './seo.js';
2
+ type $$ComponentProps = {
3
+ seo: SeoMeta;
4
+ title?: string | false;
5
+ };
6
+ /**
7
+ * Renders a page's SEO head from a SeoMeta object into <svelte:head>: a title, meta tags, link
8
+ * tags, and one escaped JSON-LD script. The title renders from seo.title by default; title={false}
9
+ * lets the site own the <title>, and a string overrides it. It carries no CSS, so it pulls in no
10
+ * admin styles.
11
+ */
12
+ declare const CairnHead: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type CairnHead = ReturnType<typeof CairnHead>;
14
+ export default CairnHead;
15
+ //# sourceMappingURL=CairnHead.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CairnHead.svelte.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/CairnHead.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAGvC,KAAK,gBAAgB,GAAI;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;CAAE,CAAC;AAkCnE;;;;;GAKG;AACH,QAAA,MAAM,SAAS,sDAAwC,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -17,17 +17,19 @@ export interface ContentSummary {
17
17
  wordCount: number;
18
18
  draft: boolean;
19
19
  }
20
- /** The detail view: a summary plus the frontmatter and the body to render. */
21
- export interface ContentEntry extends ContentSummary {
22
- frontmatter: Record<string, unknown>;
20
+ /** The detail view: a summary plus the frontmatter and the body to render. The frontmatter
21
+ * type defaults to `Record<string, unknown>`; the typed-reads pass infers it from the concept
22
+ * fields. Generic now so that change does not break this signature. */
23
+ export interface ContentEntry<F = Record<string, unknown>> extends ContentSummary {
24
+ frontmatter: F;
23
25
  body: string;
24
26
  }
25
27
  /** The per-concept query surface. */
26
- export interface ContentIndex {
28
+ export interface ContentIndex<F = Record<string, unknown>> {
27
29
  all(opts?: {
28
30
  includeDrafts?: boolean;
29
31
  }): ContentSummary[];
30
- byId(id: string): ContentEntry | undefined;
32
+ byId(id: string): ContentEntry<F> | undefined;
31
33
  byTag(tag: string, opts?: {
32
34
  includeDrafts?: boolean;
33
35
  }): ContentSummary[];
@@ -43,5 +45,5 @@ export interface ContentIndex {
43
45
  /** Map a Vite eager `?raw` glob record (`{ path: raw }`) to `RawFile[]`. */
44
46
  export declare function fromGlob(record: Record<string, string>): RawFile[];
45
47
  /** Build a concept's index from its raw files and normalized descriptor. */
46
- export declare function createContentIndex(files: RawFile[], descriptor: ConceptDescriptor): ContentIndex;
48
+ export declare function createContentIndex<F = Record<string, unknown>>(files: RawFile[], descriptor: ConceptDescriptor): ContentIndex<F>;
47
49
  //# sourceMappingURL=content-index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"content-index.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/content-index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,yFAAyF;AACzF,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED,8EAA8E;AAC9E,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACrC,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qCAAqC;AACrC,MAAM,WAAW,YAAY;IAC3B,GAAG,CAAC,IAAI,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,cAAc,EAAE,CAAC;IAC1D,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IAC3C,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,cAAc,EAAE,CAAC;IACzE,OAAO,IAAI;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5C,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,EAAE,cAAc,CAAC;QAAC,KAAK,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC;CAC1E;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,EAAE,CAElE;AAqBD,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,EAAE,UAAU,EAAE,iBAAiB,GAAG,YAAY,CA2DhG"}
1
+ {"version":3,"file":"content-index.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/content-index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,yFAAyF;AACzF,MAAM,WAAW,OAAO;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED,kFAAkF;AAClF,MAAM,WAAW,cAAc;IAC7B,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;wEAEwE;AACxE,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAE,SAAQ,cAAc;IAC/E,WAAW,EAAE,CAAC,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;CACd;AAED,qCAAqC;AACrC,MAAM,WAAW,YAAY,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACvD,GAAG,CAAC,IAAI,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,cAAc,EAAE,CAAC;IAC1D,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,CAAC,CAAC,CAAC,GAAG,SAAS,CAAC;IAC9C,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,cAAc,EAAE,CAAC;IACzE,OAAO,IAAI;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC5C,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG;QAAE,KAAK,CAAC,EAAE,cAAc,CAAC;QAAC,KAAK,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC;CAC1E;AAED,4EAA4E;AAC5E,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,EAAE,CAElE;AAqBD,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5D,KAAK,EAAE,OAAO,EAAE,EAChB,UAAU,EAAE,iBAAiB,GAC5B,YAAY,CAAC,CAAC,CAAC,CA2DjB"}
@@ -45,7 +45,7 @@ export function createContentIndex(files, descriptor) {
45
45
  excerpt: deriveExcerpt(body, { description: asString(frontmatter.description) }),
46
46
  wordCount: wordCount(body),
47
47
  draft: frontmatter.draft === true,
48
- frontmatter,
48
+ frontmatter: frontmatter,
49
49
  body,
50
50
  };
51
51
  });
@@ -0,0 +1,22 @@
1
+ export { createContentIndex, fromGlob } from './content-index.js';
2
+ export type { RawFile, ContentSummary, ContentEntry, ContentIndex } from './content-index.js';
3
+ export { createSiteIndex } from './site-index.js';
4
+ export type { SiteIndex, ConceptIndex } from './site-index.js';
5
+ export { siteDescriptors } from './site-descriptors.js';
6
+ export { deriveExcerpt, wordCount } from './excerpt.js';
7
+ export { buildRssFeed, buildJsonFeed } from './feeds.js';
8
+ export type { FeedChannel, FeedItem } from './feeds.js';
9
+ export { buildSitemap } from './sitemap.js';
10
+ export type { SitemapUrl } from './sitemap.js';
11
+ export { buildRobots } from './robots.js';
12
+ export { buildSeoMeta } from './seo.js';
13
+ export type { SeoInput, SeoMeta } from './seo.js';
14
+ export { paginate } from './paginate.js';
15
+ export type { Page } from './paginate.js';
16
+ export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
17
+ export { jsonLdScript } from './json-ld.js';
18
+ export { permalink } from '../content/permalink.js';
19
+ export { createPublicRoutes } from '../sveltekit/public-routes.js';
20
+ export type { PublicRoutesDeps, ListData, TagData, TagIndexData, EntryData, } from '../sveltekit/public-routes.js';
21
+ export { default as CairnHead } from './CairnHead.svelte';
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/index.ts"],"names":[],"mappings":"AAKA,OAAO,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAClE,YAAY,EAAE,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAC9F,OAAO,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAClD,YAAY,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,eAAe,EAAE,MAAM,uBAAuB,CAAC;AACxD,OAAO,EAAE,aAAa,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACzD,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AACxD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AAC/C,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAC;AAC1C,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,YAAY,EAAE,QAAQ,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAClD,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AACzC,YAAY,EAAE,IAAI,EAAE,MAAM,eAAe,CAAC;AAC1C,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAChG,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AACpD,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAC;AACnE,YAAY,EACV,gBAAgB,EAChB,QAAQ,EACR,OAAO,EACP,YAAY,EACZ,SAAS,GACV,MAAM,+BAA+B,CAAC;AACvC,OAAO,EAAE,OAAO,IAAI,SAAS,EAAE,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,19 @@
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 { createSiteIndex } from './site-index.js';
8
+ export { siteDescriptors } from './site-descriptors.js';
9
+ export { deriveExcerpt, wordCount } from './excerpt.js';
10
+ export { buildRssFeed, buildJsonFeed } from './feeds.js';
11
+ export { buildSitemap } from './sitemap.js';
12
+ export { buildRobots } from './robots.js';
13
+ export { buildSeoMeta } from './seo.js';
14
+ export { paginate } from './paginate.js';
15
+ export { rssResponse, jsonFeedResponse, sitemapResponse, robotsResponse } from './responses.js';
16
+ export { jsonLdScript } from './json-ld.js';
17
+ export { permalink } from '../content/permalink.js';
18
+ export { createPublicRoutes } from '../sveltekit/public-routes.js';
19
+ export { default as CairnHead } from './CairnHead.svelte';
@@ -0,0 +1,2 @@
1
+ export declare function jsonLdScript(data: Record<string, unknown>): string;
2
+ //# sourceMappingURL=json-ld.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json-ld.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/json-ld.ts"],"names":[],"mappings":"AAOA,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAQlE"}
@@ -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) {
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,14 @@
1
+ import { type FeedChannel, type FeedItem } from './feeds.js';
2
+ import { type SitemapUrl } from './sitemap.js';
3
+ /** An RSS 2.0 feed response. */
4
+ export declare function rssResponse(channel: FeedChannel, items: FeedItem[]): Response;
5
+ /** A JSON Feed 1.1 response. */
6
+ export declare function jsonFeedResponse(channel: FeedChannel, items: FeedItem[]): Response;
7
+ /** A sitemap response. */
8
+ export declare function sitemapResponse(urls: SitemapUrl[]): Response;
9
+ /** A robots.txt response. */
10
+ export declare function robotsResponse(opts: {
11
+ sitemapUrl: string;
12
+ disallow?: string[];
13
+ }): Response;
14
+ //# sourceMappingURL=responses.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"responses.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/responses.ts"],"names":[],"mappings":"AAGA,OAAO,EAA+B,KAAK,WAAW,EAAE,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAC1F,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAG7D,gCAAgC;AAChC,wBAAgB,WAAW,CAAC,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAI7E;AAED,gCAAgC;AAChC,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,WAAW,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,QAAQ,CAIlF;AAED,0BAA0B;AAC1B,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,EAAE,GAAG,QAAQ,CAI5D;AAED,6BAA6B;AAC7B,wBAAgB,cAAc,CAAC,IAAI,EAAE;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,QAAQ,CAI1F"}
@@ -0,0 +1,30 @@
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 } from './feeds.js';
5
+ import { buildSitemap } from './sitemap.js';
6
+ import { buildRobots } from './robots.js';
7
+ /** An RSS 2.0 feed response. */
8
+ export function rssResponse(channel, items) {
9
+ return new Response(buildRssFeed(channel, items), {
10
+ headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' },
11
+ });
12
+ }
13
+ /** A JSON Feed 1.1 response. */
14
+ export function jsonFeedResponse(channel, items) {
15
+ return new Response(buildJsonFeed(channel, items), {
16
+ headers: { 'Content-Type': 'application/feed+json; charset=utf-8' },
17
+ });
18
+ }
19
+ /** A sitemap response. */
20
+ export function sitemapResponse(urls) {
21
+ return new Response(buildSitemap(urls), {
22
+ headers: { 'Content-Type': 'application/xml; charset=utf-8' },
23
+ });
24
+ }
25
+ /** A robots.txt response. */
26
+ export function robotsResponse(opts) {
27
+ return new Response(buildRobots(opts), {
28
+ headers: { 'Content-Type': 'text/plain; charset=utf-8' },
29
+ });
30
+ }
@@ -12,6 +12,10 @@ export interface SeoInput {
12
12
  json?: string;
13
13
  };
14
14
  image?: string;
15
+ /** A robots meta directive, e.g. "noindex, nofollow". Omitted from the head when absent. */
16
+ robots?: string;
17
+ /** Author name, emitted as article:author for the article type. */
18
+ author?: string;
15
19
  }
16
20
  /** Plain-data head: a title, meta tags, link tags, and one JSON-LD object. */
17
21
  export interface SeoMeta {
@@ -1 +1 @@
1
- {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/seo.ts"],"names":[],"mappings":"AAIA,6EAA6E;AAC7E,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,8EAA8E;AAC9E,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9D,KAAK,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,sCAAsC;AACtC,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CA6CrD"}
1
+ {"version":3,"file":"seo.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/seo.ts"],"names":[],"mappings":"AAIA,6EAA6E;AAC7E,MAAM,WAAW,QAAQ;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;IAC7B,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,8EAA8E;AAC9E,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9D,KAAK,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IACtE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACjC;AAED,sCAAsC;AACtC,wBAAgB,YAAY,CAAC,KAAK,EAAE,QAAQ,GAAG,OAAO,CAsDrD"}
@@ -17,6 +17,17 @@ export function buildSeoMeta(input) {
17
17
  meta.push({ property: 'og:image', content: input.image });
18
18
  meta.push({ name: 'twitter:image', content: input.image });
19
19
  }
20
+ if (input.robots) {
21
+ meta.push({ name: 'robots', content: input.robots });
22
+ }
23
+ if (type === 'article') {
24
+ if (input.published)
25
+ meta.push({ property: 'article:published_time', content: input.published });
26
+ if (input.modified)
27
+ meta.push({ property: 'article:modified_time', content: input.modified });
28
+ if (input.author)
29
+ meta.push({ property: 'article:author', content: input.author });
30
+ }
20
31
  const links = [{ rel: 'canonical', href: input.canonicalUrl }];
21
32
  if (input.feeds?.rss) {
22
33
  links.push({ rel: 'alternate', type: 'application/rss+xml', href: input.feeds.rss, title: input.siteName });
@@ -0,0 +1,5 @@
1
+ import type { CairnAdapter, ConceptDescriptor } from '../content/types.js';
2
+ import type { SiteConfig } from '../nav/site-config.js';
3
+ /** Per-concept descriptors for a site, from its adapter content and its parsed site config. */
4
+ export declare function siteDescriptors(adapter: CairnAdapter, siteConfig: SiteConfig): ConceptDescriptor[];
5
+ //# sourceMappingURL=site-descriptors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"site-descriptors.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/site-descriptors.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC3E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAExD,+FAA+F;AAC/F,wBAAgB,eAAe,CAAC,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,UAAU,GAAG,iBAAiB,EAAE,CAElG"}
@@ -0,0 +1,9 @@
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
+ /** Per-concept descriptors for a site, from its adapter content and its parsed site config. */
7
+ export function siteDescriptors(adapter, siteConfig) {
8
+ return normalizeConcepts(adapter.content, urlPolicyFrom(siteConfig));
9
+ }
@@ -23,6 +23,12 @@ export interface SiteIndex {
23
23
  /** Every non-draft summary across concepts, for the site-wide sitemap. */
24
24
  all(): ContentSummary[];
25
25
  }
26
- /** Union per-concept indexes into a site-level resolver; throw on a duplicate permalink. */
27
- export declare function createSiteIndex(concepts: ConceptIndex[]): SiteIndex;
26
+ /**
27
+ * Union per-concept indexes into a site-level resolver. Throws on a duplicate permalink and,
28
+ * unless `validate` is `false`, on any entry whose frontmatter fails its concept's validator,
29
+ * so malformed content fails the build instead of shipping.
30
+ */
31
+ export declare function createSiteIndex(concepts: ConceptIndex[], opts?: {
32
+ validate?: boolean;
33
+ }): SiteIndex;
28
34
  //# sourceMappingURL=site-index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"site-index.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/site-index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAErF,4DAA4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,iBAAiB,CAAC;IAC9B,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,8EAA8E;AAC9E,MAAM,WAAW,SAAS;IACxB,4FAA4F;IAC5F,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IACpD,iFAAiF;IACjF,QAAQ,CAAC,KAAK,EAAE,cAAc,GAAG;QAAE,KAAK,CAAC,EAAE,cAAc,CAAC;QAAC,KAAK,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC;IACpF,uGAAuG;IACvG,OAAO,IAAI;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9B,mEAAmE;IACnE,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IAC9C,0EAA0E;IAC1E,GAAG,IAAI,cAAc,EAAE,CAAC;CACzB;AAOD,4FAA4F;AAC5F,wBAAgB,eAAe,CAAC,QAAQ,EAAE,YAAY,EAAE,GAAG,SAAS,CAkCnE"}
1
+ {"version":3,"file":"site-index.d.ts","sourceRoot":"","sources":["../../src/lib/delivery/site-index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7D,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAErF,4DAA4D;AAC5D,MAAM,WAAW,YAAY;IAC3B,UAAU,EAAE,iBAAiB,CAAC;IAC9B,KAAK,EAAE,YAAY,CAAC;CACrB;AAED,8EAA8E;AAC9E,MAAM,WAAW,SAAS;IACxB,4FAA4F;IAC5F,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IACpD,iFAAiF;IACjF,QAAQ,CAAC,KAAK,EAAE,cAAc,GAAG;QAAE,KAAK,CAAC,EAAE,cAAc,CAAC;QAAC,KAAK,CAAC,EAAE,cAAc,CAAA;KAAE,CAAC;IACpF,uGAAuG;IACvG,OAAO,IAAI;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;IAC9B,mEAAmE;IACnE,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,YAAY,GAAG,SAAS,CAAC;IAC9C,0EAA0E;IAC1E,GAAG,IAAI,cAAc,EAAE,CAAC;CACzB;AA2BD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,YAAY,EAAE,EAAE,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,SAAS,CAmCtG"}
@@ -2,8 +2,34 @@
2
2
  function normalizePath(path) {
3
3
  return path.length > 1 ? path.replace(/\/+$/, '') : path;
4
4
  }
5
- /** Union per-concept indexes into a site-level resolver; throw on a duplicate permalink. */
6
- export function createSiteIndex(concepts) {
5
+ /** Validate every entry (drafts included) against its concept, aggregating failures. */
6
+ function validateAll(concepts) {
7
+ const problems = [];
8
+ for (const { descriptor, index } of concepts) {
9
+ for (const summary of index.all({ includeDrafts: true })) {
10
+ const entry = index.byId(summary.id);
11
+ if (!entry)
12
+ continue;
13
+ const result = descriptor.validate(entry.frontmatter, entry.body);
14
+ if (!result.ok) {
15
+ for (const [field, message] of Object.entries(result.errors)) {
16
+ problems.push(`${descriptor.dir}/${summary.id}: ${field}: ${message}`);
17
+ }
18
+ }
19
+ }
20
+ }
21
+ if (problems.length > 0) {
22
+ throw new Error(`site index: ${problems.length} invalid frontmatter field(s):\n ${problems.join('\n ')}`);
23
+ }
24
+ }
25
+ /**
26
+ * Union per-concept indexes into a site-level resolver. Throws on a duplicate permalink and,
27
+ * unless `validate` is `false`, on any entry whose frontmatter fails its concept's validator,
28
+ * so malformed content fails the build instead of shipping.
29
+ */
30
+ export function createSiteIndex(concepts, opts = {}) {
31
+ if (opts.validate !== false)
32
+ validateAll(concepts);
7
33
  const byPath = new Map();
8
34
  const byId = new Map();
9
35
  for (const { descriptor, index } of concepts) {
@@ -1,5 +1,6 @@
1
1
  import type { ContentSummary, ContentEntry } from '../delivery/content-index.js';
2
2
  import type { SiteIndex } from '../delivery/site-index.js';
3
+ import type { SeoMeta } from '../delivery/seo.js';
3
4
  /** Injected dependencies for the public loaders. */
4
5
  export interface PublicRoutesDeps {
5
6
  site: SiteIndex;
@@ -7,6 +8,15 @@ export interface PublicRoutesDeps {
7
8
  stagger?: boolean;
8
9
  }) => string | Promise<string>;
9
10
  origin: string;
11
+ /** Site name for og:site_name and the SEO head. */
12
+ siteName: string;
13
+ /** Default description used when an entry has none. */
14
+ description: string;
15
+ /** Absolute feed URLs for the head's autodiscovery links. */
16
+ feeds?: {
17
+ rss?: string;
18
+ json?: string;
19
+ };
10
20
  }
11
21
  /** The archive and tag list data: summaries the template renders. */
12
22
  export interface ListData {
@@ -28,6 +38,7 @@ export interface EntryData {
28
38
  entry: ContentEntry;
29
39
  html: string;
30
40
  canonicalUrl: string;
41
+ seo: SeoMeta;
31
42
  newer?: ContentSummary;
32
43
  older?: ContentSummary;
33
44
  }
@@ -1 +1 @@
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
+ {"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,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAC;AAElD,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;IACf,mDAAmD;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;CACzC;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,GAAG,EAAE,OAAO,CAAC;IACb,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;6BAoBjC,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"}
@@ -4,9 +4,10 @@
4
4
  // and tag-index loaders stay concept-scoped, keyed by concept id. The index is built in site code
5
5
  // from globs, so it stays in the prerender graph and out of the runtime Worker.
6
6
  import { error } from '@sveltejs/kit';
7
+ import { buildSeoMeta } from '../delivery/seo.js';
7
8
  /** Build the public loaders for a site's unified index. */
8
9
  export function createPublicRoutes(deps) {
9
- const { site, render, origin } = deps;
10
+ const { site, render, origin, siteName, description, feeds } = deps;
10
11
  /** Resolve one concept's index by id, or a 404 (the route names an unconfigured concept). */
11
12
  function indexOf(conceptId) {
12
13
  const index = site.concept(conceptId);
@@ -20,7 +21,19 @@ export function createPublicRoutes(deps) {
20
21
  if (!entry)
21
22
  throw error(404, `Not found: ${event.url.pathname}`);
22
23
  const { newer, older } = site.adjacent(entry);
23
- return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl: origin + entry.permalink, newer, older };
24
+ const canonicalUrl = origin + entry.permalink;
25
+ // A dated entry is an article; an undated one (a page) is a website.
26
+ const seo = buildSeoMeta({
27
+ title: entry.title,
28
+ description: entry.frontmatter.description || entry.excerpt || description,
29
+ canonicalUrl,
30
+ siteName,
31
+ type: entry.date ? 'article' : 'website',
32
+ ...(entry.date ? { published: entry.date } : {}),
33
+ ...(entry.updated ? { modified: entry.updated } : {}),
34
+ feeds,
35
+ });
36
+ return { entry, html: await render(entry.body, { stagger: true }), canonicalUrl, seo, newer, older };
24
37
  }
25
38
  /** The chronological archive for one concept: every non-draft summary, newest-first. */
26
39
  function archiveLoad(conceptId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -48,6 +48,11 @@
48
48
  "svelte": "./dist/components/index.js",
49
49
  "default": "./dist/components/index.js"
50
50
  },
51
+ "./delivery": {
52
+ "types": "./dist/delivery/index.d.ts",
53
+ "svelte": "./dist/delivery/index.js",
54
+ "default": "./dist/delivery/index.js"
55
+ },
51
56
  "./package.json": "./package.json"
52
57
  },
53
58
  "files": [
@@ -0,0 +1,36 @@
1
+ <!--
2
+ @component
3
+ Renders a page's SEO head from a SeoMeta object into <svelte:head>: a title, meta tags, link
4
+ tags, and one escaped JSON-LD script. The title renders from seo.title by default; title={false}
5
+ lets the site own the <title>, and a string overrides it. It carries no CSS, so it pulls in no
6
+ admin styles.
7
+ -->
8
+ <script lang="ts">
9
+ import type { SeoMeta } from './seo.js';
10
+ import { jsonLdScript } from './json-ld.js';
11
+
12
+ let {
13
+ /** The plain-data head to render. */
14
+ seo,
15
+ /** Title override: a string replaces seo.title, false lets the site own <title>. */
16
+ title,
17
+ }: { seo: SeoMeta; title?: string | false } = $props();
18
+ const titleText = $derived(title === undefined ? seo.title : title);
19
+ </script>
20
+
21
+ <svelte:head>
22
+ {#if titleText !== false}
23
+ <title>{titleText}</title>
24
+ {/if}
25
+ {#each seo.meta as m}
26
+ {#if m.name}
27
+ <meta name={m.name} content={m.content} />
28
+ {:else if m.property}
29
+ <meta property={m.property} content={m.content} />
30
+ {/if}
31
+ {/each}
32
+ {#each seo.links as l}
33
+ <link rel={l.rel} type={l.type} href={l.href} title={l.title} />
34
+ {/each}
35
+ {@html jsonLdScript(seo.jsonLd)}
36
+ </svelte:head>
@@ -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) {
@@ -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. */