@coywolf/emdash 0.0.0-stage → 0.35.13
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/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the RSS feeds (inside the plugin context): the site feed at /feed/
|
|
3
|
+
* and term feeds at <term archive>/feed/ (see ./feed.ts). Entries come from
|
|
4
|
+
* EmDash's content API; a term feed's entries are found with one D1 query on
|
|
5
|
+
* EmDash's taxonomy tables (the content API can't filter by term). URLs come
|
|
6
|
+
* from the pack-wide resolver, so they match the sitemaps.
|
|
7
|
+
*/
|
|
8
|
+
import { type CollectionSchemaInfo, type PluginContentItem, type PluginContext, getSiteSetting } from "emdash";
|
|
9
|
+
|
|
10
|
+
import { COLLECTION_SLUG, ancestorTrail, expandPattern, hostDefaultLocale, primaryTerms, termParentMaps, urlOverride } from "../core/content-url.js";
|
|
11
|
+
import { siteName } from "../core/site.js";
|
|
12
|
+
import { workerEnv } from "../shared.js";
|
|
13
|
+
import { entryTitle, pickCollections } from "./build.js";
|
|
14
|
+
import { FEED_PATH, FEED_TAG, type FeedItem, type TermFeedRoute, buildRss, feedDate, parseFeedPath, termFeedRoute, termSegments, withDescendants } from "./feed.js";
|
|
15
|
+
import type { DiscoverySettings } from "./settings.js";
|
|
16
|
+
import { createUrlResolver, siteOrigin } from "./urls.js";
|
|
17
|
+
|
|
18
|
+
export interface FeedDocument {
|
|
19
|
+
body: string;
|
|
20
|
+
count: number;
|
|
21
|
+
/** Edge cache tags: EmDash clears a collection's tag (its slug) on every live-content write, and a taxonomy's on term edits. */
|
|
22
|
+
tags: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const str = (value: unknown) => (typeof value === "string" ? value : "");
|
|
26
|
+
|
|
27
|
+
async function db(binding: string): Promise<D1Database | null> {
|
|
28
|
+
try {
|
|
29
|
+
return ((await workerEnv())[binding] as D1Database | undefined) ?? null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Term {
|
|
36
|
+
slug: string;
|
|
37
|
+
label: string;
|
|
38
|
+
/** Archive path, e.g. /news/seo/ */
|
|
39
|
+
path: string;
|
|
40
|
+
taxonomy: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The collection and term a term feed path asks for, or null. */
|
|
44
|
+
async function findTerm(d1: D1Database, collections: CollectionSchemaInfo[], segments: string[]): Promise<{ collection: CollectionSchemaInfo; route: TermFeedRoute; term: Term; groups: string[] } | null> {
|
|
45
|
+
for (const collection of collections) {
|
|
46
|
+
const override = urlOverride(collection.slug);
|
|
47
|
+
const route = termFeedRoute(override ? expandPattern(override) : null);
|
|
48
|
+
if (!route) continue;
|
|
49
|
+
const path = termSegments(segments, route);
|
|
50
|
+
if (!path) continue;
|
|
51
|
+
const slug = path[path.length - 1];
|
|
52
|
+
const parents = (await termParentMaps(d1, [route.taxonomy])).get(route.taxonomy) ?? new Map<string, string>();
|
|
53
|
+
// {termpath}: the URL must be the term's full trail (as its entries' URLs are); {term}: just the slug.
|
|
54
|
+
if (route.hierarchical && ancestorTrail(slug, parents).join("/") !== path.join("/")) continue;
|
|
55
|
+
const slugs = withDescendants(slug, parents);
|
|
56
|
+
const defaultLocale = await hostDefaultLocale();
|
|
57
|
+
const { results } = await d1
|
|
58
|
+
.prepare(`SELECT slug, label, locale, translation_group FROM taxonomies WHERE name = ? AND slug IN (${slugs.map(() => "?").join(",")})`)
|
|
59
|
+
.bind(route.taxonomy, ...slugs)
|
|
60
|
+
.all<{ slug: string; label: string | null; locale: string | null; translation_group: string | null }>();
|
|
61
|
+
const own = results.filter((r) => r.slug === slug);
|
|
62
|
+
if (!own.length) continue;
|
|
63
|
+
const label = (own.find((r) => r.locale === defaultLocale) ?? own[0]).label || slug;
|
|
64
|
+
const groups = [...new Set(results.map((r) => r.translation_group).filter((g): g is string => Boolean(g)))];
|
|
65
|
+
return { collection, route, groups, term: { slug, label, taxonomy: route.taxonomy, path: `${route.prefix}/${path.join("/")}/` } };
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Ids of a term's published, indexable entries (default locale), newest first. */
|
|
71
|
+
async function termEntryIds(d1: D1Database, collection: string, groups: string[], limit: number): Promise<string[]> {
|
|
72
|
+
if (!groups.length || !COLLECTION_SLUG.test(collection)) return [];
|
|
73
|
+
const { results } = await d1
|
|
74
|
+
.prepare(
|
|
75
|
+
`SELECT content.id AS id FROM "ec_${collection}" AS content
|
|
76
|
+
LEFT JOIN _emdash_seo AS seo ON seo.collection = ? AND seo.content_id = content.id
|
|
77
|
+
WHERE content.status = 'published' AND content.deleted_at IS NULL AND content.published_at IS NOT NULL
|
|
78
|
+
AND coalesce(seo.seo_no_index, 0) = 0
|
|
79
|
+
AND (content.locale IS NULL OR content.locale = ?)
|
|
80
|
+
AND content.translation_group IN (SELECT entry_id FROM content_taxonomies WHERE collection = ? AND taxonomy_id IN (${groups.map(() => "?").join(",")}))
|
|
81
|
+
ORDER BY content.published_at DESC LIMIT ?`,
|
|
82
|
+
)
|
|
83
|
+
.bind(collection, await hostDefaultLocale(), collection, ...groups, limit)
|
|
84
|
+
.all<{ id: string }>();
|
|
85
|
+
return results.map((r) => r.id);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The newest `limit` published, indexable entries (default locale) of a collection: a page of 100 at a time (the content API's most). */
|
|
89
|
+
async function newest(ctx: PluginContext, collection: string, limit: number): Promise<PluginContentItem[]> {
|
|
90
|
+
if (!ctx.content) return [];
|
|
91
|
+
const locale = await hostDefaultLocale();
|
|
92
|
+
const out: PluginContentItem[] = [];
|
|
93
|
+
let cursor: string | undefined;
|
|
94
|
+
for (let pages = 0; pages < 10 && out.length < limit; pages++) {
|
|
95
|
+
const page = await ctx.content.list(collection, { limit: 100, cursor, orderBy: { publishedAt: "desc" }, where: { status: "published" } });
|
|
96
|
+
for (const item of page.items) {
|
|
97
|
+
if (item.publishedAt && !item.seo?.noIndex && (!item.locale || item.locale === locale)) out.push(item);
|
|
98
|
+
if (out.length >= limit) break;
|
|
99
|
+
}
|
|
100
|
+
cursor = page.hasMore ? page.cursor : undefined;
|
|
101
|
+
if (!cursor) break;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Labels of terms by slug (default locale first). */
|
|
107
|
+
async function termLabels(d1: D1Database | null, taxonomy: string, slugs: string[]): Promise<Map<string, string>> {
|
|
108
|
+
const out = new Map<string, string>();
|
|
109
|
+
if (!d1 || !slugs.length) return out;
|
|
110
|
+
try {
|
|
111
|
+
const defaultLocale = await hostDefaultLocale();
|
|
112
|
+
const { results } = await d1
|
|
113
|
+
.prepare(`SELECT slug, label, locale FROM taxonomies WHERE name = ? AND slug IN (${slugs.map(() => "?").join(",")})`)
|
|
114
|
+
.bind(taxonomy, ...slugs)
|
|
115
|
+
.all<{ slug: string; label: string | null; locale: string | null }>();
|
|
116
|
+
for (const r of results) if (r.label && (!out.has(r.slug) || r.locale === defaultLocale)) out.set(r.slug, r.label);
|
|
117
|
+
} catch {
|
|
118
|
+
// No taxonomy table: items go out without a category.
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The site logo (or favicon) as the channel image, when it's a GIF, JPEG or PNG (all RSS allows). */
|
|
124
|
+
async function channelImage(origin: string): Promise<string | null> {
|
|
125
|
+
for (const key of ["logo", "favicon"] as const) {
|
|
126
|
+
const ref = (await getSiteSetting(key).catch(() => undefined)) as { url?: string; contentType?: string } | undefined;
|
|
127
|
+
if (ref?.url && /^image\/(png|jpeg|gif)$/.test(ref.contentType ?? "")) return new URL(ref.url, `${origin}/`).href;
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The feed for a path (/feed/ or <term archive>/feed/), or null when there's none at that path. */
|
|
133
|
+
export async function buildFeed(ctx: PluginContext, settings: DiscoverySettings, pathname: string, database = "DB"): Promise<FeedDocument | null> {
|
|
134
|
+
const segments = parseFeedPath(pathname);
|
|
135
|
+
if (!segments) return null;
|
|
136
|
+
const { collections: chosen, items: limit } = settings.feed;
|
|
137
|
+
// An empty selection lists nothing (like the news sitemap).
|
|
138
|
+
const collections = chosen.length ? await pickCollections(ctx, chosen) : [];
|
|
139
|
+
if (!collections.length) return null;
|
|
140
|
+
const origin = siteOrigin(ctx);
|
|
141
|
+
const d1 = await db(database);
|
|
142
|
+
|
|
143
|
+
let entries: Array<{ collection: CollectionSchemaInfo; item: PluginContentItem }> = [];
|
|
144
|
+
let term: Term | null = null;
|
|
145
|
+
if (segments.length === 0) {
|
|
146
|
+
for (const collection of collections) for (const item of await newest(ctx, collection.slug, limit)) entries.push({ collection, item });
|
|
147
|
+
} else {
|
|
148
|
+
if (!settings.feed.termFeeds || !d1) return null;
|
|
149
|
+
const found = await findTerm(d1, collections, segments);
|
|
150
|
+
if (!found) return null;
|
|
151
|
+
term = found.term;
|
|
152
|
+
const ids = await termEntryIds(d1, found.collection.slug, found.groups, limit);
|
|
153
|
+
const items = await Promise.all(ids.map((id) => ctx.content?.get(found.collection.slug, id) ?? null));
|
|
154
|
+
entries = items.filter((item): item is PluginContentItem => Boolean(item && item.status === "published" && item.publishedAt)).map((item) => ({ collection: found.collection, item }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
entries = entries
|
|
158
|
+
.filter(({ item }) => !item.seo?.noIndex)
|
|
159
|
+
.sort((a, b) => (feedDate(b.item.publishedAt)?.getTime() ?? 0) - (feedDate(a.item.publishedAt)?.getTime() ?? 0));
|
|
160
|
+
|
|
161
|
+
const resolver = createUrlResolver(ctx);
|
|
162
|
+
const items: Array<FeedItem & { changed: string }> = [];
|
|
163
|
+
for (const collection of collections) {
|
|
164
|
+
const own = entries.filter((e) => e.collection.slug === collection.slug).map((e) => e.item);
|
|
165
|
+
if (!own.length) continue;
|
|
166
|
+
const urls = await resolver.urls(collection, own);
|
|
167
|
+
const taxonomy = term?.taxonomy ?? termFeedRoute(urlOverride(collection.slug) ? expandPattern(urlOverride(collection.slug)!) : null)?.taxonomy ?? "category";
|
|
168
|
+
const primary = await primaryTerms(ctx, collection.slug, own.map((i) => i.id), [taxonomy]).catch(() => new Map<string, Record<string, string>>());
|
|
169
|
+
const labels = await termLabels(d1, taxonomy, [...new Set([...primary.values()].map((t) => t[taxonomy]).filter(Boolean))]);
|
|
170
|
+
const bylines = new Map<string, string>();
|
|
171
|
+
if (ctx.bylines) {
|
|
172
|
+
for (let i = 0; i < own.length; i += 100) {
|
|
173
|
+
const credits = await ctx.bylines.getEntriesBylines(collection.slug, own.slice(i, i + 100).map((x) => x.id)).catch(() => []);
|
|
174
|
+
for (const c of credits) {
|
|
175
|
+
const name = c.bylines[0]?.byline.displayName;
|
|
176
|
+
if (name) bylines.set(c.entryId, name);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const item of own) {
|
|
181
|
+
const url = urls.get(item.id);
|
|
182
|
+
if (!url || !item.publishedAt) continue;
|
|
183
|
+
const slug = primary.get(item.id)?.[taxonomy];
|
|
184
|
+
items.push({
|
|
185
|
+
title: entryTitle(collection, item),
|
|
186
|
+
url,
|
|
187
|
+
publishedAt: item.publishedAt,
|
|
188
|
+
creator: bylines.get(item.id) ?? null,
|
|
189
|
+
category: slug ? (labels.get(slug) ?? null) : null,
|
|
190
|
+
description: str(item.data.excerpt).trim() || str(item.seo?.description).trim(),
|
|
191
|
+
changed: item.updatedAt || item.publishedAt,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
items.sort((a, b) => (feedDate(b.publishedAt)?.getTime() ?? 0) - (feedDate(a.publishedAt)?.getTime() ?? 0));
|
|
196
|
+
const listed = items.slice(0, limit);
|
|
197
|
+
const newestChange = listed
|
|
198
|
+
.map((i) => feedDate(i.changed))
|
|
199
|
+
.filter((d): d is Date => Boolean(d))
|
|
200
|
+
.sort((a, b) => b.getTime() - a.getTime())[0];
|
|
201
|
+
|
|
202
|
+
const name = (await siteName(ctx)) || new URL(origin).host;
|
|
203
|
+
const tagline = str(await getSiteSetting("tagline").catch(() => undefined)).trim();
|
|
204
|
+
const image = await channelImage(origin);
|
|
205
|
+
const body = buildRss({
|
|
206
|
+
title: term ? `${term.label} Archives - ${name}` : name,
|
|
207
|
+
link: `${origin}${term ? term.path : "/"}`,
|
|
208
|
+
selfUrl: `${origin}${term ? `${term.path}feed/` : FEED_PATH}`,
|
|
209
|
+
description: tagline,
|
|
210
|
+
language: ctx.site.locale,
|
|
211
|
+
lastBuildDate: (newestChange ?? feedDate(listed[0]?.publishedAt))?.toISOString() ?? null,
|
|
212
|
+
image: image ? { url: image, title: name, link: `${origin}/` } : null,
|
|
213
|
+
items: listed.map(({ changed: _, ...item }) => item),
|
|
214
|
+
});
|
|
215
|
+
const taxonomies = new Set(collections.map((c) => termFeedRoute(urlOverride(c.slug) ? expandPattern(urlOverride(c.slug)!) : null)?.taxonomy ?? "category"));
|
|
216
|
+
return { body, count: listed.length, tags: [FEED_TAG, ...collections.map((c) => c.slug), ...[...taxonomies].map((t) => `emdash:taxonomy:${t}`)] };
|
|
217
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS feed (pure part): feed URLs and the RSS 2.0 document. No imports, so
|
|
3
|
+
* `node --test` can load it.
|
|
4
|
+
*
|
|
5
|
+
* - `/feed/` lists the newest entries of the chosen collections, at
|
|
6
|
+
* WordPress's feed URL.
|
|
7
|
+
* - `<term archive>/feed/` lists one term's entries (and its child terms'),
|
|
8
|
+
* WordPress's category feeds. Only for a collection whose URL override is
|
|
9
|
+
* `<prefix>/{term:<taxonomy>|…}/{slug}/` or `{termpath:…}` (e.g.
|
|
10
|
+
* `/{termpath:category|uncategorized}/{slug}/` → `/news/seo/feed/`), since
|
|
11
|
+
* the term's archive is then the entry URL without the slug.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const FEED_PATH = "/feed/";
|
|
15
|
+
/** Edge cache tag on every feed, cleared when the feed settings are saved. */
|
|
16
|
+
export const FEED_TAG = "coywolf:feed";
|
|
17
|
+
/** How long the edge keeps a feed when nothing clears it sooner (changes that clear no tag, such as a byline edit). */
|
|
18
|
+
export const FEED_EDGE_MAX_AGE = 3600;
|
|
19
|
+
/** Items per feed: the default, and the most allowed. */
|
|
20
|
+
export const FEED_DEFAULT_ITEMS = 20;
|
|
21
|
+
export const FEED_MAX_ITEMS = 100;
|
|
22
|
+
/** Deepest term path followed in a feed URL. */
|
|
23
|
+
const MAX_SEGMENTS = 8;
|
|
24
|
+
/** A term slug: letters (any script), digits, "-" and "_", not starting with "_" or "-" (so never /_emdash/…). */
|
|
25
|
+
const SEGMENT = /^[\p{L}\p{N}][\p{L}\p{N}\p{M}_-]*$/u;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The term path a feed URL asks for: [] for the site feed (`/feed/`), the
|
|
29
|
+
* segments before `/feed/` otherwise, null when the path isn't a feed URL.
|
|
30
|
+
* `/feed` without the slash isn't one: the site redirects it to `/feed/`, as
|
|
31
|
+
* it does for its other pages.
|
|
32
|
+
*/
|
|
33
|
+
export function parseFeedPath(pathname: string): string[] | null {
|
|
34
|
+
const match = /^((?:\/[^/]+)*)\/feed\/$/.exec(pathname);
|
|
35
|
+
if (!match) return null;
|
|
36
|
+
const segments = match[1].split("/").filter(Boolean);
|
|
37
|
+
if (segments.length > MAX_SEGMENTS) return null;
|
|
38
|
+
let decoded: string[];
|
|
39
|
+
try {
|
|
40
|
+
decoded = segments.map((s) => decodeURIComponent(s));
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return decoded.every((s) => SEGMENT.test(s)) ? decoded : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TermFeedRoute {
|
|
48
|
+
taxonomy: string;
|
|
49
|
+
/** {termpath:…}: the URL has the term's ancestors in front. */
|
|
50
|
+
hierarchical: boolean;
|
|
51
|
+
/** Static path segments before the term ("" or e.g. "/blog"). */
|
|
52
|
+
prefix: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Where a collection's term feeds live, from its URL override (after
|
|
57
|
+
* expandPattern, so `{category}` is already `{term:category|uncategorized}`).
|
|
58
|
+
* Null when the pattern isn't "static prefix, term, slug".
|
|
59
|
+
*/
|
|
60
|
+
export function termFeedRoute(pattern: string | null): TermFeedRoute | null {
|
|
61
|
+
if (!pattern) return null;
|
|
62
|
+
const match = /^((?:\/[A-Za-z0-9_-]+)*)\/\{(term|termpath):([A-Za-z0-9_-]+)(?:\|[^{}/]*)?\}\/\{slug\}\/?$/.exec(pattern);
|
|
63
|
+
return match ? { prefix: match[1], hierarchical: match[2] === "termpath", taxonomy: match[3] } : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The term path in a feed URL's segments, once the route's prefix is removed (null: not under the prefix). */
|
|
67
|
+
export function termSegments(segments: string[], route: TermFeedRoute): string[] | null {
|
|
68
|
+
const prefix = route.prefix.split("/").filter(Boolean);
|
|
69
|
+
if (segments.length <= prefix.length || prefix.some((p, i) => p !== segments[i])) return null;
|
|
70
|
+
const rest = segments.slice(prefix.length);
|
|
71
|
+
return route.hierarchical || rest.length === 1 ? rest : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A term and all its descendants, from child → parent slugs. */
|
|
75
|
+
export function withDescendants(slug: string, parents: ReadonlyMap<string, string>): string[] {
|
|
76
|
+
const children = new Map<string, string[]>();
|
|
77
|
+
for (const [child, parent] of parents) children.set(parent, [...(children.get(parent) ?? []), child]);
|
|
78
|
+
const out = [slug];
|
|
79
|
+
for (let i = 0; i < out.length && out.length < 5000; i++) {
|
|
80
|
+
for (const child of children.get(out[i]) ?? []) if (!out.includes(child)) out.push(child);
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface FeedItem {
|
|
86
|
+
title: string;
|
|
87
|
+
url: string;
|
|
88
|
+
/** ISO 8601, or SQLite's "YYYY-MM-DD HH:MM:SS" in UTC. */
|
|
89
|
+
publishedAt: string;
|
|
90
|
+
creator?: string | null;
|
|
91
|
+
category?: string | null;
|
|
92
|
+
description?: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface FeedChannel {
|
|
96
|
+
title: string;
|
|
97
|
+
/** The page the feed is for (the home page, or the term's archive). */
|
|
98
|
+
link: string;
|
|
99
|
+
/** The feed's own URL (atom:link rel="self"). */
|
|
100
|
+
selfUrl: string;
|
|
101
|
+
description: string;
|
|
102
|
+
language: string;
|
|
103
|
+
/** Newest change among the items (ISO); now when there are none. */
|
|
104
|
+
lastBuildDate?: string | null;
|
|
105
|
+
image?: { url: string; title: string; link: string } | null;
|
|
106
|
+
items: FeedItem[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const TZ_SUFFIX = /([zZ]|[+-]\d{2}(:?\d{2})?)$/;
|
|
110
|
+
|
|
111
|
+
/** Parse a stored timestamp, treating offset-less values as UTC (as EmDash does). */
|
|
112
|
+
export function feedDate(value: string | null | undefined): Date | null {
|
|
113
|
+
if (!value) return null;
|
|
114
|
+
let normalized = value.trim();
|
|
115
|
+
if (normalized.includes(" ") && !normalized.includes("T")) normalized = normalized.replace(" ", "T");
|
|
116
|
+
if (!TZ_SUFFIX.test(normalized)) normalized += "Z";
|
|
117
|
+
const time = Date.parse(normalized);
|
|
118
|
+
return Number.isNaN(time) ? null : new Date(time);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function escapeXml(text: string): string {
|
|
122
|
+
return text
|
|
123
|
+
.replace(/&/g, "&")
|
|
124
|
+
.replace(/</g, "<")
|
|
125
|
+
.replace(/>/g, ">")
|
|
126
|
+
.replace(/"/g, """)
|
|
127
|
+
.replace(/'/g, "'")
|
|
128
|
+
// Characters XML 1.0 forbids outright.
|
|
129
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point.
|
|
130
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g, "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** RSS's <language>: the site locale as a language tag ("en-US"), "en" when unset or malformed. */
|
|
134
|
+
export function feedLanguage(locale: string | null | undefined): string {
|
|
135
|
+
const value = (locale ?? "").trim().replace(/_/g, "-");
|
|
136
|
+
return /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(value) ? value : "en";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** RSS 2.0 with atom:link rel="self" and dc:creator (as WordPress's feeds have). */
|
|
140
|
+
export function buildRss(channel: FeedChannel, now: Date = new Date()): string {
|
|
141
|
+
const lines = [
|
|
142
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
143
|
+
'<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">',
|
|
144
|
+
"\t<channel>",
|
|
145
|
+
`\t\t<title>${escapeXml(channel.title)}</title>`,
|
|
146
|
+
`\t\t<atom:link href="${escapeXml(channel.selfUrl)}" rel="self" type="application/rss+xml" />`,
|
|
147
|
+
`\t\t<link>${escapeXml(channel.link)}</link>`,
|
|
148
|
+
`\t\t<description>${escapeXml(channel.description)}</description>`,
|
|
149
|
+
`\t\t<lastBuildDate>${(feedDate(channel.lastBuildDate) ?? now).toUTCString()}</lastBuildDate>`,
|
|
150
|
+
`\t\t<language>${escapeXml(feedLanguage(channel.language))}</language>`,
|
|
151
|
+
];
|
|
152
|
+
if (channel.image) {
|
|
153
|
+
lines.push(
|
|
154
|
+
"\t\t<image>",
|
|
155
|
+
`\t\t\t<url>${escapeXml(channel.image.url)}</url>`,
|
|
156
|
+
`\t\t\t<title>${escapeXml(channel.image.title)}</title>`,
|
|
157
|
+
`\t\t\t<link>${escapeXml(channel.image.link)}</link>`,
|
|
158
|
+
"\t\t</image>",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
for (const item of channel.items) {
|
|
162
|
+
const published = feedDate(item.publishedAt);
|
|
163
|
+
if (!published) continue;
|
|
164
|
+
lines.push("\t\t<item>", `\t\t\t<title>${escapeXml(item.title)}</title>`, `\t\t\t<link>${escapeXml(item.url)}</link>`);
|
|
165
|
+
if (item.creator) lines.push(`\t\t\t<dc:creator>${escapeXml(item.creator)}</dc:creator>`);
|
|
166
|
+
lines.push(`\t\t\t<pubDate>${published.toUTCString()}</pubDate>`);
|
|
167
|
+
if (item.category) lines.push(`\t\t\t<category>${escapeXml(item.category)}</category>`);
|
|
168
|
+
lines.push(`\t\t\t<guid isPermaLink="true">${escapeXml(item.url)}</guid>`, `\t\t\t<description>${escapeXml(item.description ?? "")}</description>`, "\t\t</item>");
|
|
169
|
+
}
|
|
170
|
+
lines.push("\t</channel>", "</rss>", "");
|
|
171
|
+
return lines.join("\n");
|
|
172
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IndexNow (pure part): key handling, the submission payload, and a batcher
|
|
3
|
+
* that collects changed URLs for a short window, dedupes them, and submits
|
|
4
|
+
* them in one request.
|
|
5
|
+
*
|
|
6
|
+
* Coywolf SEO pinged Bing once per URL; here several changes in quick
|
|
7
|
+
* succession (a save followed by a publish, a bulk edit) go out as one POST,
|
|
8
|
+
* which every IndexNow endpoint accepts (up to 10,000 URLs).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const INDEXNOW_ENDPOINTS = {
|
|
12
|
+
"api.indexnow.org": "https://api.indexnow.org/indexnow",
|
|
13
|
+
"www.bing.com": "https://www.bing.com/indexnow",
|
|
14
|
+
} as const;
|
|
15
|
+
export type IndexNowEndpoint = keyof typeof INDEXNOW_ENDPOINTS;
|
|
16
|
+
|
|
17
|
+
/** IndexNow keys: 8–128 characters of a–z, A–Z, 0–9 and "-". */
|
|
18
|
+
export const KEY_PATTERN = /^[a-zA-Z0-9-]{8,128}$/;
|
|
19
|
+
export const MAX_URLS_PER_REQUEST = 10_000;
|
|
20
|
+
|
|
21
|
+
/** A new 32-character lowercase hex key. */
|
|
22
|
+
export function generateKey(): string {
|
|
23
|
+
const bytes = new Uint8Array(16);
|
|
24
|
+
crypto.getRandomValues(bytes);
|
|
25
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The key a `/<key>.txt` request asks for, or null when the path isn't key-shaped. */
|
|
29
|
+
export function keyFromPath(pathname: string): string | null {
|
|
30
|
+
const match = /^\/([a-zA-Z0-9-]{8,128})\.txt$/.exec(pathname);
|
|
31
|
+
return match ? match[1] : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface IndexNowPayload {
|
|
35
|
+
host: string;
|
|
36
|
+
key: string;
|
|
37
|
+
keyLocation: string;
|
|
38
|
+
urlList: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The POST body for a batch. URLs on other hosts are dropped: IndexNow
|
|
43
|
+
* rejects a batch whose URLs don't all belong to the key's host.
|
|
44
|
+
*/
|
|
45
|
+
export function buildPayload(siteUrl: string, key: string, urls: string[]): IndexNowPayload | null {
|
|
46
|
+
const site = new URL(siteUrl);
|
|
47
|
+
const urlList = [...new Set(urls)].filter((url) => {
|
|
48
|
+
try {
|
|
49
|
+
return new URL(url).host === site.host;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
if (!urlList.length) return null;
|
|
55
|
+
return {
|
|
56
|
+
host: site.host,
|
|
57
|
+
key,
|
|
58
|
+
keyLocation: `${site.origin}/${key}.txt`,
|
|
59
|
+
urlList: urlList.slice(0, MAX_URLS_PER_REQUEST),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface BatcherOptions {
|
|
64
|
+
/** How long to wait for more URLs before submitting. */
|
|
65
|
+
windowMs: number;
|
|
66
|
+
/** Keep work alive past the response (EmDash's `after()` / waitUntil). */
|
|
67
|
+
defer: (task: () => Promise<void>) => void;
|
|
68
|
+
sleep?: (ms: number) => Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type BatchSender = (urls: string[]) => Promise<void>;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Collects URLs for `windowMs`, then hands the deduped list to the most
|
|
75
|
+
* recent sender. One pending batch per isolate.
|
|
76
|
+
*/
|
|
77
|
+
export class IndexNowBatcher {
|
|
78
|
+
private pending = new Set<string>();
|
|
79
|
+
private sender: BatchSender | null = null;
|
|
80
|
+
private scheduled = false;
|
|
81
|
+
private readonly sleep: (ms: number) => Promise<void>;
|
|
82
|
+
private readonly options: BatcherOptions;
|
|
83
|
+
|
|
84
|
+
constructor(options: BatcherOptions) {
|
|
85
|
+
this.options = options;
|
|
86
|
+
this.sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
get size(): number {
|
|
90
|
+
return this.pending.size;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
add(urls: string | string[], sender: BatchSender): void {
|
|
94
|
+
for (const url of Array.isArray(urls) ? urls : [urls]) if (url) this.pending.add(url);
|
|
95
|
+
if (!this.pending.size) return;
|
|
96
|
+
this.sender = sender;
|
|
97
|
+
if (this.scheduled) return;
|
|
98
|
+
this.scheduled = true;
|
|
99
|
+
this.options.defer(async () => {
|
|
100
|
+
await this.sleep(this.options.windowMs);
|
|
101
|
+
await this.flush();
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Submit what's pending now. */
|
|
106
|
+
async flush(): Promise<void> {
|
|
107
|
+
this.scheduled = false;
|
|
108
|
+
const urls = [...this.pending];
|
|
109
|
+
const sender = this.sender;
|
|
110
|
+
this.pending.clear();
|
|
111
|
+
this.sender = null;
|
|
112
|
+
if (urls.length && sender) await sender(urls);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llms.txt (pure part), per https://llmstxt.org/ and Coywolf SEO's layout:
|
|
3
|
+
* an H1 with the site name, a blockquote summary, a short detail paragraph,
|
|
4
|
+
* one H2 "file list" per collection linking each entry (to its Markdown
|
|
5
|
+
* source when per-page Markdown is on), and an "## Optional" section for the
|
|
6
|
+
* long tail.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Links per collection in the main lists; the rest move to "## Optional". */
|
|
10
|
+
export const MAIN_LIMIT = 100;
|
|
11
|
+
export const DEFAULT_MAX_ENTRIES = 500;
|
|
12
|
+
|
|
13
|
+
export interface LlmsEntry {
|
|
14
|
+
title: string;
|
|
15
|
+
url: string;
|
|
16
|
+
note?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LlmsSection {
|
|
20
|
+
label: string;
|
|
21
|
+
entries: LlmsEntry[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface LlmsInput {
|
|
25
|
+
/** Site name (falls back to the site URL). */
|
|
26
|
+
name: string;
|
|
27
|
+
siteUrl: string;
|
|
28
|
+
/** Blockquote summary: the site tagline, or a default. */
|
|
29
|
+
summary?: string | null;
|
|
30
|
+
/** Detail paragraph under the summary. */
|
|
31
|
+
intro?: string | null;
|
|
32
|
+
/** Whether links point at Markdown sources (changes the default intro). */
|
|
33
|
+
markdownLinks: boolean;
|
|
34
|
+
sections: LlmsSection[];
|
|
35
|
+
/** Total links across the file. Default 500. */
|
|
36
|
+
maxEntries?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_SUMMARY = "A curated, agent-readable index of this site's public content.";
|
|
40
|
+
export const DEFAULT_INTRO_MARKDOWN = "Each link below points at the Markdown source of a page, so an agent can read the content directly.";
|
|
41
|
+
export const DEFAULT_INTRO_HTML = "Each link below points at a page on this site.";
|
|
42
|
+
|
|
43
|
+
/** Plain text for llms.txt: one line, no Markdown link breakers. */
|
|
44
|
+
export function llmsText(text: string): string {
|
|
45
|
+
return text.replace(/\s+/g, " ").trim().replace(/([[\]])/g, "\\$1");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function linkLine(entry: LlmsEntry): string {
|
|
49
|
+
const url = entry.url.replace(/[()\s]/g, (c) => encodeURIComponent(c));
|
|
50
|
+
const note = entry.note ? llmsText(entry.note) : "";
|
|
51
|
+
return `- [${llmsText(entry.title) || url}](${url})${note ? `: ${note}` : ""}\n`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildLlmsTxt(input: LlmsInput): string {
|
|
55
|
+
const max = Math.max(1, input.maxEntries ?? DEFAULT_MAX_ENTRIES);
|
|
56
|
+
const name = llmsText(input.name) || input.siteUrl;
|
|
57
|
+
const summary = input.summary?.trim() ? input.summary.replace(/\s+/g, " ").trim() : DEFAULT_SUMMARY;
|
|
58
|
+
const intro = input.intro?.trim() ? input.intro.trim() : input.markdownLinks ? DEFAULT_INTRO_MARKDOWN : DEFAULT_INTRO_HTML;
|
|
59
|
+
|
|
60
|
+
let out = `# ${name}\n\n> ${summary}\n\n${intro}\n`;
|
|
61
|
+
let used = 0;
|
|
62
|
+
const optional: LlmsEntry[] = [];
|
|
63
|
+
|
|
64
|
+
for (const section of input.sections) {
|
|
65
|
+
if (!section.entries.length || used >= max) continue;
|
|
66
|
+
const main = section.entries.slice(0, Math.min(MAIN_LIMIT, max - used));
|
|
67
|
+
used += main.length;
|
|
68
|
+
out += `\n## ${llmsText(section.label)}\n\n`;
|
|
69
|
+
for (const entry of main) out += linkLine(entry);
|
|
70
|
+
optional.push(...section.entries.slice(main.length));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const rest = optional.slice(0, Math.max(0, max - used));
|
|
74
|
+
if (rest.length) {
|
|
75
|
+
out += "\n## Optional\n\n";
|
|
76
|
+
for (const entry of rest) out += linkLine(entry);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|