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
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ turns it into JSX. Add a field to the view model, then to both renderers.
- **Status is derived once linked.** `rollup.ts` is a pure function; every
branch has a fixture test. Manual `status:` on a linked item is a validation
error, not a preference.
- **Item links leave resolved.** `links.ts` turns a relative `url` into a
`github.com/<repo>/blob/HEAD/…` URL in BOTH projections and drops what it
cannot resolve (V15 warns). Renderers never see a relative href; do not add
a second resolver in a renderer.
- **dispatch is dry-run by default** and creates only issues + the `roadmap`
label. It never edits an existing issue, never removes a label, never writes
yaml, never sets `auto-merge-ok` without `--auto-merge`, and puts `ready`
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ items:
phase: P1
visibility: public
depends_on: [p0-simcore]
links:
- { title: Design note, url: docs/design/garage-loop.md }
tasks:
- id: job-board
title: Job board with three job archetypes
Expand Down Expand Up @@ -53,6 +55,13 @@ milestones:
`source: { kind, by, at }`. Undated milestones render as drafts. A computed
date is not a date.

Item `links` are written from the repo's point of view and leave resolved: a
relative `url` such as `docs/design/garage-loop.md` becomes
`https://github.com/<meta.repo>/blob/HEAD/docs/design/garage-loop.md` in
every projection (`HEAD` is the default branch, whatever it is called).
Absolute URLs, `mailto:` and `#fragment` links pass through untouched; a link
that cannot be resolved (`../outside`) is dropped and `validate` warns (V15).

## Commands

