@cogenta/seo 0.1.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 (53) hide show
  1. package/dist/feeds.d.ts +66 -0
  2. package/dist/feeds.d.ts.map +1 -0
  3. package/dist/feeds.js +199 -0
  4. package/dist/feeds.js.map +1 -0
  5. package/dist/hreflang.d.ts +63 -0
  6. package/dist/hreflang.d.ts.map +1 -0
  7. package/dist/hreflang.js +86 -0
  8. package/dist/hreflang.js.map +1 -0
  9. package/dist/index.d.ts +40 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +29 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/indexable.d.ts +28 -0
  14. package/dist/indexable.d.ts.map +1 -0
  15. package/dist/indexable.js +39 -0
  16. package/dist/indexable.js.map +1 -0
  17. package/dist/indexnow.d.ts +47 -0
  18. package/dist/indexnow.d.ts.map +1 -0
  19. package/dist/indexnow.js +136 -0
  20. package/dist/indexnow.js.map +1 -0
  21. package/dist/json-ld.d.ts +69 -0
  22. package/dist/json-ld.d.ts.map +1 -0
  23. package/dist/json-ld.js +319 -0
  24. package/dist/json-ld.js.map +1 -0
  25. package/dist/llms-txt.d.ts +50 -0
  26. package/dist/llms-txt.d.ts.map +1 -0
  27. package/dist/llms-txt.js +85 -0
  28. package/dist/llms-txt.js.map +1 -0
  29. package/dist/metadata.d.ts +61 -0
  30. package/dist/metadata.d.ts.map +1 -0
  31. package/dist/metadata.js +200 -0
  32. package/dist/metadata.js.map +1 -0
  33. package/dist/robots.d.ts +35 -0
  34. package/dist/robots.d.ts.map +1 -0
  35. package/dist/robots.js +50 -0
  36. package/dist/robots.js.map +1 -0
  37. package/dist/sitemap.d.ts +65 -0
  38. package/dist/sitemap.d.ts.map +1 -0
  39. package/dist/sitemap.js +197 -0
  40. package/dist/sitemap.js.map +1 -0
  41. package/dist/types.d.ts +67 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +2 -0
  44. package/dist/types.js.map +1 -0
  45. package/dist/url.d.ts +41 -0
  46. package/dist/url.d.ts.map +1 -0
  47. package/dist/url.js +110 -0
  48. package/dist/url.js.map +1 -0
  49. package/dist/xml.d.ts +32 -0
  50. package/dist/xml.d.ts.map +1 -0
  51. package/dist/xml.js +134 -0
  52. package/dist/xml.js.map +1 -0
  53. package/package.json +43 -0
