From 7736a3d67de51dfe9a449e659fec2cd127a922a3 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 1 Apr 2026 18:18:42 -1000 Subject: [PATCH 1/5] feat: add `docs` command for browsing Prismic documentation Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/docs-list.ts | 89 ++++++++++++++++++++++++++++++++++++ src/commands/docs-view.ts | 95 +++++++++++++++++++++++++++++++++++++++ src/commands/docs.ts | 19 ++++++++ src/index.ts | 7 ++- 4 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 src/commands/docs-list.ts create mode 100644 src/commands/docs-view.ts create mode 100644 src/commands/docs.ts diff --git a/src/commands/docs-list.ts b/src/commands/docs-list.ts new file mode 100644 index 0000000..09b005b --- /dev/null +++ b/src/commands/docs-list.ts @@ -0,0 +1,89 @@ +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; + +const DOCS_INDEX_URL = new URL("https://prismic.io/docs/api/index/"); + +const config = { + name: "prismic docs list", + description: ` + List available documentation pages. + + With a path argument, list the anchors within that page. + `, + positionals: { + path: { + description: "Documentation path to list anchors for", + required: false, + }, + }, + options: { + json: { type: "boolean", description: "Output as JSON" }, + }, +} satisfies CommandConfig; + +type IndexPage = { + path: string; + title: string; + description: string; +}; + +type IndexPageWithAnchors = IndexPage & { + anchors: { slug: string; excerpt: string }[]; +}; + +export default createCommand(config, async ({ positionals, values }) => { + const [path] = positionals; + const { json } = values; + + if (path) { + const url = new URL(path, DOCS_INDEX_URL); + const response = await fetch(url); + + if (!response.ok) { + if (response.status === 404) { + throw new CommandError(`Documentation page not found: ${path}`); + } + throw new CommandError(`Failed to fetch documentation index: ${response.statusText}`); + } + + const entry: IndexPageWithAnchors = await response.json(); + entry.anchors.sort((a, b) => a.slug.localeCompare(b.slug)); + + if (json) { + console.info(stringify(entry)); + return; + } + + if (entry.anchors.length === 0) { + console.info("(no anchors)"); + return; + } + + for (const anchor of entry.anchors) { + console.info(`${path}#${anchor.slug}: ${anchor.excerpt}`); + } + } else { + const response = await fetch(DOCS_INDEX_URL); + + if (!response.ok) { + throw new CommandError(`Failed to fetch documentation index: ${response.statusText}`); + } + + const pages: IndexPage[] = await response.json(); + pages.sort((a, b) => a.path.localeCompare(b.path)); + + if (json) { + console.info(stringify(pages)); + return; + } + + if (pages.length === 0) { + console.info("No documentation pages found."); + return; + } + + for (const page of pages) { + console.info(`${page.path}: ${page.title} — ${page.description}`); + } + } +}); diff --git a/src/commands/docs-view.ts b/src/commands/docs-view.ts new file mode 100644 index 0000000..f5a5f91 --- /dev/null +++ b/src/commands/docs-view.ts @@ -0,0 +1,95 @@ +import { CommandError, createCommand, type CommandConfig } from "../lib/command"; +import { stringify } from "../lib/json"; + +const DOCS_BASE_URL = new URL("https://prismic.io/docs/"); + +const config = { + name: "prismic docs view", + description: ` + View a documentation page as Markdown. + + Append #anchor to the path to view only the section under that heading. + `, + positionals: { + path: { + description: "Documentation path, optionally with #anchor (e.g., setup#install)", + required: true, + }, + }, + options: { + json: { type: "boolean", description: "Output as JSON" }, + }, +} satisfies CommandConfig; + +export default createCommand(config, async ({ positionals, values }) => { + const [rawPath] = positionals; + const { json } = values; + + const hashIndex = rawPath.indexOf("#"); + const path = hashIndex >= 0 ? rawPath.slice(0, hashIndex) : rawPath; + const anchor = hashIndex >= 0 ? rawPath.slice(hashIndex + 1) : undefined; + + const url = new URL(path, DOCS_BASE_URL); + const response = await fetch(url, { + headers: { Accept: "text/markdown" }, + }); + + if (!response.ok) { + throw new CommandError(`Failed to fetch documentation page: ${response.statusText}`); + } + + let markdown = await response.text(); + + if (anchor) { + const section = extractSection(markdown, anchor); + if (!section) { + throw new CommandError(`Anchor not found: #${anchor}`); + } + markdown = section; + } + + if (json) { + console.info(stringify({ path, anchor, content: markdown })); + return; + } + + console.info(markdown); +}); + +function extractSection(markdown: string, anchor: string): string | undefined { + const lines = markdown.split("\n"); + let startIndex = -1; + let headingLevel = 0; + + for (let i = 0; i < lines.length; i++) { + const match = lines[i].match(/^(#{1,6})\s+(.*)/); + if (!match) { + continue; + } + + const level = match[1].length; + const text = match[2]; + + if (startIndex >= 0 && level <= headingLevel) { + return lines.slice(startIndex, i).join("\n").trimEnd(); + } + + if (kebabCase(text) === anchor) { + startIndex = i; + headingLevel = level; + } + } + + if (startIndex >= 0) { + return lines.slice(startIndex).join("\n").trimEnd(); + } + + return undefined; +} + +function kebabCase(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} diff --git a/src/commands/docs.ts b/src/commands/docs.ts new file mode 100644 index 0000000..39cd4ba --- /dev/null +++ b/src/commands/docs.ts @@ -0,0 +1,19 @@ +import { createCommandRouter } from "../lib/command"; + +import docsList from "./docs-list"; +import docsView from "./docs-view"; + +export default createCommandRouter({ + name: "prismic docs", + description: "Browse Prismic documentation.", + commands: { + list: { + handler: docsList, + description: "List available documentation pages", + }, + view: { + handler: docsView, + description: "View a documentation page", + }, + }, +}); diff --git a/src/index.ts b/src/index.ts index 8f9ff5e..8e6d4ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import packageJson from "../package.json" with { type: "json" }; import { getAdapter, NoSupportedFrameworkError } from "./adapters"; import { getHost, refreshToken } from "./auth"; import { getProfile } from "./clients/user"; +import docs from "./commands/docs"; import gen from "./commands/gen"; import init from "./commands/init"; import locale from "./commands/locale"; @@ -37,7 +38,7 @@ import { import { dedent } from "./lib/string"; import { safeGetRepositoryName, TypeBuilderRequiredError } from "./project"; -const UNTRACKED_COMMANDS = ["login", "logout", "whoami", "sync"]; +const UNTRACKED_COMMANDS = ["login", "logout", "whoami", "sync", "docs"]; const SKIP_REFRESH_COMMANDS = ["login", "logout"]; const router = createCommandRouter({ @@ -48,6 +49,10 @@ const router = createCommandRouter({ handler: init, description: "Initialize a Prismic project", }, + docs: { + handler: docs, + description: "Browse Prismic documentation", + }, gen: { handler: gen, description: "Generate files from local models", From a18c75a3524cfcfce759c2f8186ad97d146fd6a3 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 8 Apr 2026 09:27:03 -1000 Subject: [PATCH 2/5] fix: skip fenced code blocks when extracting docs sections Lines like `# install deps` inside shell/Python code blocks were being treated as level-1 headings, prematurely truncating sections requested via `prismic docs view #`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/docs-view.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/commands/docs-view.ts b/src/commands/docs-view.ts index f5a5f91..cab7f9a 100644 --- a/src/commands/docs-view.ts +++ b/src/commands/docs-view.ts @@ -60,8 +60,17 @@ function extractSection(markdown: string, anchor: string): string | undefined { const lines = markdown.split("\n"); let startIndex = -1; let headingLevel = 0; + let inFence = false; for (let i = 0; i < lines.length; i++) { + if (/^(```|~~~)/.test(lines[i])) { + inFence = !inFence; + continue; + } + if (inFence) { + continue; + } + const match = lines[i].match(/^(#{1,6})\s+(.*)/); if (!match) { continue; From d2e530d26e9dd331412c032325455cd2985e05c9 Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 8 Apr 2026 09:53:35 -1000 Subject: [PATCH 3/5] refactor: extract docs API into dedicated client Co-Authored-By: Claude Opus 4.6 (1M context) --- src/clients/docs.ts | 47 ++++++++++++++++++++++++++++++++++++++ src/commands/docs-list.ts | 48 +++++++++++++++++++-------------------- src/commands/docs-view.ts | 25 +++++++++++--------- src/env.ts | 1 + 4 files changed, 85 insertions(+), 36 deletions(-) create mode 100644 src/clients/docs.ts diff --git a/src/clients/docs.ts b/src/clients/docs.ts new file mode 100644 index 0000000..5f7d3da --- /dev/null +++ b/src/clients/docs.ts @@ -0,0 +1,47 @@ +import * as z from "zod/mini"; + +import { DEFAULT_PRISMIC_HOST, env } from "../env"; +import { request } from "../lib/request"; + +const DocsIndexEntrySchema = z.object({ + path: z.string(), + title: z.string(), + description: z.optional(z.string()), +}); +type DocsIndexEntry = z.infer; + +const DocsPageSchema = z.object({ + path: z.string(), + title: z.string(), + description: z.optional(z.string()), + anchors: z.array( + z.object({ + slug: z.string(), + excerpt: z.string(), + }), + ), +}); +type DocsPage = z.infer; + +export async function getDocsIndex(): Promise { + const url = new URL("api/index/", getDocsBaseUrl()); + return await request(url, { schema: z.array(DocsIndexEntrySchema) }); +} + +export async function getDocsPageIndex(path: string): Promise { + const url = new URL(`api/index/${path}`, getDocsBaseUrl()); + return await request(url, { schema: DocsPageSchema }); +} + +export async function getDocsPageContent(path: string): Promise { + const url = new URL(path, getDocsBaseUrl()); + return await request(url, { + headers: { Accept: "text/markdown" }, + schema: z.string(), + }); +} + +function getDocsBaseUrl(): URL { + const host = env.PRISMIC_DOCS_HOST ?? DEFAULT_PRISMIC_HOST; + return new URL(`https://${host}/docs/`); +} diff --git a/src/commands/docs-list.ts b/src/commands/docs-list.ts index 09b005b..d968ffa 100644 --- a/src/commands/docs-list.ts +++ b/src/commands/docs-list.ts @@ -1,7 +1,7 @@ +import { getDocsIndex, getDocsPageIndex } from "../clients/docs"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; - -const DOCS_INDEX_URL = new URL("https://prismic.io/docs/api/index/"); +import { NotFoundRequestError, UnknownRequestError } from "../lib/request"; const config = { name: "prismic docs list", @@ -21,32 +21,25 @@ const config = { }, } satisfies CommandConfig; -type IndexPage = { - path: string; - title: string; - description: string; -}; - -type IndexPageWithAnchors = IndexPage & { - anchors: { slug: string; excerpt: string }[]; -}; - export default createCommand(config, async ({ positionals, values }) => { const [path] = positionals; const { json } = values; if (path) { - const url = new URL(path, DOCS_INDEX_URL); - const response = await fetch(url); - - if (!response.ok) { - if (response.status === 404) { + let entry; + try { + entry = await getDocsPageIndex(path); + } catch (error) { + if (error instanceof NotFoundRequestError) { throw new CommandError(`Documentation page not found: ${path}`); } - throw new CommandError(`Failed to fetch documentation index: ${response.statusText}`); + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to fetch documentation index: ${message}`); + } + throw error; } - const entry: IndexPageWithAnchors = await response.json(); entry.anchors.sort((a, b) => a.slug.localeCompare(b.slug)); if (json) { @@ -63,13 +56,17 @@ export default createCommand(config, async ({ positionals, values }) => { console.info(`${path}#${anchor.slug}: ${anchor.excerpt}`); } } else { - const response = await fetch(DOCS_INDEX_URL); - - if (!response.ok) { - throw new CommandError(`Failed to fetch documentation index: ${response.statusText}`); + let pages; + try { + pages = await getDocsIndex(); + } catch (error) { + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to fetch documentation index: ${message}`); + } + throw error; } - const pages: IndexPage[] = await response.json(); pages.sort((a, b) => a.path.localeCompare(b.path)); if (json) { @@ -83,7 +80,8 @@ export default createCommand(config, async ({ positionals, values }) => { } for (const page of pages) { - console.info(`${page.path}: ${page.title} — ${page.description}`); + const description = page.description ? ` — ${page.description}` : ""; + console.info(`${page.path}: ${page.title}${description}`); } } }); diff --git a/src/commands/docs-view.ts b/src/commands/docs-view.ts index cab7f9a..8e6e9fb 100644 --- a/src/commands/docs-view.ts +++ b/src/commands/docs-view.ts @@ -1,7 +1,7 @@ +import { getDocsPageContent } from "../clients/docs"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; - -const DOCS_BASE_URL = new URL("https://prismic.io/docs/"); +import { NotFoundRequestError, UnknownRequestError } from "../lib/request"; const config = { name: "prismic docs view", @@ -29,17 +29,20 @@ export default createCommand(config, async ({ positionals, values }) => { const path = hashIndex >= 0 ? rawPath.slice(0, hashIndex) : rawPath; const anchor = hashIndex >= 0 ? rawPath.slice(hashIndex + 1) : undefined; - const url = new URL(path, DOCS_BASE_URL); - const response = await fetch(url, { - headers: { Accept: "text/markdown" }, - }); - - if (!response.ok) { - throw new CommandError(`Failed to fetch documentation page: ${response.statusText}`); + let markdown: string; + try { + markdown = await getDocsPageContent(path); + } catch (error) { + if (error instanceof NotFoundRequestError) { + throw new CommandError(`Documentation page not found: ${path}`); + } + if (error instanceof UnknownRequestError) { + const message = await error.text(); + throw new CommandError(`Failed to fetch documentation page: ${message}`); + } + throw error; } - let markdown = await response.text(); - if (anchor) { const section = extractSection(markdown, anchor); if (!section) { diff --git a/src/env.ts b/src/env.ts index 1f629ef..5ddaf4f 100644 --- a/src/env.ts +++ b/src/env.ts @@ -14,6 +14,7 @@ const Env = z.object({ PRISMIC_SENTRY_ENVIRONMENT: z.optional(z.string()), PRISMIC_SENTRY_ENABLED: z.optional(z.stringbool()), PRISMIC_HOST: z.optional(z.string()), + PRISMIC_DOCS_HOST: z.optional(z.string()), PRISMIC_TYPE_BUILDER_ENABLED: z.optional(z.stringbool()), }); From 16dbdd930c951595f252cf1c3f2d6571de46295b Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 8 Apr 2026 11:24:23 -1000 Subject: [PATCH 4/5] fix: track fence delimiter type and length when extracting docs sections Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/docs-view.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/commands/docs-view.ts b/src/commands/docs-view.ts index 8e6e9fb..1f6262e 100644 --- a/src/commands/docs-view.ts +++ b/src/commands/docs-view.ts @@ -63,14 +63,23 @@ function extractSection(markdown: string, anchor: string): string | undefined { const lines = markdown.split("\n"); let startIndex = -1; let headingLevel = 0; - let inFence = false; + let fence: string | null = null; for (let i = 0; i < lines.length; i++) { - if (/^(```|~~~)/.test(lines[i])) { - inFence = !inFence; + const fenceMatch = lines[i].match(/^(`{3,}|~{3,})(.*)$/); + if (fenceMatch) { + const run = fenceMatch[1]; + const rest = fenceMatch[2]; + if (fence === null) { + fence = run; + continue; + } + if (run.startsWith(fence) && rest.trim() === "") { + fence = null; + } continue; } - if (inFence) { + if (fence !== null) { continue; } From d89c484633b5129ec645e27d01ca60a144e29e8c Mon Sep 17 00:00:00 2001 From: Angelo Ashmore Date: Wed, 8 Apr 2026 12:26:01 -1000 Subject: [PATCH 5/5] fix: use github-slugger for docs section anchors Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 8 +++++ package.json | 1 + src/commands/docs-view.ts | 76 +++++++++++++++++---------------------- 3 files changed, 42 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ed9c51..3618395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "cross-env": "^10.1.0", "dedent": "^1.7.2", "detect-indent": "^7.0.2", + "github-slugger": "2.0.0", "magicast": "0.5.1", "oxfmt": "^0.24.0", "oxlint": "1.39.0", @@ -2635,6 +2636,13 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "dev": true, + "license": "ISC" + }, "node_modules/glob-parent": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", diff --git a/package.json b/package.json index 615cc98..a1310e7 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "cross-env": "^10.1.0", "dedent": "^1.7.2", "detect-indent": "^7.0.2", + "github-slugger": "2.0.0", "magicast": "0.5.1", "oxfmt": "^0.24.0", "oxlint": "1.39.0", diff --git a/src/commands/docs-view.ts b/src/commands/docs-view.ts index 1f6262e..c1b7a8a 100644 --- a/src/commands/docs-view.ts +++ b/src/commands/docs-view.ts @@ -1,3 +1,5 @@ +import GithubSlugger from "github-slugger"; + import { getDocsPageContent } from "../clients/docs"; import { CommandError, createCommand, type CommandConfig } from "../lib/command"; import { stringify } from "../lib/json"; @@ -34,11 +36,11 @@ export default createCommand(config, async ({ positionals, values }) => { markdown = await getDocsPageContent(path); } catch (error) { if (error instanceof NotFoundRequestError) { - throw new CommandError(`Documentation page not found: ${path}`); + throw new CommandError(`Page not found: ${path}`); } if (error instanceof UnknownRequestError) { const message = await error.text(); - throw new CommandError(`Failed to fetch documentation page: ${message}`); + throw new CommandError(`Failed to fetch page: ${message}`); } throw error; } @@ -61,56 +63,44 @@ export default createCommand(config, async ({ positionals, values }) => { function extractSection(markdown: string, anchor: string): string | undefined { const lines = markdown.split("\n"); - let startIndex = -1; - let headingLevel = 0; - let fence: string | null = null; + const slugger = new GithubSlugger(); + + let currentFence: string | undefined; + let startIndex: number | undefined; + let endIndex: number | undefined; + let headingLevel: number | undefined; for (let i = 0; i < lines.length; i++) { - const fenceMatch = lines[i].match(/^(`{3,}|~{3,})(.*)$/); - if (fenceMatch) { - const run = fenceMatch[1]; - const rest = fenceMatch[2]; - if (fence === null) { - fence = run; - continue; - } - if (run.startsWith(fence) && rest.trim() === "") { - fence = null; - } - continue; - } - if (fence !== null) { - continue; - } + const line = lines[i]; - const match = lines[i].match(/^(#{1,6})\s+(.*)/); - if (!match) { + const fenceMatch = line.match(/^(?`{3,}|~{3,})/); + const fence = fenceMatch?.groups?.fence; + if (currentFence) { + if (fence?.startsWith(currentFence)) currentFence = undefined; + continue; + } else if (fence) { + currentFence = fence; continue; } - const level = match[1].length; - const text = match[2]; - - if (startIndex >= 0 && level <= headingLevel) { - return lines.slice(startIndex, i).join("\n").trimEnd(); - } + const headingMatch = line.match(/^(?#{1,6})\s+(?.*)/); + if (headingMatch?.groups?.level && headingMatch?.groups?.text) { + if (startIndex !== undefined && headingLevel !== undefined) { + if (headingMatch.groups.level.length <= headingLevel) { + endIndex = i; + break; + } + } - if (kebabCase(text) === anchor) { - startIndex = i; - headingLevel = level; + const headingAnchor = slugger.slug(headingMatch.groups.text); + if (headingAnchor === anchor) { + startIndex = i; + headingLevel = headingMatch.groups.level.length; + } } } - if (startIndex >= 0) { - return lines.slice(startIndex).join("\n").trimEnd(); - } - - return undefined; -} + if (startIndex === undefined) return; -function kebabCase(text: string): string { - return text - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-|-$/g, ""); + return lines.slice(startIndex, endIndex).join("\n").trim(); }