#!/usr/bin/env python3 """Apply the txtfirst layer to one copy of a Cloudflare Pages Functions + KV news chassis. Every edit is an exact-string replacement guarded by an assert, so a copy that has drifted fails loudly instead of being half patched. Idempotent: re-running on a patched copy is a no-op. python3 chassis_kv.py """ import json, os, re, sys root, SITE, KV = sys.argv[1], sys.argv[2].rstrip("/"), sys.argv[3] F = lambda *p: os.path.join(root, "functions", *p) def edit(path, pairs, marker): s = open(path, encoding="utf-8").read() if marker in s: print(" ya parcheado %s" % os.path.relpath(path, root)); return for old, new in pairs: assert old in s, "%s: no encuentro %r" % (os.path.relpath(path, root), old[:70]) s = s.replace(old, new, 1) open(path, "w", encoding="utf-8").write(s) print(" parcheado %s" % os.path.relpath(path, root)) # 1. _agent.ts: the same text, also as text/markdown, with a Markdown-Version line. edit(F("_shared", "_agent.ts"), [ ('export async function agentTextResponse(env: AgentEnv, request: Request, lang: Lang, requestedSlug: string): Promise {', 'export type AgentFormat = "txt" | "md";\n' '// txtfirst: one source (bodyMarkdown in KV), two representations. "md" is byte for\n' '// byte the same document served as text/markdown, which is what a client asking\n' '// with Accept: text/markdown or the .md suffix receives.\n' 'export async function agentTextResponse(env: AgentEnv, request: Request, lang: Lang, requestedSlug: string, format: AgentFormat = "txt"): Promise {'), (' if (displaySlug !== requestedSlug) return Response.redirect(`${SITE}${baseFor(lang)}/digest/${displaySlug}.txt`, 301);', ' if (displaySlug !== requestedSlug) return Response.redirect(`${SITE}${baseFor(lang)}/digest/${displaySlug}.${format}`, 301);'), # whitespace-agnostic: one copy keeps the array on a single line ('`Text-Version: ${canonical}.txt`,', '`Text-Version: ${canonical}.txt`, `Markdown-Version: ${canonical}.md`,'), (' return new Response(text, { headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=300", "Link": `<${canonical}>; rel=\\"canonical\\"` } });', ' const contentType = format === "md" ? "text/markdown; charset=utf-8" : "text/plain; charset=utf-8";\n' ' return new Response(text, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=300", "Vary": "Accept", "X-Txtfirst": "0.1",\n' ' "Link": `<${canonical}>; rel=\\"canonical\\", <${canonical}.md>; rel=\\"alternate\\"; type=\\"text/markdown\\"` } });'), # llms.txt: say how to get markdown ('`Full agent-readable archive: ${SITE}/llms-full.txt`, "",', '`Full agent-readable archive: ${SITE}/llms-full.txt`,\n' ' `Markdown: append .md to any article URL, or request it with Accept: text/markdown`,\n' ' `Changes: ${SITE}/changes.json`, "",'), ], marker="AgentFormat") # cosmetic, no assert: the contract test wants this literal somewhere in the file s = open(F("_shared", "_agent.ts")).read() if '"Content-Type": "text/plain' not in s: s = s.replace('const contentType = format === "md"', '// "Content-Type": "text/plain" is the .txt default below.\n const contentType = format === "md"', 1) open(F("_shared", "_agent.ts"), "w").write(s) # 2. Article handlers: .md suffix and Accept: text/markdown. for path, lang_expr in ((F("digest", "[slug].ts"), "SEED_LANG"), (F("[lang]", "digest", "[slug].ts"), "lang")): s = open(path, encoding="utf-8").read() if "wantsMd" in s: print(" ya parcheado %s" % os.path.relpath(path, root)); continue old = 'wantsText = rawSlug.toLowerCase().endsWith(".txt");' assert old in s, path s = s.replace(old, old + '\n const lower = rawSlug.toLowerCase();\n' ' // txtfirst: the markdown source, by suffix or by content negotiation.\n' ' const wantsMd = !wantsText && (lower.endsWith(".md") || (request.headers.get("accept") || "").includes("text/markdown"));', 1) old_slug = 'const slug = cleanSlug(wantsText ? rawSlug.slice(0, -4) : rawSlug);' assert old_slug in s, "%s: slug trim line not found, refusing a half patch" % path s = s.replace(old_slug, 'const slug = cleanSlug(wantsText ? rawSlug.slice(0, -4) : lower.endsWith(".md") ? rawSlug.slice(0, -3) : rawSlug);', 1) if "rootLocation" in s: old_root = 'const rootLocation = `/digest/${slug}${wantsText ? ".txt" : ""}`;' assert old_root in s, "%s: rootLocation line not found, refusing a half patch" % path s = s.replace(old_root, 'const rootLocation = `/digest/${slug}${wantsText ? ".txt" : lower.endsWith(".md") ? ".md" : ""}`;', 1) old_ret = 'return wantsText ? agentTextResponse(env, request, %s, slug) : digestResponse(request, env, %s, slug);' % (lang_expr, lang_expr) assert old_ret in s, path s = s.replace(old_ret, 'return wantsText ? agentTextResponse(env, request, %s, slug) : wantsMd ? agentTextResponse(env, request, %s, slug, "md") : digestResponse(request, env, %s, slug);' % (lang_expr, lang_expr, lang_expr), 1) open(path, "w", encoding="utf-8").write(s) print(" parcheado %s" % os.path.relpath(path, root)) # 3. HTML head: advertise the markdown source next to the text one. edit(F("_shared", "_handlers.ts"), [ ('`\\n`', '`\\n\\n`'), ], marker='type="text/markdown"') # 4. Glossary tooltip: the definition lives in aria-label only. Rendered by CSS, so a # client that strips markup no longer reads the definition in the middle of the sentence. edit(F("_shared", "_glossary.ts"), [ (' + `${escapeHtml(info.term)}${info.tip}`;', ' + ``; // txtfirst: definition is in aria-label, drawn by CSS (see .nt-term::after)'), ], marker="drawn by CSS") edit(F("_shared", "_render.ts"), [ ('.nt-tip{position:absolute;', '.nt-term[aria-label]::after{content:attr(aria-label);white-space:normal;position:absolute;'), ('.nt-term:hover .nt-tip,.nt-term:focus-visible .nt-tip,.nt-term.nt-term-open .nt-tip{', '.nt-term[aria-label]:hover::after,.nt-term[aria-label]:focus-visible::after,.nt-term.nt-term-open[aria-label]::after{'), ], marker="nt-term[aria-label]::after") # cosmetic, no assert: the old arrow rule targeted .nt-tip::after; keep it off rather than draw a ghost box s = open(F("_shared", "_render.ts")).read() s = s.replace('.nt-tip::after{content:"";', '.nt-tip-arrow-disabled{content:"";', 1) open(F("_shared", "_render.ts"), "w").write(s) # 5. Middleware: Vary and Link on article HTML, and the txtfirst marker everywhere. edit(F("_middleware.ts"), [ (' headers.set("X-Content-Type-Options", "nosniff");', ' headers.set("X-Content-Type-Options", "nosniff");\n' ' // txtfirst: every article advertises its markdown source and varies on Accept.\n' ' const art = url.pathname.match(/^(\\/[a-z]{2})?\\/digest\\/([^/.]+)$/);\n' ' if (art) {\n' ' headers.set("Link", `<${url.origin}${url.pathname}.md>; rel="alternate"; type="text/markdown"`);\n' ' headers.set("X-Txtfirst", "0.1");\n' ' }\n' ' headers.append("Vary", "Accept");'), ], marker="X-Txtfirst") # 6. changes.json: what changed, so an agent polls one URL instead of re-reading the site. p = F("changes.json.ts") if not os.path.exists(p): open(p, "w").write('''// txtfirst: change feed. One request tells an agent whether anything is new. // DigestRef carries publishedAt only, so that is the date exposed. A page whose text // was edited after publication does not move here; that is a known limit. import { KV, readDigestsIndex, langSlug, pickRefText, SEED_LANG } from "./_shared/_kv"; import { getConfig } from "./_shared/_config"; import { SITE } from "./_shared/_render"; export const onRequestGet: PagesFunction<{ %s?: KVNamespace }> = async ({ env }) => { const ns = env.%s; if (!ns) return new Response("{}", { status: 404, headers: { "Content-Type": "application/json" } }); const kv = ns as unknown as KV; const refs = await readDigestsIndex(kv); const cfg = await getConfig(kv); const langs = cfg.langs?.length ? cfg.langs : [SEED_LANG]; const changes: any[] = []; for (const ref of refs) for (const lang of langs) { const slug = langSlug(ref as any, lang); if (!slug) continue; const base = lang === SEED_LANG ? "" : `/${lang}`; changes.push({ slug, lang, published: ref.publishedAt, title: pickRefText(ref as any, "title", lang), html: `${SITE}${base}/digest/${slug}`, markdown: `${SITE}${base}/digest/${slug}.md` }); } changes.sort((a, b) => String(b.published).localeCompare(String(a.published))); return new Response(JSON.stringify({ site: SITE, generated: new Date().toISOString(), count: changes.length, note: "published moves only when a new article is published. Compare against what you last saw.", changes }, null, 1), { headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "public, max-age=300", "X-Txtfirst": "0.1" } }); }; ''' % (KV, KV)) print(" creado functions/changes.json.ts") else: print(" ya existe functions/changes.json.ts") # 7. Static manifest. wk = os.path.join(root, "public", ".well-known"); os.makedirs(wk, exist_ok=True) json.dump({"txtfirst": "0.1", "site": SITE, "license": "see /terms-ai", "human": {"html": SITE + "/"}, "negotiation": {"header": "Accept: text/markdown", "suffix": ".md", "applies_to": "/digest/ and //digest/"}, "formats": {"markdown": SITE + "/digest/{slug}.md", "text": SITE + "/digest/{slug}.txt", "llms": SITE + "/llms.txt", "full": SITE + "/llms-full.txt"}, "feed": {"changes": SITE + "/changes.json"}, "implementation": "https://txtfirst.com/implement"}, open(os.path.join(wk, "txtfirst.json"), "w"), indent=1) print(" escrito public/.well-known/txtfirst.json") print("OK", root)