Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/base-url-config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"leadtype": minor
---

Make `baseUrl` a config field, so the common path stops repeating it.

A docs audit found `baseUrl` was the one value every snippet had to state twice — `generate --base-url https://…` for the CLI and `createDocsProject({ baseUrl })` for the runtime — because it was not part of the config. It now is: a site-owned, top-level field next to `product`.

```ts
export default defineDocsConfig({
product: { name: "Acme", tagline: "Acme does one useful thing." },
baseUrl: "https://acme.dev",
});
```

With it set, the scaffolded common path carries no knobs at all: `leadtype generate` needs no `--base-url`, and the runtime is `createDocsProject()` with zero arguments. `leadtype init` writes the value once, into `docs/docs.config.ts`, and nowhere else.

Precedence is explicit-wins: the `--base-url` flag and the `createDocsProject({ baseUrl })` argument override the config field, and a config without the field keeps the deployment-URL env fallbacks exactly as before. One shared validator now covers every authored spelling — the config field, the flag, and the argument: a base URL must be an absolute http(s) URL, optionally with a path prefix, carrying no query, fragment, or embedded credentials, and is normalized (trailing slashes stripped) so URL joins cannot produce `//`. For the flag and the argument this is stricter than before — they used to forward whatever they were given, so a value like `ftp://acme.dev`, `https://acme.dev?`, or an unparseable string flowed straight into every joined URL and corrupted the generated artifacts. Such a value now fails up front instead: `generate` exits 2 and `createDocsProject` throws.

Because a base URL says where *this* site publishes, the field is never inherited via `inheritConfig` — a source-owned `docs.config.*` consumed by several sites should leave it unset and let each site supply its own.

The resolved value is inspectable like every other derived-or-authored value: provenance carries a `baseUrl` entry (`explicit` with the config path, or `default` naming the env fallback chain), `leadtype doctor` reports the resolved URL and its origin, and `generate --explain` reports the fallback when nothing was authored anywhere.
4 changes: 2 additions & 2 deletions docs/concepts/config-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ The two entry points are structurally identical and differ in *ownership*.

| File | Helper | Owned by | Declares |
| --- | --- | --- | --- |
| `leadtype.config.ts` (project root) | `defineLeadtypeConfig` | the site that publishes the docs | sources, routes, feeds, redirects, robots policy, agent surfaces |
| `leadtype.config.ts` (project root) | `defineLeadtypeConfig` | the site that publishes the docs | `baseUrl`, sources, routes, feeds, redirects, robots policy, agent surfaces |
| `docs.config.ts` (in a docs dir) | `defineDocsConfig` | the repo that owns the content | navigation, frontmatter schema, flatteners, mounts |

**If you have one repo, use `defineDocsConfig` and stop reading this section.** The two helpers accept the same shape and behave identically; the distinction is about *ownership*, and ownership only becomes a question once more than one repo is involved.

It starts mattering when a docs site pins a source repo: the source repo ships its own `docs.config.ts`, and the site inherits the content-owned half of it with `inheritConfig`. Site controls never inherit — a source repo cannot set the robots policy of a site that renders it.
It starts mattering when a docs site pins a source repo: the source repo ships its own `docs.config.ts`, and the site inherits the content-owned half of it with `inheritConfig`. Site controls never inherit — a source repo cannot set the robots policy of a site that renders it, and it cannot set `baseUrl` either: where a site publishes is the consuming site's fact, so a source-owned config read by several sites should leave `baseUrl` unset and let each site's own config (or its `--base-url` / `createDocsProject({ baseUrl })` override) supply it. In a single-repo project the one config file is both owners at once, which is why `leadtype init` writes `baseUrl` there.

