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
7 changes: 7 additions & 0 deletions .changeset/curly-llamas-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"openwiki": patch
---

fix: emit relative markdown links so every renderer resolves them

Generated page bodies used root-relative links (`/openwiki/page.md`, `/src/foo.ts`) that no renderer resolves. Page bodies are now required to use paths relative to the linking file, the validator stamps any root-relative destination so the next update repairs it, and the visualizer graph resolves leftover root-relative links instead of dropping their edges.
5 changes: 3 additions & 2 deletions src/agent/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,9 @@ Output language:
export function createLinkIntegrityInstructions(): string {
return `
Link integrity:
- Prefer relative Markdown links to existing wiki pages and stable heading anchors. Do not invent destinations that are not written in the same run.
- OpenWiki validates relative internal links and heading anchors after the run. Broken links are left in place and marked with an HTML comment starting with "openwiki: broken internal link", so the run completes and a later update can self-correct. If you find such a comment, repair the href or restore the target page using the reason in the comment, then delete the comment.
- Write Markdown link destinations relative to the linking file, never rooted at /. The /-rooted virtual paths in the filesystem-tool instructions are only for tool calls. Use the relative form the generated index.md files use, for example ./architecture/overview.md, ../agent/workflow.md, or ../../src/main.ts; a root-relative destination such as /openwiki/page.md or /src/main.ts resolves in no renderer (GitHub, editor preview, or the OpenWiki visualizer graph) and is treated as broken.
- Prefer links to existing wiki pages and stable heading anchors. Do not invent destinations that are not written in the same run.
- OpenWiki validates internal links and heading anchors after the run. Broken links — root-relative ones included — are left in place and marked with an HTML comment starting with "openwiki: broken internal link", so the run completes and a later update can self-correct. If you find such a comment, repair the href or restore the target page using the reason in the comment, then delete the comment.
`;
}

