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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

All notable changes to Context are documented here.

## 0.7.87 - 2026-10-26

- Each website article now ends with its sources as a map. The article's sections sit in the middle and the documents they cite sit around them in flat blobs, grouped by where each document comes from: a repository directory, Lark documents or notes. Sections are purple; repository files are green, Skills magenta, Lark documents blue and notes orange. Hovering a section or document highlights what it is linked to and shows the cited line ranges; clicking a document opens the cited lines and clicking a section scrolls to it. A List view shows the same documents by group, and narrow screens show only the list. Other articles citing the same documents are linked below. The map is computed during `context build` from the recorded references, registered sources and committed snapshots, so it needs no agent step. Set `site.sourceMap: false` to keep the plain source list.
- The `context` entry Skill includes a source checklist template a team can fill in before a knowledge base is built, and explains how to register sources from a filled checklist; the planning Skill asks once whether a checklist exists before inventorying sources.

## 0.7.86 - 2026-10-26

- Website emphasis now follows the CJK-friendly amendment to CommonMark through `markdown-it-cjk-friendly`, replacing 0.7.85's built-in rule. Any East Asian wide or full-width character beside a `*` run, including full-width punctuation, counts like whitespace when deciding whether the run opens or closes emphasis; `_` and runs with no CJK character beside them keep the CommonMark result. Clean content renders as in 0.7.85.
Expand Down
32 changes: 17 additions & 15 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "context",
"version": "0.7.86",
"version": "0.7.87",
"packageManager": "bun@1.3.9",
"repository": {
"type": "git",
Expand Down
2 changes: 1 addition & 1 deletion packages/context-cli/context-workflow/provider.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
schema: agent-graph.provider.v1
id: c4a/context
version: 0.7.86
version: 0.7.87
name: Context workflow
description: Internal work contract for Context knowledge workspaces.
graphs:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,18 @@ zoom, full-screen and source controls. Controls appear on hover or keyboard focu
(and remain visible on touch devices). Layout and source/copy actions are on the
left, sizing actions on the right. Source view replaces the diagram. Flowcharts default to ELK and can switch to Dagre without changing the article. Offline Review reports use the same
viewer and include its resources only when a candidate contains Mermaid.
Article provenance initially shows ten sources; Show more reveals the full list.
Each article ends with its sources as a map: the article's sections in the
middle and the documents they cite around them, grouped by where each document
comes from (a repository directory, Lark documents or notes). Sections are purple;
sources are coloured by type: repository files green, Skills magenta, Lark
documents blue and notes orange. Hovering a section
or document highlights what it is linked to; clicking a document opens the cited
lines, and clicking a section scrolls to it. A List view shows the same documents
by group with their cited line ranges, and narrow screens show only the list.
Below the map, other articles citing the same documents are linked. The map is
computed while building from the recorded references, so it needs no agent
step; set `site.sourceMap: false` to show the plain source list instead, which
initially shows ten sources; Show more reveals the full list.
Invalid diagrams and diagrams with embedded image/HTML resources retain their
source with a rendering notice instead of blocking publication. Code samples and raw HTML are
displayed as content, not executed as Vue components or scripts.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,8 @@ the same build, beside the KB directory. `<base>` removes one trailing `-kb`
from the package name, if present. Omit it for KB-only output. It accepts `title`, `description`,
`lang`, a deployment `base` path, an optional top-bar `titleLink`, and an
optional `home` presentation. Navigation and language fields are `nav`, `more`,
`socialLinks`, `locales` and `localePreferenceKey`; see
`socialLinks`, `locales` and `localePreferenceKey`; `sourceMap: false` replaces
the per-article source map with a plain source list; see
[Package Outputs](../guides/package-outputs.md) and
[Site translation](../guides/site-translation.md). `home`
accepts `title`, `slogan`, `description`, hero `actions`, and `resources` shown
Expand Down
4 changes: 3 additions & 1 deletion packages/context-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@c4a/context-cli",
"description": "Local runtime and Agent integration for traceable knowledge production",
"version": "0.7.86",
"version": "0.7.87",
"type": "module",
"license": "MIT",
"engines": {
Expand Down Expand Up @@ -67,6 +67,7 @@
"@c4a/extract-ts": "workspace:*",
"@mermaid-js/layout-elk": "0.2.0",
"commander": "^11.0.0",
"d3-force": "3.0.0",
"fast-xml-parser": "^5.10.1",
"handlebars": "^4.7.8",
"ink": "^5.0.0",
Expand All @@ -89,6 +90,7 @@
},
"devDependencies": {
"@c4a/core": "workspace:*",
"@types/d3-force": "3.0.10",
"esbuild": "0.27.3",
"playwright": "1.58.2",
"zod-to-json-schema": "^3.25.1"
Expand Down
26 changes: 25 additions & 1 deletion packages/context-cli/src/__tests__/packageSite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,12 @@ test("VitePress builds an independent site with search, safe prose, anchors and
expect(article).not.toContain("<script>unsafe()");
expect(article).not.toContain("unsafe-script");
expect(article).toContain("language-mermaid");
expect(article).toContain('class="context-sources"');
// Recorded references render as the source map by default, linking sections, cited lines and articles citing the same file.
expect(article).toContain('class="context-sources context-source-map"');
expect(article).not.toContain('class="context-sources"');
expect(article).toContain('id="context-source-line-1"');
expect(article).toContain('href="#section-source"');
expect(article).toMatch(/class="context-source-others"[\s\S]*?href="\/docs\/pages\/[0-9a-f]+\.html"/u);
expect(article).toContain("https://github.com/example/components/blob/abc123/packages/ui/src/entry.ts#L1-L1");
const sourceLinks = [...article.matchAll(/<a class="context-source-link" href="([^"]+)"[^>]*><code>([^<]+)<\/code><\/a>/gu)].map(match => [match[2], match[1]]);
expect(sourceLinks).toEqual([
Expand Down Expand Up @@ -221,6 +226,25 @@ test("VitePress builds an independent site with search, safe prose, anchors and
}, 120000);


test("site.sourceMap false keeps the plain source list", async () => {
const root = await mkdtemp(join(tmpdir(), "context-site-"));
try {
const plain = kbPackage({ name: "sample", template: "src/templates", site: { title: "Knowledge", sourceMap: false } });
const files = selected.slice(0, 1).map(file => ({ ...file, article: { ...file.article, sections: [{ id: "entry", references: [{
source_ref: "repo:20260910/sample", locator: { path: "src/entry.ts", start_line: 2, end_line: 4 }, content_digest: "sha256:" + "a".repeat(64) }] }] } }));
const page = createSiteNavigation(plain, files).pages[0]!;
await mkdir(join(root, plain.outDir, page.package_path, ".."), { recursive: true });
await writeFile(join(root, plain.outDir, page.package_path), "# Start\n\n## Entry\n");
await mkdir(join(root, "sources/repo"), { recursive: true });
await writeFile(join(root, "sources/repo/index.yaml"), 'sources:\n - name: "20260910"\n modules:\n - name: sample\n git:\n remote: https://github.com/example/components.git\n ref: abc123\n');
await writePackageSite({ projectRoot: root, pkg: plain, selected: files });
const html = await readFile(join(root, packageSiteOutputDir(plain), page.site_path), "utf8");
expect(html).toContain('class="context-sources"');
expect(html).not.toContain("context-source-map");
expect(html).toContain("https://github.com/example/components/blob/abc123/src/entry.ts#L2-L4");
} finally { await rm(root, { recursive: true, force: true }); }
}, 120000);

test("deployment roots retain subpaths and reject non-site URLs without network access", async () => {
expect(normalizeSiteUrl("https://example.com/docs")).toBe("https://example.com/docs/");
expect(normalizeSiteUrl("https://example.com/")).toBe("https://example.com/");
Expand Down
88 changes: 88 additions & 0 deletions packages/context-cli/src/__tests__/packageSiteSourceMap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { expect, test } from "bun:test";
import type { ArticleStructureEntry, SourcesRegistry } from "@c4a/context";
import { createSiteSourceMaps, siteDocumentKey, siteSectionHeadings } from "../project/packageSiteSourceMap.js";
import { siteArticleSources } from "../project/packageSiteSources.js";

const registry: SourcesRegistry = {
kind: "sources.registry", registryPaths: { repo: "", file: "", lark: "" }, absolutePaths: { repo: "", file: "", lark: "" },
repos: [{ id: "snapshot/ui", name: "ui", namespace: "snapshot", module: "ui", materializedAt: "", remote: "git@github.com:example/widgets.git", ref: "abc123def4567890abc123def4567890abc123de" }],
larks: [{ id: "snapshot/guide", name: "guide", title: "Usage guide", materializedAt: "", url: "https://docs.example.com/guide" }], files: [],
notes: [{ id: "20261001/decisions.md", name: "20261001/decisions.md", namespace: "20261001", module: "decisions.md", materializedAt: "sources/note/20261001/decisions.md" }], sessions: [],
};
const digest = `sha256:${"a".repeat(64)}`;
const ref = (source_ref: string, path: string, start_line = 1, end_line = 4) => ({ source_ref, locator: { path, start_line, end_line }, content_digest: digest });
const entry = (id: string, sections: ArticleStructureEntry["sections"]) => ({
article: { article_id: id, path: `${id}.md`, collection: "codeindex", visibility: "public", sections } as ArticleStructureEntry,
href: `/pages/${id}.html`, titles: { "en-US": `Title ${id}`, "zh-CN": `标题 ${id}` },
sectionTitles: Object.fromEntries(sections.map(section => [section.id, { "en-US": section.id, "zh-CN": "" }])),
});
const button = entry("button", [
{ id: "usage", references: [ref("repo:snapshot/ui", "src/components/button/index.ts", 3, 8), ref("lark:snapshot/guide", "guide.md")] },
{ id: "styling", references: [ref("repo:snapshot/ui", "src/components/button/index.ts", 20, 30), ref("repo:snapshot/ui", "skills/button-style/SKILL.md")] },
{ id: "why", references: [ref("note:20261001/decisions.md", "decisions.md")] },
]);
const input = { registry, titles: new Map([[siteDocumentKey("note:20261001/decisions.md", "decisions.md"), "Design decisions"]]),
articles: [button, entry("theme", [{ id: "tokens", references: [ref("repo:snapshot/ui", "skills/button-style/SKILL.md", 9, 12)] }]),
entry("empty", [{ id: "intro", references: [] }])] };

test("source maps come from recorded references only, grouped by where each document lives", () => {
const map = createSiteSourceMaps(input).get("button")!;
expect(map.docs.map(doc => [doc.title, doc.path])).toEqual([
["index", "src/components/button/index.ts"], ["Usage guide", "guide.md"], ["button-style", "skills/button-style/SKILL.md"], ["Design decisions", "decisions.md"],
]);
// Two regions of one file stay distinct citations, each with the link the plain list uses.
expect(map.docs[0]!.refs).toEqual([
{ s: 0, lines: [3, 8], href: siteArticleSources(button.article, registry)[0]!.href! },
{ s: 1, lines: [20, 30], href: siteArticleSources(button.article, registry)[2]!.href! },
]);
expect(map.areas.map(area => [area.kind, area.label])).toEqual([
["repo", "ui · button"], ["lark", { zh: "飞书文档", en: "Lark docs" }], ["repo", "ui · button-style"], ["note", { zh: "笔记", en: "Notes" }],
]);
expect(map.sections.map(section => section.titles)).toEqual([{ "en-US": "usage", "zh-CN": "" }, { "en-US": "styling", "zh-CN": "" }, { "en-US": "why", "zh-CN": "" }]);
// Articles citing the same document link to each other; an article without references gets no map.
expect(map.others).toEqual([{ titles: { "en-US": "Title theme", "zh-CN": "标题 theme" }, href: "/pages/theme.html", docs: [2] }]);
expect(createSiteSourceMaps(input).has("empty")).toBe(false);
});

test("the layout is deterministic and stays inside the drawing", () => {
const first = createSiteSourceMaps(input).get("button")!;
expect(createSiteSourceMaps(input).get("button")).toEqual(first);
for (const node of [...first.sections, ...first.docs]) {
expect(node.x).toBeGreaterThanOrEqual(70); expect(node.x).toBeLessThanOrEqual(first.w - 70);
expect(node.y).toBeGreaterThanOrEqual(50); expect(node.y).toBeLessThanOrEqual(first.h - 64);
}
expect(first.sectionArea.blobs).toHaveLength(first.sections.length * 4);
// Each label's free space reaches halfway to its row neighbours and stays inside the drawing.
for (const node of [...first.sections, ...first.docs]) {
expect(node.rl).toBeGreaterThan(0); expect(node.rr).toBeGreaterThan(0);
expect(node.x - node.rl).toBeGreaterThanOrEqual(8); expect(node.x + node.rr).toBeLessThanOrEqual(first.w - 8);
}
// Area names lean with their blob's edge, never steeper than about 29 degrees.
for (const area of [first.sectionArea, ...first.areas]) expect(Math.abs(area.la)).toBeLessThanOrEqual(28.7);
});

test("many directories coarsen into a few source areas", () => {
const many = entry("many", [{ id: "all", references: ["a", "b", "c"].map(name => ref("repo:snapshot/ui", `packages/${name}/src/index.ts`)) },
{ id: "more", references: ["d", "e", "f", "g"].map(name => ref("repo:snapshot/ui", `packages/${name}/src/index.ts`)) }]);
const map = createSiteSourceMaps({ ...input, articles: [many] }).get("many")!;
expect(map.areas.map(area => area.label)).toEqual(["ui · packages"]);
});

test("sections are named by their heading, else by their first prose; the opening has no name", () => {
const headings = siteSectionHeadings([
'<!-- context:section id="intro" -->', "Opening text.", "<!-- /context:section -->",
'<!-- context:section id="setup" -->', "## Set up **the** [client](./client.md) {#setup}", "", "### Detail", "<!-- /context:section -->",
'<!-- context:section id="note" -->', "> [!NOTE]", "> Use **version 2** when you install `two` packages.", "<!-- /context:section -->",
].join("\n"));
expect([...headings]).toEqual([["setup", "Set up the client"], ["note", "Use version 2 when you install two packages."]]);
});

test("each source type has one colour: repository files, Skills, Lark and notes", () => {
const map = createSiteSourceMaps(input).get("button")!;
// src/components/button/index.ts is code, skills/button-style/SKILL.md a Skill.
expect(map.areas.map(area => [area.kind, area.color])).toEqual([
["repo", "#16a34a"], ["lark", "#2e7dd6"], ["repo", "#db2777"], ["note", "#ea7a1e"],
]);
const docs = entry("docs", [{ id: "guide", references: [ref("repo:snapshot/ui", "docs/guide.md")] }]);
expect(createSiteSourceMaps({ ...input, articles: [docs] }).get("docs")!.areas[0]!.color).toBe("#16a34a");
});
2 changes: 1 addition & 1 deletion packages/context-cli/src/project/indexerBaseContracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { bundledMarkdownReaderQuestionContracts } from
"./indexerBaseMarkdownAuthoringCatalog.js";

const BASE_CONTRACT_VERSION = "1.1.0";
export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.86";
export const BUNDLED_INDEXER_PARSER_PACKAGE_VERSION = "0.7.87";
const BUNDLED_PARSER_REQUIREMENTS = buildIndexerParserCapabilityRequirements(
BUNDLED_INDEXER_PARSER_PACKAGE_VERSION,
);
Expand Down
Loading
Loading