@@ -0,0 +1,197 @@
1
+ import { CogentaError } from '@cogenta/core';
2
+ import { buildHreflangMap } from './hreflang.js';
3
+ import { indexableResources } from './indexable.js';
4
+ import { absoluteUrl, canonicalUrl } from './url.js';
5
+ import { renderXmlDocument, xmlElementByteLength } from './xml.js';
6
+ /**
7
+ * `sitemap.xml`, split and indexed when it has to be.
8
+ *
9
+ * The two limits are the protocol's, not a preference: sitemaps.org caps a
10
+ * single file at **50 000 URLs and 50 MB uncompressed**, and a file that
11
+ * exceeds either is rejected whole. Both are enforced, because a site can hit
12
+ * the byte limit well before the URL limit once `xhtml:link` alternates are
13
+ * included — twelve languages multiply the size of every entry by roughly
14
+ * thirteen while the URL count stays put.
15
+ */
16
+ /** sitemaps.org: 50 000 URLs per file, and the same cap on entries in an index. */
17
+ export const SITEMAP_MAX_URLS = 50_000;
18
+ /** sitemaps.org: 50 MB uncompressed, counted as 50 × 2²⁰ bytes. */
19
+ export const SITEMAP_MAX_BYTES = 52_428_800;
20
+ const SITEMAP_NS = 'http://www.sitemaps.org/schemas/sitemap/0.9';
21
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
22
+ const DEFAULT_INDEX_PATH = '/sitemap.xml';
23
+ const defaultChunkPath = (index) => `/sitemap-${index}.xml`;
24
+ function urlElement(url) {
25
+ const children = [{ name: 'loc', text: url.loc }];
26
+ if (url.lastmod !== undefined)
27
+ children.push({ name: 'lastmod', text: url.lastmod });
28
+ if (url.changefreq !== undefined)
29
+ children.push({ name: 'changefreq', text: url.changefreq });
30
+ if (url.priority !== undefined) {
31
+ children.push({ name: 'priority', text: clampPriority(url.priority) });
32
+ }
33
+ for (const alternate of url.alternates ?? []) {
34
+ children.push({
35
+ name: 'xhtml:link',
36
+ attributes: { rel: 'alternate', hreflang: alternate.hreflang, href: alternate.href },
37
+ });
38
+ }
39
+ return { name: 'url', children };
40
+ }
41
+ function clampPriority(priority) {
42
+ const bounded = Math.min(1, Math.max(0, priority));
43
+ return bounded.toFixed(1);
44
+ }
45
+ /**
46
+ * Bytes a chunk costs before any URL: declaration, root tag with both
47
+ * namespaces, closing tag and newlines. Measured rather than estimated so the
48
+ * budget stays right if the header ever changes.
49
+ */
50
+ function chunkOverhead() {
51
+ return Buffer.byteLength(renderXmlDocument(urlsetElement([])), 'utf8');
52
+ }
53
+ function urlsetElement(urls) {
54
+ return {
55
+ name: 'urlset',
56
+ attributes: { xmlns: SITEMAP_NS, 'xmlns:xhtml': XHTML_NS },
57
+ children: urls.map(urlElement),
58
+ };
59
+ }
60
+ function splitIntoChunks(urls, maxUrls, maxBytes) {
61
+ const overhead = chunkOverhead();
62
+ const chunks = [];
63
+ let current = [];
64
+ let size = overhead;
65
+ for (const url of urls) {
66
+ // +1 for the newline the renderer puts between siblings.
67
+ const cost = xmlElementByteLength(urlElement(url), 1) + 1;
68
+ if (overhead + cost > maxBytes) {
69
+ throw new CogentaError({
70
+ code: 'CONTENT_INVALID',
71
+ message: `A single sitemap entry for "${url.loc}" exceeds the ${maxBytes}-byte file limit.`,
72
+ hint: 'It almost certainly carries far too many hreflang alternates. A sitemap entry cannot be split across files.',
73
+ details: { loc: url.loc, bytes: cost, maxBytes },
74
+ });
75
+ }
76
+ if (current.length >= maxUrls || size + cost > maxBytes) {
77
+ chunks.push(current);
78
+ current = [];
79
+ size = overhead;
80
+ }
81
+ current.push(url);
82
+ size += cost;
83
+ }
84
+ if (current.length > 0 || chunks.length === 0)
85
+ chunks.push(current);
86
+ return chunks;
87
+ }
88
+ /**
89
+ * The files to write for a set of URLs.
90
+ *
91
+ * One file below the limits, an index plus N chunks above them. The caller
92
+ * writes what it gets and never has to know which case it is in — a shape that
93
+ * makes the split untestable in production is how sites discover at 60 000 URLs
94
+ * that nobody ever exercised the second branch.
95
+ */
96
+ export function buildSitemap(site, urls, options = {}) {
97
+ const maxUrls = options.maxUrls ?? SITEMAP_MAX_URLS;
98
+ const maxBytes = options.maxBytes ?? SITEMAP_MAX_BYTES;
99
+ if (maxUrls < 1 || maxBytes < 1) {
100
+ throw new CogentaError({
101
+ code: 'CONFIG_INVALID',
102
+ message: 'A sitemap file must allow at least one URL and one byte.',
103
+ hint: `Leave maxUrls and maxBytes unset to use the protocol limits (${SITEMAP_MAX_URLS} URLs, ${SITEMAP_MAX_BYTES} bytes).`,
104
+ details: { maxUrls, maxBytes },
105
+ });
106
+ }
107
+ const indexPath = options.indexPath ?? DEFAULT_INDEX_PATH;
108
+ const chunkPath = options.chunkPath ?? defaultChunkPath;
109
+ const chunks = splitIntoChunks(urls, maxUrls, maxBytes);
110
+ if (chunks.length === 1) {
111
+ const only = chunks[0] ?? [];
112
+ return [
113
+ {
114
+ path: indexPath,
115
+ contents: renderXmlDocument(urlsetElement(only)),
116
+ isIndex: false,
117
+ urlCount: only.length,
118
+ },
119
+ ];
120
+ }
121
+ // The index cap is the protocol's own, deliberately *not* `maxUrls`. The two
122
+ // limits happen to share a number, and conflating them means that lowering
123
+ // `maxUrls` — which a caller does to force a split, and a test does to
124
+ // exercise one — also lowers how many files an index may list, so the split
125
+ // it just asked for is rejected.
126
+ const maxIndexEntries = options.maxIndexEntries ?? SITEMAP_MAX_URLS;
127
+ if (chunks.length > maxIndexEntries) {
128
+ throw new CogentaError({
129
+ code: 'CONTENT_INVALID',
130
+ message: `The site needs ${chunks.length} sitemap files, more than the ${maxIndexEntries} an index may list.`,
131
+ hint: 'A sitemap index cannot itself be indexed. Split the site across several hosts, or raise maxUrls so each file holds more.',
132
+ details: { files: chunks.length, maxIndexEntries },
133
+ });
134
+ }
135
+ const files = chunks.map((chunk, position) => ({
136
+ path: chunkPath(position + 1),
137
+ contents: renderXmlDocument(urlsetElement(chunk)),
138
+ isIndex: false,
139
+ urlCount: chunk.length,
140
+ }));
141
+ const lastmod = options.lastmod ?? newestLastmod(urls);
142
+ const index = {
143
+ name: 'sitemapindex',
144
+ attributes: { xmlns: SITEMAP_NS },
145
+ children: files.map((file) => ({
146
+ name: 'sitemap',
147
+ children: [
148
+ { name: 'loc', text: absoluteUrl(site, file.path) },
149
+ lastmod === undefined ? null : { name: 'lastmod', text: lastmod },
150
+ ],
151
+ })),
152
+ };
153
+ return [
154
+ {
155
+ path: indexPath,
156
+ contents: renderXmlDocument(index),
157
+ isIndex: true,
158
+ urlCount: files.length,
159
+ },
160
+ ...files,
161
+ ];
162
+ }
163
+ function newestLastmod(urls) {
164
+ let newest;
165
+ for (const url of urls) {
166
+ if (url.lastmod === undefined)
167
+ continue;
168
+ if (newest === undefined || url.lastmod > newest)
169
+ newest = url.lastmod;
170
+ }
171
+ return newest;
172
+ }
173
+ /**
174
+ * Sitemap URLs for a set of resources: published only, alternates attached.
175
+ *
176
+ * `lastmod` is `updatedAt` rather than `publishedAt`: the field answers "has
177
+ * this changed since you last fetched it", which is what makes a crawler come
178
+ * back for a corrected article.
179
+ */
180
+ export function sitemapUrlsFor(site, resources, options = {}) {
181
+ const published = indexableResources(site, resources, options);
182
+ const hreflang = buildHreflangMap(site, resources, options);
183
+ const urls = [];
184
+ for (const resource of published) {
185
+ const loc = canonicalUrl(site, resource);
186
+ if (loc === null)
187
+ continue;
188
+ const alternates = hreflang.get(resource.entry.id);
189
+ urls.push({
190
+ loc,
191
+ lastmod: resource.entry.updatedAt,
192
+ ...(alternates === undefined ? {} : { alternates }),
193
+ });
194
+ }
195
+ return urls;
196
+ }
197
+ //# sourceMappingURL=sitemap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sitemap.js","sourceRoot":"","sources":["../src/sitemap.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import type { CollectionDefinition, ContentEntry } from '@cogenta/schema';
2
+ /**
3
+ * What the SEO layer needs to know about the site.
4
+ *
5
+ * Deliberately not the whole site configuration: this package is consumed by
6
+ * the render process, which owns neither secrets nor a database connection
7
+ * (rule R5). Everything here is public information that already appears in the
8
+ * page source.
9
+ */
10
+ export interface SeoSite {
11
+ /** Absolute origin, with or without a path prefix. `https://example.com`. */
12
+ readonly baseUrl: string;
13
+ /** The site name, used in `og:site_name` and as the feed title. */
14
+ readonly name: string;
15
+ readonly description?: string;
16
+ /** The language a URL carries when it has no locale prefix. */
17
+ readonly defaultLocale: string;
18
+ /** Every language the site serves. Used to validate what a family claims. */
19
+ readonly locales?: readonly string[];
20
+ /**
21
+ * Whether the default locale is served without its prefix — `/blog/hello`
22
+ * next to `/fr/blog/bonjour`.
23
+ *
24
+ * It has to be stated rather than guessed: `buildPath` always prefixes a
25
+ * localised route, while `matchPath` accepts both forms, so only the site
26
+ * knows which of the two a crawler will find. Guessing produces canonical
27
+ * URLs that 301 to themselves.
28
+ */
29
+ readonly unprefixedDefaultLocale?: boolean;
30
+ /** `@handle` for `twitter:site`. */
31
+ readonly twitterSite?: string;
32
+ }
33
+ /** An entry together with the collection that describes it. */
34
+ export interface SeoResource<TEntry extends ContentEntry = ContentEntry> {
35
+ readonly collection: CollectionDefinition;
36
+ readonly entry: TEntry;
37
+ }
38
+ export interface SeoImage {
39
+ readonly url: string;
40
+ readonly width?: number;
41
+ readonly height?: number;
42
+ readonly alt?: string;
43
+ readonly mimeType?: string;
44
+ }
45
+ /** A related entity — an author, a tag — reduced to what a crawler can use. */
46
+ export interface SeoReference {
47
+ readonly name: string;
48
+ readonly url?: string;
49
+ /** schema.org type, when the caller knows it. `Person` for an author. */
50
+ readonly type?: string;
51
+ }
52
+ /**
53
+ * How to turn an identifier into something publishable.
54
+ *
55
+ * A `media` field stores a media id and a `relation` field stores an entry id;
56
+ * neither is meaningful to a crawler. Resolution needs the media pipeline and
57
+ * the content store, which this package must not reach, so it is injected.
58
+ *
59
+ * Both are optional, and an unresolved id is **omitted** rather than emitted
60
+ * raw: `"image": "0192f3a1-…"` is worse than no image at all, because it makes
61
+ * structured data invalid rather than incomplete.
62
+ */
63
+ export interface SeoResolvers {
64
+ readonly media?: (id: string) => SeoImage | null;
65
+ readonly reference?: (collection: string, id: string) => SeoReference | null;
66
+ }
67
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEzE;;;;;;;GAOG;AACH,MAAM,WAAW,OAAO;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,mEAAmE;IACnE,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,+DAA+D;IAC/D,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,6EAA6E;IAC7E,QAAQ,CAAC,OAAO,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC;;;;;;;;OAQG;IACH,QAAQ,CAAC,uBAAuB,CAAC,EAAE,OAAO,CAAA;IAC1C,oCAAoC;IACpC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;CAC9B;AAED,+DAA+D;AAC/D,MAAM,WAAW,WAAW,CAAC,MAAM,SAAS,YAAY,GAAG,YAAY;IACrE,QAAQ,CAAC,UAAU,EAAE,oBAAoB,CAAA;IACzC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAC3B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CACvB;AAED;;;;;;;;;;GAUG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,QAAQ,GAAG,IAAI,CAAA;IAChD,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,YAAY,GAAG,IAAI,CAAA;CAC7E"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
package/dist/url.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import { type CollectionDefinition, type ContentEntry } from '@cogenta/schema';
2
+ import type { SeoResource, SeoSite } from './types.js';
3
+ /**
4
+ * Every URL this package emits is absolute, and every one of them is built
5
+ * here.
6
+ *
7
+ * Sitemaps, feeds and `hreflang` all reject relative URLs, and a canonical that
8
+ * disagrees with the sitemap by a trailing slash is treated as a different page
9
+ * — the single most common cause of "Google indexed the wrong URL". One
10
+ * function, one form.
11
+ */
12
+ /** `https://example.com/blog/` and `https://example.com/blog` are the same origin. */
13
+ export declare function normaliseBaseUrl(baseUrl: string): string;
14
+ /**
15
+ * An absolute URL for a site-relative path.
16
+ *
17
+ * The path is expected to be already encoded — `buildPath` percent-encodes each
18
+ * segment — so it is concatenated rather than passed through `new URL`, which
19
+ * would double-encode a `%` that is already an escape.
20
+ */
21
+ export declare function absoluteUrl(site: SeoSite, path: string): string;
22
+ /**
23
+ * The route parameters an entry supplies.
24
+ *
25
+ * A pattern segment `:slug` reads `values.slug`; `:id` reads the system field,
26
+ * since it is the only parameter an entry always has and the natural fallback
27
+ * for a collection with no slug.
28
+ */
29
+ export declare function routeParams(collection: CollectionDefinition, entry: ContentEntry): Record<string, string>;
30
+ /** True when the collection can produce a URL at all. */
31
+ export declare function hasRoute(collection: CollectionDefinition): boolean;
32
+ /**
33
+ * The canonical, absolute URL of an entry, or null when it has no route.
34
+ *
35
+ * Null rather than an exception: a collection without `routing` is a perfectly
36
+ * ordinary thing — an author, a tag, a site setting — and asking "what is the
37
+ * canonical URL of every entry" must not blow up on the first one that has
38
+ * none.
39
+ */
40
+ export declare function canonicalUrl(site: SeoSite, resource: SeoResource): string | null;
41
+ //# sourceMappingURL=url.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url.d.ts","sourceRoot":"","sources":["../src/url.ts"],"names":[],"mappings":"AACA,OAAO,EAAa,KAAK,oBAAoB,EAAE,KAAK,YAAY,EAAE,MAAM,iBAAiB,CAAA;AACzF,OAAO,KAAK,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,YAAY,CAAA;AAEtD;;;;;;;;GAQG;AAEH,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAwBxD;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAI/D;AAED;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,UAAU,EAAE,oBAAoB,EAChC,KAAK,EAAE,YAAY,GAClB,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAwBxB;AAED,yDAAyD;AACzD,wBAAgB,QAAQ,CAAC,UAAU,EAAE,oBAAoB,GAAG,OAAO,CAElE;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAW,GAAG,MAAM,GAAG,IAAI,CAoBhF"}
package/dist/url.js ADDED
@@ -0,0 +1,110 @@
1
+ import { CogentaError } from '@cogenta/core';
2
+ import { buildPath } from '@cogenta/schema';
3
+ /**
4
+ * Every URL this package emits is absolute, and every one of them is built
5
+ * here.
6
+ *
7
+ * Sitemaps, feeds and `hreflang` all reject relative URLs, and a canonical that
8
+ * disagrees with the sitemap by a trailing slash is treated as a different page
9
+ * — the single most common cause of "Google indexed the wrong URL". One
10
+ * function, one form.
11
+ */
12
+ /** `https://example.com/blog/` and `https://example.com/blog` are the same origin. */
13
+ export function normaliseBaseUrl(baseUrl) {
14
+ let parsed;
15
+ try {
16
+ parsed = new URL(baseUrl);
17
+ }
18
+ catch {
19
+ throw new CogentaError({
20
+ code: 'CONFIG_INVALID',
21
+ message: `The site base URL "${baseUrl}" is not a URL.`,
22
+ hint: 'Give an absolute origin, protocol included: https://example.com',
23
+ details: { baseUrl },
24
+ });
25
+ }
26
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
27
+ throw new CogentaError({
28
+ code: 'CONFIG_INVALID',
29
+ message: `The site base URL uses "${parsed.protocol}", which no crawler follows.`,
30
+ hint: 'Use http or https. Sitemaps and feeds carry absolute http(s) URLs only.',
31
+ details: { baseUrl },
32
+ });
33
+ }
34
+ const path = parsed.pathname.replace(/\/+$/u, '');
35
+ return `${parsed.origin}${path}`;
36
+ }
37
+ /**
38
+ * An absolute URL for a site-relative path.
39
+ *
40
+ * The path is expected to be already encoded — `buildPath` percent-encodes each
41
+ * segment — so it is concatenated rather than passed through `new URL`, which
42
+ * would double-encode a `%` that is already an escape.
43
+ */
44
+ export function absoluteUrl(site, path) {
45
+ const base = normaliseBaseUrl(site.baseUrl);
46
+ if (path.length === 0 || path === '/')
47
+ return `${base}/`;
48
+ return `${base}${path.startsWith('/') ? path : `/${path}`}`;
49
+ }
50
+ /**
51
+ * The route parameters an entry supplies.
52
+ *
53
+ * A pattern segment `:slug` reads `values.slug`; `:id` reads the system field,
54
+ * since it is the only parameter an entry always has and the natural fallback
55
+ * for a collection with no slug.
56
+ */
57
+ export function routeParams(collection, entry) {
58
+ const pattern = collection.routing?.pattern ?? '';
59
+ const params = {};
60
+ for (const segment of pattern.split('/')) {
61
+ if (!segment.startsWith(':'))
62
+ continue;
63
+ const name = segment.slice(1);
64
+ if (name === 'id') {
65
+ params[name] = entry.id;
66
+ continue;
67
+ }
68
+ const value = entry.values[name];
69
+ if (typeof value === 'string' && value.length > 0) {
70
+ params[name] = value;
71
+ continue;
72
+ }
73
+ if (typeof value === 'number') {
74
+ params[name] = String(value);
75
+ }
76
+ }
77
+ return params;
78
+ }
79
+ /** True when the collection can produce a URL at all. */
80
+ export function hasRoute(collection) {
81
+ return collection.routing !== undefined;
82
+ }
83
+ /**
84
+ * The canonical, absolute URL of an entry, or null when it has no route.
85
+ *
86
+ * Null rather than an exception: a collection without `routing` is a perfectly
87
+ * ordinary thing — an author, a tag, a site setting — and asking "what is the
88
+ * canonical URL of every entry" must not blow up on the first one that has
89
+ * none.
90
+ */
91
+ export function canonicalUrl(site, resource) {
92
+ const { collection, entry } = resource;
93
+ const routing = collection.routing;
94
+ if (routing === undefined)
95
+ return null;
96
+ const params = routeParams(collection, entry);
97
+ for (const segment of routing.pattern.split('/')) {
98
+ if (segment.startsWith(':') && params[segment.slice(1)] === undefined)
99
+ return null;
100
+ }
101
+ const localised = routing.locale === true;
102
+ const path = buildPath(collection, params, localised ? entry.locale : undefined);
103
+ if (localised && site.unprefixedDefaultLocale === true && entry.locale === site.defaultLocale) {
104
+ const prefix = `/${encodeURIComponent(entry.locale)}`;
105
+ const stripped = path.slice(prefix.length);
106
+ return absoluteUrl(site, stripped.length === 0 ? '/' : stripped);
107
+ }
108
+ return absoluteUrl(site, path);
109
+ }
110
+ //# sourceMappingURL=url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"url.js","sourceRoot":"","sources":["../src/url.ts"],"names":[],"mappings":"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"}
package/dist/xml.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ export declare function stripIllegalXmlChars(value: string): string;
2
+ /**
3
+ * Text content.
4
+ *
5
+ * `>` is escaped although it is only required inside a `]]>` run. Escaping it
6
+ * unconditionally costs three bytes and removes the need for anyone to reason
7
+ * about where the exception applies.
8
+ */
9
+ export declare function escapeXmlText(value: string): string;
10
+ export declare function escapeXmlAttribute(value: string): string;
11
+ export type XmlAttributes = Readonly<Record<string, string | number | undefined>>;
12
+ export interface XmlElement {
13
+ readonly name: string;
14
+ readonly attributes?: XmlAttributes;
15
+ /** Text content. Ignored when `children` holds anything. */
16
+ readonly text?: string;
17
+ /** `null` entries are dropped, so a conditional child needs no array surgery. */
18
+ readonly children?: readonly (XmlElement | null | undefined)[];
19
+ }
20
+ /**
21
+ * A whole document, declaration included.
22
+ *
23
+ * UTF-8 is stated rather than assumed: a sitemap served without a charset
24
+ * header and without a declaration is read as US-ASCII by a conforming parser,
25
+ * which mangles every non-Latin slug on the site.
26
+ */
27
+ export declare function renderXmlDocument(root: XmlElement): string;
28
+ /** The serialised size of one element, used to keep a sitemap file under its byte budget. */
29
+ export declare function xmlElementByteLength(element: XmlElement, depth: number): number;
30
+ /** Exposed so a caller measuring a document can measure exactly what will be written. */
31
+ export declare function renderXmlElement(element: XmlElement, depth?: number): string;
32
+ //# sourceMappingURL=xml.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xml.d.ts","sourceRoot":"","sources":["../src/xml.ts"],"names":[],"mappings":"AA8CA,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAE1D;AAQD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnD;AAiBD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAKxD;AAED,MAAM,MAAM,aAAa,GAAG,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;AAEjF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,UAAU,CAAC,EAAE,aAAa,CAAA;IACnC,4DAA4D;IAC5D,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAA;IACtB,iFAAiF;IACjF,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,CAAC,UAAU,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,CAAA;CAC/D;AAsDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAE1D;AAED,6FAA6F;AAC7F,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE/E;AAED,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,UAAU,EAAE,KAAK,SAAI,GAAG,MAAM,CAEvE"}
package/dist/xml.js ADDED
@@ -0,0 +1,134 @@
1
+ import { CogentaError } from '@cogenta/core';
2
+ /**
3
+ * XML serialisation, written by hand and on purpose (rule R9).
4
+ *
5
+ * The reason this file exists rather than a dependency: the whole of Cogenta's
6
+ * XML output is four fixed document shapes — sitemap, sitemap index, RSS, Atom
7
+ * — and the only hard part is escaping. A library would bring a parser, a DOM
8
+ * and a stream API to solve a problem that is one substitution table wide.
9
+ *
10
+ * Escaping is where sitemaps and feeds actually break. A crawler does not
11
+ * repair a malformed document: it rejects the file whole, so one article whose
12
+ * title contains `<` silently removes every other URL in the same file from the
13
+ * index. That failure is invisible in production — the file is served with a
14
+ * 200 and looks fine in a browser — which is why it is tested here against a
15
+ * real parse rather than against a snapshot.
16
+ */
17
+ /**
18
+ * Characters XML 1.0 forbids outright, escaped or not.
19
+ *
20
+ * `&#0;` is not a legal escape for a NUL: the character is simply not
21
+ * representable, so the only correct handling is removal. Content reaches us
22
+ * from imports and from agents, both of which produce stray control bytes, and
23
+ * a single one of them makes the document unparsable.
24
+ */
25
+ // Assembled from code points rather than written as a regex literal. A literal
26
+ // is what a reader would expect, and it is the one form that cannot be used: it
27
+ // puts control characters in the source, where every editor, diff and terminal
28
+ // renders them differently, and the linter rejects it for exactly that reason.
29
+ // Tab (09), newline (0A) and carriage return (0D) are absent from the ranges —
30
+ // XML allows all three, and stripping them would silently reflow content.
31
+ const ILLEGAL_XML_RANGES = [
32
+ [0x00, 0x08],
33
+ [0x0b, 0x0c],
34
+ [0x0e, 0x1f],
35
+ [0xfffe, 0xffff],
36
+ ];
37
+ const ILLEGAL_XML_CHARS = new RegExp(`[${ILLEGAL_XML_RANGES.map(([from, to]) => `${String.fromCodePoint(from)}-${String.fromCodePoint(to)}`).join('')}]`, 'gu');
38
+ export function stripIllegalXmlChars(value) {
39
+ return value.replace(ILLEGAL_XML_CHARS, '');
40
+ }
41
+ const TEXT_ESCAPES = {
42
+ '&': '&amp;',
43
+ '<': '&lt;',
44
+ '>': '&gt;',
45
+ };
46
+ /**
47
+ * Text content.
48
+ *
49
+ * `>` is escaped although it is only required inside a `]]>` run. Escaping it
50
+ * unconditionally costs three bytes and removes the need for anyone to reason
51
+ * about where the exception applies.
52
+ */
53
+ export function escapeXmlText(value) {
54
+ return stripIllegalXmlChars(value).replace(/[&<>]/gu, (char) => TEXT_ESCAPES[char] ?? char);
55
+ }
56
+ const ATTRIBUTE_ESCAPES = {
57
+ '&': '&amp;',
58
+ '<': '&lt;',
59
+ '>': '&gt;',
60
+ '"': '&quot;',
61
+ "'": '&apos;',
62
+ // Attribute-value normalisation turns a literal tab, newline or carriage
63
+ // return into a space before the application ever sees it. A title that wraps
64
+ // would come back out of the parser silently different, so the whitespace is
65
+ // written as a character reference, which normalisation leaves alone.
66
+ '\t': '&#9;',
67
+ '\n': '&#10;',
68
+ '\r': '&#13;',
69
+ };
70
+ export function escapeXmlAttribute(value) {
71
+ return stripIllegalXmlChars(value).replace(/[&<>"'\t\n\r]/gu, (char) => ATTRIBUTE_ESCAPES[char] ?? char);
72
+ }
73
+ /**
74
+ * A conservative subset of the XML `Name` production: ASCII letters, digits,
75
+ * `_`, `-`, `.` and one `:` for a namespace prefix. Every tag this package
76
+ * emits is a literal, so this only ever fires on a programming mistake — but a
77
+ * mistake that would produce a document no crawler can read.
78
+ */
79
+ const XML_NAME = /^[A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?$/;
80
+ function assertName(name) {
81
+ if (XML_NAME.test(name))
82
+ return;
83
+ throw new CogentaError({
84
+ code: 'CONTENT_INVALID',
85
+ message: `"${name}" is not a usable XML element or attribute name.`,
86
+ hint: 'Element and attribute names are fixed by the feed format; they are never built from content.',
87
+ details: { name },
88
+ });
89
+ }
90
+ function renderAttributes(attributes) {
91
+ if (attributes === undefined)
92
+ return '';
93
+ let rendered = '';
94
+ for (const [name, value] of Object.entries(attributes)) {
95
+ if (value === undefined)
96
+ continue;
97
+ assertName(name);
98
+ rendered += ` ${name}="${escapeXmlAttribute(String(value))}"`;
99
+ }
100
+ return rendered;
101
+ }
102
+ function renderElement(element, depth) {
103
+ assertName(element.name);
104
+ const pad = ' '.repeat(depth);
105
+ const open = `${pad}<${element.name}${renderAttributes(element.attributes)}`;
106
+ const children = (element.children ?? []).filter((child) => child !== null && child !== undefined);
107
+ if (children.length > 0) {
108
+ const inner = children.map((child) => renderElement(child, depth + 1)).join('\n');
109
+ return `${open}>\n${inner}\n${pad}</${element.name}>`;
110
+ }
111
+ if (element.text !== undefined) {
112
+ return `${open}>${escapeXmlText(element.text)}</${element.name}>`;
113
+ }
114
+ return `${open} />`;
115
+ }
116
+ /**
117
+ * A whole document, declaration included.
118
+ *
119
+ * UTF-8 is stated rather than assumed: a sitemap served without a charset
120
+ * header and without a declaration is read as US-ASCII by a conforming parser,
121
+ * which mangles every non-Latin slug on the site.
122
+ */
123
+ export function renderXmlDocument(root) {
124
+ return `<?xml version="1.0" encoding="UTF-8"?>\n${renderElement(root, 0)}\n`;
125
+ }
126
+ /** The serialised size of one element, used to keep a sitemap file under its byte budget. */
127
+ export function xmlElementByteLength(element, depth) {
128
+ return Buffer.byteLength(renderElement(element, depth), 'utf8');
129
+ }
130
+ /** Exposed so a caller measuring a document can measure exactly what will be written. */
131
+ export function renderXmlElement(element, depth = 0) {
132
+ return renderElement(element, depth);
133
+ }
134
+ //# sourceMappingURL=xml.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"xml.js","sourceRoot":"","sources":["../src/xml.ts"],"names":[],"mappings":"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"}