From 9200ec978a66332a78d7b885cca517d1325f8beb Mon Sep 17 00:00:00 2001 From: Nik Cubrilovic Date: Fri, 12 Jun 2026 07:40:35 +1000 Subject: [PATCH 1/2] fix(plugin): resolve Windows cross-drive content-module crash #6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project on a different drive than the runtime (project E:\, global tangly C:\) rendered pages blank. Astro stores `posixRelative(root, file)` as the deferred-module fileName; cross-drive there's no relative path so the absolute drive-letter path is stored, and `new URL("E:/…", root)` reads `E:` as a URL scheme -> ERR_INVALID_URL_SCHEME. Rewrite drive-letter fileNames in .astro/content-modules.mjs to file:// URLs (segments percent-encoded) inside the existing enforce:"pre" transform, which runs before vite:import-analysis -- Astro's own pre content plugin wins resolveId first, so a transform is the only reliable interception. Inert on POSIX / same-drive. Add scripts/smoke-winpath.ts: subst-maps a free drive letter to reproduce cross-drive deterministically on the windows-latest CI leg and asserts the page body renders. --- .github/workflows/ci.yml | 6 + .../plugin/content-modules-winpath.test.ts | 67 ++++++++ .../src/plugin/content-modules-winpath.ts | 75 +++++++++ packages/tangly/src/plugin/vite-plugin.ts | 13 ++ scripts/smoke-winpath.ts | 145 ++++++++++++++++++ 5 files changed, 306 insertions(+) create mode 100644 packages/tangly/src/plugin/content-modules-winpath.test.ts create mode 100644 packages/tangly/src/plugin/content-modules-winpath.ts create mode 100644 scripts/smoke-winpath.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c656776..c44e359 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,12 @@ jobs: - run: bun run build - name: tangly init + migrate + build (Mintlify aliases) run: bun run scripts/smoke-init.ts + # issue #6: project on a different drive than the runtime crashed with + # ERR_INVALID_URL_SCHEME. subst-maps a free drive letter to reproduce + # cross-drive deterministically; self-skips off Windows. + - name: Windows cross-drive build (issue #6) + if: runner.os == 'Windows' + run: bun run scripts/smoke-winpath.ts - name: build own docs run: bun run build:docs # Catch packaging regressions (missing files, broken workspace rewrites) diff --git a/packages/tangly/src/plugin/content-modules-winpath.test.ts b/packages/tangly/src/plugin/content-modules-winpath.test.ts new file mode 100644 index 0000000..87ba37e --- /dev/null +++ b/packages/tangly/src/plugin/content-modules-winpath.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; +import { isContentModulesId, rewriteContentModulesDrivePaths } from "./content-modules-winpath.js"; + +// The exact map body Astro emits when the project (E:\) and the runtime (C:\) +// are on different Windows drives — taken from the issue #6 error log. +const CROSS_DRIVE = ` +export default new Map([ +["E:/project/Github/docs-poultry/api-reference/overview.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=E%3A%2Fproject%2FGithub%2Fdocs-poultry%2Fapi-reference%2Foverview.mdx&astroContentModuleFlag=true")], +["E:/project/Github/docs-poultry/index.mdx", () => import("astro:content-layer-deferred-module?astro%3Acontent-layer-deferred-module=&fileName=E%3A%2Fproject%2FGithub%2Fdocs-poultry%2Findex.mdx&astroContentModuleFlag=true")]]); +`; + +describe("rewriteContentModulesDrivePaths", () => { + test("rewrites drive-letter fileName params to file:// URLs", () => { + const out = rewriteContentModulesDrivePaths(CROSS_DRIVE); + // fileName now decodes to a file:// URL that `new URL(x, root)` accepts. + const param = out.match(/fileName=([^&"']+)/)?.[1] ?? ""; + expect(decodeURIComponent(param)).toBe( + "file:///E:/project/Github/docs-poultry/api-reference/overview.mdx", + ); + expect(out).not.toContain("fileName=E%3A"); + }); + + test("leaves the Map key (entry.filePath) untouched", () => { + const out = rewriteContentModulesDrivePaths(CROSS_DRIVE); + // The lookup key must still match the data store's stored filePath. + expect(out).toContain('["E:/project/Github/docs-poultry/api-reference/overview.mdx", () =>'); + }); + + test("the rewritten fileName survives new URL(fileName, root) cross-drive", () => { + const out = rewriteContentModulesDrivePaths(CROSS_DRIVE); + const param = out.match(/fileName=([^&"']+)/)?.[1] ?? ""; + const fileName = decodeURIComponent(param); + // Mirror Astro: resolve against a root on a *different* drive. + const root = "file:///C:/Users/quader/AppData/Roaming/npm/node_modules/tangly/runtime/"; + expect(() => new URL(fileName, root)).not.toThrow(); + expect(new URL(fileName, root).protocol).toBe("file:"); + }); + + test("percent-encodes URL-significant chars so the path is not truncated", () => { + // `#` and `%` are legal in filenames; a naive `new URL("file:///"+path)` + // would treat `#b.mdx` as a fragment and drop it. Round-trip the rewritten + // value exactly as Astro does (URLSearchParams decode → new URL(_, root)) + // and assert the full path survives. pathname is asserted (not + // fileURLToPath) so it's platform-independent on the POSIX CI runners. + const raw = "E:/docs/a#b%c.mdx"; + const input = `import("astro:content-layer-deferred-module?fileName=${encodeURIComponent(raw)}&astroContentModuleFlag=true")`; + const out = rewriteContentModulesDrivePaths(input); + const param = decodeURIComponent(out.match(/fileName=([^&"']+)/)?.[1] ?? ""); + const resolved = new URL(param, "file:///C:/runtime/"); + expect(resolved.protocol).toBe("file:"); + expect(resolved.hash).toBe(""); // nothing leaked into a fragment + expect(decodeURIComponent(resolved.pathname)).toBe("/E:/docs/a#b%c.mdx"); + }); + + test("is inert on POSIX-style relative fileNames (no drive letter)", () => { + const posix = `["api-reference/overview.mdx", () => import("astro:content-layer-deferred-module?fileName=api-reference%2Foverview.mdx&astroContentModuleFlag=true")]`; + expect(rewriteContentModulesDrivePaths(posix)).toBe(posix); + }); + + test("isContentModulesId matches POSIX and Windows ids", () => { + expect(isContentModulesId("/home/x/runtime/.astro/content-modules.mjs")).toBe(true); + expect(isContentModulesId("C:\\Users\\q\\tangly\\runtime\\.astro\\content-modules.mjs")).toBe( + true, + ); + expect(isContentModulesId("/home/x/runtime/.astro/data-store.json")).toBe(false); + }); +}); diff --git a/packages/tangly/src/plugin/content-modules-winpath.ts b/packages/tangly/src/plugin/content-modules-winpath.ts new file mode 100644 index 0000000..f3fd85b --- /dev/null +++ b/packages/tangly/src/plugin/content-modules-winpath.ts @@ -0,0 +1,75 @@ +/** + * Windows cross-drive fix for Astro's content layer (issue #6). + * + * Tangly runs Astro with `root` pointing at the runtime shipped inside the + * installed `tangly` package, while the user's docs live at `TANGLY_USER_ROOT`. + * When those are on *different drives* — e.g. a global install on `C:\` but a + * project on `E:\` — Astro's glob loader can't express the content path + * relative to `root` (there is no relative path across Windows drives), so it + * stores the absolute drive-letter path (`E:/project/.../page.mdx`) as the + * deferred-module `fileName`. + * + * At render time Astro resolves that entry with `new URL(fileName, root)`. A + * leading `E:` is parsed as a URL *scheme*, not a drive, so the result is a + * non-`file:` URL and `fileURLToPath` throws `ERR_INVALID_URL_SCHEME` — the + * page renders blank with no body. (Reported on `api-reference/overview` with + * project on `E:\` and a global `tangly` on `C:\`.) + * + * The generated `.astro/content-modules.mjs` looks like: + * + * export default new Map([ + * ["E:/project/docs/overview.mdx", () => import( + * "astro:content-layer-deferred-module?...&fileName=E%3A%2Fproject%2Fdocs%2Foverview.mdx&astroContentModuleFlag=true")], + * ]); + * + * We rewrite only the `fileName=` query value inside each deferred `import(...)` + * specifier from a drive-letter path to a `file://` URL. `new URL(fileUrl, + * root)` then ignores the base and `fileURLToPath` succeeds. The Map *key* is + * left untouched — it must keep matching `entry.filePath` from the data store, + * which the runtime uses to look the module up. + * + * Inert everywhere else: on POSIX and on same-drive Windows the stored fileName + * is a relative path, so the drive-letter guard never matches. + */ + +/** Matches an absolute Windows drive-letter path, e.g. `E:/...` or `E:\...`. */ +const DRIVE_LETTER = /^[A-Za-z]:[/\\]/; + +/** + * Turn an absolute Windows drive-letter path into a `file://` URL, percent- + * encoding each path segment. We build the URL string by hand rather than via + * `new URL("file:///" + path)` because URL-significant characters that are + * legal in filenames — `#` (fragment), `?` (query) — would otherwise be parsed + * as URL syntax and truncate the path (`E:/a#b.mdx` → `file:///E:/a`). The + * drive segment (`E:`) is kept literal; `fileURLToPath` decodes the rest. + */ +function driveLetterPathToFileUrl(path: string): string { + const norm = path.replace(/\\/g, "/"); + const slash = norm.indexOf("/"); + const drive = norm.slice(0, slash); // "E:" + const rest = norm.slice(slash + 1); // "a#b.mdx" / "dir/file.mdx" + const encoded = rest.split("/").map(encodeURIComponent).join("/"); + return `file:///${drive}/${encoded}`; +} + +/** + * Rewrite drive-letter `fileName=` params in a content-modules.mjs body to + * `file://` URLs. Returns the input unchanged when nothing matched. + */ +export function rewriteContentModulesDrivePaths(code: string): string { + return code.replace(/fileName=([^&"']+)/g, (whole, encoded: string) => { + let decoded: string; + try { + decoded = decodeURIComponent(encoded); + } catch { + return whole; + } + if (!DRIVE_LETTER.test(decoded)) return whole; + return `fileName=${encodeURIComponent(driveLetterPathToFileUrl(decoded))}`; + }); +} + +/** True when `id` points at Astro's generated content-modules.mjs. */ +export function isContentModulesId(id: string): boolean { + return id.replace(/\\/g, "/").endsWith("/.astro/content-modules.mjs"); +} diff --git a/packages/tangly/src/plugin/vite-plugin.ts b/packages/tangly/src/plugin/vite-plugin.ts index 52c724d..fb33382 100644 --- a/packages/tangly/src/plugin/vite-plugin.ts +++ b/packages/tangly/src/plugin/vite-plugin.ts @@ -9,6 +9,7 @@ import { generateSitemap, } from "../build-outputs/index.js"; import { buildIgnoreMatcher } from "../build-outputs/ignore-matcher.js"; +import { isContentModulesId, rewriteContentModulesDrivePaths } from "./content-modules-winpath.js"; import { applyMdxSourceCompat } from "./mdx-source-compat.js"; import { buildManifest } from "../manifest/index.js"; import { scanPages } from "../manifest/scan-pages.js"; @@ -246,6 +247,18 @@ export function tanglyVitePlugin(opts: TanglyPluginOptions): Plugin { transform(code, id) { const cleanId = id.split("?")[0] ?? id; + // Windows cross-drive fix (issue #6): when the user's project lives on a + // different drive than the runtime (project on `E:\`, global `tangly` on + // `C:\`), Astro stores absolute drive-letter paths as the deferred + // module `fileName`. `new URL("E:/…", root)` then reads `E:` as a URL + // scheme → `ERR_INVALID_URL_SCHEME` → blank page. Rewrite those to + // `file://` URLs before vite:import-analysis resolves them. Runs first + // because this plugin is `enforce: "pre"`. Inert on POSIX / same-drive. + if (isContentModulesId(cleanId)) { + const rewritten = rewriteContentModulesDrivePaths(code); + return rewritten === code ? null : { code: rewritten, map: null }; + } + // Mintlify `/snippets/*.{jsx,tsx}` snippets reference React hooks as // globals (no import statement). Prepend the standard hook import so // they compile under the preact/compat renderer. Skip files that diff --git a/scripts/smoke-winpath.ts b/scripts/smoke-winpath.ts new file mode 100644 index 0000000..caf5f67 --- /dev/null +++ b/scripts/smoke-winpath.ts @@ -0,0 +1,145 @@ +#!/usr/bin/env bun +// Windows cross-drive regression gate for issue #6. +// +// When a user's project lives on a different drive than the installed `tangly` +// runtime (project on E:\, global install on C:\), Astro's content layer stores +// absolute drive-letter paths as deferred-module fileNames; `new URL("E:/…", +// root)` then reads `E:` as a URL *scheme* → ERR_INVALID_URL_SCHEME → blank/500 +// page. The ubuntu/macos CI legs can't see this — it needs two drive letters. +// +// `subst` maps a free drive letter to a directory. Node's `path.relative` keys +// off the *letter*, not the physical volume, so a subst-ed drive reproduces the +// cross-drive crash deterministically on any Windows runner (no admin needed). +// Pre-fix this build crashes; post-fix it renders. Self-skips off Windows so it +// is safe to invoke from any OS. +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +if (process.platform !== "win32") { + log("· not Windows — skipping cross-drive gate (issue #6 is Windows-only)"); + process.exit(0); +} + +const repoRoot = resolve(import.meta.dirname, ".."); +const cli = resolve(repoRoot, "packages/tangly/bin/tangly.js"); +if (!existsSync(cli)) fail(`CLI not found at ${cli} — run \`bun run build\` first`); + +const OVERVIEW_MARKER = "TANGLY_WINPATH_OVERVIEW_BODY"; +const INDEX_MARKER = "TANGLY_WINPATH_INDEX_BODY"; + +const work = mkdtempSync(join(tmpdir(), "tangly-winpath-")); +log(`tmp: ${work}`); + +// Mirror the reported page: an MDX page with a component and body text under +// it, under api-reference/ (where the user saw the blank render). +writeFileSync(join(work, "index.mdx"), `---\ntitle: Home\n---\n\n# Home\n\n${INDEX_MARKER}\n`); +mkdirSync(join(work, "api-reference"), { recursive: true }); +writeFileSync( + join(work, "api-reference", "overview.mdx"), + `---\ntitle: Overview\n---\n\n# Overview\n\nHeads up.\n\n${OVERVIEW_MARKER}\n`, +); + +// Synthesize docs.json from the files (same scaffolder smoke-init uses). Runs +// on the real path — init doesn't trigger the content-render path; only build +// does, which is what must run cross-drive. +step("init --from (synthesize docs.json)"); +runCli(["init", "--from", work, work]); +assertExists(join(work, "docs.json")); + +// Pick a free drive letter and map it to the project dir. +const drive = pickFreeDrive(); +const driveRoot = `${drive}:\\`; +step(`subst ${drive}: → ${work} (cross-drive: runtime lives on ${repoRoot.slice(0, 2)})`); +subst([`${drive}:`, work]); + +try { + // Build with --root on the subst drive. The runtime is on the checkout + // drive, so root↔content are cross-drive — the exact issue #6 trigger. + step(`build --root ${driveRoot} (cross-drive render)`); + runCli(["build", "--out", "dist", "--root", driveRoot], { TANGLY_USER_ROOT: driveRoot }); + + const dist = join(work, "dist"); + assertExists(join(dist, "index.html")); + // The crash blanks the page body (or fails the build). Assert both pages' + // body markers actually rendered into the static HTML. + step("assert page bodies rendered (not blank)"); + assertHtmlContains(dist, INDEX_MARKER); + assertHtmlContains(dist, OVERVIEW_MARKER); + + log(`\n✓ windows cross-drive smoke passed (${work})`); +} finally { + // Always release the drive mapping, even on failure. + subst(["/d", `${drive}:`], { allowFail: true }); +} + +function pickFreeDrive(): string { + for (const letter of ["Z", "Y", "X", "W", "V", "T"]) { + if (!existsSync(`${letter}:\\`)) return letter; + } + fail("no free drive letter available for subst"); +} + +function subst(args: string[], opts: { allowFail?: boolean } = {}): void { + const result = spawnSync("subst", args, { stdio: "inherit" }); + if (result.status !== 0 && !opts.allowFail) { + fail(`subst ${args.join(" ")} exited ${result.status}`); + } +} + +function runCli(args: string[], env: Record = {}): void { + const result = spawnSync("bun", [cli, ...args], { + stdio: "inherit", + env: { ...process.env, ...env }, + }); + if (result.status !== 0) fail(`tangly ${args.join(" ")} exited ${result.status}`); +} + +/** Recursively search every .html under `dir` for `needle`. */ +function assertHtmlContains(dir: string, needle: string): void { + if (htmlContains(dir, needle)) { + log(` ✓ rendered HTML contains "${needle}"`); + return; + } + fail( + `expected some .html under ${dir} to contain "${needle}" (blank render → issue #6 regression)`, + ); +} + +function htmlContains(dir: string, needle: string): boolean { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + if (htmlContains(full, needle)) return true; + } else if (entry.name.endsWith(".html") && readFileSync(full, "utf8").includes(needle)) { + return true; + } + } + return false; +} + +function step(name: string): void { + log(`\n→ ${name}`); +} + +function assertExists(path: string): void { + if (!existsSync(path)) fail(`expected ${path} to exist`); + log(` ✓ ${path}`); +} + +function log(msg: string): void { + process.stdout.write(`${msg}\n`); +} + +function fail(msg: string): never { + process.stderr.write(`✗ ${msg}\n`); + process.exit(1); +} From 1280167b4fba7d3d8b6bef9180ca6044da4249bc Mon Sep 17 00:00:00 2001 From: Nik Cubrilovic Date: Fri, 12 Jun 2026 07:41:48 +1000 Subject: [PATCH 2/2] docs(changelog): add v0.2.2 entry (Windows cross-drive fix) --- docs/changelog.mdx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 4e8a181..0dfc9a4 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -5,6 +5,22 @@ description: Release notes for Tangly. Notable changes per release. Each entry is authored as an [``](/reference/components/callouts#update) block, the same component you can use in your own docs. + + ## Highlights + + Windows fix for projects that live on a different drive than the installed `tangly` (issue [#6](https://github.com/tanglydocs/tangly/issues/6)). + + - **Cross-drive projects render again.** With your docs on `E:\` and a global `tangly` on `C:\`, pages rendered blank: Astro stored an absolute drive-letter path for each page, and resolving it read the `E:` drive as a URL scheme (`ERR_INVALID_URL_SCHEME`). Those paths are now rewritten to `file://` URLs before resolution, so cross-drive builds and dev servers work. Same-drive Windows and macOS/Linux were never affected. ([`9200ec9`](https://github.com/tanglydocs/tangly/commit/9200ec978a66332a78d7b885cca517d1325f8beb)) + + ## Changes + + ### Fixes + - fix(plugin): resolve a cross-drive `ERR_INVALID_URL_SCHEME` that blanked pages when the project and the runtime were on different Windows drives ([`9200ec9`](https://github.com/tanglydocs/tangly/commit/9200ec978a66332a78d7b885cca517d1325f8beb)) + + ### Tests + - test(build): add a `windows-latest` CI gate that `subst`-maps a virtual drive to reproduce the cross-drive case before every release + + ## Highlights