```ts
// leadtype.config.ts — the docs site
Expand Down
4 changes: 2 additions & 2 deletions docs/paths.lock.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
},
{
"path": "/docs/concepts/config-model",
"hash": "a386659da90a3883"
"hash": "b3688a7023a192ff"
},
{
"path": "/docs/concepts/evals",
Expand Down Expand Up @@ -119,7 +119,7 @@
},
{
"path": "/docs/reference/cli",
"hash": "945fe8c9ae5db187"
"hash": "b77a062a42ec870b"
},
{
"path": "/docs/reference/convert",
Expand Down
16 changes: 8 additions & 8 deletions docs/reference/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ leadtype init [options]
| --- | --- | --- |
| `--framework <name>`, `-f` | auto-detect | Target framework: `next`, `astro`, `nuxt`, or `sveltekit`. Inferred from `package.json` dependencies when omitted. |
| `--dir <dir>` | `.` | Project root to scaffold into. |
| `--base-url <url>` | per-framework dev URL | Base URL written into the source config (e.g. `http://localhost:3000`). |
| `--base-url <url>` | per-framework dev URL | Site base URL written once into the scaffolded `docs/docs.config.ts` as `baseUrl` — the CLI and the runtime both read it from there. Validated like the config field: a malformed value (not absolute http(s), or carrying a query, fragment, or credentials) is a usage error, exit 2. |
| `--name <name>` | `My docs` | Product name written into `docs/docs.config.ts`. |
| `--summary <text>` | placeholder | One-line product summary. |
| `--force` | off | Overwrite files that already exist. Without it, existing files are skipped. |
| `--dry-run` | off | Print the file plan without writing or generating. |
| `--dry-run` | off | Print the file plan without writing or generating. When a write-mode run would refuse over a `--base-url` conflict (exit 2), a warning after the plan says so. |
| `--no-generate` | off | Skip the post-scaffold `leadtype generate` run. |
| `--webmcp` | deprecated | Compatibility shortcut that adds browser-side WebMCP registration glue. Prefer registering `leadtype/webmcp` from app code; this flag will be removed in the next major version. |
| `--json` | — | Emit the file plan as JSON and exit (no writes). |
| `--json` | — | Emit the file plan as JSON and exit (no writes). The plan carries a `warnings` string array: empty when the plan is clean, otherwise the conflict and note lines a write-mode run would print — a conflict entry means that run refuses with exit 2. |
| `-h`, `--help` | — | Print usage. |

