@riebeckite/plugin-taxonomy 0.0.6

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/index.js ADDED
@@ -0,0 +1,675 @@
1
+ // index.ts
2
+ import {
3
+ createStyleAsset,
4
+ defineEndpoint,
5
+ definePlugin,
6
+ resolvePlugins as resolvePlugins2
7
+ } from "@riebeckite/core";
8
+
9
+ // src/collections.ts
10
+ import {
11
+ buildContentCollections,
12
+ stripHtml
13
+ } from "@riebeckite/core";
14
+
15
+ // src/slug.ts
16
+ function slugifyTaxonomyValue(value) {
17
+ return value.split("/").map(slugifyTaxonomySegment).filter((segment) => segment.length > 0).join("/");
18
+ }
19
+ function slugifyTaxonomySegment(segment) {
20
+ const slug = segment.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
21
+ if (slug) return slug;
22
+ const raw = segment.trim();
23
+ return raw === "" ? "" : encodeURIComponent(raw);
24
+ }
25
+
26
+ // src/collections.ts
27
+ function buildTaxonomyIndex(entries, options) {
28
+ const tags = options.tags ? buildTerms(entries, {
29
+ kind: "tag",
30
+ basePath: options.tagsBasePath,
31
+ groupBy: { by: "tags" },
32
+ options
33
+ }) : [];
34
+ const folders = options.folders ? buildTerms(entries, {
35
+ kind: "folder",
36
+ basePath: options.foldersBasePath,
37
+ groupBy: options.folderDepth > 0 ? { by: "folder", depth: options.folderDepth } : { by: "folder" },
38
+ options
39
+ }) : [];
40
+ if (options.related && tags.length > 0) {
41
+ attachRelatedTerms(tags, entries, options);
42
+ }
43
+ return { tags, folders };
44
+ }
45
+ function serializeTaxonomyTerm(term) {
46
+ return {
47
+ kind: term.kind,
48
+ value: term.value,
49
+ title: term.title,
50
+ path: term.path,
51
+ permalink: term.path,
52
+ count: term.entries.length,
53
+ entries: term.entries.map(toEntryReference),
54
+ related: term.related,
55
+ feeds: term.feeds
56
+ };
57
+ }
58
+ function serializeTaxonomyIndex(index) {
59
+ return {
60
+ tags: index.tags.map(serializeTaxonomyTerm),
61
+ folders: index.folders.map(serializeTaxonomyTerm)
62
+ };
63
+ }
64
+ function toEntryReference(entry) {
65
+ return {
66
+ slug: entry.slug,
67
+ permalink: entry.permalink,
68
+ title: entry.title,
69
+ updated: getEntryUpdatedTime(entry),
70
+ summary: getEntrySummary(entry)
71
+ };
72
+ }
73
+ function buildTerms(entries, input) {
74
+ const { kind, basePath, groupBy, options } = input;
75
+ const definition = {
76
+ kind,
77
+ basePath,
78
+ groupBy,
79
+ resolveTitle: (context) => resolveTermTitle(context, options),
80
+ resolvePath: ({ value }) => `${basePath}/${slugifyTaxonomyValue(value)}`.replace(/\/{2,}/g, "/")
81
+ };
82
+ const collections = buildContentCollections(entries, [definition]);
83
+ const terms = [];
84
+ const seen = /* @__PURE__ */ new Set();
85
+ for (const collection of collections) {
86
+ if (collection.value.trim() === "") continue;
87
+ if (collection.entries.length < options.minEntries) continue;
88
+ const path = normalizeTermPath(collection.path, basePath);
89
+ if (seen.has(path)) continue;
90
+ seen.add(path);
91
+ terms.push({
92
+ kind,
93
+ value: collection.value,
94
+ title: collection.title,
95
+ path,
96
+ entries: collection.entries,
97
+ related: [],
98
+ feeds: buildFeedLinks(path, options),
99
+ feedFiles: buildFeedFiles(path, options)
100
+ });
101
+ }
102
+ return terms;
103
+ }
104
+ function resolveTermTitle(context, options) {
105
+ const kind = context.kind === "tag" ? "tag" : "folder";
106
+ const custom = options.resolveTitle?.({
107
+ kind,
108
+ value: context.value,
109
+ basePath: context.basePath
110
+ });
111
+ if (typeof custom === "string" && custom.trim() !== "") return custom.trim();
112
+ return context.kind === "tag" ? `#${context.value}` : context.value;
113
+ }
114
+ function attachRelatedTerms(tags, entries, options) {
115
+ const byValue = new Map(tags.map((term) => [term.value, term]));
116
+ const counts = /* @__PURE__ */ new Map();
117
+ for (const entry of entries) {
118
+ for (const tag of entry.tags) {
119
+ if (!byValue.has(tag)) continue;
120
+ let coOccurrence = counts.get(tag);
121
+ if (!coOccurrence) {
122
+ coOccurrence = /* @__PURE__ */ new Map();
123
+ counts.set(tag, coOccurrence);
124
+ }
125
+ for (const other of entry.tags) {
126
+ if (other === tag) continue;
127
+ coOccurrence.set(other, (coOccurrence.get(other) ?? 0) + 1);
128
+ }
129
+ }
130
+ }
131
+ for (const term of tags) {
132
+ const coOccurrence = counts.get(term.value);
133
+ if (!coOccurrence) continue;
134
+ term.related = [...coOccurrence.entries()].map(([value, count]) => ({ term: byValue.get(value), count })).filter(
135
+ (candidate) => candidate.term !== void 0
136
+ ).sort(
137
+ (left, right) => right.count - left.count || left.term.value.localeCompare(right.term.value, "en")
138
+ ).slice(0, options.relatedLimit).map(({ term: related, count }) => ({
139
+ kind: related.kind,
140
+ value: related.value,
141
+ title: related.title,
142
+ path: related.path,
143
+ permalink: related.path,
144
+ count
145
+ }));
146
+ }
147
+ }
148
+ function buildFeedLinks(path, options) {
149
+ const links = {};
150
+ if (options.feeds.rss) links.rss = `${path}/feed.xml`;
151
+ if (options.feeds.atom) links.atom = `${path}/atom.xml`;
152
+ if (options.feeds.json) links.json = `${path}/feed.json`;
153
+ return links;
154
+ }
155
+ function buildFeedFiles(path, options) {
156
+ const base = path.replace(/^\/+/, "");
157
+ const files = [];
158
+ if (options.feeds.rss) files.push(`${base}/feed.xml`);
159
+ if (options.feeds.atom) files.push(`${base}/atom.xml`);
160
+ if (options.feeds.json) files.push(`${base}/feed.json`);
161
+ return files;
162
+ }
163
+ function normalizeTermPath(path, basePath) {
164
+ const normalized = path.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
165
+ if (normalized !== "") return normalized;
166
+ return basePath || "/";
167
+ }
168
+ function getEntrySummary(entry) {
169
+ const description = entry.frontmatter.description;
170
+ if (typeof description === "string" && description.trim() !== "") {
171
+ return description.trim();
172
+ }
173
+ return stripHtml(entry.html).replace(/\s+/g, " ").trim().slice(0, 200);
174
+ }
175
+ function getEntryUpdatedTime(entry) {
176
+ const value = entry.frontmatter.updated ?? entry.frontmatter.published ?? entry.frontmatter.date ?? entry.frontmatter.created;
177
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
178
+ return value.toISOString();
179
+ }
180
+ if (typeof value !== "string" || value.trim() === "") return null;
181
+ const date = new Date(value);
182
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
183
+ }
184
+
185
+ // src/url.ts
186
+ function buildTaxonomyAbsoluteUrl(config, pathOrUrl) {
187
+ if (!pathOrUrl) return config.site.baseUrl;
188
+ if (/^https?:\/\//.test(pathOrUrl)) return pathOrUrl;
189
+ return new URL(
190
+ pathOrUrl,
191
+ config.site.baseUrl || "https://example.com"
192
+ ).toString();
193
+ }
194
+
195
+ // src/xml.ts
196
+ function escapeTaxonomyXml(value) {
197
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
198
+ }
199
+
200
+ // src/feeds.ts
201
+ function renderTermFeed(config, term, format, limit = 0) {
202
+ const entries = limit > 0 ? term.entries.slice(0, limit) : term.entries;
203
+ const title = `${term.title} | ${config.site.title}`;
204
+ const feedUrl = buildTaxonomyAbsoluteUrl(config, term.path);
205
+ switch (format) {
206
+ case "rss":
207
+ return renderRss(config, term, entries, title, feedUrl);
208
+ case "atom":
209
+ return renderAtom(config, term, entries, title, feedUrl);
210
+ case "json":
211
+ return renderJson(config, term, entries, title);
212
+ default:
213
+ return "";
214
+ }
215
+ }
216
+ function buildFeedHeadTags(term) {
217
+ const tags = [];
218
+ if (term.feeds.rss) {
219
+ tags.push({
220
+ tag: "link",
221
+ attrs: {
222
+ rel: "alternate",
223
+ type: "application/rss+xml",
224
+ title: term.title,
225
+ href: term.feeds.rss
226
+ }
227
+ });
228
+ }
229
+ if (term.feeds.atom) {
230
+ tags.push({
231
+ tag: "link",
232
+ attrs: {
233
+ rel: "alternate",
234
+ type: "application/atom+xml",
235
+ title: term.title,
236
+ href: term.feeds.atom
237
+ }
238
+ });
239
+ }
240
+ if (term.feeds.json) {
241
+ tags.push({
242
+ tag: "link",
243
+ attrs: {
244
+ rel: "alternate",
245
+ type: "application/feed+json",
246
+ title: term.title,
247
+ href: term.feeds.json
248
+ }
249
+ });
250
+ }
251
+ return tags;
252
+ }
253
+ function renderRss(config, term, entries, title, feedUrl) {
254
+ const feed = getResolvedFeedMetadata(config);
255
+ const self = buildTaxonomyAbsoluteUrl(config, `${term.path}/feed.xml`);
256
+ const items = entries.map((entry) => {
257
+ const url = buildTaxonomyAbsoluteUrl(config, entry.permalink);
258
+ const pubDate = getPublishedTime(entry);
259
+ return `<item><title>${escapeTaxonomyXml(entry.title)}</title><link>${escapeTaxonomyXml(url)}</link><guid>${escapeTaxonomyXml(url)}</guid><description>${escapeTaxonomyXml(getDescription(entry))}</description>${pubDate ? `<pubDate>${new Date(pubDate).toUTCString()}</pubDate>` : ""}</item>`;
260
+ }).join("");
261
+ return `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>${escapeTaxonomyXml(title)}</title><link>${escapeTaxonomyXml(feedUrl)}</link><description>${escapeTaxonomyXml(feed.description)}</description><language>${escapeTaxonomyXml(feed.language)}</language><atom:link href="${escapeTaxonomyXml(self)}" rel="self" type="application/rss+xml"/>${items}</channel></rss>`;
262
+ }
263
+ function renderAtom(config, term, entries, title, feedUrl) {
264
+ const self = buildTaxonomyAbsoluteUrl(config, `${term.path}/atom.xml`);
265
+ const updated = entries.map(getUpdatedTime).find(Boolean) ?? (/* @__PURE__ */ new Date(0)).toISOString();
266
+ const items = entries.map((entry) => {
267
+ const url = buildTaxonomyAbsoluteUrl(config, entry.permalink);
268
+ return `<entry><title>${escapeTaxonomyXml(entry.title)}</title><link href="${escapeTaxonomyXml(url)}"/><id>${escapeTaxonomyXml(url)}</id><updated>${escapeTaxonomyXml(getUpdatedTime(entry) ?? updated)}</updated><summary>${escapeTaxonomyXml(getDescription(entry))}</summary></entry>`;
269
+ }).join("");
270
+ return `<?xml version="1.0" encoding="UTF-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title>${escapeTaxonomyXml(title)}</title><link href="${escapeTaxonomyXml(feedUrl)}"/><link rel="self" href="${escapeTaxonomyXml(self)}"/><id>${escapeTaxonomyXml(feedUrl)}</id><updated>${escapeTaxonomyXml(updated)}</updated>${items}</feed>`;
271
+ }
272
+ function renderJson(config, term, entries, title) {
273
+ const feed = getResolvedFeedMetadata(config);
274
+ const self = buildTaxonomyAbsoluteUrl(config, `${term.path}/feed.json`);
275
+ return JSON.stringify({
276
+ version: "https://jsonfeed.org/version/1.1",
277
+ title,
278
+ home_page_url: buildTaxonomyAbsoluteUrl(config, ""),
279
+ feed_url: self,
280
+ description: feed.description,
281
+ language: feed.language,
282
+ items: entries.map((entry) => {
283
+ const url = buildTaxonomyAbsoluteUrl(config, entry.permalink);
284
+ return {
285
+ id: url,
286
+ url,
287
+ title: entry.title,
288
+ content_html: entry.html,
289
+ summary: getDescription(entry),
290
+ date_published: getPublishedTime(entry) ?? void 0,
291
+ date_modified: getUpdatedTime(entry) ?? void 0,
292
+ tags: entry.tags
293
+ };
294
+ })
295
+ });
296
+ }
297
+ function getResolvedFeedMetadata(config) {
298
+ return {
299
+ description: config.site.feed.description ?? config.site.description,
300
+ language: config.site.feed.language ?? config.site.locale.replace("_", "-")
301
+ };
302
+ }
303
+ function getDescription(entry) {
304
+ const description = entry.frontmatter.description;
305
+ if (typeof description === "string" && description.trim() !== "") {
306
+ return description.trim();
307
+ }
308
+ return entry.title;
309
+ }
310
+ function getPublishedTime(entry) {
311
+ return toIso(
312
+ entry.frontmatter.published ?? entry.frontmatter.date ?? entry.frontmatter.created
313
+ );
314
+ }
315
+ function getUpdatedTime(entry) {
316
+ return toIso(entry.frontmatter.updated) ?? getPublishedTime(entry);
317
+ }
318
+ function toIso(value) {
319
+ if (value instanceof Date && !Number.isNaN(value.getTime())) {
320
+ return value.toISOString();
321
+ }
322
+ if (typeof value !== "string" || value.trim() === "") return null;
323
+ const date = new Date(value);
324
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
325
+ }
326
+
327
+ // src/locations.ts
328
+ import {
329
+ resolveDefaultContentLocation
330
+ } from "@riebeckite/core";
331
+ var PLUGIN_NAME = "taxonomy";
332
+ function resolveFolderIndexLocations(entries, options, diagnostics) {
333
+ if (!options.folderIndexes) return [];
334
+ const permalinkOwners = /* @__PURE__ */ new Map();
335
+ for (const entry of entries) {
336
+ const location = resolveDefaultContentLocation(entry);
337
+ if (!permalinkOwners.has(location.permalink)) {
338
+ permalinkOwners.set(location.permalink, entry.slug);
339
+ }
340
+ }
341
+ const locations = [];
342
+ for (const entry of entries) {
343
+ const suffix = "/index";
344
+ if (!entry.slug.endsWith(suffix)) continue;
345
+ const folder = entry.slug.slice(0, -suffix.length);
346
+ if (folder === "") continue;
347
+ const target = `/${folder}`;
348
+ const owner = permalinkOwners.get(target);
349
+ if (owner && owner !== entry.slug) {
350
+ diagnostics.push({
351
+ code: "taxonomy-folder-index-collision",
352
+ severity: "warning",
353
+ pluginName: PLUGIN_NAME,
354
+ message: `Folder index "${entry.path}" already maps to a note at "${target}"; keeping the default location.`,
355
+ slug: entry.slug,
356
+ target
357
+ });
358
+ continue;
359
+ }
360
+ locations.push({
361
+ slug: entry.slug,
362
+ permalink: target,
363
+ metadata: { "taxonomy.folder": folder }
364
+ });
365
+ }
366
+ return locations;
367
+ }
368
+
369
+ // src/options.ts
370
+ var DEFAULT_TAXONOMY_TAGS_BASE_PATH = "/tags";
371
+ var DEFAULT_TAXONOMY_FOLDERS_BASE_PATH = "/folders";
372
+ var DEFAULT_TAXONOMY_MIN_ENTRIES = 1;
373
+ var DEFAULT_TAXONOMY_RELATED_LIMIT = 8;
374
+ var DEFAULT_TAXONOMY_FEED_LIMIT = 50;
375
+ var DEFAULT_TAXONOMY_CLASS_NAME = "rr-taxonomy";
376
+ var DEFAULT_TAXONOMY_DATA_ENDPOINT = "/taxonomy/index.json";
377
+ function resolveTaxonomyOptions(options = {}) {
378
+ return {
379
+ tags: options.tags ?? true,
380
+ folders: options.folders ?? true,
381
+ tagsBasePath: normalizeBasePath(
382
+ options.tagsBasePath,
383
+ DEFAULT_TAXONOMY_TAGS_BASE_PATH
384
+ ),
385
+ foldersBasePath: normalizeBasePath(
386
+ options.foldersBasePath,
387
+ DEFAULT_TAXONOMY_FOLDERS_BASE_PATH
388
+ ),
389
+ folderDepth: normalizeCount(options.folderDepth, 0),
390
+ minEntries: normalizeMinEntries(options.minEntries),
391
+ related: options.related ?? true,
392
+ relatedLimit: normalizeCount(
393
+ options.relatedLimit,
394
+ DEFAULT_TAXONOMY_RELATED_LIMIT
395
+ ),
396
+ folderIndexes: options.folderIndexes ?? false,
397
+ feeds: {
398
+ rss: options.feeds?.rss ?? true,
399
+ atom: options.feeds?.atom ?? true,
400
+ json: options.feeds?.json ?? true
401
+ },
402
+ feedLimit: normalizeCount(options.feedLimit, DEFAULT_TAXONOMY_FEED_LIMIT),
403
+ className: normalizeClassName(options.className),
404
+ dataEndpoint: normalizeDataEndpoint(options.dataEndpoint),
405
+ resolveTitle: options.resolveTitle
406
+ };
407
+ }
408
+ function normalizeBasePath(value, fallback) {
409
+ if (typeof value !== "string") return fallback;
410
+ const trimmed = value.trim().replace(/\/+$/, "");
411
+ if (trimmed === "") return "";
412
+ return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
413
+ }
414
+ function normalizeCount(value, fallback) {
415
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
416
+ }
417
+ function normalizeMinEntries(value) {
418
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : DEFAULT_TAXONOMY_MIN_ENTRIES;
419
+ }
420
+ function normalizeClassName(value) {
421
+ if (typeof value !== "string") return DEFAULT_TAXONOMY_CLASS_NAME;
422
+ const trimmed = value.trim();
423
+ return trimmed === "" ? DEFAULT_TAXONOMY_CLASS_NAME : trimmed;
424
+ }
425
+ function normalizeDataEndpoint(value) {
426
+ if (typeof value !== "string") return DEFAULT_TAXONOMY_DATA_ENDPOINT;
427
+ const trimmed = value.trim();
428
+ if (trimmed === "" || !trimmed.startsWith("/")) {
429
+ return DEFAULT_TAXONOMY_DATA_ENDPOINT;
430
+ }
431
+ return trimmed;
432
+ }
433
+
434
+ // src/pages.ts
435
+ import { escapeHtml, escapeHtmlAttribute } from "@riebeckite/core";
436
+ function renderTaxonomyPage(term, options) {
437
+ const className = escapeHtmlAttribute(options.className);
438
+ const entries = term.entries.map(
439
+ (entry) => `<li class="${className}__item"><a class="${className}__link" href="${escapeHtmlAttribute(
440
+ entry.permalink
441
+ )}">${escapeHtml(entry.title)}</a></li>`
442
+ ).join("");
443
+ const feeds = renderFeedLinks(term, className);
444
+ const related = renderRelatedTerms(term, options);
445
+ const html = `<section class="${className}" data-rr-taxonomy="${term.kind}" data-rr-taxonomy-value="${escapeHtmlAttribute(
446
+ term.value
447
+ )}"><h1 class="${className}__title">${escapeHtml(term.title)}</h1><p class="${className}__count" data-rr-taxonomy-count="${term.entries.length}">${term.entries.length}</p>${feeds}<ul class="${className}__list">${entries}</ul>${related}</section>`;
448
+ return {
449
+ title: term.title,
450
+ frontmatter: { title: term.title },
451
+ html,
452
+ headTags: buildFeedHeadTags(term)
453
+ };
454
+ }
455
+ function renderRelatedTerms(term, options) {
456
+ if (!options.related || term.kind !== "tag" || term.related.length === 0) {
457
+ return "";
458
+ }
459
+ const className = escapeHtmlAttribute(options.className);
460
+ const items = term.related.map(
461
+ (related) => `<li class="${className}__related-item"><a class="${className}__related-link" href="${escapeHtmlAttribute(
462
+ related.path
463
+ )}" data-rr-taxonomy-related-count="${related.count}">${escapeHtml(related.title)}</a></li>`
464
+ ).join("");
465
+ return `<nav class="${className}__related" aria-label="Related tags" data-rr-taxonomy-related><ul>${items}</ul></nav>`;
466
+ }
467
+ function renderFeedLinks(term, className) {
468
+ const links = [];
469
+ if (term.feeds.rss) {
470
+ links.push(
471
+ `<a class="${className}__feed" type="application/rss+xml" href="${escapeHtmlAttribute(term.feeds.rss)}">RSS</a>`
472
+ );
473
+ }
474
+ if (term.feeds.atom) {
475
+ links.push(
476
+ `<a class="${className}__feed" type="application/atom+xml" href="${escapeHtmlAttribute(term.feeds.atom)}">Atom</a>`
477
+ );
478
+ }
479
+ if (term.feeds.json) {
480
+ links.push(
481
+ `<a class="${className}__feed" type="application/feed+json" href="${escapeHtmlAttribute(term.feeds.json)}">JSON</a>`
482
+ );
483
+ }
484
+ if (links.length === 0) return "";
485
+ return `<nav class="${className}__feeds" aria-label="Feeds" data-rr-taxonomy-feeds>${links.join("")}</nav>`;
486
+ }
487
+
488
+ // src/seo.ts
489
+ import { resolvePlugins } from "@riebeckite/core";
490
+ function buildTaxonomySeo(config, term) {
491
+ const description = buildDescription(term);
492
+ const provider = resolvePlugins(config.plugins).find(
493
+ (plugin) => plugin.seo !== void 0
494
+ )?.seo;
495
+ if (provider) {
496
+ return provider.buildWebsiteSeo(config, {
497
+ title: term.title,
498
+ description,
499
+ path: term.path,
500
+ kind: term.kind === "tag" ? "tag" : "website"
501
+ });
502
+ }
503
+ const image = config.site.defaultOgImage;
504
+ return {
505
+ title: config.site.title && config.site.title !== term.title ? `${term.title} | ${config.site.title}` : term.title,
506
+ description,
507
+ canonicalUrl: buildTaxonomyAbsoluteUrl(config, term.path),
508
+ imageUrl: image ? buildTaxonomyAbsoluteUrl(config, image) : "",
509
+ type: "website",
510
+ noindex: false,
511
+ tags: term.kind === "tag" ? [term.value] : []
512
+ };
513
+ }
514
+ function buildDescription(term) {
515
+ if (term.kind === "tag") {
516
+ return `Notes tagged with ${term.title}.`;
517
+ }
518
+ return `Notes filed under ${term.title}.`;
519
+ }
520
+
521
+ // index.ts
522
+ var TAXONOMY_PLUGIN_NAME = "taxonomy";
523
+ function taxonomy(options = {}) {
524
+ const resolved = resolveTaxonomyOptions(options);
525
+ const emittedFeeds = /* @__PURE__ */ new Set();
526
+ return definePlugin({
527
+ name: TAXONOMY_PLUGIN_NAME,
528
+ options,
529
+ validateOptions: validateTaxonomyOptions,
530
+ resolveContentLocations: ({ entries, diagnostics }) => resolveFolderIndexLocations(entries, resolved, diagnostics),
531
+ endpoints: createTaxonomyEndpoints(resolved),
532
+ buildEnd(context) {
533
+ const { config, manifest } = context;
534
+ if (!config) return;
535
+ const index = buildTaxonomyIndex(manifest.publicEntries, resolved);
536
+ for (const term of [...index.tags, ...index.folders]) {
537
+ for (const format of enabledFormats(resolved)) {
538
+ const path = feedFilePath(term.path, format);
539
+ if (emittedFeeds.has(path)) continue;
540
+ emittedFeeds.add(path);
541
+ context.output.emit({
542
+ path,
543
+ content: renderTermFeed(config, term, format, resolved.feedLimit)
544
+ });
545
+ }
546
+ }
547
+ },
548
+ assets: [createStyleAsset(TAXONOMY_PLUGIN_NAME)]
549
+ });
550
+ }
551
+ var taxonomyPlugin = taxonomy;
552
+ function resolveTaxonomyOptionsFromConfig(config) {
553
+ const plugin = resolvePlugins2(config.plugins).find(
554
+ (candidate) => candidate.name === TAXONOMY_PLUGIN_NAME
555
+ );
556
+ return resolveTaxonomyOptions(
557
+ plugin?.options ?? {}
558
+ );
559
+ }
560
+ function createTaxonomyEndpoints(resolved) {
561
+ if (!resolved.tags && !resolved.folders) return [];
562
+ return [
563
+ defineEndpoint(
564
+ resolved.dataEndpoint,
565
+ ({ manifest }) => ({
566
+ json: serializeTaxonomyIndex(
567
+ buildTaxonomyIndex(manifest.publicEntries, resolved)
568
+ )
569
+ }),
570
+ { cacheControl: "public, max-age=300" }
571
+ )
572
+ ];
573
+ }
574
+ function enabledFormats(resolved) {
575
+ const formats = [];
576
+ if (resolved.feeds.rss) formats.push("rss");
577
+ if (resolved.feeds.atom) formats.push("atom");
578
+ if (resolved.feeds.json) formats.push("json");
579
+ return formats;
580
+ }
581
+ function feedFilePath(path, format) {
582
+ const base = path.replace(/^\/+/, "");
583
+ if (format === "rss") return `${base}/feed.xml`;
584
+ if (format === "atom") return `${base}/atom.xml`;
585
+ return `${base}/feed.json`;
586
+ }
587
+ function validateTaxonomyOptions(options) {
588
+ if (!options) return [];
589
+ const issues = [];
590
+ const assertBoolean = (key) => {
591
+ const value = options[key];
592
+ if (value !== void 0 && typeof value !== "boolean") {
593
+ issues.push({ path: key, message: "Expected a boolean." });
594
+ }
595
+ };
596
+ const assertNonEmptyString = (key) => {
597
+ const value = options[key];
598
+ if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) {
599
+ issues.push({ path: key, message: "Expected a non-empty string." });
600
+ }
601
+ };
602
+ const assertNonNegativeInteger = (key) => {
603
+ const value = options[key];
604
+ if (value !== void 0 && (typeof value !== "number" || !Number.isInteger(value) || value < 0)) {
605
+ issues.push({
606
+ path: key,
607
+ message: "Expected a non-negative integer."
608
+ });
609
+ }
610
+ };
611
+ for (const key of ["tags", "folders", "related", "folderIndexes"]) {
612
+ assertBoolean(key);
613
+ }
614
+ for (const key of [
615
+ "tagsBasePath",
616
+ "foldersBasePath",
617
+ "className",
618
+ "dataEndpoint"
619
+ ]) {
620
+ assertNonEmptyString(key);
621
+ }
622
+ for (const key of [
623
+ "folderDepth",
624
+ "minEntries",
625
+ "relatedLimit",
626
+ "feedLimit"
627
+ ]) {
628
+ assertNonNegativeInteger(key);
629
+ }
630
+ if (options.feeds !== void 0) {
631
+ if (typeof options.feeds !== "object" || options.feeds === null) {
632
+ issues.push({ path: "feeds", message: "Expected an object." });
633
+ } else {
634
+ for (const key of ["rss", "atom", "json"]) {
635
+ const value = options.feeds[key];
636
+ if (value !== void 0 && typeof value !== "boolean") {
637
+ issues.push({
638
+ path: `feeds.${key}`,
639
+ message: "Expected a boolean."
640
+ });
641
+ }
642
+ }
643
+ }
644
+ }
645
+ if (options.resolveTitle !== void 0 && typeof options.resolveTitle !== "function") {
646
+ issues.push({ path: "resolveTitle", message: "Expected a function." });
647
+ }
648
+ return issues;
649
+ }
650
+ export {
651
+ DEFAULT_TAXONOMY_CLASS_NAME,
652
+ DEFAULT_TAXONOMY_DATA_ENDPOINT,
653
+ DEFAULT_TAXONOMY_FEED_LIMIT,
654
+ DEFAULT_TAXONOMY_FOLDERS_BASE_PATH,
655
+ DEFAULT_TAXONOMY_MIN_ENTRIES,
656
+ DEFAULT_TAXONOMY_RELATED_LIMIT,
657
+ DEFAULT_TAXONOMY_TAGS_BASE_PATH,
658
+ TAXONOMY_PLUGIN_NAME,
659
+ buildFeedHeadTags,
660
+ buildTaxonomyAbsoluteUrl,
661
+ buildTaxonomyIndex,
662
+ buildTaxonomySeo,
663
+ renderRelatedTerms,
664
+ renderTaxonomyPage,
665
+ renderTermFeed,
666
+ resolveFolderIndexLocations,
667
+ resolveTaxonomyOptions,
668
+ resolveTaxonomyOptionsFromConfig,
669
+ serializeTaxonomyIndex,
670
+ serializeTaxonomyTerm,
671
+ slugifyTaxonomyValue,
672
+ taxonomy,
673
+ taxonomyPlugin,
674
+ toEntryReference
675
+ };
@@ -0,0 +1,17 @@
1
+ import { type ContentManifestEntry } from "@riebeckite/core";
2
+ import type { ResolvedTaxonomyOptions, TaxonomyEntryReference, TaxonomyIndex, TaxonomyIndexData, TaxonomyTerm, TaxonomyTermData } from "./types.js";
3
+ /**
4
+ * Builds the tag and folder taxonomy for the given entries. It reuses the Core
5
+ * collection contract (`buildContentCollections`), so entries are selected and
6
+ * ordered by the shared query engine and links always use the resolved
7
+ * `permalink`.
8
+ *
9
+ * `entries` should be the manifest's public view; unpublished notes never reach
10
+ * generated data or feeds.
11
+ */
12
+ export declare function buildTaxonomyIndex(entries: readonly ContentManifestEntry[], options: ResolvedTaxonomyOptions): TaxonomyIndex;
13
+ /** Projects a term into a JSON-safe shape for endpoints and client data. */
14
+ export declare function serializeTaxonomyTerm(term: TaxonomyTerm): TaxonomyTermData;
15
+ /** Projects the whole index into a JSON-safe shape. */
16
+ export declare function serializeTaxonomyIndex(index: TaxonomyIndex): TaxonomyIndexData;
17
+ export declare function toEntryReference(entry: ContentManifestEntry): TaxonomyEntryReference;
@@ -0,0 +1,10 @@
1
+ import type { ResolvedRiebeckiteConfig } from "@riebeckite/core";
2
+ import type { TaxonomyFeedFormat, TaxonomyHeadTag, TaxonomyTerm } from "./types.js";
3
+ /**
4
+ * Renders a per-term feed. Term feeds carry their own channel title and self
5
+ * link so a tag subscription is distinguishable from the site-wide feed that
6
+ * `@riebeckite/plugin-seo` owns.
7
+ */
8
+ export declare function renderTermFeed(config: ResolvedRiebeckiteConfig, term: TaxonomyTerm, format: TaxonomyFeedFormat, limit?: number): string;
9
+ /** Builds the `<link rel="alternate">` discovery tags for a term. */
10
+ export declare function buildFeedHeadTags(term: TaxonomyTerm): TaxonomyHeadTag[];