Skip to content
Open
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
43 changes: 43 additions & 0 deletions portal-template/failed-renders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// How React 19.2 hands the browser a part of a page that threw on the server, where it throws again: marked
// `<!--$!-->` when it threw before the page was sent, and switched by an `$RX("…")` call when it threw after.
// The prerender asks as a bot, so React waits for the whole page and only the first appears; `$RX` is for a
// prerender that streams.
const FAILED_BOUNDARY = /<!--\$!-->|\$RX\("/;

// How TanStack reports an error React caught while rendering, which it writes to the console and nowhere else.
const RENDER_ERROR = /^Error in renderTo\w+Stream:/;

interface PrerenderedPage {
page: { path: string };
html: string;
}

/**
* The prerender's `onSuccess`: fails the build over a page that would show readers an error screen, which it
* otherwise ships as blank, with the errors the server reported while rendering.
*/
export function refuseFailedRenders(output: Pick<Console, 'error'> = console): (prerendered: PrerenderedPage) => void {
const errors: string[] = [];
const write = output.error.bind(output);
output.error = (...data: unknown[]) => {
if (typeof data[0] === 'string' && RENDER_ERROR.test(data[0])) {
errors.push(firstLine(data[1]));
}
write(...data);
};

return ({ page, html }) => {
if (!FAILED_BOUNDARY.test(html)) {
return;
}
// Every error so far, not only this page's: the build stops at the first page refused, and the others are next.
const reported =
errors.length === 0 ? 'Its error is in the build log.' : `The server reported: ${errors.join('\n')}`;
throw new Error(`${page.path} failed to render, so readers would see an error in its place. ${reported}`);
};
}

function firstLine(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
return message.split('\n')[0];
}
6 changes: 4 additions & 2 deletions portal-template/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@ import tailwindcss from '@tailwindcss/vite';
import { fumadocsMdx } from 'fumadocs-mdx/vite';
import { dependencyDirectories } from './dependency-directories.ts';
import { downloads } from './downloads.ts';
import { refuseFailedRenders } from './failed-renders.ts';
import { generatedPagesReload } from './generated-pages-reload.ts';
import { readBuildPaths, readPortalIdentity } from './portal-config.ts';
import { prerenderPages } from './prerender-pages.ts';
import { specReload } from './spec-reload.ts';
import { remarkImageReferences } from './src/lib/remark-image-references.ts';
import { staticFunctionsBase } from './static-functions-base.ts';

export default defineConfig(async () => {
export default defineConfig(async ({ command }) => {
const [paths, identity] = await Promise.all([readBuildPaths(), readPortalIdentity()]);
const pages = await prerenderPages(paths, identity.siteUrl);
const publicDir: string | false = paths.staticDir ?? false;
Expand Down Expand Up @@ -47,7 +48,8 @@ export default defineConfig(async () => {
// The page list above is complete, and crawling would also follow root-relative links
// in the rendered pages -- a description linking to "/docs/connect" on the author's
// own site would fail the whole build with a 404.
prerender: { crawlLinks: false },
// Only a build prerenders, and only it should have its console watched.
prerender: { crawlLinks: false, onSuccess: command === 'build' ? refuseFailedRenders() : undefined },
// Modules named *.server.* read the specification off disk; a client import ships
// that code to the browser, where it throws before React can hydrate and leaves every
// page inert. Fail the build rather than mocking the import.
Expand Down
47 changes: 46 additions & 1 deletion src/prompts/portal/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
import { API_REFERENCE_TOKEN, NAVIGATION_FILE_NAME, TabEntry } from '../../types/portal/portal-navigation.js';
import {
ContentProblem,
InvalidPage,
MissingFile,
MissingImage,
PortalSourceProblem,
ReservedAddressPage,
SharedAddress,
SharedEndpoint,
SpecConversion
SpecConversion,
UnknownComponent
} from '../../types/portal/portal-source.js';
import { FUMADOCS_COMPONENTS } from '../../types/portal/portal-components.js';
import { Shared } from '../../types/portal/shared.js';
import { MERGE_SETTINGS } from '../../types/portal/spec-metadata.js';
import { TRANSFORMATIONS_DIRECTORY_NAME } from '../../types/transform-context.js';
Expand Down Expand Up @@ -273,9 +276,51 @@
reportMissingImages(problem.images, sourceDirectory);
return;
}
case 'unknownComponents': {
reportUnknownComponents(problem.components, sourceDirectory);
return;
}
case 'invalidMdx': {
reportInvalidPages(problem.pages, sourceDirectory);
return;
}
}
}

function reportInvalidPages(pages: InvalidPage[], sourceDirectory: DirectoryPath): void {
const lines = pages.map(
({ page, line, column, reason }) =>
` • ${relative(page, sourceDirectory)}, line ${line}, column ${column}: ${reason}`
);
log.error(
pages.length === 1
? `A page in ${f.path(sourceDirectory)} is not valid MDX:`
: `Pages in ${f.path(sourceDirectory)} are not valid MDX:`
);
log.message(lines.join('\n'));
log.message('MDX stops at the first mistake in a page, so a page can have more after the one shown.');
}

function reportUnknownComponents(components: UnknownComponent[], sourceDirectory: DirectoryPath): void {
const lines = components.map(({ page, line, name, nearest }) => {
const where = ` • ${relative(page, sourceDirectory)}, line ${line}: ${f.var(`<${name}>`)}`;

Check warning on line 306 in src/prompts/portal/source.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=apimatic_apimatic-cli&issues=AaFUISpsopP96vTSA_dd&open=AaFUISpsopP96vTSA_dd&pullRequest=444
return nearest === null ? where : `${where}. Did you mean ${f.var(`<${nearest}>`)}?`;

Check warning on line 307 in src/prompts/portal/source.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=apimatic_apimatic-cli&issues=AaFUISpsopP96vTSA_de&open=AaFUISpsopP96vTSA_de&pullRequest=444
});
log.error(
components.length === 1
? `A page in ${f.path(sourceDirectory)} uses a component the portal does not provide:`
: `Pages in ${f.path(sourceDirectory)} use components the portal does not provide:`
);
log.message(lines.join('\n'));
log.message(
`Readers would see an error in place of the page. Import the component in the page, or use one of ` +
`${listedInProse(
FUMADOCS_COMPONENTS.map((name) => f.var(`<${name}>`)),
'or'
)}.`
);
}

function reportMissingImages(images: MissingImage[], sourceDirectory: DirectoryPath): void {
const lines = images.map(({ page, line, url, missing }) => {
const where = ` • ${relative(page, sourceDirectory)}, line ${line}: ${f.var(url)}`;
Expand Down
27 changes: 26 additions & 1 deletion src/types/portal/content-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { FilePath } from '../file/filePath.js';
import { ContentNotices } from './content-notices.js';
import { GENERATED_SECTIONS, GeneratedPages } from './generated-pages.js';
import { ParsedPage } from './page.js';
import { isPortalComponent, nearestPortalComponent } from './portal-components.js';
import {
API_REFERENCE_NAME,
GROUP_FOLDER,
Expand All @@ -20,11 +21,13 @@ import {
} from './portal-navigation.js';
import {
ContentProblem,
InvalidPage,
MissingImage,
PortalSpec,
ReservedAddress,
ReservedAddressPage,
SharedAddress
SharedAddress,
UnknownComponent
} from './portal-source.js';
import { PortalTab, sharedTabNames, TabOwner, untitledTabName } from './portal-tabs.js';
import { shared } from './shared.js';
Expand Down Expand Up @@ -170,6 +173,16 @@ export class ContentTree {
if (read.missingImages.length > 0) {
problems.push({ kind: 'missingImages', images: read.missingImages });
}
// Refused here too, so a preview hears of them, and a build of every page at once rather than the first.
const invalidPages = ContentTree.invalidPages(read.pages);
if (invalidPages.length > 0) {
problems.push({ kind: 'invalidMdx', pages: invalidPages });
}
// Refused here because the build would not: MDX looks a component up only when the page renders.
const unknownComponents = ContentTree.unknownComponents(read.pages);
if (unknownComponents.length > 0) {
problems.push({ kind: 'unknownComponents', components: unknownComponents });
}

// The walk takes each page to be served where it sits, so it would misjudge an entry naming one of these.
if (addressProblems.length > 0) {
Expand Down Expand Up @@ -227,6 +240,18 @@ export class ContentTree {
};
}

private static invalidPages(pages: ReadPage[]): InvalidPage[] {
return pages.flatMap(({ file, parsed }) => (parsed?.syntaxError ? [{ page: file, ...parsed.syntaxError }] : []));
}

private static unknownComponents(pages: ReadPage[]): UnknownComponent[] {
return pages.flatMap(({ file, parsed }) =>
(parsed?.components ?? [])
.filter(({ name }) => !isPortalComponent(name))
.map(({ name, line }) => ({ page: file, line, name, nearest: nearestPortalComponent(name) }))
);
}

/**
* Every `nav.json` in the tree, validated against the directory it orders, plus any `nav.json`
* in a case the build does not match. One walk, because both come from the same tree, and a
Expand Down
139 changes: 130 additions & 9 deletions src/types/portal/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,28 @@
path: string;
}

/** A page as the build parses it: its front matter held to the build's schema, and the images the build imports. */
/** A tag in an `.mdx` page naming a component the page neither imports nor defines, which the portal has to provide. */
export interface PageComponent {
/** As the tag writes it: `Banner`, or `Icons.Star`. */
name: string;
line: number;
}

/** Where an `.mdx` page stops being valid MDX, which fails the build, or leaves the page an error screen in a preview. */
export interface PageSyntaxError {
line: number;
column: number;
/** As MDX gives it, less the parser's own names. */
reason: string;
}

/** A page as the build parses it: its front matter held to the build's schema, the images the build imports, and the components it takes from the portal. */
export interface ParsedPage {
frontMatter: Result<PageFrontMatter, string[]>;
images: PageImage[];
components: PageComponent[];
/** The first mistake in its body, if any: MDX reads no further. */
syntaxError: PageSyntaxError | null;
}

type SchemaIssue = NonNullable<ReturnType<typeof pageSchema.safeParse>['error']>['issues'][number];
Expand All @@ -36,27 +54,61 @@
type: string;
url?: string;
identifier?: string;
/** A JSX tag's name; null for a fragment. */
name?: string | null;
/** An ESM block's program, which remark-mdx parses it into. */
data?: { estree?: { body: EsmNode[] } };
position?: { start: { line: number } };
children?: MarkdownNode[];
}

/** As much of an ESM block's syntax tree as says what it defines. */
interface EsmNode {
type: string;
name?: string;
id?: EsmNode | null;
local?: EsmNode;
specifiers?: EsmNode[];
declaration?: EsmNode | null;
declarations?: EsmNode[];
}

/** What MDX throws, a vfile message, as far as it says where and why. */
interface ParseFailure {
reason?: unknown;
line?: unknown;
column?: unknown;
cause?: unknown;
}

// A scheme, a drive letter or a network path: none of them names a file the build imports.
const NOT_A_FILE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;

/** Parsed once, as the build parses it, `.mdx` as MDX; a body that does not parse is the build's to report. */
// A tag's place, as MDX writes it into a reason: `(3:1-3:8)`.
const PLACE_IN_REASON = /\s*\((\d+):(\d+)-\d+:\d+\)$/;

Check warning on line 88 in src/types/portal/page.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Simplify this regular expression to reduce its runtime, as it has super-linear performance due to backtracking.

See more on https://sonarcloud.io/project/issues?id=apimatic_apimatic-cli&issues=AaFUISjgopP96vTSA_dc&open=AaFUISjgopP96vTSA_dc&pullRequest=444

/** Parsed once, as the build parses it, `.mdx` as MDX. */
export async function parsePage(contents: string, fileName: FileName, label: string): Promise<ParsedPage> {
let parsed: { matter: string; data: unknown };
try {
parsed = frontmatter(contents);
} catch (error) {
const reason = errorMessage(error).split('\n')[0];
return { frontMatter: err([`${label}: its front matter is not valid YAML. ${reason}`]), images: [] };
return {
frontMatter: err([`${label}: its front matter is not valid YAML. ${reason}`]),
images: [],
components: [],
syntaxError: null
};
}
// Blanked rather than cut, so every line keeps its number.
const body = '\n'.repeat(parsed.matter.split('\n').length - 1) + contents.slice(parsed.matter.length);
const tree = await parsedBody(body, fileName.hasExactExtension('.mdx'));
return {
frontMatter: await checkedFrontMatter(parsed, label),
images: await imagesIn(body, fileName.hasExactExtension('.mdx'))
images: tree.map(imagesIn).unwrapOr([]),
components: tree.map(componentsIn).unwrapOr([]),
syntaxError: tree.isErr() ? tree.error : null
};
}

Expand Down Expand Up @@ -104,17 +156,38 @@
return `${label}: 'title' must be text. Put it in quotes if it looks like a number or true or false.`;
}

async function imagesIn(body: string, isMdx: boolean): Promise<PageImage[]> {
async function parsedBody(body: string, isMdx: boolean): Promise<Result<MarkdownNode, PageSyntaxError>> {
// On first use, as the schema is: most commands never read a page.
const { remark } = await import('remark');
const processor = isMdx ? remark().use((await import('remark-mdx')).default) : remark();
let tree: MarkdownNode;
try {
tree = processor.parse(body) as MarkdownNode;
} catch {
return [];
return ok(processor.parse(body) as MarkdownNode);
} catch (error) {
return err(syntaxError(isJsonObject(error) ? error : {}));
}
}

/**
* Where MDX stopped, and why. An unclosed tag is only found at the end of the page, so MDX names the
* tag's own place in its reason, and gives the error none.
*/
function syntaxError({ reason, line, column, cause }: ParseFailure): PageSyntaxError {
const text = typeof reason === 'string' ? reason : 'It could not be read as MDX.';
const tag = PLACE_IN_REASON.exec(text);
const said = text.replace(PLACE_IN_REASON, '').replace(/ with acorn$/, '');
// "Could not parse expression" says nothing of what was wrong with it; acorn's own reason does.
const detail = cause instanceof Error ? `: ${cause.message}` : '';
if (typeof line === 'number' && typeof column === 'number') {
return { line, column, reason: said + (tag === null ? '' : ` on line ${tag[1]}`) + detail };
}
return {
line: tag === null ? 1 : Number(tag[1]),
column: tag === null ? 1 : Number(tag[2]),
reason: said + detail
};
}

function imagesIn(tree: MarkdownNode): PageImage[] {
// The template writes `![a][ref]` as `![a](url)` before the build imports it (remark-image-references.ts).
const definitions = new Map<string, string>();
const define = (node: MarkdownNode) => {
Expand Down Expand Up @@ -144,6 +217,54 @@
return images;
}

/**
* The tags MDX compiles to a lookup in the components the portal provides, which fails when a reader
* opens the page rather than when it builds: those naming nothing an import or export of the page defines.
*/
function componentsIn(tree: MarkdownNode): PageComponent[] {
const defined = new Set(tree.children?.flatMap(({ data }) => data?.estree?.body.flatMap(definedNames) ?? []));
const components: PageComponent[] = [];
const visit = (node: MarkdownNode) => {
const isTag = node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement';
const name = isTag ? node.name : null;
if (typeof name === 'string' && isComponentName(name) && !defined.has(name.split('.')[0])) {
components.push({ name, line: node.position?.start.line ?? 0 });
}
node.children?.forEach(visit);
};
visit(tree);
return components;
}

// MDX's own rule: `x.y` and a name that does not start with a lowercase letter are components; `b-ar` and `svg:rect` are HTML.
function isComponentName(name: string): boolean {
return name.includes('.') || (!/^[a-z]/.test(name) && !/[-:]/.test(name));
}

function definedNames(statement: EsmNode): string[] {
if (statement.type === 'ImportDeclaration') {
return statement.specifiers?.flatMap(({ local }) => local?.name ?? []) ?? [];
}
const declaration = statement.type === 'ExportNamedDeclaration' ? statement.declaration : null;
if (declaration?.type === 'VariableDeclaration') {
return declaration.declarations?.flatMap(({ id }) => identifiersIn(id)) ?? [];
}
return declaration?.id?.name === undefined ? [] : [declaration.id.name];
}

// A destructuring pattern's keys and defaults too: more than it binds, which can let a tag through but never refuse one.
function identifiersIn(pattern: unknown): string[] {
if (Array.isArray(pattern)) {
return pattern.flatMap(identifiersIn);
}
if (!isJsonObject(pattern)) {
return [];
}
return pattern.type === 'Identifier' && typeof pattern.name === 'string'
? [pattern.name]
: Object.values(pattern).flatMap(identifiersIn);
}

function located(url: string): Omit<PageImage, 'line'> | null {
const address = url.split(/[?#]/, 1)[0];
if (address.length === 0 || address.endsWith('/') || NOT_A_FILE.test(address)) {
Expand Down
Loading
Loading