@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.
@@ -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: {
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.4.29",
3
+ "version": "0.5.2",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -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.
@@ -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
- // A `GET` export is special: it's a RAW handler. Instead of returning void +
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 — no React render, no hydration tail. This is the
13
- // mechanism behind dynamic raw GET routes (RSS/Atom feeds, dynamic XML, text,
14
- // JSON, .well-known files) — the GET analogue of `app/sitemap.ts`/`robots.ts`,
15
- // but at an arbitrary route path. A GET handler may still throw
16
- // `response.redirect()`/`notFound()` instead of returning a body.
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
- if (method === "GET") {
160
- // Raw GET: stream `out.body` with the handler's content-type/status/
161
- // headers (merged with anything set via the response controller). No
162
- // React, no hydration tail — verbatim bytes, like sitemap/robots.
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
- const rawStatus = raw.status ?? responseState.status;
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
+ });
@@ -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
- : null;
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.