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
8 changes: 8 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
47 changes: 47 additions & 0 deletions src/clients/docs.ts
Original file line number Diff line number Diff line change
@@ -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<typeof DocsIndexEntrySchema>;

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<typeof DocsPageSchema>;

export async function getDocsIndex(): Promise<DocsIndexEntry[]> {
const url = new URL("api/index/", getDocsBaseUrl());
return await request(url, { schema: z.array(DocsIndexEntrySchema) });
}

export async function getDocsPageIndex(path: string): Promise<DocsPage> {
const url = new URL(`api/index/${path}`, getDocsBaseUrl());
return await request(url, { schema: DocsPageSchema });
}

export async function getDocsPageContent(path: string): Promise<string> {
const url = new URL(path, getDocsBaseUrl());
Comment thread
angeloashmore marked this conversation as resolved.
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/`);
}
87 changes: 87 additions & 0 deletions src/commands/docs-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { getDocsIndex, getDocsPageIndex } from "../clients/docs";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { stringify } from "../lib/json";
import { NotFoundRequestError, UnknownRequestError } from "../lib/request";

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;

export default createCommand(config, async ({ positionals, values }) => {
const [path] = positionals;
const { json } = values;

if (path) {
let entry;
try {
entry = await getDocsPageIndex(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 index: ${message}`);
}
throw error;
}

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 {
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;
}

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) {
const description = page.description ? ` — ${page.description}` : "";
console.info(`${page.path}: ${page.title}${description}`);
}
}
});
106 changes: 106 additions & 0 deletions src/commands/docs-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import GithubSlugger from "github-slugger";

import { getDocsPageContent } from "../clients/docs";
import { CommandError, createCommand, type CommandConfig } from "../lib/command";
import { stringify } from "../lib/json";
import { NotFoundRequestError, UnknownRequestError } from "../lib/request";

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;

let markdown: string;
try {
markdown = await getDocsPageContent(path);
} catch (error) {
if (error instanceof NotFoundRequestError) {
throw new CommandError(`Page not found: ${path}`);
}
if (error instanceof UnknownRequestError) {
const message = await error.text();
throw new CommandError(`Failed to fetch page: ${message}`);
}
throw error;
}

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");
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 line = lines[i];

const fenceMatch = line.match(/^(?<fence>`{3,}|~{3,})/);
const fence = fenceMatch?.groups?.fence;
if (currentFence) {
if (fence?.startsWith(currentFence)) currentFence = undefined;
continue;
} else if (fence) {
currentFence = fence;
continue;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fence closing check ignores trailing content on line

Medium Severity

The fence-closing check on line 79 only verifies the line starts with the same fence characters via startsWith, but doesn't verify the rest of the line is empty (or whitespace-only). Per CommonMark, closing code fences cannot have info strings — a line like ```python inside a ``` block is content, not a closing fence. The current regex /^(?<fence>\{3,}|~{3,})/matches the fence prefix, andstartsWithsucceeds, so the code incorrectly exits the fenced block. This causes subsequent lines still inside the code block to be scanned for headings, potentially producing wrong anchor matches or corrupting theGithubSlugger` state — likely in documentation about code that nests fenced examples.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d89c484. Configure here.


const headingMatch = line.match(/^(?<level>#{1,6})\s+(?<text>.*)/);
if (headingMatch?.groups?.level && headingMatch?.groups?.text) {
if (startIndex !== undefined && headingLevel !== undefined) {
if (headingMatch.groups.level.length <= headingLevel) {
endIndex = i;
break;
}
}

const headingAnchor = slugger.slug(headingMatch.groups.text);
if (headingAnchor === anchor) {
startIndex = i;
headingLevel = headingMatch.groups.level.length;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

if (startIndex === undefined) return;

return lines.slice(startIndex, endIndex).join("\n").trim();
}
19 changes: 19 additions & 0 deletions src/commands/docs.ts
Original file line number Diff line number Diff line change
@@ -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",
},
},
});
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
});

Expand Down
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import packageJson from "../package.json" with { type: "json" };
import { getAdapter, NoSupportedFrameworkError } from "./adapters";
import { AUTH_FILE_PATH, 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";
Expand Down Expand Up @@ -38,7 +39,7 @@ import { dedent } from "./lib/string";
import { initUpdateNotifier } from "./lib/update-notifier";
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({
Expand All @@ -49,6 +50,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",
Expand Down