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
100 changes: 100 additions & 0 deletions web/frontend/src/changelogNotes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import { parseNote, splitHeadline, toRuns } from "./changelogNotes";

describe("parseNote", () => {
it("extracts label, tone and PR numbers from a decorated prefix", () => {
const n = parseNote("New (#658, #664): a Ctrl+K command palette. It jumps to any page.");
expect(n.label).toBe("New");
expect(n.tone).toBe("green");
expect(n.prs).toEqual([658, 664]);
expect(n.headline).toBe("a Ctrl+K command palette.");
expect(n.rest).toBe("It jumps to any page.");
});

it("recognises prefixes case-insensitively and non-PR decorations", () => {
const n = parseNote("BREAKING (sec-20 follow-up): tokens rotate.");
expect(n.label).toBe("BREAKING");
expect(n.tone).toBe("red");
expect(n.prs).toEqual([]);
});

it("prefers the longest prefix alternative", () => {
expect(parseNote("Plugin docs: refreshed.").label).toBe("Plugin docs");
expect(parseNote("Plugin docs: refreshed.").tone).toBe("dim");
});

it("leaves unprefixed notes label-less with the full text as body", () => {
const n = parseNote("Just a plain remark. With detail.");
expect(n.label).toBeNull();
expect(n.headline).toBe("Just a plain remark.");
expect(n.rest).toBe("With detail.");
});

it("maps every documented prefix to its CLI tone", () => {
expect(parseNote("Fix: x.").tone).toBe("amber");
expect(parseNote("Change: x.").tone).toBe("blue");
expect(parseNote("UX: x.").tone).toBe("magenta");
expect(parseNote("Note: x.").tone).toBe("cyan");
expect(parseNote("Security: x.").tone).toBe("red");
expect(parseNote("Internal: x.").tone).toBe("dim");
});
});

describe("splitHeadline", () => {
it("does not break inside version numbers", () => {
const { headline } = splitHeadline("since 0.57.0 the flow works. Detail here.");
expect(headline).toBe("since 0.57.0 the flow works.");
});

it("does not break after e.g.", () => {
const { headline, rest } = splitHeadline("some flags, e.g. `--json`, help. More.");
expect(headline).toBe("some flags, e.g. `--json`, help.");
expect(rest).toBe("More.");
});

it("returns the whole text when there is a single sentence", () => {
const { headline, rest } = splitHeadline("One sentence only.");
expect(headline).toBe("One sentence only.");
expect(rest).toBe("");
});

it("treats ! and ? as sentence ends even after a digit", () => {
const { headline } = splitHeadline("exit code 5! And more.");
expect(headline).toBe("exit code 5!");
});

// The abbreviation set is an exact port of _HEADLINE_ABBREVIATIONS in
// changelog.py; every member must suppress the split here too, or a note
// reads differently on the page than under `kbagent changelog`.
it.each(["e.g", "i.e", "vs", "etc", "cf", "no", "al", "inc"])(
"does not break after the %s. abbreviation",
(abbr) => {
const { headline } = splitHeadline(`before ${abbr}. after. Second sentence.`);
expect(headline).toBe(`before ${abbr}. after.`);
},
);

it("breaks after an abbreviation-like word outside the ported set", () => {
const { headline } = splitHeadline("shipped incl. extras. More.");
expect(headline).toBe("shipped incl.");
});

it("resolves the trailing token to its final word, digits and underscores aside", () => {
const { headline } = splitHeadline("the flag kbagent_no. after. More.");
expect(headline).toBe("the flag kbagent_no. after.");
});
});

describe("toRuns", () => {
it("splits backtick spans into code runs", () => {
expect(toRuns("run `kbagent serve --ui` today")).toEqual([
{ code: false, text: "run " },
{ code: true, text: "kbagent serve --ui" },
{ code: false, text: " today" },
]);
});

it("passes through text without backticks", () => {
expect(toRuns("plain")).toEqual([{ code: false, text: "plain" }]);
});
});
148 changes: 148 additions & 0 deletions web/frontend/src/changelogNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Client-side parser for changelog notes.
* =======================================
*
* `GET /changelog` returns the raw bullet strings from `changelog.py`, which
* follow the authoring contract the CLI renderer already exploits
* (`commands/changelog.py`): a leading `Prefix (#PR):` tag, a self-contained
* first sentence, and inline backtick spans. This module ports that parsing
* so the web page can render the same semantics -- typed badges, PR links,
* headline-first collapsing -- instead of dumping the raw text.
*
* Keep the prefix list and sentence rules in sync with
* `commands/changelog.py` (`_PREFIX_RE`) and `changelog.py` (`headline()`).
*/

/** Visual grouping for a note type; maps 1:1 to the CLI's Rich styles. */
export type NoteTone = "red" | "green" | "amber" | "blue" | "magenta" | "cyan" | "dim";