Exit codes: `0` success, `2` CLI usage error or no framework detected. Existing files are never overwritten unless `--force` is passed. TanStack Start and Fumadocs are not scaffolded by `init` (heavier app-specific setup) — follow their recipes in [Use the source primitive](/docs/pipeline/use-the-source-primitive).
Expand Down Expand Up @@ -96,7 +96,7 @@ leadtype generate [options]
| `--out <dir>` | `public` | Output root. The pipeline writes to `<out>/llms.txt` and docs-scoped artifacts under `<out>/docs/*` in site mode, or `<out>/AGENTS.md` and `<out>/docs/*.md` in bundle mode. |
| `--bundle` | off | Bundle mode. Emits `AGENTS.md` and `docs/*.md` for offline agents. Skips URL-anchored site artifacts like `llms.txt`, `llms-full.txt`, sitemap, and robots. |
| `--mcp` | deprecated | Compatibility shortcut for bundle MCP artifacts. Prefer `agents.mcp.enabled` in `docs.config.ts`; this flag will be removed in the next major version. |
| `--base-url <url>` | | Base URL for canonical artifacts like `sitemap.xml`, `robots.txt`, search metadata, and full-context fallback URLs. Site mode only; ignored in `--bundle`. |
| `--base-url <url>` | config `baseUrl` | Base URL for canonical artifacts like `sitemap.xml`, `robots.txt`, search metadata, and full-context fallback URLs. Overrides the config's `baseUrl` field; absent both, deployment URL env vars apply. Validated like the config field: a malformed value (not absolute http(s), or carrying a query, fragment, or credentials) is a usage error, exit 2. Site mode only; ignored in `--bundle`. |
| `--name <name>` | from `docs.config.ts` or `package.json#name` | Product name written into the index file (`llms.txt` or `AGENTS.md`). Overrides config. |
| `--summary <text>` | from `docs.config.ts` or `package.json#description` | Product summary written into the index file. Overrides config. |
| `--include <glob>` | none | Docs-root-relative glob to include. Repeatable. Works in both modes. |
Expand Down Expand Up @@ -223,10 +223,10 @@ export default defineDocsConfig({
});
```

Feeds are site-mode only and require a base URL so item links are absolute. Pass
`--base-url`, or set the same deployment URL environment variables used by the
rest of site-mode generation, such as `NEXT_PUBLIC_SITE_URL` or Vercel URL
variables. If neither is set and Leadtype would fall back to an implicit
Feeds are site-mode only and require a base URL so item links are absolute. Set
`baseUrl` in the docs config, pass `--base-url`, or set the same deployment URL
environment variables used by the rest of site-mode generation, such as
`NEXT_PUBLIC_SITE_URL` or Vercel URL variables. If none is set and Leadtype would fall back to an implicit
localhost URL, feed generation fails instead of publishing accidental local
links. Entries use generated markdown frontmatter: `title`, `description`,
`date`, and `draft`. Every selected page must carry a `date` or
Expand Down
197 changes: 196 additions & 1 deletion packages/leadtype/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1815,7 +1815,7 @@ description: "First release."

expect(code).toBe(1);
expect(capture.stderr).toContain(
"configured feeds require --base-url or a deployment URL env var"
"configured feeds require `baseUrl` in the docs config, --base-url, or a deployment URL env var"
);
} finally {
for (const [key, value] of previousBaseUrlEnv) {
Expand Down Expand Up @@ -3311,3 +3311,198 @@ export default {
expect(capture.stderr).toContain("Linting collection [guide]");
});
});

describe("config-owned baseUrl", () => {
async function baseUrlFixture(configBody: string): Promise<{
srcDir: string;
outDir: string;
}> {
const srcDir = await createTempDir();
const outDir = await createTempDir();
await mkdir(path.join(srcDir, "docs"), { recursive: true });
await writeFile(path.join(srcDir, "docs", "docs.config.ts"), configBody);
await writeMdxPage(
srcDir,
"quickstart.mdx",
'title: "Quickstart"\ndescription: "Start here."'
);
return { srcDir, outDir };
}

const configWithBaseUrl = `export default {
product: { name: "Configured", tagline: "Configured docs." },
baseUrl: "https://config.acme.dev/",
};`;

it("uses the config's baseUrl for site artifacts, normalized", async () => {
const { srcDir, outDir } = await baseUrlFixture(configWithBaseUrl);
const capture = createCapture();

const code = await runCli(
["generate", "--src", srcDir, "--out", outDir],
capture.io
);

expect(code).toBe(0);
const sitemap = await readFile(path.join(outDir, "sitemap.xml"), "utf8");
expect(sitemap).toContain("https://config.acme.dev/docs/quickstart");
expect(sitemap).not.toContain("acme.dev//");
});

it("lets --base-url override the config field", async () => {
const { srcDir, outDir } = await baseUrlFixture(configWithBaseUrl);
const capture = createCapture();

const code = await runCli(
[
"generate",
"--src",
srcDir,
"--out",
outDir,
// Trailing slash on purpose: the flag now runs through the authored
// base-URL validator, and values it previously tolerated must keep
// working, normalized.
"--base-url",
"https://preview.acme.dev/",
],
capture.io
);

expect(code).toBe(0);
const sitemap = await readFile(path.join(outDir, "sitemap.xml"), "utf8");
expect(sitemap).toContain("https://preview.acme.dev/docs/quickstart");
expect(sitemap).not.toContain("acme.dev//");
expect(sitemap).not.toContain("https://config.acme.dev");
});

it("rejects an invalid --base-url as a usage error", async () => {
const capture = createCapture();

const code = await runCli(
["generate", "--base-url", "acme.dev"],
capture.io
);

// The flag feeds URL joins directly — same rules as the config field,
// failing at parse time before any source is read or file is written.
expect(code).toBe(2);
expect(capture.stderr).toContain(
'--base-url "acme.dev" is not an absolute URL'
);
});

it("satisfies configured feeds without repeating --base-url", async () => {
const { srcDir, outDir } = await baseUrlFixture(`export default {
product: { name: "Configured", tagline: "Configured docs." },
baseUrl: "https://config.acme.dev",
feeds: [
{
id: "docs",
title: "Docs",
source: { urlPrefix: "/docs" },
formats: ["rss"],
output: { rss: "/docs/rss.xml" },
},
],
};`);
// Feeds only list dated pages.
await writeMdxPage(
srcDir,
"quickstart.mdx",
'title: "Quickstart"\ndescription: "Start here."\ndate: 2026-06-01'
);
const capture = createCapture();
const previousBaseUrlEnv = BASE_URL_ENV_KEYS.map(
(key) => [key, process.env[key]] as const
);

try {
for (const key of BASE_URL_ENV_KEYS) {
delete process.env[key];
}
const code = await runCli(
["generate", "--src", srcDir, "--out", outDir],
capture.io
);

expect(code).toBe(0);
const rss = await readFile(path.join(outDir, "docs", "rss.xml"), "utf8");
expect(rss).toContain("https://config.acme.dev/docs/quickstart");
} finally {
for (const [key, value] of previousBaseUrlEnv) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});

it("rejects an invalid config baseUrl with the field named", async () => {
const { srcDir, outDir } = await baseUrlFixture(`export default {
product: { name: "Configured", tagline: "Configured docs." },
baseUrl: "not-a-url",
};`);
const capture = createCapture();

const code = await runCli(
["generate", "--src", srcDir, "--out", outDir],
capture.io
);

expect(code).toBe(1);
expect(capture.stderr).toContain(
'baseUrl "not-a-url" is not an absolute URL'
);
});

it("--explain reports the fallback when no baseUrl is set anywhere", async () => {
const { srcDir, outDir } = await baseUrlFixture(`export default {
product: { name: "Configured", tagline: "Configured docs." },
};`);
const capture = createCapture();
const previousBaseUrlEnv = BASE_URL_ENV_KEYS.map(
(key) => [key, process.env[key]] as const
);

try {
for (const key of BASE_URL_ENV_KEYS) {
delete process.env[key];
}
const code = await runCli(
["generate", "--src", srcDir, "--out", outDir, "--explain"],
capture.io
);

expect(code).toBe(0);
expect(capture.stdout).toContain("baseUrl");
expect(capture.stdout).toContain("deployment URL env vars");
expect(capture.stdout).toContain(
"Set `baseUrl` in the docs config, or pass --base-url."
);
} finally {
for (const [key, value] of previousBaseUrlEnv) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
});

it("--explain stays quiet about baseUrl when the config authors it", async () => {
const { srcDir, outDir } = await baseUrlFixture(configWithBaseUrl);
const capture = createCapture();

const code = await runCli(
["generate", "--src", srcDir, "--out", outDir, "--explain"],
capture.io
);

expect(code).toBe(0);
expect(capture.stdout).not.toContain("deployment URL env vars");
});
});
55 changes: 55 additions & 0 deletions packages/leadtype/src/cli/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1042,3 +1042,58 @@ describe("integrations", () => {
expect(report.integrations.surfaces).toContain("robots:balanced");
});
});

describe("baseUrl reporting", () => {
it("reports an authored baseUrl as explicit, normalized", async () => {
const dir = await fixture({
"docs/docs.config.ts": `export default {
product: { name: "Acme", tagline: "Acme docs." },
baseUrl: "https://acme.dev/",
};`,
"docs/index.mdx": page("Home"),
});

const { report } = await runJson(dir);
expect(report.config.baseUrl).toEqual({
value: "https://acme.dev",
origin: "explicit",
});
expect(report.config.provenance.baseUrl).toMatchObject({
origin: "explicit",
});
expect(
report.issues.filter((entry) => entry.id === "config.unknown-key")
).toEqual([]);
});

it("reports the env/localhost fallback as the default origin", async () => {
const dir = await fixture({
"docs/docs.config.ts": `export default {
product: { name: "Acme", tagline: "Acme docs." },
};`,
"docs/index.mdx": page("Home"),
});

const { report } = await runJson(dir);
expect(report.config.baseUrl?.origin).toBe("default");
expect(report.config.baseUrl?.value).toBeTruthy();
expect(report.config.provenance.baseUrl).toMatchObject({
origin: "default",
});
});

it("prints the resolved baseUrl and origin in the human report", async () => {
const dir = await fixture({
"docs/docs.config.ts": `export default {
product: { name: "Acme", tagline: "Acme docs." },
baseUrl: "https://acme.dev",
};`,
"docs/index.mdx": page("Home"),
});

const capture = createCapture();
const code = await runDoctorCommand(["--src", dir], capture.io);
expect(code).toBe(0);
expect(capture.stdout).toContain("baseUrl: https://acme.dev (explicit)");
});
});
Loading
Loading