Expand Down
73 changes: 58 additions & 15 deletions src/agent/wiki-link-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export async function validateWikiInternalLinks(

report.issuesFound += issues.length;
const stamped = stampBrokenLinks(cleaned, issues);
if (stamped === original) {
if (stamped === normalizeLineEndings(original)) {
continue;
}

Expand Down Expand Up @@ -170,6 +170,16 @@ export function stripBrokenLinkStamps(content: string): string {
.join("\n");
}

/**
* Normalizes CRLF and lone-CR line endings to LF, so a rewrite comparison is
* immune to the host platform's line-ending convention. `stripBrokenLinkStamps`
* already returns LF, so a file whose only difference from the clean content is
* line endings must not be rewritten (or reported as stamped).
*/
function normalizeLineEndings(content: string): string {
return content.replace(/\r\n?/gu, "\n");
}

/**
* Inserts broken-link stamps above each failing link line (bottom-up).
*/
Expand Down Expand Up @@ -221,6 +231,19 @@ async function validateLink(
return null;
}

// A leading-slash destination is root-relative: no renderer (GitHub, editor
// preview, or the visualizer graph) resolves it, so it cannot be relied on.
// Flag it so a later update run rewrites it as a path relative to this file,
// matching the relative form the deterministic index.md files already use.
if (path.posix.isAbsolute(href)) {
return {
href,
line,
message: `root-relative link "${href}" resolves in no renderer; rewrite it as a path relative to ${sourcePath}`,
sourcePath,
};
}

const { anchor, path: linkPath } = parseLinkDestination(href);
if (!linkPath) {
if (!anchor) {
Expand Down Expand Up @@ -252,7 +275,11 @@ async function validateLink(
? resolvedPath.replace(/\/+$/u, "")
: resolvedPath;

if (!(await pathExists(backend, targetPath, isDirectory))) {
const exists =
(await pathExists(backend, targetPath, isDirectory)) ||
(!isDirectory && (await pathExists(backend, targetPath, true)));

if (!exists) {
return {
href,
line,
Expand Down Expand Up @@ -428,13 +455,12 @@ function parseLinkDestination(rawHref: string): {
}

/**
* Resolves a link path to a normalized repo-absolute path, or undefined when it
* cannot be contained within the repo root.
* Resolves a relative link path against its source file's directory into a
* normalized repo-absolute path, or undefined when it cannot be contained
* within the repo root.
*
* A leading-slash link is absolute from the virtual filesystem root (the repo
* root in `repository` mode, the wiki dir in `local-wiki` mode) — the same
* convention the generation prompt teaches and GitHub renders. A relative link
* resolves against its source file's directory.
* Root-relative destinations are rejected by `validateLink` before this runs,
* so only relative paths reach this resolver.
*
* The result is not constrained to the wiki subtree: wiki pages may link out to
* other repo files, so containment is enforced at the repo root instead.
Expand All @@ -449,9 +475,7 @@ function resolveRepoLinkPath(
linkPath: string,
): string | null {
const candidate = path.posix.normalize(
linkPath.startsWith("/")
? linkPath
: path.posix.join(path.posix.dirname(sourcePath), linkPath),
path.posix.join(path.posix.dirname(sourcePath), linkPath),
);

return path.posix.isAbsolute(candidate) ? candidate : null;
Expand All @@ -468,20 +492,39 @@ function isExternalHref(href: string): boolean {
/**
* True when a wiki-absolute path resolves to an existing file or directory on
* the backend. Any read error is treated as "does not exist".
*
* Directory existence cannot be inferred from `ls` alone, because the backend
* lists a missing directory as an empty result rather than an error. Instead
* the parent directory is listed and the target's entry is matched by name, so
* a genuinely missing directory (e.g. a clamps-at-root `../` escape) still
* resolves to false.
*/
async function pathExists(
backend: BackendProtocolV2,
targetPath: string,
isDirectory: boolean,
): Promise<boolean> {
try {
if (isDirectory) {
const result = await backend.ls(targetPath);
if (!isDirectory) {
const result = await backend.readRaw(targetPath);
return !result.error;
}

const result = await backend.readRaw(targetPath);
return !result.error;
const normalized = targetPath.replace(/\/+$/u, "");
if (!normalized || normalized === "/") {
return true;
}

const parent = path.posix.dirname(normalized);
const name = path.posix.basename(normalized);
const result = await backend.ls(parent);
if (result.error) {
return false;
}

return (result.files ?? []).some(
(entry) => entry.is_dir === true && entryName(entry) === name,
);
} catch {
return false;
}
Expand Down
32 changes: 31 additions & 1 deletion src/visualize/graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,35 @@ async function readNode(file: string, wikiRoot: string): Promise<WikiNode> {
};
}

/**
* Resolve one markdown link target from its source file into a node id, or
* undefined when it matches no node. A relative link resolves against the
* source file's directory. A leading-slash (root-relative) link — which older
* generated wikis carry — is tried against the wiki root first, then against
* the repo root (the wiki root's parent), so both code-mode
* `/openwiki/foo.md` and local-mode `/foo.md` links resolve without the
* builder knowing the mode. Only candidates naming an existing node are
* accepted, so no edge is ever fabricated.
*/
function resolveNodeId(
wikiRoot: string,
fileDir: string,
link: string,
byId: Map<string, WikiNode>,
): string | undefined {
if (!link.startsWith("/")) {
const id = toId(wikiRoot, path.resolve(fileDir, link));
return byId.has(id) ? id : undefined;
}

const repoRelative = link.replace(/^\/+/, "");
const candidates = [
toId(wikiRoot, path.join(wikiRoot, repoRelative)),
toId(wikiRoot, path.join(path.dirname(wikiRoot), repoRelative)),
];
return candidates.find((id) => byId.has(id));
}

/**
* Resolve each node's markdown links into directed edges between existing nodes,
* recording them on the nodes' `links`/`backlinks` in place. Self-links, links to
Expand All @@ -306,7 +335,8 @@ function linkNodes(nodes: WikiNode[], wikiRoot: string): WikiEdge[] {
for (const node of nodes) {
const fileDir = path.dirname(path.join(wikiRoot, `${node.id}.md`));
for (const link of markdownLinks(node.body)) {
const target = toId(wikiRoot, path.resolve(fileDir, link));
const target = resolveNodeId(wikiRoot, fileDir, link, byId);
if (!target) continue;
const targetNode = byId.get(target);
const key = `${node.id}\n${target}`;
if (!targetNode || target === node.id || seen.has(key)) continue;
Expand Down
45 changes: 45 additions & 0 deletions test/visualize-graph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,51 @@ describe("buildGraph", () => {
expect(graph.edges).toEqual([]);
});

test("resolves root-relative links against the wiki root", async () => {
const root = await makeWiki({
"quickstart.md": "# Home\nSee [workflow](/agent/workflow.md).\n",
"agent/workflow.md": "# Workflow\n",
});

const graph = await buildGraph(root);

expect(graph.edges).toContainEqual({
source: "quickstart",
target: "agent/workflow",
});
expect(
graph.nodes.find((n) => n.id === "agent/workflow")?.backlinks,
).toContain("quickstart");
});

test("resolves root-relative links under an openwiki/ subdir", async () => {
const root = await makeWiki({
"openwiki/quickstart.md":
"# Home\nSee [Viewer Runtime](/openwiki/rendering/viewer.md).\n",
"openwiki/rendering/viewer.md": "# Viewer\n",
});

const graph = await buildGraph(path.join(root, "openwiki"));

expect(graph.edges).toContainEqual({
source: "quickstart",
target: "rendering/viewer",
});
expect(
graph.nodes.find((n) => n.id === "rendering/viewer")?.backlinks,
).toContain("quickstart");
});

test("does not create edges from root-relative links to unknown pages", async () => {
const root = await makeWiki({
"quickstart.md": "# Home\nSee [nope](/openwiki/missing.md).\n",
});

const graph = await buildGraph(root);

expect(graph.edges).toEqual([]);
});

test("does not follow a symlink that escapes the wiki root", async () => {
const secret = await mkdtemp(path.join(tmpdir(), "openwiki-secret-"));
tempDirs.push(secret);
Expand Down
Loading
Loading