@michaelthielemann/kestrel-delivery-static 0.1.0 → 5.0.2

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/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # delivery/static
2
+ Static delivery with a publish status per document and locale. Requires `content@1`, `site@1`,
3
+ `renderer@1` (the site's own renderer, e.g. the Nuxt layer), `blobstore@1` (filesystem or S3) and
4
+ `persistence@1`. `delivery.publish:<type>` after `content.create/update`: for every locale whose
5
+ own `statusField` equals `publishedValue` the document (fields with fallback, internal links
6
+ resolved to public paths via `site.resolveLinks`, `_links` attached) is rendered in each
7
+ configured format and stored under the path `site.pathOf` returns (default locale
8
+ unprefixed unless `prefixPrimary`, `home` slug → `/`); other locales are removed. Outcome per
9
+ locale in `delivery_publish_status` (`live` | `error` | `draft`, `path`, `error`, `publishedAt`); a failed
10
+ render keeps the previous live output. `delivery.unpublish:<type>` after remove, `delivery.readStatus:<type>`
11
+ for the editor's second lamp, `delivery.publishAll:<type>` to re-render everything.
12
+ Formats come from the renderer (`html`, `pdf`, …) – `formats: ["html", "pdf"]` writes both.
13
+ Assets the renderer returns (`assets: [{ path: "/_nuxt/app.js", … }]` – hydration bundles, CSS)
14
+ are stored under `<prefix><path>` once per process; they are never removed on unpublish.
15
+ With `media` configured, text output is scanned for `<publicPath>/<id>/file` and `<publicPath>/<id>/variants/<size>.<ext>` (HTML-escaped slashes, e.g. `&#x2F;`, are not matched) and copied to
16
+ `<prefix><target><folder>/<filename>[.<size>.<ext>]`, rewritten in place; each target is copied once per process, so a file re-uploaded under the same name is refreshed in the export only by `publishAll`.
17
+ Unresolved references are logged and left untouched.
18
+ `delivery.exportLlms` (always on; `llms` defaults to paths instead of URLs and no full file) (no argument; append it after `delivery.publish`/`unpublish`/`publishAll`
19
+ and after saving the settings) writes `<prefix>llms.txt` per [llmstxt.org](https://llmstxt.org) from the `live` status rows
20
+ of every delivered type: `# <settings title>`, `> <settings description>`, one `## <headings[type] ?? type>` section with
21
+ `- [<seo.title || title>](<siteUrl><path>): <seo.description>` per page; `seo.noindex === true` excludes a page. `full: true`
22
+ also writes `<prefix>llms-full.txt` – the stored HTML of every page, restricted to `<main>`, converted with turndown
23
+ (headings shifted three levels, root-relative links and images made absolute with `siteUrl`) – and needs `"html"` in
24
+ `formats`; `full: false` removes a stale `llms-full.txt`. The result gains `llms: { entries, full }`.
25
+ Every `Delivery` method returns a `Result`; a failure is an `Err(KestrelError)`, never an exception.
26
+ A render failure (`RENDER_FAILED` or `TRANSIENT` from `renderer@1`) is not a step failure: it is
27
+ recorded on the status row as `state: "error"` with the message and the remaining locales are still
28
+ published. Only wiring bugs throw (unknown type, unconfigured type, unsafe media/asset path, a media
29
+ row whose blob is gone).
30
+
31
+ | Step | reads | writes | codes |
32
+ |---|---|---|---|
33
+ | `delivery.publish:<type>` | `result.id` | `result` (`document`, `delivery`) | TRANSIENT |
34
+ | `delivery.unpublish:<type>` | `params.id` | – | VALIDATION (missing id), TRANSIENT |
35
+ | `delivery.readStatus:<type>` | `params.id` | `result` | VALIDATION (missing id), TRANSIENT |
36
+ | `delivery.publishAll:<type>` | – | `result` | TRANSIENT |
37
+ | `delivery.exportLlms` | – | `result.llms` | TRANSIENT |
38
+
39
+ Not included: sitemap, CDN invalidation.
package/dist/impl.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { z } from "zod";
2
+ import type { Blobstore } from "@michaelthielemann/kestrel-contracts/blobstore";
3
+ import type { Content } from "@michaelthielemann/kestrel-contracts/content";
4
+ import { type KestrelError, type Result } from "@michaelthielemann/kestrel-contracts/errors";
5
+ import type { Document, Persistence } from "@michaelthielemann/kestrel-contracts/persistence";
6
+ import type { Renderer } from "@michaelthielemann/kestrel-contracts/renderer";
7
+ import type { Site } from "@michaelthielemann/kestrel-contracts/site";
8
+ import type { Logger } from "@michaelthielemann/kestrel/logger";
9
+ import type { configSchema } from "./module.ts";
10
+ export declare const STATUS = "delivery_publish_status";
11
+ export type Config = z.output<typeof configSchema>;
12
+ export type TypeConfig = Config["types"][string];
13
+ export type MediaConfig = NonNullable<Config["media"]>;
14
+ export type State = "live" | "error" | "draft";
15
+ export type DeliveryError = KestrelError<"TRANSIENT">;
16
+ export interface PublishStatus extends Document {
17
+ type: string;
18
+ docId: string;
19
+ locale: string;
20
+ state: State;
21
+ path: string | null;
22
+ error: string | null;
23
+ publishedAt: number | null;
24
+ updatedAt: number;
25
+ }
26
+ export interface Delivery {
27
+ publish(type: string, id: string): Promise<Result<PublishStatus[], DeliveryError>>;
28
+ unpublish(type: string, id: string): Promise<Result<number, DeliveryError>>;
29
+ status(type: string, id: string): Promise<Result<PublishStatus[], DeliveryError>>;
30
+ publishAll(type: string): Promise<Result<{
31
+ documents: number;
32
+ live: number;
33
+ errors: number;
34
+ }, DeliveryError>>;
35
+ exportLlms(): Promise<Result<{
36
+ entries: number;
37
+ full: boolean;
38
+ }, DeliveryError>>;
39
+ }
40
+ export declare function keyFor(prefix: string, path: string, extension: string): string;
41
+ export interface MediaMatch {
42
+ raw: string;
43
+ id: string;
44
+ size?: string;
45
+ }
46
+ export declare function rewriteMedia(html: string, publicPath: string, resolve: (match: MediaMatch) => string | undefined): string;
47
+ export declare function createDeliveryStatic(config: Config, deps: {
48
+ content: Content;
49
+ site: Site;
50
+ renderer: Renderer;
51
+ blobs: Blobstore;
52
+ db: Persistence;
53
+ logger: Logger;
54
+ }, now?: () => number): Promise<Delivery>;
package/dist/impl.js ADDED
@@ -0,0 +1,377 @@
1
+ import { extname } from "node:path";
2
+ import { err, isErr, ok } from "@michaelthielemann/kestrel-contracts/errors";
3
+ import { exportLlms, validateLlmsConfig } from "./llms.js";
4
+ export const STATUS = "delivery_publish_status";
5
+ const PAGE = 100;
6
+ // Collections are addressed by generated id or by delivery's own filters and every locale comes from
7
+ // the content model, so only a transient failure of a dependency is expected here.
8
+ function transientOnly(error, source) {
9
+ if (error.code !== "TRANSIENT")
10
+ throw new Error(`delivery/static: unexpected ${source} failure ${error.code}: ${error.message}`);
11
+ return error;
12
+ }
13
+ function bytes(data) {
14
+ return typeof data === "string" ? new TextEncoder().encode(data) : data;
15
+ }
16
+ export function keyFor(prefix, path, extension) {
17
+ const dir = path === "/" ? "" : `${path.slice(1)}/`;
18
+ return `${prefix}${dir}index.${extension}`;
19
+ }
20
+ function escapeRegExp(value) {
21
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
22
+ }
23
+ // The negative lookaheads pin the match to a segment boundary: "/file-x" or "/fileabc" must not
24
+ // match "/file", and "thumb.webp.bak" must not match "thumb.webp" — but "?" and "/" (query strings,
25
+ // trailing path segments) are not in the class, so those still terminate a match correctly.
26
+ function mediaPattern(publicPath) {
27
+ return new RegExp(`${escapeRegExp(publicPath)}/([0-9a-f-]{36})/(?:file(?![A-Za-z0-9._-])|variants/([a-z][a-z0-9-]*)\\.([a-z0-9]+)(?![A-Za-z0-9._-]))`, "g");
28
+ }
29
+ function mediaKey(id, size) {
30
+ return size === undefined ? `${id}|file` : `${id}|${size}`;
31
+ }
32
+ function mediaMatches(html, publicPath) {
33
+ const re = mediaPattern(publicPath);
34
+ const out = [];
35
+ let m;
36
+ while ((m = re.exec(html)) !== null)
37
+ out.push({ raw: m[0], id: m[1], ...(m[2] === undefined ? {} : { size: m[2] }) });
38
+ return out;
39
+ }
40
+ // Pure so it can be unit-tested without a Persistence/Blobstore fake: resolution of ids and copying
41
+ // of blobs happens beforehand, this only substitutes text it already has the answer for.
42
+ export function rewriteMedia(html, publicPath, resolve) {
43
+ return html.replace(mediaPattern(publicPath), (raw, id, size) => resolve({ raw, id, ...(size === undefined ? {} : { size }) }) ?? raw);
44
+ }
45
+ // Mirrors the check the renderer-asset branch already applies to asset.path: media rows are normally
46
+ // sanitized by media-default, but delivery-static must not trust that blindly when building blob keys.
47
+ function assertSafeMediaSegment(value, label, id) {
48
+ if (value.startsWith("/") || value.split("/").some((p) => p === ".."))
49
+ throw new Error(`delivery/static: invalid media ${label} ${JSON.stringify(value)} for ${id}`);
50
+ }
51
+ async function findManyIn(db, collection, field, values) {
52
+ const out = [];
53
+ for (let i = 0; i < values.length; i += 200) {
54
+ const chunk = values.slice(i, i + 200);
55
+ for (let offset = 0;; offset += 500) {
56
+ const page = await db.findMany(collection, { [field]: { in: chunk } }, { limit: 500, offset });
57
+ if (isErr(page))
58
+ return err(transientOnly(page.error, "persistence@1"));
59
+ out.push(...page.value.items);
60
+ if (page.value.items.length < 500)
61
+ break;
62
+ }
63
+ }
64
+ return ok(out);
65
+ }
66
+ export async function createDeliveryStatic(config, deps, now = Date.now) {
67
+ const { content, site, renderer, blobs, db, logger } = deps;
68
+ const model = content.model();
69
+ const locales = model.locales ?? [];
70
+ for (const [type, tc] of Object.entries(config.types)) {
71
+ const fields = model.types[type]?.fields;
72
+ if (!fields)
73
+ throw new Error(`delivery/static: unknown content type "${type}"`);
74
+ for (const f of [tc.slugField, tc.statusField])
75
+ if (!(f in fields))
76
+ throw new Error(`delivery/static: field "${f}" does not exist on "${type}"`);
77
+ }
78
+ const supported = new Set(renderer.formats());
79
+ for (const f of config.formats)
80
+ if (!supported.has(f))
81
+ throw new Error(`delivery/static: renderer does not support format "${f}" (has ${[...supported].join(", ") || "none"})`);
82
+ validateLlmsConfig(config.llms, model, config.formats);
83
+ const prepared = await db.ensureCollection(STATUS, { type: "string", docId: "string", locale: "string", state: "string", path: "string", error: "string", publishedAt: "number", updatedAt: "number" });
84
+ if (isErr(prepared))
85
+ throw new Error(`delivery/static: cannot prepare "${STATUS}": ${prepared.error.message}`);
86
+ const uploadedAssets = new Set();
87
+ const typeConfig = (type) => {
88
+ const tc = config.types[type];
89
+ if (!tc)
90
+ throw new Error(`delivery/static: type "${type}" is not configured for delivery`);
91
+ return tc;
92
+ };
93
+ const statusRows = async (type, id) => {
94
+ const page = await db.findMany(STATUS, { type, docId: id }, { sort: { locale: "asc" } });
95
+ if (isErr(page))
96
+ return err(transientOnly(page.error, "persistence@1"));
97
+ return ok(page.value.items);
98
+ };
99
+ const liveSources = async (type) => {
100
+ const out = [];
101
+ for (let offset = 0;; offset += 500) {
102
+ const page = await db.findMany(STATUS, { type, state: "live" }, { sort: { path: "asc" }, limit: 500, offset });
103
+ if (isErr(page))
104
+ return err(transientOnly(page.error, "persistence@1"));
105
+ for (const row of page.value.items) {
106
+ if (row.path === null)
107
+ continue;
108
+ const path = row.path;
109
+ out.push({
110
+ type,
111
+ path,
112
+ locale: row.locale,
113
+ docId: row.docId,
114
+ html: async () => {
115
+ const blob = await blobs.get(keyFor(config.prefix, path, "html"));
116
+ if (isErr(blob))
117
+ return err(transientOnly(blob.error, "blobstore@1"));
118
+ return ok(blob.value ? new TextDecoder().decode(blob.value.data) : null);
119
+ },
120
+ });
121
+ }
122
+ if (page.value.items.length < 500)
123
+ break;
124
+ }
125
+ return ok(out);
126
+ };
127
+ const setStatus = async (type, id, locale, patch) => {
128
+ const existing = await db.findOne(STATUS, { type, docId: id, locale });
129
+ if (isErr(existing))
130
+ return err(transientOnly(existing.error, "persistence@1"));
131
+ const at = now();
132
+ const row = existing.value
133
+ ? await db.updateOne(STATUS, existing.value.id, { ...patch, updatedAt: at })
134
+ : await db.createOne(STATUS, { type, docId: id, locale, state: "draft", path: null, error: null, publishedAt: null, ...patch, updatedAt: at });
135
+ if (isErr(row))
136
+ return err(transientOnly(row.error, "persistence@1"));
137
+ return ok(row.value);
138
+ };
139
+ const removeBlobs = async (path) => {
140
+ if (path === null)
141
+ return ok();
142
+ for (const format of config.formats) {
143
+ const removed = await blobs.remove(keyFor(config.prefix, path, format));
144
+ if (isErr(removed))
145
+ return err(transientOnly(removed.error, "blobstore@1"));
146
+ }
147
+ return ok();
148
+ };
149
+ // Resolution (findMany) and copying (blobs.get/put) both need to happen before the pure rewriteMedia()
150
+ // substitution, so this batches them up front and then feeds a synchronous resolver into it.
151
+ const rewriteAndCopyMedia = async (html, logged) => {
152
+ const media = config.media;
153
+ if (!media)
154
+ return ok(html);
155
+ const matches = mediaMatches(html, media.publicPath);
156
+ if (matches.length === 0)
157
+ return ok(html);
158
+ const ids = [...new Set(matches.map((m) => m.id))];
159
+ const mediaRows = await findManyIn(db, media.collection, "id", ids);
160
+ if (isErr(mediaRows))
161
+ return mediaRows;
162
+ const mediaById = new Map(mediaRows.value.map((r) => [r.id, r]));
163
+ const variantIds = [...new Set(matches.filter((m) => m.size !== undefined).map((m) => m.id))];
164
+ const variantByKey = new Map();
165
+ if (variantIds.length > 0) {
166
+ const variantRows = await findManyIn(db, media.variants, "mediaId", variantIds);
167
+ if (isErr(variantRows))
168
+ return variantRows;
169
+ for (const r of variantRows.value)
170
+ variantByKey.set(`${r.mediaId}|${r.size}`, r);
171
+ }
172
+ const resolutions = new Map();
173
+ const copies = [];
174
+ for (const match of matches) {
175
+ const key = mediaKey(match.id, match.size);
176
+ if (resolutions.has(key))
177
+ continue;
178
+ const row = mediaById.get(match.id);
179
+ if (!row) {
180
+ if (!logged.has(key)) {
181
+ logger.error("delivery/static: media reference not exported", { id: match.id, path: match.raw });
182
+ logged.add(key);
183
+ }
184
+ resolutions.set(key, undefined);
185
+ continue;
186
+ }
187
+ assertSafeMediaSegment(row.folder, "folder", match.id);
188
+ assertSafeMediaSegment(row.filename, "filename", match.id);
189
+ const folderPrefix = row.folder ? `${row.folder}/` : "";
190
+ if (match.size === undefined) {
191
+ const dest = `${media.target}${folderPrefix}${row.filename}`;
192
+ copies.push({ source: row.key, dest, id: match.id });
193
+ resolutions.set(key, `/${dest}`);
194
+ continue;
195
+ }
196
+ const variant = variantByKey.get(`${match.id}|${match.size}`);
197
+ if (!variant || variant.state !== "done") {
198
+ if (!logged.has(key)) {
199
+ logger.error("delivery/static: media reference not exported", { id: match.id, size: match.size, path: match.raw });
200
+ logged.add(key);
201
+ }
202
+ resolutions.set(key, undefined);
203
+ continue;
204
+ }
205
+ const ext = extname(variant.key).replace(/^\./, "");
206
+ const dest = `${media.target}${folderPrefix}${row.filename}.${match.size}.${ext}`;
207
+ copies.push({ source: variant.key, dest, id: match.id });
208
+ resolutions.set(key, `/${dest}`);
209
+ }
210
+ for (const { source, dest, id } of copies) {
211
+ const destKey = `${config.prefix}${dest}`;
212
+ if (uploadedAssets.has(destKey))
213
+ continue;
214
+ const blob = await blobs.get(source);
215
+ if (isErr(blob))
216
+ return err(transientOnly(blob.error, "blobstore@1"));
217
+ // a row without its blob is not "unresolved" (which leaves the URL untouched) — the HTML would
218
+ // otherwise be rewritten to a path nothing ever writes, so this fails the publish like any other
219
+ // copy failure instead.
220
+ if (!blob.value)
221
+ throw new Error(`delivery/static: media blob ${source} missing for ${id}`);
222
+ const stored = await blobs.put(destKey, { data: blob.value.data, contentType: blob.value.contentType });
223
+ if (isErr(stored))
224
+ return err(transientOnly(stored.error, "blobstore@1"));
225
+ uploadedAssets.add(destKey);
226
+ }
227
+ return ok(rewriteMedia(html, media.publicPath, (match) => resolutions.get(mediaKey(match.id, match.size))));
228
+ };
229
+ const publishLocale = async (type, id, locale) => {
230
+ const tc = typeConfig(type);
231
+ const key = locale ?? "";
232
+ const found = await db.findOne(STATUS, { type, docId: id, locale: key });
233
+ if (isErr(found))
234
+ return err(transientOnly(found.error, "persistence@1"));
235
+ const previous = found.value;
236
+ const unpublished = async () => {
237
+ const removed = await removeBlobs(previous?.path ?? null);
238
+ if (isErr(removed))
239
+ return removed;
240
+ return setStatus(type, id, key, { state: "draft", path: null, error: null });
241
+ };
242
+ const read = await content.get(type, id, locale === undefined ? {} : { locale });
243
+ if (isErr(read))
244
+ return err(transientOnly(read.error, "content@1"));
245
+ const strict = read.value;
246
+ if (!strict)
247
+ return unpublished();
248
+ if (strict[tc.statusField] !== tc.publishedValue)
249
+ return unpublished();
250
+ let fetched = strict;
251
+ if (config.fallback && locale !== undefined) {
252
+ const merged = await content.get(type, id, { locale, fallback: true });
253
+ if (isErr(merged))
254
+ return err(transientOnly(merged.error, "content@1"));
255
+ if (merged.value !== null)
256
+ fetched = merged.value;
257
+ }
258
+ const rules = { home: tc.home, slugField: tc.slugField, prefixPrimary: config.prefixPrimary, fallback: config.fallback, filter: { [tc.statusField]: tc.publishedValue } };
259
+ const options = { ...(locale === undefined ? {} : { locale }), rules };
260
+ const linked = await site.resolveLinks(type, fetched, options);
261
+ if (isErr(linked))
262
+ return err(transientOnly(linked.error, "site@1"));
263
+ const doc = linked.value;
264
+ const path = site.pathOf(type, doc, options);
265
+ if (path === null)
266
+ return setStatus(type, id, key, { state: "error", error: `no ${tc.slugField}` });
267
+ const failed = (message) => setStatus(type, id, key, { state: "error", path: previous?.state === "live" ? previous.path : null, error: message });
268
+ const loggedMedia = new Set();
269
+ try {
270
+ for (const format of config.formats) {
271
+ const rendered = await renderer.render({ type, id, ...(locale === undefined ? {} : { locale }), path, format, document: doc });
272
+ if (isErr(rendered))
273
+ return failed(rendered.error.message);
274
+ const out = rendered.value;
275
+ let data = out.data;
276
+ if (config.media && (typeof out.data === "string" || out.contentType.startsWith("text/"))) {
277
+ const html = typeof out.data === "string" ? out.data : new TextDecoder().decode(out.data);
278
+ const rewritten = await rewriteAndCopyMedia(html, loggedMedia);
279
+ if (isErr(rewritten))
280
+ return failed(rewritten.error.message);
281
+ data = rewritten.value;
282
+ }
283
+ const stored = await blobs.put(keyFor(config.prefix, path, out.extension), { data: bytes(data), contentType: out.contentType });
284
+ if (isErr(stored))
285
+ return failed(stored.error.message);
286
+ for (const asset of out.assets ?? []) {
287
+ const assetPath = asset.path.replace(/^\/+/, "");
288
+ if (assetPath === "" || assetPath.split("/").some((p) => p === ".."))
289
+ throw new Error(`delivery/static: invalid asset path ${JSON.stringify(asset.path)}`);
290
+ const key = `${config.prefix}${assetPath}`;
291
+ if (!uploadedAssets.has(key)) {
292
+ const put = await blobs.put(key, { data: bytes(asset.data), contentType: asset.contentType });
293
+ if (isErr(put))
294
+ return failed(put.error.message);
295
+ uploadedAssets.add(key);
296
+ }
297
+ }
298
+ }
299
+ if (previous?.path && previous.path !== path) {
300
+ const removed = await removeBlobs(previous.path);
301
+ if (isErr(removed))
302
+ return failed(removed.error.message);
303
+ }
304
+ return setStatus(type, id, key, { state: "live", path, error: null, publishedAt: now() });
305
+ }
306
+ catch (error) {
307
+ return failed(error instanceof Error ? error.message : String(error));
308
+ }
309
+ };
310
+ const publish = async (type, id) => {
311
+ typeConfig(type);
312
+ const targets = locales.length > 0 ? locales : [undefined];
313
+ const out = [];
314
+ for (const locale of targets) {
315
+ const status = await publishLocale(type, id, locale);
316
+ if (isErr(status))
317
+ return status;
318
+ out.push(status.value);
319
+ }
320
+ return ok(out);
321
+ };
322
+ return {
323
+ publish,
324
+ async unpublish(type, id) {
325
+ typeConfig(type);
326
+ const rows = await statusRows(type, id);
327
+ if (isErr(rows))
328
+ return rows;
329
+ for (const row of rows.value) {
330
+ const removed = await removeBlobs(row.path);
331
+ if (isErr(removed))
332
+ return removed;
333
+ }
334
+ const deleted = await db.deleteMany(STATUS, { type, docId: id });
335
+ if (isErr(deleted))
336
+ return err(transientOnly(deleted.error, "persistence@1"));
337
+ return ok(deleted.value);
338
+ },
339
+ async status(type, id) {
340
+ typeConfig(type);
341
+ return statusRows(type, id);
342
+ },
343
+ async publishAll(type) {
344
+ typeConfig(type);
345
+ uploadedAssets.clear();
346
+ let documents = 0;
347
+ let live = 0;
348
+ let errors = 0;
349
+ for (let offset = 0;; offset += PAGE) {
350
+ const page = await content.list(type, {}, { limit: PAGE, offset });
351
+ if (isErr(page))
352
+ return err(transientOnly(page.error, "content@1"));
353
+ for (const doc of page.value.items) {
354
+ documents += 1;
355
+ const statuses = await publish(type, doc.id);
356
+ if (isErr(statuses))
357
+ return statuses;
358
+ for (const s of statuses.value) {
359
+ if (s.state === "live")
360
+ live += 1;
361
+ if (s.state === "error")
362
+ errors += 1;
363
+ }
364
+ }
365
+ if (page.value.items.length < PAGE)
366
+ break;
367
+ }
368
+ return ok({ documents, live, errors });
369
+ },
370
+ async exportLlms() {
371
+ const exported = await exportLlms(config.llms, { content, blobs, logger, prefix: config.prefix, fallback: config.fallback, defaultLocale: model.defaultLocale, hasLocales: locales.length > 0, types: Object.keys(config.types), sources: liveSources });
372
+ if (isErr(exported))
373
+ return err(transientOnly(exported.error, "llms export"));
374
+ return ok(exported.value);
375
+ },
376
+ };
377
+ }
package/dist/llms.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import type { Blobstore } from "@michaelthielemann/kestrel-contracts/blobstore";
2
+ import type { Content, ContentModel } from "@michaelthielemann/kestrel-contracts/content";
3
+ import { type KestrelError, type Result } from "@michaelthielemann/kestrel-contracts/errors";
4
+ import type { Logger } from "@michaelthielemann/kestrel/logger";
5
+ export interface LlmsSettingsConfig {
6
+ type: string;
7
+ titleField: string;
8
+ descriptionField: string;
9
+ }
10
+ export interface LlmsConfig {
11
+ siteUrl?: string | undefined;
12
+ full: boolean;
13
+ settings: LlmsSettingsConfig;
14
+ titleField: string;
15
+ seoField: string;
16
+ headings: Record<string, string>;
17
+ }
18
+ export interface LlmsEntry {
19
+ title: string;
20
+ url: string;
21
+ description?: string;
22
+ }
23
+ export interface LlmsSection {
24
+ heading: string;
25
+ entries: LlmsEntry[];
26
+ }
27
+ export interface LlmsFullPage extends LlmsEntry {
28
+ body: string;
29
+ }
30
+ export interface LlmsFullSection {
31
+ heading: string;
32
+ pages: LlmsFullPage[];
33
+ }
34
+ export declare const LLMS_KEY = "llms.txt";
35
+ export declare const LLMS_FULL_KEY = "llms-full.txt";
36
+ export declare const LLMS_CONTENT_TYPE = "text/plain; charset=utf-8";
37
+ export declare function buildLlmsTxt(opts: {
38
+ siteName: string;
39
+ siteDescription?: string;
40
+ sections: LlmsSection[];
41
+ }): string;
42
+ export declare function buildLlmsFullTxt(opts: {
43
+ siteName: string;
44
+ siteDescription?: string;
45
+ sections: LlmsFullSection[];
46
+ }): string;
47
+ export declare function extractMain(html: string): string | null;
48
+ export declare function absolutize(url: string | null, siteUrl: string | undefined): string | null;
49
+ export declare function htmlToMarkdown(html: string, options?: {
50
+ siteUrl?: string;
51
+ headingOffset?: number;
52
+ }): string;
53
+ export interface LlmsSource {
54
+ type: string;
55
+ path: string;
56
+ locale: string;
57
+ docId: string;
58
+ html(): Promise<Result<string | null, KestrelError>>;
59
+ }
60
+ export interface LlmsDeps {
61
+ content: Content;
62
+ blobs: Blobstore;
63
+ logger: Logger;
64
+ prefix: string;
65
+ fallback: boolean;
66
+ defaultLocale: string | undefined;
67
+ hasLocales: boolean;
68
+ types: string[];
69
+ sources(type: string): Promise<Result<LlmsSource[], KestrelError>>;
70
+ }
71
+ export declare function validateLlmsConfig(config: LlmsConfig, model: ContentModel, formats: string[]): void;
72
+ export declare function exportLlms(config: LlmsConfig, deps: LlmsDeps): Promise<Result<{
73
+ entries: number;
74
+ full: boolean;
75
+ }, KestrelError>>;