Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 16 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ description: Release notes for Tangly.

Notable changes per release. Each entry is authored as an [`<Update>`](/reference/components/callouts#update) block, the same component you can use in your own docs.

<Update label="v0.2.2" description="12 Jun 26" tags={["fix"]}>
## 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
</Update>

<Update label="v0.2.1" description="10 Jun 26" tags={["feat", "fix"]}>
## Highlights

Expand Down
67 changes: 67 additions & 0 deletions packages/tangly/src/plugin/content-modules-winpath.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
75 changes: 75 additions & 0 deletions packages/tangly/src/plugin/content-modules-winpath.ts
Original file line number Diff line number Diff line change
@@ -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");
}
13 changes: 13 additions & 0 deletions packages/tangly/src/plugin/vite-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
145 changes: 145 additions & 0 deletions scripts/smoke-winpath.ts
Original file line number Diff line number Diff line change
@@ -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\n<Note>Heads up.</Note>\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<string, string> = {}): 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);
}
Loading