@pylonsync/functions 0.4.29 → 0.5.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/dist/ssr-form-runtime.d.ts +2 -0
- package/dist/ssr-runtime.d.ts +36 -1
- package/package.json +1 -1
- package/src/hydrationAppName.test.ts +44 -0
- package/src/ssr-client-bundler.ts +8 -0
- package/src/ssr-form-runtime.ts +35 -12
- package/src/ssr-llms.test.ts +175 -0
- package/src/ssr-runtime.ts +97 -3
|
@@ -9,6 +9,8 @@ export interface HandleFormMessage {
|
|
|
9
9
|
params: Record<string, string>;
|
|
10
10
|
search_params: Record<string, string>;
|
|
11
11
|
form: Record<string, string | string[]>;
|
|
12
|
+
/** The raw request body, as sent. Empty for GET. */
|
|
13
|
+
body: string;
|
|
12
14
|
headers: Record<string, string>;
|
|
13
15
|
cookies: Record<string, string>;
|
|
14
16
|
auth: {
|
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -598,6 +598,41 @@ export interface Robots {
|
|
|
598
598
|
}
|
|
599
599
|
/** Serialize sitemap entries to a sitemaps.org 0.9 XML document. */
|
|
600
600
|
export declare function serializeSitemap(entries: Sitemap | undefined): string;
|
|
601
|
+
/** One link in an `llms.txt` section. */
|
|
602
|
+
export interface LlmsLink {
|
|
603
|
+
title: string;
|
|
604
|
+
url: string;
|
|
605
|
+
/** Short note after the link — what an agent finds there. */
|
|
606
|
+
notes?: string;
|
|
607
|
+
}
|
|
608
|
+
/** An H2-delimited section of file links. */
|
|
609
|
+
export interface LlmsSection {
|
|
610
|
+
title: string;
|
|
611
|
+
links: LlmsLink[];
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Return type of a default export in `app/llms.ts`, serialized to
|
|
615
|
+
* <https://llmstxt.org> format.
|
|
616
|
+
*
|
|
617
|
+
* The spec's order is fixed and load-bearing, because the file is parsed by
|
|
618
|
+
* "standard programmatic-based tools": an H1 title, a blockquote summary, free
|
|
619
|
+
* prose with NO headings, then H2 sections of markdown links.
|
|
620
|
+
*/
|
|
621
|
+
export interface LlmsTxt {
|
|
622
|
+
/** H1 — the site or project name. The one required element. */
|
|
623
|
+
title: string;
|
|
624
|
+
/** The blockquote under it: what this is, in one or two sentences. */
|
|
625
|
+
summary?: string;
|
|
626
|
+
/**
|
|
627
|
+
* Prose paragraphs between the summary and the first section. Headings are
|
|
628
|
+
* not allowed here — the parser reads the first H2 as the start of the link
|
|
629
|
+
* lists, so a heading in this block would swallow the rest of the file.
|
|
630
|
+
*/
|
|
631
|
+
details?: string | string[];
|
|
632
|
+
sections?: LlmsSection[];
|
|
633
|
+
}
|
|
634
|
+
/** Serialize an {@link LlmsTxt} to llmstxt.org format. */
|
|
635
|
+
export declare function serializeLlms(doc: LlmsTxt | undefined): string;
|
|
601
636
|
/** Serialize a robots config to robots.txt text. */
|
|
602
637
|
export declare function serializeRobots(robots: Robots | undefined): string;
|
|
603
638
|
/**
|
|
@@ -606,7 +641,7 @@ export declare function serializeRobots(robots: Robots | undefined): string;
|
|
|
606
641
|
* 500 with a short plain-text message (so a broken sitemap doesn't wedge the
|
|
607
642
|
* runner). 1-hour cache — sitemaps/robots change rarely; tune via a CDN.
|
|
608
643
|
*/
|
|
609
|
-
export declare function handleDataRoute(msg: RenderRouteMessage, kind: "sitemap" | "robots", send: Send): Promise<void>;
|
|
644
|
+
export declare function handleDataRoute(msg: RenderRouteMessage, kind: "sitemap" | "robots" | "llms", send: Send): Promise<void>;
|
|
610
645
|
/**
|
|
611
646
|
* Import an `opengraph-image.tsx`, call its default export, render the
|
|
612
647
|
* returned `ImageResponse` (or raw React element) to a PNG via Satori +
|
package/package.json
CHANGED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Regression: the browser client namespaces its storage by the app's manifest
|
|
2
|
+
// name, but the name only lives server-side. The SSR runtime surfaces it as
|
|
3
|
+
// PYLON_APP_NAME and buildHydrationTail forwards it into __PYLON_DATA__ as `app`
|
|
4
|
+
// so the client can `configureClient({ appName })` at hydrate — before any token
|
|
5
|
+
// read. These pin that the field appears exactly when the env is set.
|
|
6
|
+
|
|
7
|
+
import { expect, test } from "bun:test";
|
|
8
|
+
|
|
9
|
+
import { buildHydrationTail } from "./ssr-runtime";
|
|
10
|
+
|
|
11
|
+
const base = {
|
|
12
|
+
component: "app/page.tsx",
|
|
13
|
+
layouts: [] as string[],
|
|
14
|
+
props: {},
|
|
15
|
+
ssrData: {},
|
|
16
|
+
manifestRoute: null,
|
|
17
|
+
publicPrefix: "/_pylon/build/",
|
|
18
|
+
manifestErr: null,
|
|
19
|
+
dataOnly: true, // return just the __PYLON_DATA__ script
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function withEnv(value: string | undefined, fn: () => void) {
|
|
23
|
+
const prev = process.env.PYLON_APP_NAME;
|
|
24
|
+
if (value === undefined) delete process.env.PYLON_APP_NAME;
|
|
25
|
+
else process.env.PYLON_APP_NAME = value;
|
|
26
|
+
try {
|
|
27
|
+
fn();
|
|
28
|
+
} finally {
|
|
29
|
+
if (prev === undefined) delete process.env.PYLON_APP_NAME;
|
|
30
|
+
else process.env.PYLON_APP_NAME = prev;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("injects app from PYLON_APP_NAME", () => {
|
|
35
|
+
withEnv("revtrail", () => {
|
|
36
|
+
expect(buildHydrationTail(base)).toContain('"app":"revtrail"');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("omits app when PYLON_APP_NAME is unset", () => {
|
|
41
|
+
withEnv(undefined, () => {
|
|
42
|
+
expect(buildHydrationTail(base)).not.toContain('"app"');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -330,6 +330,7 @@ const CLIENT_RUNTIME_SOURCE = `// Generated by Pylon SSR (Phase 2 client runtime
|
|
|
330
330
|
|
|
331
331
|
import { createElement } from "react";
|
|
332
332
|
import { hydrateRoot } from "react-dom/client";
|
|
333
|
+
import { configureClient } from "@pylonsync/react";
|
|
333
334
|
import { createPylonBoundary, nearestBoundaryComponent } from "./client-boundary";
|
|
334
335
|
import { LOADING_MODULES } from "./loading-registry";
|
|
335
336
|
import { createNavPayloadCache } from "./nav-cache";
|
|
@@ -672,6 +673,13 @@ export function hydrate(component, Page, Layouts) {
|
|
|
672
673
|
// of all prefetching. In flight from here, it resolves during hydration.
|
|
673
674
|
void loadManifest();
|
|
674
675
|
const data = readPylonData();
|
|
676
|
+
// Namespace client storage by the app's manifest name (injected into
|
|
677
|
+
// __PYLON_DATA__ as \`app\`) BEFORE any db hook / auth helper reads a token.
|
|
678
|
+
// Without this every app on a shared origin (all localhost:4321 in dev)
|
|
679
|
+
// shares the default \`pylon_token\` and clobbers each other's sessions.
|
|
680
|
+
// configureClient runs once here; it also migrates a legacy default-keyspace
|
|
681
|
+
// token so an existing session survives the switch.
|
|
682
|
+
if (data && data.app) configureClient({ appName: data.app });
|
|
675
683
|
// First hydrate: the entry's component MATCHES the SSR'd page.
|
|
676
684
|
// Establish the root + install the click + popstate handlers
|
|
677
685
|
// exactly once.
|
package/src/ssr-form-runtime.ts
CHANGED
|
@@ -6,14 +6,19 @@
|
|
|
6
6
|
// POST-redirect-GET: write something, then `response.redirect("/x?ok=1")`
|
|
7
7
|
// (303 by default here) so the no-JS browser follows with a GET.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
// shaping the reply through `response`, it returns
|
|
9
|
+
// ANY handler may instead RETURN a raw response —
|
|
11
10
|
// { body, contentType?, status?, headers? }
|
|
12
|
-
// which is streamed verbatim
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// — which is streamed verbatim, with no React render and no hydration tail.
|
|
12
|
+
// For `GET` that is the normal shape (dynamic RSS/Atom, XML, text, JSON: the
|
|
13
|
+
// GET analogue of `app/sitemap.ts`/`robots.ts` at an arbitrary path, where the
|
|
14
|
+
// default status is 200 rather than the form default of 303).
|
|
15
|
+
//
|
|
16
|
+
// The same return works on POST/PUT/PATCH/DELETE, because an endpoint that
|
|
17
|
+
// answers a machine has to answer with a body: a JSON API, a webhook receiver
|
|
18
|
+
// that must echo a challenge, a JSON-RPC endpoint such as MCP. Without it the
|
|
19
|
+
// only reply a non-GET route could make was a redirect, which is right for a
|
|
20
|
+
// browser form and wrong for everything else. Returning nothing keeps the
|
|
21
|
+
// POST-redirect-GET behavior, so existing handlers are untouched.
|
|
17
22
|
import {
|
|
18
23
|
makeResponseController,
|
|
19
24
|
PylonRouteControl,
|
|
@@ -35,6 +40,8 @@ export interface HandleFormMessage {
|
|
|
35
40
|
params: Record<string, string>;
|
|
36
41
|
search_params: Record<string, string>;
|
|
37
42
|
form: Record<string, string | string[]>;
|
|
43
|
+
/** The raw request body, as sent. Empty for GET. */
|
|
44
|
+
body: string;
|
|
38
45
|
headers: Record<string, string>;
|
|
39
46
|
cookies: Record<string, string>;
|
|
40
47
|
auth: {
|
|
@@ -97,6 +104,9 @@ export async function handleForm(
|
|
|
97
104
|
const response = makeResponseController(responseState, 303);
|
|
98
105
|
const req = {
|
|
99
106
|
form: makeFormFields(msg.form ?? {}),
|
|
107
|
+
// The exact bytes. `form` is only populated for urlencoded bodies, so a
|
|
108
|
+
// JSON API / JSON-RPC / signature-verifying webhook handler reads this.
|
|
109
|
+
body: msg.body ?? "",
|
|
100
110
|
params: msg.params,
|
|
101
111
|
searchParams: msg.search_params,
|
|
102
112
|
auth: msg.auth,
|
|
@@ -156,10 +166,18 @@ export async function handleForm(
|
|
|
156
166
|
|
|
157
167
|
try {
|
|
158
168
|
const out = await handler(req);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
169
|
+
// A returned object is a RAW response, whatever the method. GET always
|
|
170
|
+
// takes this path (its return value IS the reply, even when empty); the
|
|
171
|
+
// other methods take it only when the handler actually returned one, so a
|
|
172
|
+
// form handler that returns void still gets POST-redirect-GET.
|
|
173
|
+
const returnedRaw =
|
|
174
|
+
out != null &&
|
|
175
|
+
typeof out === "object" &&
|
|
176
|
+
("body" in out || "contentType" in out || "status" in out || "headers" in out);
|
|
177
|
+
if (method === "GET" || returnedRaw) {
|
|
178
|
+
// Stream `out.body` with the handler's content-type/status/headers
|
|
179
|
+
// (merged with anything set via the response controller). No React, no
|
|
180
|
+
// hydration tail — verbatim bytes, like sitemap/robots.
|
|
163
181
|
const raw = (out ?? {}) as {
|
|
164
182
|
body?: unknown;
|
|
165
183
|
contentType?: string;
|
|
@@ -174,7 +192,12 @@ export async function handleForm(
|
|
|
174
192
|
for (const [k, v] of Object.entries(raw.headers ?? {})) {
|
|
175
193
|
extra[k.toLowerCase()] = String(v);
|
|
176
194
|
}
|
|
177
|
-
|
|
195
|
+
// A non-GET handler's response state still defaults to 303 (the form
|
|
196
|
+
// default). A raw return that names no status means 200 — a JSON reply
|
|
197
|
+
// with an accidental 303 and no Location is a broken response.
|
|
198
|
+
const rawStatus =
|
|
199
|
+
raw.status ??
|
|
200
|
+
(method === "GET" || responseState.status !== 303 ? responseState.status : 200);
|
|
178
201
|
send({
|
|
179
202
|
type: "response_start",
|
|
180
203
|
call_id: msg.call_id,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Tests for the app/llms.ts → /llms.txt data-route convention: the pure
|
|
2
|
+
// serializer (where the llmstxt.org element order and the newline-escaping
|
|
3
|
+
// bugs live) plus handleDataRoute end-to-end.
|
|
4
|
+
//
|
|
5
|
+
// The format is parsed by tools, not just read by models, so the tests assert
|
|
6
|
+
// on exact document structure rather than on "contains the word".
|
|
7
|
+
|
|
8
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
handleDataRoute,
|
|
14
|
+
serializeLlms,
|
|
15
|
+
type RenderRouteMessage,
|
|
16
|
+
} from "./ssr-runtime";
|
|
17
|
+
|
|
18
|
+
describe("serializeLlms", () => {
|
|
19
|
+
test("emits the spec's element order", () => {
|
|
20
|
+
const txt = serializeLlms({
|
|
21
|
+
title: "Acme",
|
|
22
|
+
summary: "Invoicing for freelancers.",
|
|
23
|
+
details: ["Use Acme to issue an invoice.", "Free tier, no card."],
|
|
24
|
+
sections: [
|
|
25
|
+
{
|
|
26
|
+
title: "Docs",
|
|
27
|
+
links: [
|
|
28
|
+
{ title: "API", url: "https://acme.com/api", notes: "REST + webhooks" },
|
|
29
|
+
{ title: "CLI", url: "https://acme.com/cli" },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{ title: "Optional", links: [{ title: "Blog", url: "https://acme.com/blog" }] },
|
|
33
|
+
],
|
|
34
|
+
});
|
|
35
|
+
expect(txt).toBe(
|
|
36
|
+
[
|
|
37
|
+
"# Acme",
|
|
38
|
+
"",
|
|
39
|
+
"> Invoicing for freelancers.",
|
|
40
|
+
"",
|
|
41
|
+
"Use Acme to issue an invoice.",
|
|
42
|
+
"",
|
|
43
|
+
"Free tier, no card.",
|
|
44
|
+
"",
|
|
45
|
+
"## Docs",
|
|
46
|
+
"",
|
|
47
|
+
"- [API](https://acme.com/api): REST + webhooks",
|
|
48
|
+
"- [CLI](https://acme.com/cli)",
|
|
49
|
+
"",
|
|
50
|
+
"## Optional",
|
|
51
|
+
"",
|
|
52
|
+
"- [Blog](https://acme.com/blog)",
|
|
53
|
+
"",
|
|
54
|
+
].join("\n"),
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("title alone is a valid document", () => {
|
|
59
|
+
expect(serializeLlms({ title: "Acme" })).toBe("# Acme\n");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("no title → empty, rather than a headless document", () => {
|
|
63
|
+
expect(serializeLlms(undefined)).toBe("");
|
|
64
|
+
expect(serializeLlms({ title: " " } as any)).toBe("");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("newlines inside a summary or link cannot break the structure", () => {
|
|
68
|
+
// A multi-line summary would end the blockquote after the first line and
|
|
69
|
+
// silently turn the rest into prose.
|
|
70
|
+
const txt = serializeLlms({
|
|
71
|
+
title: "Acme",
|
|
72
|
+
summary: "Line one.\nLine two.",
|
|
73
|
+
sections: [
|
|
74
|
+
{
|
|
75
|
+
title: "Docs",
|
|
76
|
+
links: [{ title: "A\nB", url: "https://acme.com/a", notes: "x\ny" }],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
});
|
|
80
|
+
expect(txt).toContain("> Line one. Line two.");
|
|
81
|
+
expect(txt).toContain("- [A B](https://acme.com/a): x y");
|
|
82
|
+
expect(txt.split("\n").filter((l) => l.startsWith(">")).length).toBe(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("headings in the details block are stripped", () => {
|
|
86
|
+
// The first H2 marks where link sections begin; a heading in the prose
|
|
87
|
+
// would swallow everything after it.
|
|
88
|
+
const txt = serializeLlms({
|
|
89
|
+
title: "Acme",
|
|
90
|
+
details: "## Sneaky\nreal prose",
|
|
91
|
+
});
|
|
92
|
+
expect(txt).not.toContain("## Sneaky");
|
|
93
|
+
expect(txt).toContain("Sneaky");
|
|
94
|
+
expect(txt).toContain("real prose");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("incomplete links and sections are dropped, not half-emitted", () => {
|
|
98
|
+
const txt = serializeLlms({
|
|
99
|
+
title: "Acme",
|
|
100
|
+
sections: [
|
|
101
|
+
{ title: "Docs", links: [{ title: "", url: "https://a" } as any, { title: "B", url: "" } as any] },
|
|
102
|
+
{ title: "", links: [{ title: "C", url: "https://c" }] } as any,
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
expect(txt).toContain("## Docs");
|
|
106
|
+
expect(txt).not.toContain("](");
|
|
107
|
+
expect(txt).not.toContain("https://c");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("handleDataRoute — llms", () => {
|
|
112
|
+
const tmpdirs: string[] = [];
|
|
113
|
+
const prevCwd = process.cwd();
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
process.chdir(prevCwd);
|
|
116
|
+
for (const d of tmpdirs.splice(0)) {
|
|
117
|
+
try {
|
|
118
|
+
fs.rmSync(d, { recursive: true, force: true });
|
|
119
|
+
} catch {
|
|
120
|
+
/* best effort */
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
function fixture(file: string, src: string): void {
|
|
126
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-llms-route-"));
|
|
127
|
+
tmpdirs.push(dir);
|
|
128
|
+
fs.mkdirSync(path.join(dir, "app"), { recursive: true });
|
|
129
|
+
fs.writeFileSync(path.join(dir, "app", file), src);
|
|
130
|
+
process.chdir(dir);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const collect = async (component: string): Promise<any[]> => {
|
|
134
|
+
const sent: any[] = [];
|
|
135
|
+
const msg = { component, call_id: "c1" } as unknown as RenderRouteMessage;
|
|
136
|
+
await handleDataRoute(msg, "llms", (m) => sent.push(m));
|
|
137
|
+
return sent;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
test("async app/llms.ts → 200 text/plain with the rendered document", async () => {
|
|
141
|
+
fixture(
|
|
142
|
+
"llms.ts",
|
|
143
|
+
`export default async function llms() {
|
|
144
|
+
return {
|
|
145
|
+
title: "Acme",
|
|
146
|
+
summary: "Invoicing.",
|
|
147
|
+
sections: [{ title: "Docs", links: [{ title: "API", url: "https://acme.com/api" }] }],
|
|
148
|
+
};
|
|
149
|
+
}`,
|
|
150
|
+
);
|
|
151
|
+
const sent = await collect("app/llms");
|
|
152
|
+
const start = sent.find((m) => m.type === "response_start");
|
|
153
|
+
expect(start.status).toBe(200);
|
|
154
|
+
expect(start.headers["content-type"]).toContain("text/plain");
|
|
155
|
+
expect(start.headers["cache-control"]).toBe("public, max-age=3600");
|
|
156
|
+
const body = Buffer.from(
|
|
157
|
+
sent.find((m) => m.type === "render_chunk").data,
|
|
158
|
+
"base64",
|
|
159
|
+
).toString("utf8");
|
|
160
|
+
expect(body.startsWith("# Acme\n")).toBe(true);
|
|
161
|
+
expect(body).toContain("- [API](https://acme.com/api)");
|
|
162
|
+
expect(sent.some((m) => m.type === "render_done")).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("a throwing llms.ts surfaces as a 500 (does not wedge the runner)", async () => {
|
|
166
|
+
fixture("llms.ts", `export default function llms() { throw new Error("boom"); }`);
|
|
167
|
+
const sent = await collect("app/llms");
|
|
168
|
+
expect(sent.find((m) => m.type === "response_start").status).toBe(500);
|
|
169
|
+
const body = Buffer.from(
|
|
170
|
+
sent.find((m) => m.type === "render_chunk").data,
|
|
171
|
+
"base64",
|
|
172
|
+
).toString("utf8");
|
|
173
|
+
expect(body).toContain("boom");
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -2030,6 +2030,14 @@ export function buildHydrationTail(args: {
|
|
|
2030
2030
|
props: serializableProps,
|
|
2031
2031
|
ssrData: args.ssrData,
|
|
2032
2032
|
};
|
|
2033
|
+
// App name (from the manifest, surfaced by the runtime as PYLON_APP_NAME) so
|
|
2034
|
+
// the client namespaces its localStorage/IndexedDB per-app at hydrate. Without
|
|
2035
|
+
// it every app on a shared origin (all localhost:4321 in dev) collides on the
|
|
2036
|
+
// default `pylon_token`. App-global + identity-free, so it stays byte-identical
|
|
2037
|
+
// across users — safe in the PPR-bucketed shared tail too.
|
|
2038
|
+
const appName =
|
|
2039
|
+
typeof process !== "undefined" ? process.env.PYLON_APP_NAME : undefined;
|
|
2040
|
+
if (appName) hydrationPayload.app = appName;
|
|
2033
2041
|
if (args.kind) hydrationPayload.kind = args.kind;
|
|
2034
2042
|
const json = escapeScriptJson(JSON.stringify(hydrationPayload));
|
|
2035
2043
|
let tail = `<script id="__PYLON_DATA__" type="application/json">${json}</script>`;
|
|
@@ -2729,6 +2737,79 @@ export function serializeSitemap(entries: Sitemap | undefined): string {
|
|
|
2729
2737
|
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset ${ns}>${body}</urlset>\n`;
|
|
2730
2738
|
}
|
|
2731
2739
|
|
|
2740
|
+
/** One link in an `llms.txt` section. */
|
|
2741
|
+
export interface LlmsLink {
|
|
2742
|
+
title: string;
|
|
2743
|
+
url: string;
|
|
2744
|
+
/** Short note after the link — what an agent finds there. */
|
|
2745
|
+
notes?: string;
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2748
|
+
/** An H2-delimited section of file links. */
|
|
2749
|
+
export interface LlmsSection {
|
|
2750
|
+
title: string;
|
|
2751
|
+
links: LlmsLink[];
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
/**
|
|
2755
|
+
* Return type of a default export in `app/llms.ts`, serialized to
|
|
2756
|
+
* <https://llmstxt.org> format.
|
|
2757
|
+
*
|
|
2758
|
+
* The spec's order is fixed and load-bearing, because the file is parsed by
|
|
2759
|
+
* "standard programmatic-based tools": an H1 title, a blockquote summary, free
|
|
2760
|
+
* prose with NO headings, then H2 sections of markdown links.
|
|
2761
|
+
*/
|
|
2762
|
+
export interface LlmsTxt {
|
|
2763
|
+
/** H1 — the site or project name. The one required element. */
|
|
2764
|
+
title: string;
|
|
2765
|
+
/** The blockquote under it: what this is, in one or two sentences. */
|
|
2766
|
+
summary?: string;
|
|
2767
|
+
/**
|
|
2768
|
+
* Prose paragraphs between the summary and the first section. Headings are
|
|
2769
|
+
* not allowed here — the parser reads the first H2 as the start of the link
|
|
2770
|
+
* lists, so a heading in this block would swallow the rest of the file.
|
|
2771
|
+
*/
|
|
2772
|
+
details?: string | string[];
|
|
2773
|
+
sections?: LlmsSection[];
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
/** Serialize an {@link LlmsTxt} to llmstxt.org format. */
|
|
2777
|
+
export function serializeLlms(doc: LlmsTxt | undefined): string {
|
|
2778
|
+
if (!doc || typeof doc.title !== "string" || !doc.title.trim()) {
|
|
2779
|
+
return "";
|
|
2780
|
+
}
|
|
2781
|
+
// A stray newline would end the blockquote / list item early and silently
|
|
2782
|
+
// reshape the document.
|
|
2783
|
+
const oneLine = (s: unknown): string =>
|
|
2784
|
+
String(s).replace(/\s*\n+\s*/g, " ").trim();
|
|
2785
|
+
const out: string[] = [`# ${oneLine(doc.title)}`];
|
|
2786
|
+
if (doc.summary && oneLine(doc.summary)) {
|
|
2787
|
+
out.push("", `> ${oneLine(doc.summary)}`);
|
|
2788
|
+
}
|
|
2789
|
+
const details =
|
|
2790
|
+
doc.details == null
|
|
2791
|
+
? []
|
|
2792
|
+
: Array.isArray(doc.details)
|
|
2793
|
+
? doc.details
|
|
2794
|
+
: [doc.details];
|
|
2795
|
+
for (const p of details) {
|
|
2796
|
+
const text = String(p).trim();
|
|
2797
|
+
if (!text) continue;
|
|
2798
|
+
// Headings here would be read as the start of a link section.
|
|
2799
|
+
out.push("", text.replace(/^#+\s*/gm, ""));
|
|
2800
|
+
}
|
|
2801
|
+
for (const section of doc.sections ?? []) {
|
|
2802
|
+
if (!section || !section.title) continue;
|
|
2803
|
+
out.push("", `## ${oneLine(section.title)}`, "");
|
|
2804
|
+
for (const link of section.links ?? []) {
|
|
2805
|
+
if (!link || !link.title || !link.url) continue;
|
|
2806
|
+
const notes = link.notes ? `: ${oneLine(link.notes)}` : "";
|
|
2807
|
+
out.push(`- [${oneLine(link.title)}](${oneLine(link.url)})${notes}`);
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
return `${out.join("\n").trim()}\n`;
|
|
2811
|
+
}
|
|
2812
|
+
|
|
2732
2813
|
/** Serialize a robots config to robots.txt text. */
|
|
2733
2814
|
export function serializeRobots(robots: Robots | undefined): string {
|
|
2734
2815
|
const arr = (v: string | string[] | undefined): string[] =>
|
|
@@ -2761,7 +2842,7 @@ export function serializeRobots(robots: Robots | undefined): string {
|
|
|
2761
2842
|
*/
|
|
2762
2843
|
export async function handleDataRoute(
|
|
2763
2844
|
msg: RenderRouteMessage,
|
|
2764
|
-
kind: "sitemap" | "robots",
|
|
2845
|
+
kind: "sitemap" | "robots" | "llms",
|
|
2765
2846
|
send: Send,
|
|
2766
2847
|
): Promise<void> {
|
|
2767
2848
|
const emit = (status: number, contentType: string, body: string): void => {
|
|
@@ -2788,6 +2869,10 @@ export async function handleDataRoute(
|
|
|
2788
2869
|
const data = typeof exp === "function" ? await exp() : exp;
|
|
2789
2870
|
if (kind === "sitemap") {
|
|
2790
2871
|
emit(200, "application/xml; charset=utf-8", serializeSitemap(data as Sitemap));
|
|
2872
|
+
} else if (kind === "llms") {
|
|
2873
|
+
// text/plain, not text/markdown: llms.txt is fetched by name, and a
|
|
2874
|
+
// `.txt` served as markdown makes some clients offer a download.
|
|
2875
|
+
emit(200, "text/plain; charset=utf-8", serializeLlms(data as LlmsTxt));
|
|
2791
2876
|
} else {
|
|
2792
2877
|
emit(200, "text/plain; charset=utf-8", serializeRobots(data as Robots));
|
|
2793
2878
|
}
|
|
@@ -2910,13 +2995,15 @@ export async function handleRenderRoute(
|
|
|
2910
2995
|
// app/robots.*, so the basename is exactly "sitemap"/"robots" (a real page
|
|
2911
2996
|
// component always ends in "/page"). Mirrors the not-found/error basename
|
|
2912
2997
|
// check used for boundaries.
|
|
2913
|
-
const dataKind: "sitemap" | "robots" | null = /(^|[\\/])sitemap$/.test(
|
|
2998
|
+
const dataKind: "sitemap" | "robots" | "llms" | null = /(^|[\\/])sitemap$/.test(
|
|
2914
2999
|
msg.component,
|
|
2915
3000
|
)
|
|
2916
3001
|
? "sitemap"
|
|
2917
3002
|
: /(^|[\\/])robots$/.test(msg.component)
|
|
2918
3003
|
? "robots"
|
|
2919
|
-
:
|
|
3004
|
+
: /(^|[\\/])llms$/.test(msg.component)
|
|
3005
|
+
? "llms"
|
|
3006
|
+
: null;
|
|
2920
3007
|
if (dataKind) return handleDataRoute(msg, dataKind, send);
|
|
2921
3008
|
|
|
2922
3009
|
// `opengraph-image` (and future `twitter-image`) render to a PNG, not
|
|
@@ -3419,6 +3506,13 @@ export async function handleRenderRoute(
|
|
|
3419
3506
|
: cacheable
|
|
3420
3507
|
? { "x-pylon-cacheable": String(revalidateSecs) }
|
|
3421
3508
|
: {}),
|
|
3509
|
+
// `export const markdown = false` — this page declines its markdown
|
|
3510
|
+
// representation (an app shell whose value is the interaction, not the
|
|
3511
|
+
// prose). The host reads this AFTER the render: a client that also
|
|
3512
|
+
// accepts HTML gets the HTML we just produced, one that doesn't gets a
|
|
3513
|
+
// 406, and the `<path>.md` URL 404s. Trusted channel, stripped by the
|
|
3514
|
+
// host, so a page can't forge the inverse and force a conversion.
|
|
3515
|
+
...((mod as any).markdown === false ? { "x-pylon-md": "0" } : {}),
|
|
3422
3516
|
// Dev-only: the host parses this into its diagnostics ring (served at
|
|
3423
3517
|
// /_pylon/dev/diagnostics + `pylon diagnostics`). Single-line JSON, no
|
|
3424
3518
|
// newlines. Stripped before the client like every x-pylon-* header.
|