export interface ParsedNote {
/** Canonical prefix label, e.g. "New", "Fix", "BREAKING". Null when the note has no recognised prefix. */
label: string | null;
tone: NoteTone;
/** PR numbers pulled from the prefix decoration, e.g. "(#658, #664)" -> [658, 664]. */
prs: number[];
/** First sentence of the body (prefix stripped) -- the collapsed view. */
headline: string;
/** Body text after the headline; empty string when the headline is the whole note. */
rest: string;
}

/** One piece of a rendered text run: plain text or an inline code span. */
export interface TextRun {
code: boolean;
text: string;
}

// Longest-alternative-first, mirroring _PREFIX_RE in commands/changelog.py.
const PREFIX_RE = new RegExp(
"^(Plugin docs|Review fixes|Observability|Breaking|Security|Closed|Tests|" +
"Internal|Change|Note|Fix|New|UX|E2E|Why)" +
"(\\s*\\(([^)]*)\\))?" + // optional "(#274)" / "(sec-20 follow-up)" decoration
":\\s+",
"i",
);

const TONES: Record<string, NoteTone> = {
breaking: "red",
security: "red",
new: "green",
fix: "amber",
change: "blue",
closed: "blue",
ux: "magenta",
note: "cyan",
tests: "dim",
"plugin docs": "dim",
internal: "dim",
observability: "dim",
e2e: "dim",
"review fixes": "dim",
why: "dim",
};

/** Render the label the way the changelog writes it: BREAKING stays shouted, the rest title-case. */
const LABELS: Record<string, string> = {
breaking: "BREAKING",
security: "Security",
new: "New",
fix: "Fix",
change: "Change",
closed: "Closed",
ux: "UX",
note: "Note",
tests: "Tests",
"plugin docs": "Plugin docs",
internal: "Internal",
observability: "Observability",
e2e: "E2E",
"review fixes": "Review fixes",
why: "Why",
};

// Abbreviations whose trailing period is not a sentence end. Exact port of
// _HEADLINE_ABBREVIATIONS in changelog.py -- a note must split the same way
// here and in `kbagent changelog`, so this set carries no additions of its own.
const ABBREVIATIONS = new Set(["e.g", "i.e", "vs", "etc", "cf", "no", "al", "inc"]);

// Final alphabetic token (internal dots included) before a period, tested
// against ABBREVIATIONS. Port of _TRAILING_TOKEN: letters only, so a token
// carrying digits or underscores resolves to its trailing word exactly as it
// does in Python ("kbagent_no." -> "no").
const TRAILING_TOKEN = /[A-Za-z][A-Za-z.]*$/;

/**
* First-sentence split, porting `headline()`'s rules: a `.`/`!`/`?` followed
* by whitespace ends the sentence, unless the period sits inside a version
* number (digit before it) or terminates a known abbreviation.
*/
export function splitHeadline(text: string): { headline: string; rest: string } {
const boundary = /[.!?](?=\s)/g;
let m: RegExpExecArray | null = boundary.exec(text);
while (m !== null) {
const dot = m.index;
const before = dot > 0 ? text[dot - 1] : "";
const isDigitPeriod = text[dot] === "." && before >= "0" && before <= "9";
const tokenMatch = TRAILING_TOKEN.exec(text.slice(0, dot));
const token = tokenMatch ? tokenMatch[0].replace(/\.+$/, "").toLowerCase() : "";
if (!isDigitPeriod && !ABBREVIATIONS.has(token)) {
return {
headline: text.slice(0, dot + 1),
rest: text.slice(dot + 1).trim(),
};
}
m = boundary.exec(text);
}
return { headline: text, rest: "" };
}

export function parseNote(note: string): ParsedNote {
const m = PREFIX_RE.exec(note);
let label: string | null = null;
let tone: NoteTone = "dim";
let prs: number[] = [];
let body = note;
if (m) {
const key = m[1].toLowerCase();
label = LABELS[key] ?? m[1];
tone = TONES[key] ?? "dim";
prs = [...(m[3] ?? "").matchAll(/#(\d+)/g)].map((g) => Number(g[1]));
body = note.slice(m[0].length);
}
const { headline, rest } = splitHeadline(body);
return { label, tone, prs, headline, rest };
}

/** Split text into plain/inline-code runs on backtick spans (`` `like this` ``). */
export function toRuns(text: string): TextRun[] {
const runs: TextRun[] = [];
for (const part of text.split(/(`[^`\n]+`)/)) {
if (!part) continue;
if (part.startsWith("`") && part.endsWith("`") && part.length >= 2) {
runs.push({ code: true, text: part.slice(1, -1) });
} else {
runs.push({ code: false, text: part });
}
}
return runs;
}
Loading