Run it with `npx @hansenexus/nextup <command>` (node ≥ 20) or
Expand Down
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type { ClientOptions, CreateIssueInput, GitHubClient } from "./github";
export { createGitHubClient, fetchRoadmapIssues, GitHubError } from "./github";
export { reachable, topoSort } from "./graph";
export { localesOf, missingLocale, resolveText } from "./i18n";
export { GITHUB_LINK_REF, resolveLink, resolveLinks } from "./links";
export type { LoadAllResult, LoadedRoadmap, Located, ParseResult } from "./load";
export {
expandGlobs,
Expand Down
69 changes: 69 additions & 0 deletions links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { GITHUB_LINK_REF, resolveLink, resolveLinks } from "./links";

const REPO = "hansenexus/new-campus";
const BASE = `https://github.com/${REPO}/blob/${GITHUB_LINK_REF}/`;

describe("resolveLink", () => {
it("leaves absolute URLs untouched", () => {
for (const url of [
"https://example.com/x",
"http://example.com/x?y=1#z",
"HTTPS://EXAMPLE.COM",
"//cdn.example.com/asset.png",
"ftp://host/file",
])
expect(resolveLink(url, REPO)).toBe(url);
});
it("leaves #fragment-only and mailto:/tel: links untouched", () => {
expect(resolveLink("#section", REPO)).toBe("#section");
expect(resolveLink("mailto:hello@example.com", REPO)).toBe("mailto:hello@example.com");
expect(resolveLink("tel:+491234", REPO)).toBe("tel:+491234");
// …even when no repo is known: nothing to resolve against is needed.
expect(resolveLink("#section", null)).toBe("#section");
expect(resolveLink("mailto:a@b", null)).toBe("mailto:a@b");
});
it("resolves a relative path against the repo at HEAD", () => {
expect(resolveLink("docs/design/blueprint-module.md", REPO)).toBe(
`${BASE}docs/design/blueprint-module.md`
);
expect(GITHUB_LINK_REF).toBe("HEAD");
});
it("keeps the fragment and query of a relative path", () => {
expect(resolveLink("docs/PLAN.md#phasing", REPO)).toBe(`${BASE}docs/PLAN.md#phasing`);
expect(resolveLink("docs/PLAN.md?plain=1#phasing", REPO)).toBe(
`${BASE}docs/PLAN.md?plain=1#phasing`
);
});
it("normalizes ./, /, // and a trailing slash to a repo-root path", () => {
expect(resolveLink("./docs/x.md", REPO)).toBe(`${BASE}docs/x.md`);
expect(resolveLink("/docs/x.md", REPO)).toBe(`${BASE}docs/x.md`);
expect(resolveLink("docs//x.md", REPO)).toBe(`${BASE}docs/x.md`);
expect(resolveLink("docs/a/../x.md", REPO)).toBe(`${BASE}docs/x.md`);
expect(resolveLink("docs/", REPO)).toBe(`${BASE}docs`);
});
it("drops what it cannot resolve: no repo, a path above the root, query-only, empty", () => {
expect(resolveLink("docs/x.md", null)).toBeNull();
expect(resolveLink("../other-repo/x.md", REPO)).toBeNull();
expect(resolveLink("docs/../../x.md", REPO)).toBeNull();
expect(resolveLink("?tab=readme", REPO)).toBeNull();
expect(resolveLink(".", REPO)).toBeNull();
expect(resolveLink("", REPO)).toBeNull();
});
});

describe("resolveLinks", () => {
it("maps every link and drops the unresolvable ones, keeping order and titles", () => {
const links = [
{ title: "site", url: "https://example.com" },
{ title: "escapes", url: "../x" },
{ title: "doc", url: "docs/x.md" },
];
expect(resolveLinks(links, REPO)).toEqual([
{ title: "site", url: "https://example.com" },
{ title: "doc", url: `${BASE}docs/x.md` },
]);
expect(resolveLinks(links, null)).toEqual([{ title: "site", url: "https://example.com" }]);
expect(resolveLinks([], REPO)).toEqual([]);
});
});
74 changes: 74 additions & 0 deletions links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Item links, resolved against the roadmap's repository.
*
* A `links[].url` in roadmap.yaml is written from the repo's point of view —
* `docs/design/blueprint-module.md` — but rendered on a consumer's site, where
* a relative href resolves against THAT site's origin and 404s. So both
* projections resolve every relative link before it leaves:
*
* docs/PLAN.md#phasing → https://github.com/<owner>/<name>/blob/HEAD/docs/PLAN.md#phasing
*
* `HEAD` on purpose, not the default branch: GitHub resolves it server-side,
* so the output is identical with and without a token, and `build --check`
* never reports drift because someone renamed `master` to `main`.
*
* Untouched: anything with a scheme (`https:`, `mailto:`, `tel:` …),
* protocol-relative `//host/…`, and a bare `#fragment`. Dropped — and flagged
* by `validate` as V15 — is what cannot resolve: a path that climbs out of
* the repo (`../x`), a query-only `?x`, or any relative link when no repo is
* known. A link that cannot resolve is worse than no link.
*
* Pure and node-free so the renderers' dependency graph stays browser-safe.
*/

import type { Link } from "./types";

/** The ref relative links resolve to. GitHub maps it to the default branch. */
export const GITHUB_LINK_REF = "HEAD";

const SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;

/** `a/./b/../c/` → `a/c`; null when the path climbs above the root or is empty. */
function normalizeRepoPath(p: string): string | null {
const out: string[] = [];
for (const seg of p.split("/")) {
if (seg === "" || seg === ".") continue;
if (seg === "..") {
if (out.length === 0) return null;
out.pop();
continue;
}
out.push(seg);
}
return out.length === 0 ? null : out.join("/");
}

/**
* The href a renderer may emit for `url`, or null when there is none to emit.
* `repo` is `owner/name`; null means "unknown", which drops every relative link.
*/
export function resolveLink(url: string, repo: string | null): string | null {
if (url === "") return null;
if (SCHEME_RE.test(url) || url.startsWith("//") || url.startsWith("#")) return url;
if (url.startsWith("?")) return null;
if (!repo) return null;
const hash = url.indexOf("#");
const fragment = hash >= 0 ? url.slice(hash) : "";
const beforeHash = hash >= 0 ? url.slice(0, hash) : url;
const q = beforeHash.indexOf("?");
const query = q >= 0 ? beforeHash.slice(q) : "";
const rawPath = q >= 0 ? beforeHash.slice(0, q) : beforeHash;
const repoPath = normalizeRepoPath(rawPath);
if (repoPath === null) return null;
return `https://github.com/${repo}/blob/${GITHUB_LINK_REF}/${repoPath}${query}${fragment}`;
}

/** The links a projection ships: resolved, with the unresolvable ones dropped. */
export function resolveLinks(links: readonly Link[], repo: string | null): Link[] {
const out: Link[] = [];
for (const l of links) {
const url = resolveLink(l.url, repo);
if (url !== null) out.push({ title: l.title, url });
}
return out;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hansenexus/nextup",
"version": "0.1.3",
"version": "0.1.4",
"description": "Roadmaps as a committed roadmap.yaml: phases, items, milestones with provenance. Status rolls up from GitHub issues, tasks dispatch as ready-labelled issues, and one file projects to an internal view, a public JSON feed, a React component and a web component.",
"license": "MIT",
"keywords": [
Expand Down
45 changes: 45 additions & 0 deletions project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,43 @@ items:
const r2 = rollup({ roadmap: p2.value, file: "roadmap.yaml", issues: [] });
expect(toPublic(p2.value, r2).items[0]?.depends_on).toEqual([]);
});
it("resolves relative item links against meta.repo at HEAD; absolute ones pass through", () => {
// examples/dev-empire links `docs/PLAN.md#phasing` — the exact case new-campus hit.
const simcore = pub.items.find((i) => i.id === "p0-simcore");
expect(simcore?.links).toEqual([
{
title: "PLAN.md §Phasing",
url: "https://github.com/example/dev-empire/blob/HEAD/docs/PLAN.md#phasing",
},
]);
const p2 = parseRoadmap(`schema: 1
meta: { project: x, repo: o/r, title: X, version: 1, updated: 2026-09-01, public: true, current_phase: P0 }
phases: [{ id: P0, title: A }]
items:
- id: a
title: A
summary: S
phase: P0
visibility: public
links:
- { title: site, url: "https://example.com/x" }
- { title: mail, url: "mailto:a@b" }
- { title: anchor, url: "#top" }
- { title: doc, url: ./docs/x.md }
- { title: escapes, url: ../other/x.md }
`);
if (!p2.ok) throw new Error("parse");
const r2 = rollup({ roadmap: p2.value, file: "roadmap.yaml", issues: [] });
const links = toPublic(p2.value, r2).items[0]?.links.map((l) => l.url);
expect(links).toEqual([
"https://example.com/x",
"mailto:a@b",
"#top",
"https://github.com/o/r/blob/HEAD/docs/x.md",
]);
// Every link a projection ships is one a browser can follow from any origin.
expect(findForbiddenKeys(toPublic(p2.value, r2))).toEqual([]);
});
it("an internal phase hides its public items", () => {
const p2 = parseRoadmap(`schema: 1
meta: { project: x, repo: o/r, title: X, version: 1, updated: 2026-09-01, public: true, current_phase: P0 }
Expand Down Expand Up @@ -106,4 +143,12 @@ describe("toInternal", () => {
it("would be caught by the forbidden-key walk (sanity check of the guard itself)", () => {
expect(findForbiddenKeys(internal).length).toBeGreaterThan(0);
});
it("resolves item links the same way the public projection does", () => {
// The internal page is served from a tailnet forward, not from the repo:
// a relative href is just as broken there.
const simcore = internal.items.find((i) => i.id === "p0-simcore");
expect(simcore?.links.map((l) => l.url)).toEqual([
"https://github.com/example/dev-empire/blob/HEAD/docs/PLAN.md#phasing",
]);
});
});
8 changes: 7 additions & 1 deletion project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,13 @@
* apart silently.
*
* `toInternal` is everything, plus what the rollup learned.
*
* Both resolve item `links` against `meta.repo` (see `links.ts`): a
* projection is consumed on somebody else's origin, so a relative href must
* not leave here.
*/

import { resolveLinks } from "./links";
import type {
InternalRoadmap,
Item,
Expand Down Expand Up @@ -75,7 +80,7 @@ export function toPublic(roadmap: Roadmap, roll: Rollup): PublicRoadmap {
kind: it.kind,
...(it.area ? { area: it.area } : {}),
depends_on: it.depends_on.filter((d) => visibleIds.has(d)),
links: it.links,
links: resolveLinks(it.links, roadmap.meta.repo),
...(it.since ? { since: it.since } : {}),
};
});
Expand Down Expand Up @@ -149,6 +154,7 @@ export function toInternal(roadmap: Roadmap, roll: Rollup, file: string): Intern
const r = roll.items[it.id];
return {
...it,
links: resolveLinks(it.links, roadmap.meta.repo),
horizon: r?.horizon ?? "later",
derived_status: r?.status ?? it.status ?? "proposed",
because: r?.because ?? "",
Expand Down
3 changes: 2 additions & 1 deletion render-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ function renderPhase(p: PhaseView, view: RoadmapView, mode: ViewMode = "phases")
if (p.getsYou) parts.push(`<p class="nextup-gets">${esc(p.getsYou)}</p>`);
if (p.goal) parts.push(`<p class="nextup-goal">${esc(p.goal)}</p>`);
parts.push(`<div class="nextup-meta"><span>${esc(p.progress.label)}</span></div>`);
// Counted in items, not percent, and named — see the React component.
parts.push(
`<div class="nextup-progress" role="progressbar" aria-valuenow="${pct}" aria-valuemin="0" aria-valuemax="100"><span style="width:${pct}%"></span></div>`
`<div class="nextup-progress" role="progressbar" aria-label="${esc(p.progress.label)}" aria-valuemin="0" aria-valuemax="${p.progress.total}" aria-valuenow="${p.progress.done}"><span style="width:${pct}%"></span></div>`
);
if (p.items.length === 0) parts.push(`<p class="nextup-empty">—</p>`);
else {
Expand Down
29 changes: 28 additions & 1 deletion site/src/content/docs/roadmap-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ Ordered. Each phase:
| `since` | — | required with a manual status |
| `reason` | — | required with `dropped` |
| `mechanics_wave` | `null` | `done` additionally needs this wave all green |
| `owners`, `notes`, `links` | | internal |
| `owners`, `notes` | | internal |
| `links` | `[]` | `{ title, url }` pairs; ship in both projections, resolved against `meta.repo` (below) |
| `clients` | `[]` | reserved for a client tier; must stay empty in schema 1 |

### tasks
Expand All @@ -104,6 +105,32 @@ tasks:
`acceptance` and `verify` each need at least one entry: an issue without a
way to check it is not something an agent should pick up.

### links

```yaml
links:
- { title: Design note, url: docs/design/blueprint-module.md }
- { title: Tracking board, url: "https://github.com/orgs/acme/projects/4" }
```

A `url` is written from the repository's point of view, but the projection is
rendered on somebody else's origin — a consumer site, a Pages build — where a
relative href would resolve against the wrong host. So `build` (and every
other path that writes a projection) resolves links before they leave:

| `url` in the file | in the projection |
|---|---|
| `docs/x.md`, `./docs/x.md`, `/docs/x.md`, `docs/x.md#section` | `https://github.com/<meta.repo>/blob/HEAD/docs/x.md` (fragment kept) |
| `https://…`, `//cdn.…`, `mailto:…`, `tel:…` | untouched |
| `#section` | untouched |
| `../outside-the-repo`, `?query-only` | dropped; `validate` warns (V15) |

`HEAD` rather than a branch name on purpose: GitHub resolves it to the
default branch, so the output is the same with or without a token and does
not drift when `master` becomes `main`. The React component and the web
component render whatever the projection carries, so a relative href never
reaches a page.

## milestones

| field | default | |
Expand Down
10 changes: 10 additions & 0 deletions site/src/content/docs/sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ Every projection carries `version`, `updated`, `generated_at` and `stale`;
the components render "v3 · Stand 2026-09-07" and a *Stand veraltet* badge
when the data is stale.

Item links arrive resolved: a relative `url` in the yaml is already a
`https://github.com/<repo>/blob/HEAD/…` URL by the time a component sees it
(see [roadmap.yaml → links](/roadmap-yaml/#links)), so a site never emits an
href that resolves against its own origin by accident.

Each phase's progress bar is a `role="progressbar"` counted in items
(`aria-valuenow` done, `aria-valuemax` total) and named in the requested
locale — "3 of 8 done" / "3 von 8 erledigt" — so a screen reader announces
the phase's progress rather than a bare bar.

## React

```tsx
Expand Down
1 change: 1 addition & 0 deletions site/src/content/docs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ roadmap.yaml: warning V5 items[5].depends_on[0]: depends on "p2-staff" which is
| V12 | `meta.updated` and `reached_at` are not in the future | error |
| V13 | a phase with no items | warning |
| V14 | `mechanics_wave` references need `meta.mechanics_waves` and the wave file must exist | error |
| V15 | an item link that cannot be resolved against `meta.repo` (climbs out of the repo, query-only) — it is dropped from the projections | warning |

`--live` adds three network checks: every linked issue number exists,
markers on `roadmap`-labelled issues resolve to an item, and a manual status
Expand Down
18 changes: 18 additions & 0 deletions ui-react/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,22 @@ describe("<Roadmap>", () => {
const html = renderToStaticMarkup(<Roadmap data={toPublic(roadmap, roll)} />);
expect(html).toContain('lang="en"');
});
it("names the phase progress bar and counts it in items, in the requested locale", () => {
// P0 in dev-empire: 2 of 2 public items done.
const en = renderToStaticMarkup(<Roadmap data={toPublic(roadmap, roll)} locale="en" />);
expect(en).toContain(
'<div class="nextup-progress" role="progressbar" aria-label="2 of 2 done" aria-valuemin="0" aria-valuemax="2" aria-valuenow="2">'
);
const de = renderToStaticMarkup(<Roadmap data={toPublic(roadmap, roll)} locale="de" />);
expect(de).toContain('aria-label="2 von 2 erledigt" aria-valuemin="0" aria-valuemax="2"');
// Never percent: a bar over 100 would read as "2 of 100".
expect(en).not.toContain('aria-valuemax="100"');
});
it("emits item links already resolved against the repo", () => {
const html = renderToStaticMarkup(<Roadmap data={toPublic(roadmap, roll)} locale="en" />);
expect(html).toContain(
'href="https://github.com/example/dev-empire/blob/HEAD/docs/PLAN.md#phasing"'
);
expect(html).not.toContain('href="docs/');
});
});
Loading
Loading