From e0f89a7196cf48f1bb0023bb10668431f9be7a92 Mon Sep 17 00:00:00 2001 From: mehnoorsiddiqui Date: Mon, 19 Oct 2026 17:26:00 +0500 Subject: [PATCH] fix(portal): catch unknown components and invalid MDX in content pages before readers see them A page naming a component the portal does not provide, or a page that is not valid MDX, is now refused by `portal generate` and `portal serve`, at startup and on save, with every broken page listed by its line and column. A page that still fails to render while it is prerendered fails the build, quoting the server's error. Closes #442 Closes #443 Co-Authored-By: Claude Opus 5.5 (1M context) --- portal-template/failed-renders.ts | 43 ++++++ portal-template/vite.config.ts | 6 +- src/prompts/portal/source.ts | 47 +++++- src/types/portal/content-tree.ts | 27 +++- src/types/portal/page.ts | 139 ++++++++++++++++-- src/types/portal/portal-components.ts | 80 ++++++++++ src/types/portal/portal-source.ts | 19 ++- test/e2e/portal-build.test.ts | 37 ++++- test/portal-template/failed-renders.test.ts | 59 ++++++++ test/portal-template/mdx-components.test.ts | 57 +++++++ test/prompts/portal/source.test.ts | 64 ++++++++ .../failed-render-late/src/apimatic.json | 11 ++ .../failed-render-late/src/content/index.md | 5 + .../failed-render-late/src/content/late.mdx | 16 ++ .../src/spec/Apimatic-Calculator.json | 104 +++++++++++++ .../failed-render/src/apimatic.json | 11 ++ .../failed-render/src/content/broken.mdx | 7 + .../failed-render/src/content/index.md | 5 + .../src/spec/Apimatic-Calculator.json | 104 +++++++++++++ test/types/portal-source-context.test.ts | 75 ++++++++++ test/types/portal/content-tree.test.ts | 55 +++++++ test/types/portal/page.test.ts | 98 +++++++++++- test/types/portal/portal-components.test.ts | 37 +++++ 23 files changed, 1090 insertions(+), 16 deletions(-) create mode 100644 portal-template/failed-renders.ts create mode 100644 src/types/portal/portal-components.ts create mode 100644 test/portal-template/failed-renders.test.ts create mode 100644 test/portal-template/mdx-components.test.ts create mode 100644 test/resources/portal-inputs/failed-render-late/src/apimatic.json create mode 100644 test/resources/portal-inputs/failed-render-late/src/content/index.md create mode 100644 test/resources/portal-inputs/failed-render-late/src/content/late.mdx create mode 100644 test/resources/portal-inputs/failed-render-late/src/spec/Apimatic-Calculator.json create mode 100644 test/resources/portal-inputs/failed-render/src/apimatic.json create mode 100644 test/resources/portal-inputs/failed-render/src/content/broken.mdx create mode 100644 test/resources/portal-inputs/failed-render/src/content/index.md create mode 100644 test/resources/portal-inputs/failed-render/src/spec/Apimatic-Calculator.json create mode 100644 test/types/portal/portal-components.test.ts diff --git a/portal-template/failed-renders.ts b/portal-template/failed-renders.ts new file mode 100644 index 000000000..d31e7cb06 --- /dev/null +++ b/portal-template/failed-renders.ts @@ -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): (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]; +} diff --git a/portal-template/vite.config.ts b/portal-template/vite.config.ts index 27025c9d4..883f1bdc0 100644 --- a/portal-template/vite.config.ts +++ b/portal-template/vite.config.ts @@ -6,6 +6,7 @@ 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'; @@ -13,7 +14,7 @@ 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; @@ -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. diff --git a/src/prompts/portal/source.ts b/src/prompts/portal/source.ts index fa72765cd..05fe0b38c 100644 --- a/src/prompts/portal/source.ts +++ b/src/prompts/portal/source.ts @@ -6,14 +6,17 @@ import { ContentNotices, NoTabsListed } from '../../types/portal/content-notices 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'; @@ -273,9 +276,51 @@ function reportContentProblem(problem: ContentProblem, sourceDirectory: Director 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}>`)}`; + return nearest === null ? where : `${where}. Did you mean ${f.var(`<${nearest}>`)}?`; + }); + 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)}`; diff --git a/src/types/portal/content-tree.ts b/src/types/portal/content-tree.ts index 655c5d950..6fd1e55e7 100644 --- a/src/types/portal/content-tree.ts +++ b/src/types/portal/content-tree.ts @@ -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, @@ -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'; @@ -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) { @@ -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 diff --git a/src/types/portal/page.ts b/src/types/portal/page.ts index 7e423b4b3..3db4746ad 100644 --- a/src/types/portal/page.ts +++ b/src/types/portal/page.ts @@ -22,10 +22,28 @@ export interface PageImage { 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; images: PageImage[]; + components: PageComponent[]; + /** The first mistake in its body, if any: MDX reads no further. */ + syntaxError: PageSyntaxError | null; } type SchemaIssue = NonNullable['error']>['issues'][number]; @@ -36,27 +54,61 @@ interface MarkdownNode { 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+\)$/; + +/** Parsed once, as the build parses it, `.mdx` as MDX. */ export async function parsePage(contents: string, fileName: FileName, label: string): Promise { 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 }; } @@ -104,17 +156,38 @@ function titleError(title: unknown, label: string): string { 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 { +async function parsedBody(body: string, isMdx: boolean): Promise> { // 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(); const define = (node: MarkdownNode) => { @@ -144,6 +217,54 @@ async function imagesIn(body: string, isMdx: boolean): Promise { 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 | null { const address = url.split(/[?#]/, 1)[0]; if (address.length === 0 || address.endsWith('/') || NOT_A_FILE.test(address)) { diff --git a/src/types/portal/portal-components.ts b/src/types/portal/portal-components.ts new file mode 100644 index 000000000..d6c4c8623 --- /dev/null +++ b/src/types/portal/portal-components.ts @@ -0,0 +1,80 @@ +import levenshtein from 'fast-levenshtein'; + +/** Fumadocs' components that `portal-template/src/components/mdx.tsx` registers, so a page uses them without importing them. */ +export const FUMADOCS_COMPONENTS: readonly string[] = [ + 'Accordion', + 'Accordions', + 'Callout', + 'CalloutContainer', + 'CalloutDescription', + 'CalloutTitle', + 'Card', + 'Cards', + 'CodeBlockTab', + 'CodeBlockTabs', + 'CodeBlockTabsList', + 'CodeBlockTabsTrigger', + 'File', + 'Files', + 'Folder', + 'InlineTOC', + 'Step', + 'Steps', + 'Tab', + 'Tabs', + 'TypeTable' +]; + +/** The template's own components, which the generated pages are made of and which an author's page can use too. */ +export const GENERATED_PAGE_COMPONENTS: readonly string[] = [ + 'PluginInstall', + 'PluginLanguage', + 'PluginLanguages', + 'PluginPlatforms', + 'SdkActions', + 'SdkCard', + 'SdkCards' +]; + +const PORTAL_COMPONENTS: ReadonlySet = new Set([...FUMADOCS_COMPONENTS, ...GENERATED_PAGE_COMPONENTS]); + +/** Components of the documentation sites pages are moved from (Mintlify, Docusaurus), by the portal's own that does their job. */ +const COUNTERPARTS: ReadonlyMap = new Map([ + ['AccordionGroup', 'Accordions'], + ['Admonition', 'Callout'], + ['CardGroup', 'Cards'], + ['Caution', 'Callout'], + ['Check', 'Callout'], + ['CodeGroup', 'Tabs'], + ['Danger', 'Callout'], + ['Info', 'Callout'], + ['Note', 'Callout'], + ['TabItem', 'Tab'], + ['Tip', 'Callout'], + ['Warning', 'Callout'] +]); + +// Two edits turn a short name into another word altogether: `Tip` is two from `Step`. +const mostEditsForATypo = (name: string): number => (name.length <= 3 ? 1 : 2); + +export function isPortalComponent(name: string): boolean { + return PORTAL_COMPONENTS.has(name); +} + +/** The component a misspelt or borrowed name most likely meant, or null when none is close. */ +export function nearestPortalComponent(name: string): string | null { + const counterpart = COUNTERPARTS.get(name); + if (counterpart !== undefined) { + return counterpart; + } + let nearest: string | null = null; + let fewestEdits = mostEditsForATypo(name) + 1; + for (const component of PORTAL_COMPONENTS) { + const edits = levenshtein.get(name.toLowerCase(), component.toLowerCase()); + if (edits < fewestEdits) { + nearest = component; + fewestEdits = edits; + } + } + return nearest; +} diff --git a/src/types/portal/portal-source.ts b/src/types/portal/portal-source.ts index f7b71d8a7..c8b24c169 100644 --- a/src/types/portal/portal-source.ts +++ b/src/types/portal/portal-source.ts @@ -5,6 +5,7 @@ import { Endpoint } from './endpoint.js'; import { SuggestedSite } from './config/site-config.js'; import { ContentNotices } from './content-notices.js'; import { GeneratedPages } from './generated-pages.js'; +import { PageSyntaxError } from './page.js'; import { PortalConfig } from './portal-config.js'; import { Shared } from './shared.js'; @@ -96,6 +97,20 @@ export interface MissingImage { missing: MissingFile | null; } +/** A page's tag naming a component the portal does not provide, which builds, and leaves the page an error screen. */ +export interface UnknownComponent { + page: FilePath; + line: number; + name: string; + /** The component a typo most likely meant, or null when none is close. */ + nearest: string | null; +} + +/** A page that is not valid MDX, at its first mistake. */ +export interface InvalidPage extends PageSyntaxError { + page: FilePath; +} + /** Why `content/` cannot be built; each variant maps to its own message. */ export type ContentProblem = | { kind: 'unreadableContent' } @@ -104,7 +119,9 @@ export type ContentProblem = | { kind: 'sharedAddresses'; addresses: SharedAddress[] } | { kind: 'invalidFrontMatter'; errors: string[] } | { kind: 'invalidNavigation'; errors: string[] } - | { kind: 'missingImages'; images: MissingImage[] }; + | { kind: 'missingImages'; images: MissingImage[] } + | { kind: 'unknownComponents'; components: UnknownComponent[] } + | { kind: 'invalidMdx'; pages: InvalidPage[] }; /** How `api transform` would turn the first document in a directory into one the portal reads. */ export interface SpecConversion { diff --git a/test/e2e/portal-build.test.ts b/test/e2e/portal-build.test.ts index fb785eac9..0569a26a8 100644 --- a/test/e2e/portal-build.test.ts +++ b/test/e2e/portal-build.test.ts @@ -11,6 +11,7 @@ import { DirectoryPath } from '../../src/types/file/directoryPath'; import { FilePath } from '../../src/types/file/filePath'; import { CodeSampleCatalog, CodeSampleCatalogs } from '../../src/types/portal/code-samples'; import { PortalArtifacts } from '../../src/types/portal/portal-artifacts'; +import { logTail } from '../../src/prompts/prompt'; import { Language } from '../../src/types/sdk/generate'; import { ensurePortalProjectDirectoryBase, @@ -83,7 +84,10 @@ async function buildFixture(name: string, delivered: Delivered = {}): Promise { expect(read(`${LIST_ORDERS}.md`)).to.contain('grandTotalCents'); }); }); + +/** + * A page that compiles and throws while it renders: React leaves its body for the browser, which + * throws the same and shows readers an error screen. A component inside an expression is one the + * content check does not look into. + */ +(enabled ? describe : describe.skip)('portal build, a page that fails to render (end to end)', function () { + this.timeout(10 * 60 * 1000); + + const failureOf = (fixture: string) => + buildFixture(fixture).then( + () => null, + (error: Error) => error.message + ); + + it('fails the build, naming the page and its error', async () => { + const failure = await failureOf('failed-render'); + + expect(failure).to.contain('The portal build failed.'); + expect(failure).to.contain('/broken failed to render'); + expect(failure).to.contain('Expected component `Banner` to be defined'); + }); + + // Streamed to a reader, React would send the page first and switch the part in later. + it('fails the build over a page that throws once it has waited, naming its error', async () => { + const failure = await failureOf('failed-render-late'); + + expect(failure).to.contain('/late failed to render'); + expect(failure).to.contain('Late threw after the page was sent'); + }); +}); diff --git a/test/portal-template/failed-renders.test.ts b/test/portal-template/failed-renders.test.ts new file mode 100644 index 000000000..43f47d860 --- /dev/null +++ b/test/portal-template/failed-renders.test.ts @@ -0,0 +1,59 @@ +import fs from 'node:fs'; +import { expect } from 'chai'; +import { refuseFailedRenders } from '../../portal-template/failed-renders'; + +describe('refuseFailedRenders', () => { + const page = { path: '/components/banner' }; + const FAILED = '
'; + + /** The check, watching a console of its own, and what reached that console. */ + const watching = () => { + const written: unknown[][] = []; + const output = { error: (...data: unknown[]) => written.push(data) }; + return { refuse: refuseFailedRenders(output), output, written }; + }; + + // As React wrote them into a page whose MDX named a component the portal does not provide. + for (const [when, html] of [ + ['before the page was sent', FAILED], + [ + 'after the page was sent', + '
' + ] + ]) { + it(`fails a page whose content threw ${when}, naming it`, () => { + expect(() => watching().refuse({ page, html })).to.throw(/^\/components\/banner failed to render/); + }); + } + + it('passes a page that rendered in full, and one whose text mentions the markers', () => { + const html = '

Write <!--$!--> or $RX("B:0") in a code sample.

'; + + expect(() => watching().refuse({ page, html })).to.not.throw(); + }); + + // The build log's tail, all the CLI shows, can leave the error out. + it('quotes the error the server reported while rendering, and still writes it out', () => { + const { refuse, output, written } = watching(); + const error = new Error('Expected component `Banner` to be defined: you likely forgot to import it.\nMore.'); + + output.error('Error in renderToReadableStream:', error, {}); + output.error('Something else went wrong:', new Error('Not a render.')); + + expect(() => refuse({ page, html: FAILED })).to.throw( + /an error in its place\. The server reported: Expected component `Banner` to be defined: you likely forgot to import it\.$/ + ); + expect(written).to.have.length(2); + }); + + it('points to the build log when the server reported nothing', () => { + expect(() => watching().refuse({ page, html: FAILED })).to.throw(/Its error is in the build log\.$/); + }); + + // The markers are React's own, read from 19.2's output: another React has to be read again. + it('reads the markers of the React the portal is built with', () => { + const { dependencies } = JSON.parse(fs.readFileSync('package.json', 'utf8')); + + expect(dependencies['react-dom']).to.match(/^19\.2\./); + }); +}); diff --git a/test/portal-template/mdx-components.test.ts b/test/portal-template/mdx-components.test.ts new file mode 100644 index 000000000..dc579ff75 --- /dev/null +++ b/test/portal-template/mdx-components.test.ts @@ -0,0 +1,57 @@ +import fs from 'node:fs'; +import { expect } from 'chai'; +import defaultMdxComponents from 'fumadocs-ui/mdx'; +import ts from 'typescript'; +import { FileName } from '../../src/types/file/fileName'; +import { parsePage } from '../../src/types/portal/page'; +import { + FUMADOCS_COMPONENTS, + GENERATED_PAGE_COMPONENTS, + isPortalComponent +} from '../../src/types/portal/portal-components'; + +// The CLI refuses a page naming a component outside its list, so the list has to be the template's own. +describe("the template's MDX components", () => { + const MDX = 'portal-template/src/components/mdx.tsx'; + const COMPONENTS_PAGE = 'test/resources/portal-inputs/default/src/content/guides/components.mdx'; + + /** The object `getMDXComponents` returns, as written. */ + const returnedObject = (source: ts.SourceFile): ts.ObjectLiteralExpression => { + const getMDXComponents = source.statements.find( + (statement): statement is ts.FunctionDeclaration => + ts.isFunctionDeclaration(statement) && statement.name?.text === 'getMDXComponents' + ); + const returned = getMDXComponents?.body?.statements.find(ts.isReturnStatement)?.expression; + if (returned === undefined || !ts.isObjectLiteralExpression(returned)) { + throw new Error(`${MDX} no longer returns an object from getMDXComponents`); + } + return returned; + }; + + /** What `getMDXComponents` registers: Fumadocs' defaults spread in, and the components named after them. */ + const registered = (): string[] => { + const source = ts.createSourceFile(MDX, fs.readFileSync(MDX, 'utf8'), ts.ScriptTarget.Latest); + return returnedObject(source).properties.flatMap((property) => { + if (ts.isShorthandPropertyAssignment(property)) { + return [property.name.text]; + } + const isDefaults = + ts.isSpreadAssignment(property) && property.expression.getText(source) === 'defaultMdxComponents'; + return isDefaults ? Object.keys(defaultMdxComponents) : []; + }); + }; + + it('are the components the CLI accepts on a page', () => { + const components = registered().filter((name) => !/^[a-z]/.test(name)); + + expect(components.sort()).to.deep.equal([...FUMADOCS_COMPONENTS, ...GENERATED_PAGE_COMPONENTS].sort()); + }); + + it("include every component the fixture's components page uses", async () => { + const page = fs.readFileSync(COMPONENTS_PAGE, 'utf8'); + const { components } = await parsePage(page, new FileName('components.mdx'), COMPONENTS_PAGE); + + expect(components).to.not.be.empty; + expect(components.filter(({ name }) => !isPortalComponent(name))).to.deep.equal([]); + }); +}); diff --git a/test/prompts/portal/source.test.ts b/test/prompts/portal/source.test.ts index 2af4be54c..b3619e4cc 100644 --- a/test/prompts/portal/source.test.ts +++ b/test/prompts/portal/source.test.ts @@ -154,6 +154,70 @@ describe('reportSourceProblem', () => { }); }); + describe('components the portal does not provide', () => { + const page = new FilePath(source.join('content').join('components'), new FileName('banner.mdx')); + + it('names each by its page and line, with the one a typo most likely meant, and the ones there are', () => { + reportContent({ + kind: 'unknownComponents', + components: [ + { page, line: 7, name: 'Banner', nearest: null }, + { page, line: 9, name: 'Accordian', nearest: 'Accordion' } + ] + }); + + expect(printed()).to.contain('Pages in'); + expect(printed()).to.contain("'content/components/banner.mdx', line 7: ''\n"); + expect(printed()).to.contain( + "'content/components/banner.mdx', line 9: ''. Did you mean ''?" + ); + expect(printed()).to.contain("Import the component in the page, or use one of '', "); + expect(printed()).to.contain("'' or ''."); + }); + + it('speaks of one page when there is one', () => { + reportContent({ kind: 'unknownComponents', components: [{ page, line: 7, name: 'Banner', nearest: null }] }); + + expect(printed()).to.contain('A page in'); + expect(printed()).to.contain('uses a component the portal does not provide'); + }); + }); + + describe('pages that are not valid MDX', () => { + const cards = new FilePath(source.join('content').join('components'), new FileName('cards.mdx')); + const steps = new FilePath(source.join('content').join('components'), new FileName('steps.mdx')); + + it('names each by its page, line and column, and says there can be more in a page', () => { + reportContent({ + kind: 'invalidMdx', + pages: [ + { page: cards, line: 55, column: 1, reason: 'Expected a closing tag for ``' }, + { page: steps, line: 9, column: 12, reason: 'Could not parse expression: Unexpected token' } + ] + }); + + expect(printed()).to.contain('Pages in'); + expect(printed()).to.contain('are not valid MDX'); + expect(printed()).to.contain( + "'content/components/cards.mdx', line 55, column 1: Expected a closing tag for ``\n" + ); + expect(printed()).to.contain( + "'content/components/steps.mdx', line 9, column 12: Could not parse expression: Unexpected token" + ); + expect(printed()).to.contain('MDX stops at the first mistake in a page'); + }); + + it('speaks of one page when there is one', () => { + reportContent({ + kind: 'invalidMdx', + pages: [{ page: cards, line: 55, column: 1, reason: 'Expected a closing tag for ``' }] + }); + + expect(printed()).to.contain('A page in'); + expect(printed()).to.contain('is not valid MDX'); + }); + }); + describe('a spec directory the portal reads no document from', () => { const spec = source.join('spec'); const conversion = (format: string | null, others = 0) => ({ diff --git a/test/resources/portal-inputs/failed-render-late/src/apimatic.json b/test/resources/portal-inputs/failed-render-late/src/apimatic.json new file mode 100644 index 000000000..c8ec9c84e --- /dev/null +++ b/test/resources/portal-inputs/failed-render-late/src/apimatic.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "portal": { + "site": { + "name": "Calculator API" + } + }, + "languages": { + "typescript": {} + } +} diff --git a/test/resources/portal-inputs/failed-render-late/src/content/index.md b/test/resources/portal-inputs/failed-render-late/src/content/index.md new file mode 100644 index 000000000..d5f108cfb --- /dev/null +++ b/test/resources/portal-inputs/failed-render-late/src/content/index.md @@ -0,0 +1,5 @@ +--- +title: Welcome +--- + +Hello from the fixture with a page that fails to render after it waits. diff --git a/test/resources/portal-inputs/failed-render-late/src/content/late.mdx b/test/resources/portal-inputs/failed-render-late/src/content/late.mdx new file mode 100644 index 000000000..b7714ecf7 --- /dev/null +++ b/test/resources/portal-inputs/failed-render-late/src/content/late.mdx @@ -0,0 +1,16 @@ +--- +title: Late +--- + +import { use } from 'react'; + +export const arrived = new Promise((resolve) => setTimeout(resolve, 200)); + +export function Late() { + use(arrived); + throw new Error('Late threw after the page was sent'); +} + +## Release notes + + diff --git a/test/resources/portal-inputs/failed-render-late/src/spec/Apimatic-Calculator.json b/test/resources/portal-inputs/failed-render-late/src/spec/Apimatic-Calculator.json new file mode 100644 index 000000000..568861b5d --- /dev/null +++ b/test/resources/portal-inputs/failed-render-late/src/spec/Apimatic-Calculator.json @@ -0,0 +1,104 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "APIMATIC Calculator-test", + "description": "Simple calculator API hosted on APIMATIC for demo purposes", + "contact": {}, + "version": "2.3" + }, + "servers": [ + { + "url": "https://examples.apimatic.io/apps/calculator", + "description": "This environment connect to the LIVE calculator API", + "variables": {} + } + ], + "paths": { + "/{operation}": { + "get": { + "tags": [ + "Simple Calculator" + ], + "summary": "Calculate", + "description": "Calculates the expression using the specified operation.\n\n![How a calculation flows](/images/diagram.png)\n\n```json\n{ \"calculated\": 9 }\n```", + "operationId": "Calculate", + "parameters": [ + { + "name": "operation", + "in": "path", + "description": "The operator to apply on the variables", + "required": true, + "style": "simple", + "schema": { + "$ref": "#/components/schemas/OperationType" + } + }, + { + "name": "x", + "in": "query", + "description": "The LHS value", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "y", + "in": "query", + "description": "The RHS value", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "number", + "format": "double" + } + } + ], + "responses": { + "200": { + "description": "", + "headers": {}, + "content": { + "text/plain": { + "schema": { + "type": "number", + "format": "double" + } + } + } + } + }, + "deprecated": false + } + } + }, + "components": { + "schemas": { + "OperationType": { + "title": "OperationType", + "enum": [ + "SUM", + "SUBTRACT", + "MULTIPLY", + "DIVIDE" + ], + "type": "string", + "description": "Possible operators are sum, subtract, multiply, divide", + "example": "SUM" + } + } + }, + "security": [ + {} + ], + "tags": [ + { + "name": "Simple Calculator", + "description": "" + } + ] +} diff --git a/test/resources/portal-inputs/failed-render/src/apimatic.json b/test/resources/portal-inputs/failed-render/src/apimatic.json new file mode 100644 index 000000000..c8ec9c84e --- /dev/null +++ b/test/resources/portal-inputs/failed-render/src/apimatic.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "portal": { + "site": { + "name": "Calculator API" + } + }, + "languages": { + "typescript": {} + } +} diff --git a/test/resources/portal-inputs/failed-render/src/content/broken.mdx b/test/resources/portal-inputs/failed-render/src/content/broken.mdx new file mode 100644 index 000000000..e52125f6c --- /dev/null +++ b/test/resources/portal-inputs/failed-render/src/content/broken.mdx @@ -0,0 +1,7 @@ +--- +title: Broken +--- + +## Release notes + +{true && Out now.} diff --git a/test/resources/portal-inputs/failed-render/src/content/index.md b/test/resources/portal-inputs/failed-render/src/content/index.md new file mode 100644 index 000000000..de33f9363 --- /dev/null +++ b/test/resources/portal-inputs/failed-render/src/content/index.md @@ -0,0 +1,5 @@ +--- +title: Welcome +--- + +Hello from the fixture with a page that fails to render. diff --git a/test/resources/portal-inputs/failed-render/src/spec/Apimatic-Calculator.json b/test/resources/portal-inputs/failed-render/src/spec/Apimatic-Calculator.json new file mode 100644 index 000000000..568861b5d --- /dev/null +++ b/test/resources/portal-inputs/failed-render/src/spec/Apimatic-Calculator.json @@ -0,0 +1,104 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "APIMATIC Calculator-test", + "description": "Simple calculator API hosted on APIMATIC for demo purposes", + "contact": {}, + "version": "2.3" + }, + "servers": [ + { + "url": "https://examples.apimatic.io/apps/calculator", + "description": "This environment connect to the LIVE calculator API", + "variables": {} + } + ], + "paths": { + "/{operation}": { + "get": { + "tags": [ + "Simple Calculator" + ], + "summary": "Calculate", + "description": "Calculates the expression using the specified operation.\n\n![How a calculation flows](/images/diagram.png)\n\n```json\n{ \"calculated\": 9 }\n```", + "operationId": "Calculate", + "parameters": [ + { + "name": "operation", + "in": "path", + "description": "The operator to apply on the variables", + "required": true, + "style": "simple", + "schema": { + "$ref": "#/components/schemas/OperationType" + } + }, + { + "name": "x", + "in": "query", + "description": "The LHS value", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "y", + "in": "query", + "description": "The RHS value", + "required": true, + "style": "form", + "explode": true, + "schema": { + "type": "number", + "format": "double" + } + } + ], + "responses": { + "200": { + "description": "", + "headers": {}, + "content": { + "text/plain": { + "schema": { + "type": "number", + "format": "double" + } + } + } + } + }, + "deprecated": false + } + } + }, + "components": { + "schemas": { + "OperationType": { + "title": "OperationType", + "enum": [ + "SUM", + "SUBTRACT", + "MULTIPLY", + "DIVIDE" + ], + "type": "string", + "description": "Possible operators are sum, subtract, multiply, divide", + "example": "SUM" + } + } + }, + "security": [ + {} + ], + "tags": [ + { + "name": "Simple Calculator", + "description": "" + } + ] +} diff --git a/test/types/portal-source-context.test.ts b/test/types/portal-source-context.test.ts index b05b2843b..0d1403ca5 100644 --- a/test/types/portal-source-context.test.ts +++ b/test/types/portal-source-context.test.ts @@ -1610,6 +1610,81 @@ describe('PortalSourceContext', () => { }); }); + // MDX compiles a component the portal does not provide, and the page fails only when a reader opens it. + describe('the components the pages use', () => { + beforeEach(() => { + writeConfig({ site: { name: 'Calc' } }); + write('spec/api.json', OPENAPI); + }); + + it('refuses a component the portal does not provide, by its page and line', async () => { + write('content/components/banner.mdx', page('Banner') + '\nOut now.\n'); + + const problem = contentProblem((await resolve())._unsafeUnwrapErr()); + + expect(problem.kind === 'unknownComponents' && problem.components).to.deep.equal([ + { + page: new FilePath(new DirectoryPath(root, 'content', 'components'), new FileName('banner.mdx')), + line: 5, + name: 'Banner', + nearest: null + } + ]); + }); + + it('accepts the components the portal provides, and those a page imports', async () => { + write( + 'content/index.mdx', + page('Home') + + "import { Banner } from 'fumadocs-ui/components/banner';\n\n" + + 'Out now.\n\nNote.\n\n\nOne\n\n' + ); + + expect((await resolve()).isOk()).to.be.true; + }); + }); + + // `portal generate` would fail over the first only, naming its temporary copy; `portal serve`, over none. + describe('pages that are not valid MDX', () => { + const cards = () => new FilePath(new DirectoryPath(root, 'content', 'components'), new FileName('cards.mdx')); + const OPEN_CARDS = page('Cards') + '\n\n \n'; + + beforeEach(() => { + writeConfig({ site: { name: 'Calc' } }); + write('spec/api.json', OPENAPI); + write('content/index.md', page('Home')); + }); + + it('refuses each one by its page, line and column', async () => { + write('content/components/cards.mdx', OPEN_CARDS); + write('content/components/steps.mdx', page('Steps') + '\nThen {oops(}.\n'); + + const problem = contentProblem((await resolve())._unsafeUnwrapErr()); + + expect(problem.kind === 'invalidMdx' && problem.pages).to.deep.equal([ + { page: cards(), line: 5, column: 1, reason: 'Expected a closing tag for ``' }, + { + page: new FilePath(new DirectoryPath(root, 'content', 'components'), new FileName('steps.mdx')), + line: 5, + column: 12, + reason: 'Could not parse expression: Unexpected token' + } + ]); + }); + + it('refuses one saved while the preview runs, and accepts it once fixed', async () => { + const { specs, generatedPages } = (await resolve())._unsafeUnwrap(); + const context = new PortalSourceContext(new DirectoryPath(root)); + + write('content/components/cards.mdx', OPEN_CARDS); + const refused = (await context.resolveContent(specs, generatedPages))._unsafeUnwrapErr(); + write('content/components/cards.mdx', OPEN_CARDS + '\n'); + + expect(refused.map(({ kind }) => kind)).to.deep.equal(['invalidMdx']); + expect((await context.resolveContent(specs, generatedPages)).isOk()).to.be.true; + }); + }); + describe('nav.json', () => { beforeEach(() => { writeConfig({ site: { name: 'Calc' } }); diff --git a/test/types/portal/content-tree.test.ts b/test/types/portal/content-tree.test.ts index f25ecc67e..4fa080f97 100644 --- a/test/types/portal/content-tree.test.ts +++ b/test/types/portal/content-tree.test.ts @@ -99,6 +99,61 @@ describe('ContentTree', () => { expect(problems[0]).to.deep.equal({ kind: 'missingImages', images: [image] }); }); + it('refuses a component the portal does not provide, with the one a typo most likely meant', async () => { + const page = inContent('guides', 'intro.mdx'); + const withComponents = await parsed( + [inContent('index.md'), '---\ntitle: Home\n---\n'], + [page, '---\ntitle: Intro\n---\n\nOut now.\n\ny\n\n\n'] + ); + + const navigationFiles = handed([inContent('nav.json'), '{}'], [inContent('guides', 'nav.json'), '{}']); + + const problems = tree + .check({ pages: withComponents, navigationFiles, missingImages: [] }, [], GENERATED) + ._unsafeUnwrapErr(); + + expect(problems).to.deep.equal([ + { + kind: 'unknownComponents', + components: [ + { page, line: 5, name: 'Banner', nearest: null }, + { page, line: 7, name: 'Accordian', nearest: 'Accordion' } + ] + } + ]); + }); + + it('refuses every page that is not valid MDX at once, alongside the components of the pages that are', async () => { + const cards = inContent('guides', 'cards.mdx'); + const steps = inContent('guides', 'steps.mdx'); + const banner = inContent('guides', 'banner.mdx'); + const pages = await parsed( + [inContent('index.md'), '---\ntitle: Home\n---\n'], + [cards, '---\ntitle: Cards\n---\n\n\n \n'], + [steps, '---\ntitle: Steps\n---\n\n\n\n'], + [banner, '---\ntitle: Banner\n---\n\n\n'] + ); + const navigationFiles = handed([inContent('nav.json'), '{}'], [inContent('guides', 'nav.json'), '{}']); + + const problems = tree.check({ pages, navigationFiles, missingImages: [] }, [], GENERATED)._unsafeUnwrapErr(); + + expect(problems).to.deep.equal([ + { + kind: 'invalidMdx', + pages: [ + { page: cards, line: 5, column: 1, reason: 'Expected a closing tag for ``' }, + { + page: steps, + line: 6, + column: 1, + reason: 'Unexpected closing tag ``, expected corresponding closing tag for `` on line 5' + } + ] + }, + { kind: 'unknownComponents', components: [{ page: banner, line: 5, name: 'Banner', nearest: null }] } + ]); + }); + it('names the tabs from what it is handed', () => { const navigationFiles = handed( [inContent('nav.json'), JSON.stringify({ title: 'Guides', tabs: ['guides'], pages: ['index'] })], diff --git a/test/types/portal/page.test.ts b/test/types/portal/page.test.ts index a07ef80d7..09ea44a80 100644 --- a/test/types/portal/page.test.ts +++ b/test/types/portal/page.test.ts @@ -113,7 +113,7 @@ describe('parsePage', () => { expect((await images('---\ntitle: Home\n---\n\n![a](/a.png)\n')).map(({ line }) => line)).to.deep.equal([5]); }); - // The front matter check reports the first; the build reports the second. + // The front matter check reports the first; the syntax error, the second. it('finds nothing in a page that does not parse, rather than throwing', async () => { expect(await images('---\ntitle: [unclosed\n---\n![a](/a.png)\n')).to.deep.equal([]); expect(await images('\n\n![a](/a.png)\n', 'page.mdx')).to.deep.equal([]); @@ -149,4 +149,100 @@ describe('parsePage', () => { expect(found.map(({ path, line }) => [path, line])).to.deep.equal([['a.png', 3]]); }); }); + + // MDX looks each up in the components the portal provides, and only once a reader opens the page. + describe('the components', () => { + const components = async (markdown: string, name = 'page.mdx') => (await page(markdown, name)).components; + + it('names each component tag with its line, inside other tags and text too', async () => { + const markdown = + '---\ntitle: Home\n---\n\nOut now.\n\n' + + '\n Text with in it.\n\n'; + + expect(await components(markdown)).to.deep.equal([ + { name: 'Banner', line: 5 }, + { name: 'Tabs', line: 7 }, + { name: 'Tab', line: 8 }, + { name: 'Badge', line: 8 } + ]); + }); + + it('names a member tag whole, whatever the case of its object', async () => { + expect(await components('\n\nx\n')).to.deep.equal([ + { name: 'Icons.Star', line: 1 }, + { name: 'motion.div', line: 3 } + ]); + }); + + it('leaves out HTML tags and fragments', async () => { + expect(await components('
\n \n \n
\n\n<>Fragment\n')).to.deep.equal([]); + }); + + it('leaves out what the page imports or exports itself', async () => { + const markdown = + "import { Banner } from 'fumadocs-ui/components/banner';\n" + + "import Default, * as Icons from './icons';\n" + + "export function Local() { return 'local'; }\n" + + 'export const Arrow = () => null, { Picked } = Icons;\n\n' + + '\n\n\n\n\n\n\n\n\n\n\n\n\n'; + + expect((await components(markdown)).map(({ name }) => name)).to.deep.equal(['Missing']); + }); + + it('finds none in a .md page, where a tag is HTML', async () => { + expect(await components('Out now.\n', 'page.md')).to.deep.equal([]); + }); + + // The page's syntax error is reported in their place. + it('finds none in a page that does not parse, rather than throwing', async () => { + expect(await components('\n\nUnclosed.\n')).to.deep.equal([]); + expect(await components('---\ntitle: [unclosed\n---\n\n')).to.deep.equal([]); + }); + }); + + // A preview leaves such a page an error screen, and the build fails over it naming its temporary copy. + describe('the first mistake in an .mdx page', () => { + const syntaxError = async (body: string, name = 'page.mdx') => + (await page(`---\ntitle: Cards\n---\n\n${body}`, name)).syntaxError; + + it('places a tag left open at the tag, which MDX finds only at the end of the page', async () => { + expect(await syntaxError('## One\n\n\n \n\n## Two\n')).to.deep.equal({ + line: 7, + column: 1, + reason: 'Expected a closing tag for ``' + }); + }); + + it('places a closing tag that does not match at the closing tag, naming the line of the one it should close', async () => { + expect(await syntaxError('\n Text\n\n')).to.deep.equal({ + line: 7, + column: 1, + reason: 'Unexpected closing tag `
`, expected corresponding closing tag for `` on line 5' + }); + }); + + it("gives what is wrong with an expression in acorn's words, and not its name", async () => { + expect(await syntaxError('Then {oops(}.\n')).to.deep.equal({ + line: 5, + column: 12, + reason: 'Could not parse expression: Unexpected token' + }); + }); + + it('names an HTML comment as MDX does, with what to write instead', async () => { + const found = await syntaxError('\n'); + + expect(found).to.include({ line: 5, column: 2 }); + expect(found?.reason).to.contain('use `{/* text */}`'); + }); + + for (const [description, body, name] of [ + ['a valid page', '\n \n\n\n{/* a comment */}\n', 'page.mdx'], + ['a .md page, which is not read as MDX', '\n\n\n\nIf a <5.\n', 'page.md'] + ]) { + it(`finds none in ${description}`, async () => { + expect(await syntaxError(body, name)).to.be.null; + }); + } + }); }); diff --git a/test/types/portal/portal-components.test.ts b/test/types/portal/portal-components.test.ts new file mode 100644 index 000000000..8674e495f --- /dev/null +++ b/test/types/portal/portal-components.test.ts @@ -0,0 +1,37 @@ +import { expect } from 'chai'; +import { nearestPortalComponent } from '../../../src/types/portal/portal-components'; + +describe('nearestPortalComponent', () => { + for (const [name, meant] of [ + ['Accordian', 'Accordion'], + ['Stpe', 'Step'], + ['steps', 'Steps'], + ['Accordians', 'Accordions'], + ['Tag', 'Tab'] + ]) { + it(`takes <${name}> for a typo of <${meant}>`, () => { + expect(nearestPortalComponent(name)).to.equal(meant); + }); + } + + // `Tup` and `Bar` are two edits from `Tab`, too many for a three-letter name to be a typo of. + for (const name of ['Tup', 'Bar', 'Banner']) { + it(`finds nothing <${name}> is a typo of`, () => { + expect(nearestPortalComponent(name)).to.be.null; + }); + } + + for (const [name, counterpart] of [ + ['Note', 'Callout'], + ['Tip', 'Callout'], + ['Warning', 'Callout'], + ['CardGroup', 'Cards'], + ['AccordionGroup', 'Accordions'], + ['CodeGroup', 'Tabs'], + ['TabItem', 'Tab'] + ]) { + it(`points <${name}>, from another documentation site, to <${counterpart}>`, () => { + expect(nearestPortalComponent(name)).to.equal(counterpart); + }); + } +});