From 95baf5be9e0cf844fea2f3c6661eb706c38a9d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 20:54:51 -0300 Subject: [PATCH 1/2] chore: start #652 From ed7fc29fb18612a46249b18cb45810385bced9d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matheus=20Vila=C3=A7a=20de=20Jesus?= Date: Sun, 25 Oct 2026 21:03:18 -0300 Subject: [PATCH 2/2] refactor: agent-guide.ts owns the site host and the api/v1 endpoint paths SITE was declared in 9 places and ai-entry, index-build, the manifest writers, the MCP server and endpoints.json each spelled api/v1 URLs by hand. API, API_DIR, API_PATHS and apiUrl live in src/cli/agent-guide.ts; manifest --check reports no drift. Fail before: the new api-paths test fails (missing exports, then 1 hand-spelled path); pass after: 115 related tests. Closes #652 --- packages/ui/bin/ca-ui-mcp.ts | 3 +- packages/ui/scripts/ai-entry.ts | 8 ++-- packages/ui/scripts/atlas-markdown.ts | 4 +- packages/ui/scripts/build-manifest.ts | 4 +- packages/ui/scripts/index-build.ts | 9 ++-- packages/ui/scripts/manifest/demos.ts | 10 ++--- packages/ui/scripts/manifest/paths.ts | 3 +- packages/ui/scripts/manifest/writers.ts | 4 +- packages/ui/scripts/styles-markdown.ts | 4 +- packages/ui/src/cli/agent-guide.ts | 18 ++++++++ packages/ui/tests/api-paths.test.ts | 55 +++++++++++++++++++++++++ site/src/pages/api/v1/endpoints.json.ts | 10 ++--- 12 files changed, 102 insertions(+), 30 deletions(-) create mode 100644 packages/ui/tests/api-paths.test.ts diff --git a/packages/ui/bin/ca-ui-mcp.ts b/packages/ui/bin/ca-ui-mcp.ts index 4faf82dd..f7bd9004 100755 --- a/packages/ui/bin/ca-ui-mcp.ts +++ b/packages/ui/bin/ca-ui-mcp.ts @@ -35,6 +35,7 @@ import { searchIndex, } from "../scripts/index-search.ts"; import type { StyleFile } from "../scripts/styles-markdown.ts"; +import { API_DIR, API_PATHS } from "../src/cli/agent-guide.ts"; import { type Equivalent, findEquivalent } from "../src/cli/equivalents.ts"; import { type MigrationFile, migrationMarkdown } from "../src/cli/migrations.ts"; import { buildReport } from "../src/cli/report.ts"; @@ -412,7 +413,7 @@ server.registerTool( ? demos.find((d) => d.id === num.padStart(3, "0")) : demos.find((d) => raw.endsWith(d.demo.slice(d.demo.indexOf("/docs/")))); if (!hit) return fail(demos.length ? `Unknown demo id "${raw}"; use search_demos.` : noDemos); - out.push(readFileSync(join(DIST, `api/v1/en/e/${hit.id}.txt`), "utf8")); + out.push(readFileSync(join(DIST, API_DIR, API_PATHS.demoSource(hit.id)), "utf8")); } return text(out.join("\n\n---\n\n")); } diff --git a/packages/ui/scripts/ai-entry.ts b/packages/ui/scripts/ai-entry.ts index 073cc7e5..ae20e788 100644 --- a/packages/ui/scripts/ai-entry.ts +++ b/packages/ui/scripts/ai-entry.ts @@ -12,7 +12,7 @@ export { SITE, } from "../src/cli/agent-guide.ts"; -import { ENTRY_PAGES, SITE } from "../src/cli/agent-guide.ts"; +import { API_PATHS, apiUrl, ENTRY_PAGES, SITE } from "../src/cli/agent-guide.ts"; export interface DocLink { slug: string; @@ -193,17 +193,17 @@ export function buildLlmsIndex(i: { }, { title: "Demo catalog", - href: `${SITE}/api/v1/catalog.json`, + href: apiUrl(API_PATHS.catalog), description: "every numbered demo (#NNN): id, page, components, summary", }, { title: "Demo source", - href: `${SITE}/api/v1/en/e/001.txt`, + href: apiUrl(API_PATHS.demoSource("001")), description: "one demo as text: swap 001 for any id from the catalog", }, { title: "Demo index (TSV)", - href: `${SITE}/api/v1/en/catalog.tsv`, + href: apiUrl(API_PATHS.catalogTsv), description: "id, group, page, title, tags, summary: grep it", }, { diff --git a/packages/ui/scripts/atlas-markdown.ts b/packages/ui/scripts/atlas-markdown.ts index 3def68c5..860e3b95 100644 --- a/packages/ui/scripts/atlas-markdown.ts +++ b/packages/ui/scripts/atlas-markdown.ts @@ -1,6 +1,7 @@ // The atlas as plain markdown for agents: one file per term (`/llms/atlas/.txt`), an index of all terms // (`/llms/atlas.txt`) and the compact line the MCP `search_terms` returns. Pure functions over the dataset, so the // site endpoints, the MCP build and the tests share one text. + import { ATLAS_LIMITS, ATLAS_REVIEWED, @@ -24,8 +25,7 @@ import { type Reference, STRENGTH_LABEL, } from "../../../site/src/data/evidence/references.ts"; - -export const SITE = "https://ui.codeatlas.com.br"; +import { SITE } from "../src/cli/agent-guide.ts"; /** Path of a term's markdown twin. */ export const atlasTermPath = (t: Pick) => `/llms/atlas/${termSlug(t)}.txt`; diff --git a/packages/ui/scripts/build-manifest.ts b/packages/ui/scripts/build-manifest.ts index 1cd0073e..7460ff72 100644 --- a/packages/ui/scripts/build-manifest.ts +++ b/packages/ui/scripts/build-manifest.ts @@ -31,7 +31,7 @@ import { buildIndexFiles } from "./index-build.ts"; import { buildManifest } from "./manifest/components.ts"; import { buildDemos, checkCatalog } from "./manifest/demos.ts"; import { buildGuides, buildRecipes } from "./manifest/guides.ts"; -import { DEMOS_DIR, ROOT } from "./manifest/paths.ts"; +import { API_DIR, DEMOS_DIR, ROOT } from "./manifest/paths.ts"; import { buildSurfaces, buildTokens } from "./manifest/tokens.ts"; import { buildParts, demosMarkdown, skillMarkdown } from "./manifest/writers.ts"; import { buildStyleFile } from "./styles-markdown.ts"; @@ -121,7 +121,7 @@ if (import.meta.main) { mkdirSync(dirname(join(siteRoot, name)), { recursive: true }); writeFileSync(join(siteRoot, name), body); } - for (const base of [join(dist, "api/v1"), resolve(ROOT, "../../site/public/api/v1")]) + for (const base of [join(dist, API_DIR), resolve(ROOT, "../../site/public", API_DIR)]) for (const [name, body] of Object.entries(files)) { mkdirSync(dirname(join(base, name)), { recursive: true }); writeFileSync(join(base, name), body); diff --git a/packages/ui/scripts/index-build.ts b/packages/ui/scripts/index-build.ts index d224f7e5..2e21a9d1 100644 --- a/packages/ui/scripts/index-build.ts +++ b/packages/ui/scripts/index-build.ts @@ -5,6 +5,7 @@ * * Pure: takes the demo records, returns `{ path: body }`. The caller decides where the files go. */ +import { API_DIR, API_PATHS, apiUrl, SITE } from "../src/cli/agent-guide.ts"; import { type IndexFacets, PENDING_FACETS } from "./index-schema.ts"; /** The fields of a demo record the index reads (a subset of `ManifestDemo`). */ @@ -36,8 +37,6 @@ export interface IndexEntry extends Partial { names: string[]; } -const SITE = "https://ui.codeatlas.com.br"; - /** Facets that are filter chips, and how to read their values off an entry. A pending facet is absent from the entry, so it has no counts. */ const COUNTED: Record string[] | undefined> = { kind: (e) => [e.kind as string], @@ -124,7 +123,7 @@ export function entryMarkdown(d: IndexDemo): string { "", `Needs: ${d.needs}`, `Live demo: ${d.demo}`, - `Reference: ${SITE}/api/v1/en/e/${d.id}.txt`, + `Reference: ${apiUrl(API_PATHS.demoSource(d.id))}`, "", "## Source", "", @@ -143,7 +142,7 @@ function indexJson(entries: readonly IndexEntry[], version: string): string { urls: { page: `${SITE}/gallery/{id}/`, markdown: `${SITE}/gallery/{id}.md`, - reference: `${SITE}/api/v1/en/e/{id}.txt`, + reference: apiUrl(API_PATHS.demoSource("{id}")), }, facets: facetCounts(entries), }, @@ -160,7 +159,7 @@ export function buildIndexFiles( ): Record { const entries = indexEntries(demos); const files: Record = { - "api/v1/index.json": indexJson(entries, version), + [`${API_DIR}/${API_PATHS.index}`]: indexJson(entries, version), }; for (const d of demos) files[`gallery/${d.id}.md`] = entryMarkdown(d); return files; diff --git a/packages/ui/scripts/manifest/demos.ts b/packages/ui/scripts/manifest/demos.ts index d5ca8cb0..02043214 100644 --- a/packages/ui/scripts/manifest/demos.ts +++ b/packages/ui/scripts/manifest/demos.ts @@ -4,7 +4,7 @@ import { join, relative } from "node:path"; import { checkIdRange, checkIndexFacets, ID_PATTERN, type IndexFacets } from "../index-schema.ts"; import type { Manifest } from "./components.ts"; import { mdxFiles } from "./guides.ts"; -import { API, DEMOS_DIR, DOCS_DIR, PKG, SITE } from "./paths.ts"; +import { API_PATHS, apiUrl, DEMOS_DIR, DOCS_DIR, PKG, SITE } from "./paths.ts"; /** One animated property of a motion demo: literal values, each of which must appear verbatim in the demo source. */ export interface ManifestMotion { @@ -305,8 +305,8 @@ export function buildDemos( return { slug, first: g[0].id, last: g[g.length - 1].id, count: g.length }; }); const files: Record = { - "catalog.json": `${JSON.stringify({ version: manifest.version, count: demos.length, groups, elements: demos.map(({ source: _, ...d }) => d) }, null, "\t")}\n`, - "en/catalog.tsv": `${["id\tgroup\tpage\ttitle\tnames\ttags\tsummary", ...demos.map((d) => [d.id, d.group, d.page, d.title, (d.names ?? []).join(" | "), d.tags.join(","), d.summary].map((v) => v.replace(/[\t\n]/g, " ")).join("\t"))].join("\n")}\n`, + [API_PATHS.catalog]: `${JSON.stringify({ version: manifest.version, count: demos.length, groups, elements: demos.map(({ source: _, ...d }) => d) }, null, "\t")}\n`, + [API_PATHS.catalogTsv]: `${["id\tgroup\tpage\ttitle\tnames\ttags\tsummary", ...demos.map((d) => [d.id, d.group, d.page, d.title, (d.names ?? []).join(" | "), d.tags.join(","), d.summary].map((v) => v.replace(/[\t\n]/g, " ")).join("\t"))].join("\n")}\n`, }; const motionLines = (d: ManifestDemo) => d.motion?.length @@ -340,7 +340,7 @@ export function buildDemos( `Components: ${d.components.join(", ") || "none"}`, `Live demo: ${d.demo}`, ]; - files[`en/e/${d.id}.txt`] = `${header.join("\n")}\n\n${d.source}`; + files[API_PATHS.demoSource(d.id)] = `${header.join("\n")}\n\n${d.source}`; files[`en/p/${d.id}.txt`] = `${[ `Use ${PKG} demo #${d.id} "${d.title}" (${d.group}/${d.page}).`, `What it shows: ${d.summary.replace(/\.$/, "")}. When to use: ${d.use.replace(/\.$/, "")}.`, @@ -358,7 +358,7 @@ export function buildDemos( " variants of the reference as they are; no layout CSS, --ca-* tokens only (SKILL.md rules);", " replace sample data/copy with the project's; keep the first comment line.", `Report: #${d.id} ${d.title} → file, what changed, ${d.demo}; what you could not check (light/dark, 375px, keyboard, reduced motion).`, - `Reference: ${API}/en/e/${d.id}.txt Components API: ca-ui-mcp get_component(${d.components[0] ?? "…"})`, + `Reference: ${apiUrl(API_PATHS.demoSource(d.id))} Components API: ca-ui-mcp get_component(${d.components[0] ?? "…"})`, ].join("\n")}\n`; } return { demos, files }; diff --git a/packages/ui/scripts/manifest/paths.ts b/packages/ui/scripts/manifest/paths.ts index ef68ff36..184375cd 100644 --- a/packages/ui/scripts/manifest/paths.ts +++ b/packages/ui/scripts/manifest/paths.ts @@ -7,8 +7,7 @@ export const LIB = join(ROOT, "src/lib"); export const PKG = "@codeatlasdev/ui"; export const DOCS_DIR = resolve(ROOT, "../../site/src/content/docs"); export const DEMOS_DIR = resolve(ROOT, "../../site/src/demos"); -export const SITE = "https://ui.codeatlas.com.br"; -export const API = `${SITE}/api/v1`; +export { API, API_DIR, API_PATHS, apiUrl, SITE } from "../../src/cli/agent-guide.ts"; /** Public entry points: import specifier → entry file under src/lib. */ export const ENTRIES: Record = { diff --git a/packages/ui/scripts/manifest/writers.ts b/packages/ui/scripts/manifest/writers.ts index 00e8ffcb..1c7d0aaf 100644 --- a/packages/ui/scripts/manifest/writers.ts +++ b/packages/ui/scripts/manifest/writers.ts @@ -1,7 +1,7 @@ /** Writers: the parts file, API.md (agent skill reference) and DEMOS.md (demo catalog table). */ import type { Manifest } from "./components.ts"; import type { ManifestDemo } from "./demos.ts"; -import { API } from "./paths.ts"; +import { API_PATHS, apiUrl } from "./paths.ts"; import type { ManifestDataAttribute, ManifestPart, ManifestStyling } from "./styling.ts"; export function demosMarkdown(demos: ManifestDemo[]): string { @@ -9,7 +9,7 @@ export function demosMarkdown(demos: ManifestDemo[]): string { "", "# Demo catalog", "", - `Every demo has a stable number. Fetch one with \`get_demo\` (MCP) or ${API}/en/e/NNN.txt.`, + `Every demo has a stable number. Fetch one with \`get_demo\` (MCP) or ${apiUrl(API_PATHS.demoSource("NNN"))}.`, "", "| id | title | group/page | components |", "|---|---|---|---|", diff --git a/packages/ui/scripts/styles-markdown.ts b/packages/ui/scripts/styles-markdown.ts index dc56d110..cd7be152 100644 --- a/packages/ui/scripts/styles-markdown.ts +++ b/packages/ui/scripts/styles-markdown.ts @@ -1,6 +1,7 @@ // The design-style pages as plain markdown for agents: one file per style (`/llms/styles/.txt`), a digest of all // of them (`/llms/styles.txt`) and the file the MCP `get_style` tool serves offline (`dist/styles.json`). Pure functions // over the same data the pages render, so the site endpoints, the MCP build and the tests share one text. + import { STRENGTH_LABEL } from "../../../site/src/data/evidence/references.ts"; import { recipeKnobs, @@ -11,8 +12,7 @@ import { styleReferences, TRAIT_AXES, } from "../../../site/src/data/styles/styles.ts"; - -export const SITE = "https://ui.codeatlas.com.br"; +import { SITE } from "../src/cli/agent-guide.ts"; /** Path of the digest of every style. */ export const STYLES_INDEX_PATH = "/llms/styles.txt"; diff --git a/packages/ui/src/cli/agent-guide.ts b/packages/ui/src/cli/agent-guide.ts index 3dd3f1bb..45c857dd 100644 --- a/packages/ui/src/cli/agent-guide.ts +++ b/packages/ui/src/cli/agent-guide.ts @@ -2,6 +2,24 @@ // src/cli so the CLI stays self-contained; scripts/ai-entry.ts re-exports it for the site and MCP. export const SITE = "https://ui.codeatlas.com.br"; +/** Where the machine-readable endpoints live under the site root (and under `dist/` and `site/public/`). */ +export const API_DIR = "api/v1"; + +/** Base URL of the machine-readable endpoints. */ +export const API = `${SITE}/${API_DIR}`; + +/** Endpoint paths under `API_DIR`, relative to it (the manifest writes the same paths to `dist/` and `site/public/`). */ +export const API_PATHS = { + catalog: "catalog.json", + catalogTsv: "en/catalog.tsv", + index: "index.json", + /** The source of one demo as plain text; `{id}` stays literal in a template. */ + demoSource: (id: string) => `en/e/${id}.txt`, +} as const; + +/** The absolute URL of an `API_PATHS` entry. */ +export const apiUrl = (path: string) => `${API}/${path}`; + /** Docs pages an agent opens first, in this order. The slugs are checked against the real pages by a test. */ export const ENTRY_PAGES = { recipes: "getting-started/recipes", diff --git a/packages/ui/tests/api-paths.test.ts b/packages/ui/tests/api-paths.test.ts new file mode 100644 index 00000000..58e0d68d --- /dev/null +++ b/packages/ui/tests/api-paths.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { API as MANIFEST_API, SITE as MANIFEST_SITE } from "../scripts/manifest/paths.ts"; +import { API, API_PATHS, apiUrl, SITE } from "../src/cli/agent-guide.ts"; + +/** + * #652: ai-entry.ts, index-build.ts, the manifest writers, the MCP server and the site's endpoint list each + * spelled `${SITE}/api/v1/en/e/.txt` and friends by hand, and `SITE` itself was declared nine times, so a + * rename of one endpoint left the others pointing at a 404. `src/cli/agent-guide.ts` owns the host, the base + * and every endpoint name; everything else builds from it. + */ +const ROOT = join(import.meta.dir, "../../.."); + +describe("API paths (#652)", () => { + it("builds the published endpoints", () => { + expect(SITE).toBe("https://ui.codeatlas.com.br"); + expect(API).toBe(`${SITE}/api/v1`); + expect(apiUrl(API_PATHS.catalog)).toBe(`${SITE}/api/v1/catalog.json`); + expect(apiUrl(API_PATHS.catalogTsv)).toBe(`${SITE}/api/v1/en/catalog.tsv`); + expect(apiUrl(API_PATHS.index)).toBe(`${SITE}/api/v1/index.json`); + expect(apiUrl(API_PATHS.demoSource("001"))).toBe(`${SITE}/api/v1/en/e/001.txt`); + expect(apiUrl(API_PATHS.demoSource("{id}"))).toBe(`${SITE}/api/v1/en/e/{id}.txt`); + }); + + it("the manifest uses the same host and base", () => { + expect(MANIFEST_SITE).toBe(SITE); + expect(MANIFEST_API).toBe(API); + }); + + it("no script, bin or API page spells an /api/v1/ URL or declares SITE itself", () => { + const roots = ["packages/ui/scripts", "packages/ui/bin", "site/src/pages/api"]; + const owners = new Set(["packages/ui/src/cli/agent-guide.ts"]); + const hits: string[] = []; + const walk = (dir: string) => { + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) { + if (e.name !== "node_modules") walk(p); + } else if (/\.(ts|astro)$/.test(e.name) && !owners.has(relative(ROOT, p))) { + readFileSync(p, "utf8") + .split("\n") + .forEach((l, i) => { + // `//` comments and JSDoc may name the path; code may not + if (/^\s*(\/\/|\*|\/\*)/.test(l)) return; + if (/\$\{(SITE|API)\}\/api\/v1|["'`]api\/v1\/|const SITE = "https:/.test(l)) + hits.push(`${relative(ROOT, p)}:${i + 1}`); + }); + } + } + }; + for (const r of roots) walk(join(ROOT, r)); + expect(hits).toEqual([]); + }); +}); diff --git a/site/src/pages/api/v1/endpoints.json.ts b/site/src/pages/api/v1/endpoints.json.ts index 323a9d47..4c563eb7 100644 --- a/site/src/pages/api/v1/endpoints.json.ts +++ b/site/src/pages/api/v1/endpoints.json.ts @@ -1,4 +1,4 @@ -const SITE = "https://ui.codeatlas.com.br"; +import { API_PATHS, apiUrl, SITE } from "../../../../../packages/ui/src/cli/agent-guide"; /** The real machine-readable endpoints: a guessed URL under /api/v1/ is a 404, this lists what exists. (`index.json` itself is the generated gallery index, #570.) */ export const GET = () => @@ -10,10 +10,10 @@ export const GET = () => "llms.txt": `${SITE}/llms.txt`, "llms-full.txt": `${SITE}/llms-full.txt`, "prompt.txt": `${SITE}/prompt.txt`, - "gallery index (json)": `${SITE}/api/v1/index.json`, - "demo catalog (json)": `${SITE}/api/v1/catalog.json`, - "demo catalog (tsv)": `${SITE}/api/v1/en/catalog.tsv`, - "demo source (text)": `${SITE}/api/v1/en/e/{id}.txt`, + "gallery index (json)": apiUrl(API_PATHS.index), + "demo catalog (json)": apiUrl(API_PATHS.catalog), + "demo catalog (tsv)": apiUrl(API_PATHS.catalogTsv), + "demo source (text)": apiUrl(API_PATHS.demoSource("{id}")), "docs search index": `${SITE}/search-index.json`, }, note: "Component props are not served over HTTP: use the ca-ui-mcp server or node_modules/@codeatlasdev/ui/dist/components.json of the installed version.",