diff --git a/docs/README.md b/docs/README.md index 392a0d8c373..7d2612e0d06 100644 --- a/docs/README.md +++ b/docs/README.md @@ -67,7 +67,7 @@ Note that you cannot use the macros (`#include_aztec_version` and `#include_code The way docs builds work is the following: - Netlify builds this directory (`netlify.toml`). A push to `next` that changes `docs/` publishes docs.aztec.network about 15 minutes later; a push that changes nothing under `docs/` publishes nothing. A pull request that changes `docs/` gets a deploy preview. See [Publishing](#publishing). -- Netlify hosts and serves the site: the domain, the `netlify.toml` redirects, the `subscribe` function and the markdown edge function. +- Netlify hosts and serves the site: the domain, the `netlify.toml` redirects, the `subscribe` function, the markdown edge function and the migration notes range edge function (`migration_notes.md?from=&to=`, read from the `migration_notes.json` index the build writes). - [The main CI workflow](../.github/workflows/ci3.yml) runs the docs reference and sync checks on every pull request. The docs build and tests (`make docs docs-tests`) run in full CI, for example with the `ci-full` label. The preprocessing macros use environment variables to determine version numbers. For nightly builds, set `NIGHTLY_TAG` (or `COMMIT_TAG` for backwards compatibility): diff --git a/docs/developer_versioned_docs/version-v6.0.0/ai_tooling.md b/docs/developer_versioned_docs/version-v6.0.0/ai_tooling.md index c2ed218a7ef..3c4af442bbd 100644 --- a/docs/developer_versioned_docs/version-v6.0.0/ai_tooling.md +++ b/docs/developer_versioned_docs/version-v6.0.0/ai_tooling.md @@ -122,6 +122,7 @@ These resources help you understand Aztec concepts, read docs, or provide additi - [awesome-noir](https://github.com/noir-lang/awesome-noir) - community Noir resources - [awesome-aztec](https://github.com/AztecProtocol/awesome-aztec) - community Aztec resources - **Copy docs into context** - The "Copy page" button at the top of each page copies the page as markdown. Its menu also opens the page in ChatGPT or Claude so you can ask questions about it, and links to the markdown version of the page. +- **Migration notes for an upgrade** - Append `?from=[YOUR_VERSION]&to=[TARGET_VERSION]` to the migration notes markdown URL (for example `https://docs.aztec.network/developers/docs/resources/migration_notes.md?from=5.2.0&to=6.0.0`) to get only the notes an upgrade crosses: every version after `from`, up to and including `to`, oldest first. Leave out `to` to go up to the newest release these docs cover. The migration notes page has a panel that copies the same range. - **Context7** - [Context7](https://context7.com) is a generic MCP server. The Aztec index at [context7.com/aztecprotocol/aztec-packages](https://context7.com/aztecprotocol/aztec-packages) is scoped by [`context7.json`](https://github.com/AztecProtocol/aztec-packages/blob/next/context7.json) to the developer and operator docs of the version the networks run, and excludes the repository's build and CI infrastructure. Note that it may be less current than the MCP servers above. ## Aztec and Noir tool reference diff --git a/docs/docs-developers/ai_tooling.md b/docs/docs-developers/ai_tooling.md index c2ed218a7ef..3c4af442bbd 100644 --- a/docs/docs-developers/ai_tooling.md +++ b/docs/docs-developers/ai_tooling.md @@ -122,6 +122,7 @@ These resources help you understand Aztec concepts, read docs, or provide additi - [awesome-noir](https://github.com/noir-lang/awesome-noir) - community Noir resources - [awesome-aztec](https://github.com/AztecProtocol/awesome-aztec) - community Aztec resources - **Copy docs into context** - The "Copy page" button at the top of each page copies the page as markdown. Its menu also opens the page in ChatGPT or Claude so you can ask questions about it, and links to the markdown version of the page. +- **Migration notes for an upgrade** - Append `?from=[YOUR_VERSION]&to=[TARGET_VERSION]` to the migration notes markdown URL (for example `https://docs.aztec.network/developers/docs/resources/migration_notes.md?from=5.2.0&to=6.0.0`) to get only the notes an upgrade crosses: every version after `from`, up to and including `to`, oldest first. Leave out `to` to go up to the newest release these docs cover. The migration notes page has a panel that copies the same range. - **Context7** - [Context7](https://context7.com) is a generic MCP server. The Aztec index at [context7.com/aztecprotocol/aztec-packages](https://context7.com/aztecprotocol/aztec-packages) is scoped by [`context7.json`](https://github.com/AztecProtocol/aztec-packages/blob/next/context7.json) to the developer and operator docs of the version the networks run, and excludes the repository's build and CI infrastructure. Note that it may be less current than the MCP servers above. ## Aztec and Noir tool reference diff --git a/docs/lines/v6/docs-developers/ai_tooling.md b/docs/lines/v6/docs-developers/ai_tooling.md index c2ed218a7ef..3c4af442bbd 100644 --- a/docs/lines/v6/docs-developers/ai_tooling.md +++ b/docs/lines/v6/docs-developers/ai_tooling.md @@ -122,6 +122,7 @@ These resources help you understand Aztec concepts, read docs, or provide additi - [awesome-noir](https://github.com/noir-lang/awesome-noir) - community Noir resources - [awesome-aztec](https://github.com/AztecProtocol/awesome-aztec) - community Aztec resources - **Copy docs into context** - The "Copy page" button at the top of each page copies the page as markdown. Its menu also opens the page in ChatGPT or Claude so you can ask questions about it, and links to the markdown version of the page. +- **Migration notes for an upgrade** - Append `?from=[YOUR_VERSION]&to=[TARGET_VERSION]` to the migration notes markdown URL (for example `https://docs.aztec.network/developers/docs/resources/migration_notes.md?from=5.2.0&to=6.0.0`) to get only the notes an upgrade crosses: every version after `from`, up to and including `to`, oldest first. Leave out `to` to go up to the newest release these docs cover. The migration notes page has a panel that copies the same range. - **Context7** - [Context7](https://context7.com) is a generic MCP server. The Aztec index at [context7.com/aztecprotocol/aztec-packages](https://context7.com/aztecprotocol/aztec-packages) is scoped by [`context7.json`](https://github.com/AztecProtocol/aztec-packages/blob/next/context7.json) to the developer and operator docs of the version the networks run, and excludes the repository's build and CI infrastructure. Note that it may be less current than the MCP servers above. ## Aztec and Noir tool reference diff --git a/docs/netlify/edge-functions/migration-notes-range.mjs b/docs/netlify/edge-functions/migration-notes-range.mjs new file mode 100644 index 00000000000..a4b29889e92 --- /dev/null +++ b/docs/netlify/edge-functions/migration-notes-range.mjs @@ -0,0 +1,47 @@ +// Serves `…/migration_notes.md?from=X&to=Y` from the section index the build writes beside each +// twin (scripts/index_migration_notes.mjs); with neither parameter, or with no index, the static +// twin (the full notes, with no "Included versions" header) is served instead. +import { queryProblem, renderRange, selectRange } from "../../src/migration_ranges.mjs"; + +const USAGE = + "Use ?from=&to=, for example ?from=5.2.0&to=6.0.0. Versions are semver, " + + "with or without a leading v. to defaults to the newest release on the page; to=unreleased adds the notes not yet released."; + +function reply(status, body, type) { + return new Response(body, { + status, + headers: { "content-type": `${type}; charset=utf-8`, "x-content-type-options": "nosniff" }, + }); +} + +const badRange = (problem, sections) => + reply( + 400, + [`Bad range: ${problem}.`, USAGE, ...(sections ? [`Versions on this page, newest first: ${sections.map((s) => s.version).join(", ")}`] : [])].join("\n") + "\n", + "text/plain", + ); + +export default async (request) => { + const url = new URL(request.url); + const query = { from: url.searchParams.get("from"), to: url.searchParams.get("to") }; + if (query.from === null && query.to === null) return; + const problem = queryProblem(query); + if (problem) return badRange(problem); + + // Assigned, never resolved as a reference: a path that starts with `//` would name another host. + const indexUrl = new URL(url.origin); + indexUrl.pathname = url.pathname.replace(/\.md$/, ".json"); + const index = await fetch(indexUrl); + if (!index.ok) return; + const { sections } = await index.json(); + let selection; + try { + selection = selectRange(sections, query); + } catch (error) { + if (error instanceof RangeError) return badRange(error.message, sections); + throw error; + } + return reply(200, renderRange(selection, url.origin + url.pathname.replace(/\.md$/, "")), "text/markdown"); +}; + +export const config = { path: "/*/migration_notes.md" }; diff --git a/docs/package.json b/docs/package.json index e05ed6b913f..80b2ea08014 100644 --- a/docs/package.json +++ b/docs/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "node scripts/aztec_node.mts fetch && yarn clean && yarn preprocess && yarn spellcheck && yarn preprocess:move && yarn validate:redirects && yarn validate:api-ref-links && yarn docusaurus build && node scripts/aztec_nr_api_markdown.mjs build && node scripts/split_typescript_api.mjs build && node scripts/append_api_docs_to_llms.js && node scripts/inject_md_directive.js && node scripts/inject_llms_directive.js && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check_agent_readiness.mts build && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check_search_index.mts build", + "build": "node scripts/aztec_node.mts fetch && yarn clean && yarn preprocess && yarn spellcheck && yarn preprocess:move && yarn validate:redirects && yarn validate:api-ref-links && yarn docusaurus build && node scripts/aztec_nr_api_markdown.mjs build && node scripts/split_typescript_api.mjs build && node scripts/append_api_docs_to_llms.js && node scripts/inject_md_directive.js && node scripts/index_migration_notes.mjs && node scripts/inject_llms_directive.js && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check_agent_readiness.mts build && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/check_search_index.mts build", "validate:redirects": "./scripts/validate_redirect_targets.sh", "validate:api-ref-links": "./scripts/validate_api_ref_links.sh", "check:orphaned-urls": "./scripts/check_orphaned_urls.sh", diff --git a/docs/scripts/__tests__/migration_ranges.test.mts b/docs/scripts/__tests__/migration_ranges.test.mts new file mode 100644 index 00000000000..1b11350c36b --- /dev/null +++ b/docs/scripts/__tests__/migration_ranges.test.mts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { type TestContext, test } from "node:test"; +import { compareVersions, parseVersion, renderRange, selectRange } from "../../src/migration_ranges.mjs"; +import serveRange from "../../netlify/edge-functions/migration-notes-range.mjs"; +import { indexMigrationNotes, splitSections } from "../index_migration_notes.mjs"; + +const docs = path.join(import.meta.dirname, "../.."); +const page = "docs/resources/migration_notes.md"; +const copies = { + main: `docs-developers/${page}`, + "v6 line": `lines/v6/docs-developers/${page}`, + "v6.0.0 snapshot": `developer_versioned_docs/version-v6.0.0/${page}`, +}; +const read = (file: string) => fs.readFileSync(path.join(docs, file), "utf8"); +const main = splitSections(read(copies.main)); +const versions = (sections: { version: string }[]) => sections.map((s) => s.version); + +test("every copy splits into strictly descending version sections that rebuild the page", () => { + for (const [name, file] of Object.entries(copies)) { + const text = read(file); + const { preamble, sections } = splitSections(text); + assert.equal(preamble + sections.map((s) => s.markdown).join(""), text, name); + assert.ok(sections.length >= 60, `${name}: ${sections.length} sections`); + assert.equal(versions(sections).at(-1), "0.17.0", name); + } + assert.equal(versions(main.sections)[0], "TBD"); + assert.equal(versions(splitSections(read(copies["v6.0.0 snapshot"])).sections)[0], "6.0.0"); +}); + +test("headings that are not versions stay inside the version above them", () => { + const holder = (heading: string) => main.sections.find((s) => s.markdown.includes(`\n${heading}\n`))?.version; + assert.equal(holder("## [aztec.js] Removal of barrel export"), "3.0.0-devnet.4"); + assert.equal(holder("# [Aztec sandbox] TXE deployment changes"), "0.47.0"); + assert.equal(holder("## Public execution migrated to the Aztec Virtual Machine"), "0.42.0"); + const fenced = splitSections("intro\n## 2.0.0\n```md\n## 9.9.9\n```\n## \\[Aztec.nr] x\n## 1.0.0\n"); + assert.deepEqual(versions(fenced.sections), ["2.0.0", "1.0.0"]); +}); + +test("a page out of order, or with no version heading, does not split", () => { + assert.throws(() => splitSections("## 1.0.0\n## 2.0.0\n"), /2\.0\.0 .*after 1\.0\.0/); + assert.throws(() => splitSections("## 1.0.0\n## 1.0\n"), /after/); + assert.throws(() => splitSections("# Notes\n## [Aztec.nr] x\n"), /no version heading/); +}); + +test("versions order by semver precedence", () => { + const lt = (a: string, b: string) => assert.ok(compareVersions(parseVersion(a)!, parseVersion(b)!) < 0, `${a} < ${b}`); + lt("4.2.0-aztecnr-rc.2", "4.2.0"); + lt("3.0.0-devnet.4", "3.0.0-devnet.20251212"); + lt("4.0.0-devnet.2-patch.0", "4.1.0-rc.2"); + lt("6.0.0", "TBD"); + lt("9007199254740992.0.0", "9007199254740993.0.0"); + lt("1.0.0-9007199254740992", "1.0.0-9007199254740993"); + assert.equal(compareVersions(parseVersion("0.66")!, parseVersion("v0.66.0")!), 0); + for (const bad of ["", "6", "6.0.0.0", "01.2.3", "6.0.0-01", "6.0.0+build", "x6.0.0", "6.0.0-", "6.0.0-rc..1", "6." + "1".repeat(63)]) { + assert.equal(parseVersion(bad), null, bad); + } +}); + +test("a range holds the versions after from up to to, oldest first", () => { + const snapshot = splitSections(read(copies["v6.0.0 snapshot"])).sections; + const cases: [typeof main.sections, Record, string[]][] = [ + [main.sections, { from: "6.0.0-rc.1", to: "6.0.0" }, ["6.0.0-rc.3", "6.0.0"]], + [main.sections, { from: "6.0.0-rc.2", to: "v6.0.0" }, ["6.0.0-rc.3", "6.0.0"]], + [main.sections, { from: "5.1.0" }, ["5.2.0", "6.0.0-rc.1", "6.0.0-rc.3", "6.0.0"]], + [main.sections, { from: "5.1.0", to: "" }, ["5.2.0", "6.0.0-rc.1", "6.0.0-rc.3", "6.0.0"]], + [main.sections, { from: "6.0.0", to: "unreleased" }, ["TBD"]], + [snapshot, { from: "6.0.0-rc.3", to: "unreleased" }, ["6.0.0"]], + [main.sections, { from: "6.0.0" }, []], + [main.sections, { from: "0.1.0", to: "0.2.0" }, []], + [main.sections, { from: "v0.65", to: "0.67.0" }, ["0.66", "0.67.0"]], + ]; + for (const [sections, query, expected] of cases) { + assert.deepEqual(versions(selectRange(sections, query).included), expected, JSON.stringify(query)); + } +}); + +test("a bad range names its problem", () => { + const fails = (query: Record, message: RegExp) => + assert.throws(() => selectRange(main.sections, query), (e: Error) => e instanceof RangeError && message.test(e.message)); + fails({ to: "6.0.0" }, /from is required/); + fails({ from: "six" }, /from is not a version/); + fails({ from: "5.0.0", to: "latest" }, /to is not a version/); + fails({ from: "7.0.0" }, /from \(7\.0\.0\) is newer than to \(6\.0\.0\)/); +}); + +test("the rendered range names its versions above the sections", () => { + const selection = selectRange(main.sections, { from: "6.0.0-rc.1", to: "6.0.0" }); + const text = renderRange(selection, "https://docs.example/developers/docs/resources/migration_notes"); + const [title, source, included] = text.split("\n"); + assert.equal(title, "# Aztec migration notes: 6.0.0-rc.1 to 6.0.0"); + assert.equal(source, "Source: https://docs.example/developers/docs/resources/migration_notes"); + assert.equal(included, "Included versions: 6.0.0-rc.3, 6.0.0"); + assert.ok(text.indexOf("\n## 6.0.0-rc.3\n") < text.indexOf("\n## 6.0.0\n")); + const none = renderRange(selectRange(main.sections, { from: "6.0.0" }), "https://x"); + assert.match(none, /^Included versions: none$/m); +}); + +test("the index step writes a JSON index beside every migration notes twin", () => { + const build = fs.mkdtempSync(path.join(os.tmpdir(), "migration-index-")); + try { + assert.throws(() => indexMigrationNotes(build), /no migration_notes\.md/); + const dir = path.join(build, "developers/docs/resources"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "migration_notes.md"), "> index\n\n# Migration notes\n\n## 2.0.0\nb\n## 1.0.0\na\n"); + assert.deepEqual(indexMigrationNotes(build), ["developers/docs/resources/migration_notes.json"]); + const index = JSON.parse(fs.readFileSync(path.join(dir, "migration_notes.json"), "utf8")); + assert.deepEqual(index, { sections: [{ version: "2.0.0", markdown: "## 2.0.0\nb\n" }, { version: "1.0.0", markdown: "## 1.0.0\na\n" }] }); + fs.writeFileSync(path.join(dir, "migration_notes.md"), "# Migration notes\n"); + assert.throws(() => indexMigrationNotes(build), /developers\/docs\/resources\/migration_notes\.md: no version heading/); + } finally { + fs.rmSync(build, { recursive: true, force: true }); + } +}); + +test("the edge function serves a range, refuses a bad one, and otherwise steps aside", async (t: TestContext) => { + const page = "https://docs.example/developers/docs/resources/migration_notes"; + const fetched: string[] = []; + let index: Response | null = null; + t.mock.method(globalThis, "fetch", async (url: URL) => { + fetched.push(String(url)); + return index ?? new Response("Not Found", { status: 404 }); + }); + const serve = async (query: string, json: unknown = { sections: main.sections }) => { + fetched.length = 0; + index = json === null ? null : new Response(JSON.stringify(json)); + return serveRange(new Request(`${page}.md${query}`)); + }; + + assert.equal(await serve(""), undefined); + assert.deepEqual(fetched, []); + + const ok = (await serve("?from=6.0.0-rc.1&to=6.0.0"))!; + assert.equal(ok.status, 200); + assert.equal(ok.headers.get("content-type"), "text/markdown; charset=utf-8"); + assert.equal(ok.headers.get("x-content-type-options"), "nosniff"); + assert.deepEqual(fetched, [`${page}.json`]); + assert.deepEqual((await ok.text()).split("\n").slice(0, 3), [ + "# Aztec migration notes: 6.0.0-rc.1 to 6.0.0", + `Source: ${page}`, + "Included versions: 6.0.0-rc.3, 6.0.0", + ]); + + for (const query of ["?to=6.0.0", "?from=six", `?from=${"1".repeat(70)}`]) { + const bad = (await serve(query))!; + assert.equal(bad.status, 400, query); + assert.deepEqual(fetched, [], query); + } + const reversed = (await serve("?from=7.0.0"))!; + assert.equal(reversed.status, 400); + assert.match(await reversed.text(), /from \(7\.0\.0\) is newer than to \(6\.0\.0\)[\s\S]*TBD, 6\.0\.0, 6\.0\.0-rc\.3/); + + assert.equal(await serve("?from=5.2.0", null), undefined); + + fetched.length = 0; + await serveRange(new Request("https://docs.example//elsewhere.example/x/migration_notes.md?from=5.2.0")); + assert.deepEqual(fetched.map((u) => new URL(u).origin), ["https://docs.example"]); +}); diff --git a/docs/scripts/index_migration_notes.mjs b/docs/scripts/index_migration_notes.mjs new file mode 100644 index 00000000000..ac0bf262cf1 --- /dev/null +++ b/docs/scripts/index_migration_notes.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Post-build: writes `migration_notes.json` beside every `migration_notes.md` twin, the per-version +// section index that netlify/edge-functions/migration-notes-range.mjs and the page's copy panel +// read. It reads the twin, not the source, so each section is the exact text the twin serves. +// +// node scripts/index_migration_notes.mjs [build-dir] +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import fences from "../src/markdown_fences.js"; +import { compareVersions, parseVersion } from "../src/migration_ranges.mjs"; + +const TWIN = "migration_notes.md"; +// Generated API reference, never a docs page. +const SKIP_DIRS = new Set(["typescript-api", "aztec-nr-api"]); + +/** + * Cuts the page at its version headings (`## 6.0.0`, `## TBD`) outside code fences. Topic headings + * of any level stay in the section above them, and `preamble` + every section's markdown is the + * page, byte for byte. + * + * @param {string} markdown + * @returns {{preamble: string, sections: {version: string, markdown: string}[]}} + * @throws when the page has no version heading, or its versions do not run strictly newest first + * (which catches a new entry put in the wrong place) + */ +export function splitSections(markdown) { + const lines = markdown.split("\n"); + const fenced = fences.fencedLines(lines); + const starts = []; + let offset = 0; + lines.forEach((line, i) => { + const heading = fenced[i] ? null : /^## (\S+)[ \t]*$/.exec(line); + const version = heading && parseVersion(heading[1]); + if (version) { + const previous = starts.at(-1); + if (previous && compareVersions(version, previous.parsed) >= 0) { + throw new Error(`${heading[1]} is listed after ${previous.version}: versions must run newest first, each once`); + } + starts.push({ version: heading[1], parsed: version, offset }); + } + offset += line.length + 1; + }); + if (!starts.length) throw new Error("no version heading (`## ` or `## TBD`)"); + return { + preamble: markdown.slice(0, starts[0].offset), + sections: starts.map(({ version, offset }, i) => ({ version, markdown: markdown.slice(offset, starts[i + 1]?.offset) })), + }; +} + +function* twins(dir, root = dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory() && !(dir === root && SKIP_DIRS.has(entry.name))) yield* twins(full, root); + else if (entry.isFile() && entry.name === TWIN) yield full; + } +} + +/** + * @param {string} buildDir + * @returns {string[]} the index files written, relative to `buildDir` + * @throws when the build has no migration notes twin or one does not split + */ +export function indexMigrationNotes(buildDir) { + const written = []; + for (const twin of twins(buildDir)) { + const rel = path.relative(buildDir, twin).split(path.sep).join("/"); + let sections; + try { + ({ sections } = splitSections(fs.readFileSync(twin, "utf8"))); + } catch (error) { + throw new Error(`${rel}: ${error.message}`); + } + fs.writeFileSync(twin.replace(/\.md$/, ".json"), JSON.stringify({ sections })); + written.push(rel.replace(/\.md$/, ".json")); + } + if (!written.length) throw new Error(`no ${TWIN} twin under ${buildDir}`); + return written.sort(); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href) { + const buildDir = path.resolve(process.argv[2] ?? path.join(import.meta.dirname, "..", "build")); + for (const file of indexMigrationNotes(buildDir)) console.log(`migration notes index: ${file}`); +} diff --git a/docs/src/__tests__/network_docs.test.js b/docs/src/__tests__/network_docs.test.js index ebccf37200b..af9ea6f85a6 100644 --- a/docs/src/__tests__/network_docs.test.js +++ b/docs/src/__tests__/network_docs.test.js @@ -173,6 +173,10 @@ describe("Start here", () => { assert.strictEqual(links["Running a full node"], "https://docs.aztec.network/operate/operators/setup/running_a_node.md"); assert.strictEqual(links["Testing contracts"], "https://docs.aztec.network/developers/docs/aztec-nr/testing_contracts.md"); assert.strictEqual(links["Aztec.nr API reference"], "https://docs.aztec.network/aztec-nr-api/mainnet/llms.txt"); + assert.strictEqual( + links["Migration notes for a version range"], + "https://docs.aztec.network/developers/docs/resources/migration_notes.md?from=5.2.0&to=6.0.0", + ); }); it("follows a preview's host and layout, where the networks' operator docs are at /operate/alpha", () => { diff --git a/docs/src/components/CopyMarkdownButton/htmlToMarkdown.ts b/docs/src/components/CopyMarkdownButton/htmlToMarkdown.ts index da62f3ed4ed..5b7764c8cff 100644 --- a/docs/src/components/CopyMarkdownButton/htmlToMarkdown.ts +++ b/docs/src/components/CopyMarkdownButton/htmlToMarkdown.ts @@ -18,6 +18,7 @@ export function htmlToMarkdown(element: Element): string { // Skip Docusaurus UI elements if ( + el.hasAttribute('data-copy-skip') || el.classList.contains('hash-link') || el.classList.contains('anchor') || el.classList.contains('table-of-contents') || diff --git a/docs/src/components/CopyMarkdownButton/index.tsx b/docs/src/components/CopyMarkdownButton/index.tsx index 2700a2b956b..9f70c7620fe 100644 --- a/docs/src/components/CopyMarkdownButton/index.tsx +++ b/docs/src/components/CopyMarkdownButton/index.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useId, useRef, useState } from 'react'; import { useDoc, useDocsVersion } from '@docusaurus/plugin-content-docs/client'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import { buildAgentLinks } from '@site/src/utils/agentLinks'; +import { copyText } from '@site/src/utils/clipboard'; import styles from './styles.module.css'; import { htmlToMarkdown } from './htmlToMarkdown'; @@ -128,26 +129,11 @@ Source: ${sourceUrl} ${markdownContent}`; - try { - await navigator.clipboard.writeText(output); + if (await copyText(output)) { setCopied(true); setTimeout(() => setCopied(false), 2000); - } catch (err) { - // Fallback for older browsers - const textarea = document.createElement('textarea'); - textarea.value = output; - textarea.style.position = 'fixed'; - textarea.style.left = '-9999px'; - document.body.appendChild(textarea); - textarea.select(); - try { - document.execCommand('copy'); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (e) { - console.error('Failed to copy:', e); - } - document.body.removeChild(textarea); + } else { + console.error('Failed to copy page content'); } }, [metadata.title]); diff --git a/docs/src/components/MigrationRangeCopy/index.tsx b/docs/src/components/MigrationRangeCopy/index.tsx new file mode 100644 index 00000000000..7e39f67471e --- /dev/null +++ b/docs/src/components/MigrationRangeCopy/index.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useId, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { parseVersion, renderRange, selectRange } from '@site/src/migration_ranges.mjs'; +import { markdownPathFor } from '@site/src/utils/agentLinks'; +import { copyText } from '@site/src/utils/clipboard'; +import styles from './styles.module.css'; + +type Section = { version: string; markdown: string }; +type Index = { state: 'loading' } | { state: 'failed' } | { state: 'ready'; sections: Section[] }; + +const isReleased = (s: Section) => !parseVersion(s.version).unreleased; +const major = (version: string) => parseVersion(version).core[0]; + +/** The newest release before `to`'s major, the usual starting point of an upgrade. */ +function defaultFrom(sections: Section[], to: string): string | undefined { + if (!to) return undefined; + const released = sections.filter(isReleased); + return (released.find((s) => major(s.version) < major(to)) ?? released[1] ?? released[0])?.version; +} + +function formatSize(bytes: number): string { + const tokens = Math.round(bytes / 4); + const size = bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`; + return `${size}, about ${tokens < 1000 ? tokens : `${Math.round(tokens / 1000)}k`} tokens`; +} + +function Panel({ permalink }: { permalink: string }) { + const id = useId(); + const markdownPath = markdownPathFor(permalink); + const [index, setIndex] = useState({ state: 'loading' }); + const [from, setFrom] = useState(''); + const [to, setTo] = useState(''); + const [copied, setCopied] = useState(null); + + // Loaded up front so the copy runs synchronously in the click: Safari refuses clipboard writes + // that wait on a fetch. + useEffect(() => { + const abort = new AbortController(); + fetch(markdownPath.replace(/\.md$/, '.json'), { signal: abort.signal }) + .then((response) => (response.ok ? response.json() : Promise.reject(new Error(String(response.status))))) + .then(({ sections }: { sections: Section[] }) => { + const newest = sections.find(isReleased)?.version ?? ''; + setTo(newest); + setFrom(defaultFrom(sections, newest) ?? ''); + setIndex({ state: 'ready', sections }); + }) + .catch(() => abort.signal.aborted || setIndex({ state: 'failed' })); + return () => abort.abort(); + }, [markdownPath]); + + const result = useMemo(() => { + if (index.state !== 'ready' || !from || !to) return null; + try { + const selection = selectRange(index.sections, { from, to }); + const text = renderRange(selection, window.location.origin + permalink.replace(/\/+$/, '')); + return { count: selection.included.length, text, bytes: new TextEncoder().encode(text).length }; + } catch (error) { + return { problem: (error as Error).message }; + } + }, [index, from, to, permalink]); + + const query = `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`; + const onCopy = async () => { + if (!result || !('text' in result)) return; + setCopied(await copyText(result.text)); + setTimeout(() => setCopied(null), 2000); + }; + + if (index.state === 'failed') { + return ( +

+ The version picker could not load. Add ?from=<version>&to=<version> to{' '} + this page's markdown URL instead. +

+ ); + } + const versions = index.state === 'ready' ? index.sections : []; + const ready = result && 'text' in result && result.count > 0; + return ( +
+

+ Copy only the notes for your upgrade +

+
+ + + + {ready && ( + + Open as markdown + + )} +
+

+ {index.state === 'loading' + ? 'Loading versions…' + : !result + ? '' + : 'problem' in result + ? 'Pick a target newer than the version you are on.' + : result.count === 0 + ? `No notes after ${from} up to ${to}.` + : `${result.count} ${result.count === 1 ? 'version' : 'versions'} after ${from}, oldest first (${formatSize(result.bytes)}).`} +

+
+ ); +} + +/** + * The version-range copy panel, placed under the page title. It renders in the browser only, so it + * stays out of the server-rendered page and its markdown twin; `data-copy-skip` keeps it out of the + * Copy page output too. + */ +export default function MigrationRangeCopy({ permalink }: { permalink: string }) { + const [container, setContainer] = useState(null); + useEffect(() => { + const content = document.querySelector('.theme-doc-markdown'); + if (!content) return; + const node = document.createElement('div'); + node.setAttribute('data-copy-skip', ''); + const title = content.querySelector(':scope > header') ?? content.querySelector('h1'); + if (title) title.after(node); + else content.prepend(node); + setContainer(node); + return () => node.remove(); + }, [permalink]); + return container && createPortal(, container); +} diff --git a/docs/src/components/MigrationRangeCopy/styles.module.css b/docs/src/components/MigrationRangeCopy/styles.module.css new file mode 100644 index 00000000000..d63670ce688 --- /dev/null +++ b/docs/src/components/MigrationRangeCopy/styles.module.css @@ -0,0 +1,74 @@ +.panel { + display: grid; + gap: 0.5rem; + margin: 0 0 var(--ifm-spacing-vertical); + padding: 0.75rem 0.9rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 8px; + background: var(--ifm-color-emphasis-100); +} + +.title { + margin: 0; + font-weight: 600; +} + +.row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; +} + +.field { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.9rem; +} + +.field select { + font: inherit; + font-family: var(--ifm-font-family-monospace); + font-size: 0.85rem; + padding: 0.25rem 0.4rem; + color: var(--ifm-font-color-base); + background: var(--ifm-background-color); + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; +} + +.copy { + font-size: 0.85rem; + font-weight: 500; + padding: 0.35rem 0.8rem; + color: var(--ifm-color-emphasis-800); + background: var(--ifm-background-color); + border: 1px solid var(--ifm-color-emphasis-400); + border-radius: 6px; + cursor: pointer; +} + +.copy:hover:not(:disabled) { + color: var(--ifm-link-color); + border-color: var(--ifm-link-color); +} + +.copy:disabled { + cursor: default; + opacity: 0.55; +} + +.open { + font-size: 0.85rem; +} + +.note { + margin: 0; + font-size: 0.85rem; + color: var(--ifm-color-emphasis-700); +} + +.note:empty { + display: none; +} diff --git a/docs/src/migration_ranges.mjs b/docs/src/migration_ranges.mjs new file mode 100644 index 00000000000..6bc45fbf020 --- /dev/null +++ b/docs/src/migration_ranges.mjs @@ -0,0 +1,102 @@ +// Selects a version range of the migration notes from their section index (written beside each +// markdown twin by scripts/index_migration_notes.mjs). No Node or DOM APIs: the Netlify edge +// function (Deno) and the page's copy panel import this file as it is. + +const VERSION = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/i; +const UNRELEASED = /^(tbd|unreleased)$/i; + +/** + * A version as the page and its query parameters write it: semver with an optional `v`, a two-part + * `X.Y` read as `X.Y.0`, or `TBD` / `unreleased` for the notes not yet released, which sort above + * every version. Build metadata (`+…`) is refused: no Aztec version carries it. + * + * @param {unknown} text + * @returns {{text: string, unreleased: boolean, core: bigint[], pre: string[]} | null} + */ +export function parseVersion(text) { + if (typeof text !== "string" || text.length > 64) return null; + if (UNRELEASED.test(text)) return { text: "unreleased", unreleased: true, core: [], pre: [] }; + const m = VERSION.exec(text); + const pre = m?.[4] ? m[4].split(".") : []; + if (!m || pre.some((id) => /^0\d+$/.test(id))) return null; + return { + text: text.replace(/^v/i, ""), + unreleased: false, + core: [m[1], m[2], m[3] ?? "0"].map(BigInt), + pre, + }; +} + +const isNumeric = (id) => /^\d+$/.test(id); + +/** Semver precedence of two parsed versions: negative, zero or positive. */ +export function compareVersions(a, b) { + if (a.unreleased || b.unreleased) return Number(a.unreleased) - Number(b.unreleased); + for (let i = 0; i < 3; i++) if (a.core[i] !== b.core[i]) return a.core[i] < b.core[i] ? -1 : 1; + if (!a.pre.length || !b.pre.length) return b.pre.length - a.pre.length; + for (let i = 0; i < Math.min(a.pre.length, b.pre.length); i++) { + const [x, y] = [a.pre[i], b.pre[i]]; + if (x === y) continue; + if (isNumeric(x) && isNumeric(y)) return BigInt(x) < BigInt(y) ? -1 : 1; + if (isNumeric(x) !== isNumeric(y)) return isNumeric(x) ? -1 : 1; + return x < y ? -1 : 1; + } + return a.pre.length - b.pre.length; +} + +/** + * What is wrong with a range query's syntax, or null. Needs no index, so the edge function checks it + * before reading one. + * + * @param {{from?: string | null, to?: string | null}} query + */ +export function queryProblem({ from, to }) { + if (!from) return "from is required"; + if (!parseVersion(from)) return "from is not a version"; + if (to && !parseVersion(to)) return "to is not a version"; + return null; +} + +/** + * The sections a project crosses when it upgrades from `from` to `to`: every version v with + * from < v ≤ to, oldest first. `to` defaults to the newest released section, so `TBD` is included + * only when `to` is `unreleased`. Either end may be a version the page has no heading for. + * + * @param {{version: string, markdown: string}[]} sections the index, newest first + * @param {{from?: string | null, to?: string | null}} query + * @returns {{from: string, to: string, included: {version: string, markdown: string}[]}} + * @throws {RangeError} a message fit to show the requester + */ +export function selectRange(sections, { from, to } = {}) { + const problem = queryProblem({ from, to }); + if (problem) throw new RangeError(problem); + const start = parseVersion(from); + const newest = sections.find((s) => !parseVersion(s.version).unreleased); + const end = to ? parseVersion(to) : newest && parseVersion(newest.version); + if (!end) throw new RangeError("this page has no released version"); + if (compareVersions(start, end) > 0) throw new RangeError(`from (${start.text}) is newer than to (${end.text})`); + const included = sections + .filter((s) => { + const v = parseVersion(s.version); + return compareVersions(v, start) > 0 && compareVersions(v, end) <= 0; + }) + .reverse(); + return { from: start.text, to: end.text, included }; +} + +/** + * The range as one markdown document. Its header names the versions it holds, so a reader can tell + * a filtered response from the full page. + * + * @param {ReturnType} selection + * @param {string} sourceUrl the page the notes come from + */ +export function renderRange({ from, to, included }, sourceUrl) { + return [ + `# Aztec migration notes: ${from} to ${to}`, + `Source: ${sourceUrl}`, + `Included versions: ${included.map((s) => s.version).join(", ") || "none"}`, + "", + ...included.map((s) => s.markdown.trimEnd() + "\n"), + ].join("\n"); +} diff --git a/docs/src/network_docs.js b/docs/src/network_docs.js index f708f67dda3..39192188783 100644 --- a/docs/src/network_docs.js +++ b/docs/src/network_docs.js @@ -283,8 +283,8 @@ function networkSection(instances, networksPage, siteUrl) { } // The llms.txt "Start here" entries, one per task in the order a newcomer needs them. `docs` is -// the instance whose page it is (`page`, its route under the version path); `api` an API -// reference whose scoped llms.txt it is. +// the instance whose page it is (`page`, its route under the version path, with an optional +// `query`); `api` an API reference whose scoped llms.txt it is. const START_HERE = [ { title: "Getting started", task: "install the toolchain and run a local network", docs: "developers", page: "getting_started_on_local_network" }, { title: "Aztec.nr", task: "write a contract", docs: "developers", page: "docs/aztec-nr" }, @@ -301,6 +301,13 @@ const START_HERE = [ { title: "Aztec.nr API reference", task: "look up an Aztec.nr struct, trait or function signature", api: "aztec-nr-api" }, { title: "TypeScript API reference", task: "look up an aztec.js, accounts, wallet or PXE class, function or type", api: "typescript-api" }, { title: "AI tooling", task: "set up a coding agent for Aztec", docs: "developers", page: "ai_tooling" }, + { + title: "Migration notes for a version range", + task: "upgrade a project between versions. Set `from` to the version you are on and `to` to the target (default: the newest release these docs cover; `unreleased` adds notes not yet released). Returns every section after `from` up to and including `to`, oldest first, under a header naming the included versions; a bad range returns 400 with the reason", + docs: "developers", + page: "docs/resources/migration_notes", + query: "from=5.2.0&to=6.0.0", + }, ]; /** @@ -314,11 +321,11 @@ function startHereLinks(instances, siteUrl) { const read = (instance) => instance.versions.find((v) => v.networks.length); const developer = instances.find((i) => i.routeBasePath === "developers"); const folder = read(developer) && aztecNrApiFolders(developer)[read(developer).version]; - return START_HERE.flatMap(({ title, task, docs, page, api }) => { + return START_HERE.flatMap(({ title, task, docs, page, query, api }) => { if (api) return folder ? [{ title, task, url: `${siteUrl}/${api}/${folder}/llms.txt` }] : []; const instance = instances.find((i) => i.routeBasePath === docs); const version = read(instance) ?? instance.versions[0]; - return [{ title, task, url: `${siteUrl}${docsPath(docs, version.path)}/${page}.md` }]; + return [{ title, task, url: `${siteUrl}${docsPath(docs, version.path)}/${page}.md${query ? `?${query}` : ""}` }]; }); } diff --git a/docs/src/theme/DocItem/Layout/index.js b/docs/src/theme/DocItem/Layout/index.js index 68efc198a25..e987f5ee5e9 100644 --- a/docs/src/theme/DocItem/Layout/index.js +++ b/docs/src/theme/DocItem/Layout/index.js @@ -1,8 +1,9 @@ import React, { useEffect } from 'react'; import Layout from '@theme-original/DocItem/Layout'; import { createPortal } from 'react-dom'; -import { useDoc } from '@docusaurus/plugin-content-docs/client'; +import { useDoc, useDocsVersion } from '@docusaurus/plugin-content-docs/client'; import CopyMarkdownButton from '@site/src/components/CopyMarkdownButton'; +import MigrationRangeCopy from '@site/src/components/MigrationRangeCopy'; function PortalButton() { const [container, setContainer] = React.useState(null); @@ -23,11 +24,16 @@ function PortalButton() { export default function LayoutWrapper(props) { // Pages with a custom hero (the landing page) opt out via front matter so the // button does not float over the hero panel. - const { frontMatter } = useDoc(); + const { frontMatter, metadata } = useDoc(); + // noIndex versions get no markdown twin, so no section index for the panel to read. + const { noIndex } = useDocsVersion(); return ( <> {!frontMatter.hide_copy_button && } + {metadata.id === 'docs/resources/migration_notes' && !noIndex && ( + + )} ); } diff --git a/docs/src/utils/clipboard.ts b/docs/src/utils/clipboard.ts new file mode 100644 index 00000000000..e0a50ef5419 --- /dev/null +++ b/docs/src/utils/clipboard.ts @@ -0,0 +1,27 @@ +/** + * Copies text; call it inside the click handler, since browsers grant clipboard writes only during a + * user gesture. Falls back to a hidden textarea and `execCommand` where the clipboard API is missing + * or refused (an insecure origin, an embedding frame without `clipboard-write`). + * + * @returns whether the text reached the clipboard + */ +export async function copyText(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + try { + return document.execCommand('copy'); + } catch { + return false; + } finally { + textarea.remove(); + } + } +}