Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ repos:
language: system
files: ^docs/fern/scripts/check_published_styles\.py$
pass_filenames: false
- id: check-component-imports
name: Check mdx-components carry no unbundleable specifiers
entry: python3 docs/fern/scripts/check_component_imports.py
language: system
# Scan the whole components/ tree, not just the staged files: Fern
# bundles them together and reports whichever it reaches first.
files: ^docs/fern/(components/|scripts/check_component_imports\.py$)
pass_filenames: false
- id: check-component-imports-selftest
name: Self-test the component specifier matcher
entry: python3 docs/fern/scripts/check_component_imports.py --test
language: system
files: ^docs/fern/scripts/check_component_imports\.py$
pass_filenames: false
- id: pytest-marker-report
name: Report pytest markers (static + inherited)
entry: python3 tests/report_pytest_markers.py
Expand Down
19 changes: 14 additions & 5 deletions docs/fern/components/RecipeStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@
* constraint, CSS injected this exact way.
*
* Server component (no "use client"); registered via docs.yml
* `experimental.mdx-components: ./components`. IMPORT it (ambient use is
* unsupported — renders "Unsupported JSX tag"); the @/ prefix resolves to the
* fern/ root and is rewritten to a relative path at publish time:
* import { RecipeStyles } from "@/components/RecipeStyles";
* Then place <RecipeStyles /> once, right after the frontmatter, on every
* `experimental.mdx-components: ./components`. Every page must pull in the
* named RecipeStyles export from the specifier `@/components/RecipeStyles`
* (ambient use is unsupported — renders "Unsupported JSX tag"); the @/ prefix
* resolves to the fern/ root and is rewritten to a relative path at publish
* time. Then place <RecipeStyles /> once, right after the frontmatter, on every
* recipe/benchmark page (and the two landing READMEs).
*
* Do NOT spell that out as a literal ES module statement here, not even inside
* a comment. Fern collects a component's third-party dependencies with a
* plain-text regex over the whole source file, so a commented-out example reads
* as a real dependency on `@/components`, which is outside Fern's allowlist
* (react, react-dom, @mdx-js/react, next). Fern then shells out to
* `npx rolldown` to bundle it and the docs build dies with "Failed to bundle
* third-party imports". docs/fern/scripts/check_component_imports.py enforces
* this.
*/
const RECIPE_CSS = `
/* Dark-mode variable re-bind.
Expand Down
16 changes: 10 additions & 6 deletions docs/fern/components/ReferenceStyles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@
* constraint, CSS injected this exact way.
*
* Server component (no "use client"); registered via docs.yml
* `experimental.mdx-components: ./components`. IMPORT it (ambient use is
* unsupported — renders "Unsupported JSX tag"); the @/ prefix resolves to the
* fern/ root and is rewritten to a relative path at publish time:
* import { ReferenceStyles } from "@/components/ReferenceStyles";
* Then place <ReferenceStyles /> once, right after the frontmatter, on every
* Reference page that uses these components.
* `experimental.mdx-components: ./components`. Every page must pull in the
* named ReferenceStyles export from the specifier `@/components/ReferenceStyles`
* (ambient use is unsupported — renders "Unsupported JSX tag"); the @/ prefix
* resolves to the fern/ root and is rewritten to a relative path at publish
* time. Then place <ReferenceStyles /> once, right after the frontmatter, on
* every Reference page that uses these components.
*
* Do NOT spell that out as a literal ES module statement here, not even inside
* a comment — see the header of RecipeStyles.tsx for why it breaks the docs
* build. docs/fern/scripts/check_component_imports.py enforces this.
*/
const REFERENCE_CSS = `
/* Dark-mode variable re-bind.
Expand Down
15 changes: 10 additions & 5 deletions docs/fern/components/TerminalDemo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
* This repo ships NO package.json alongside fern/, and neither existing
* custom component (CustomFooter, RecipeStyles) pulls an external npm dep —
* the build-safe pattern here is self-contained, dependency-free components.
* So instead of `import "asciinema-player"` (which Fern's mdx-components
* bundling can't resolve without a declared dep), we inject the player's JS +
* CSS from jsDelivr at runtime and call the global AsciinemaPlayer.create().
* So rather than depending on the asciinema-player package directly (which
* Fern's mdx-components bundling can't resolve without a declared dep, and
* which it cannot even see named here as a literal ES module statement
* without failing the build — see the header of RecipeStyles.tsx), we inject
* the player's JS + CSS from jsDelivr at runtime and call the global
* AsciinemaPlayer.create().
* The site already loads third-party JS (adobedtm) and iframes (ghbtns), so
* an external <script>/<link> is consistent with the page's existing origins.
*
Expand All @@ -24,8 +27,10 @@
* DOM access is inside useEffect and guarded, so SSR renders an empty frame
* and hydration wires up the player. dispose() runs on unmount.
*
* USAGE (import — ambient JSX is unsupported, per the RecipeStyles note):
* import { TerminalDemo } from "@/components/TerminalDemo";
* USAGE (ambient JSX is unsupported, per the RecipeStyles note): pull in the
* named TerminalDemo export from the specifier `@/components/TerminalDemo`,
* then render it. docs/fern/scripts/check_component_imports.py enforces the
* no-literal-statement rule.
*
* <TerminalDemo
* src="../../assets/dynamo-demo.cast" // relative, so Fern rewrites it
Expand Down
187 changes: 187 additions & 0 deletions docs/fern/scripts/check_component_imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Guard against module specifiers Fern would try to bundle with rolldown.

docs.yml registers docs/fern/components via `experimental.mdx-components`. When
Fern publishes, it scans each component source for module specifiers that are
neither relative nor allowlisted, and treats what it finds as third-party
dependencies to bundle -- shelling out to `npx rolldown@<pin>` with the
component's directory as cwd. The docs-website branch ships no package.json
next to fern/, so that bundle cannot resolve anything and the build fails with:

Failed to bundle third-party imports in fern/components/<file>.tsx:
rolldown exited with code 127 ... sh: 1: rolldown: not found

The trap is that Fern's scan is a plain-text regex over the whole file -- it
does not parse the source, so it does not skip comments. A usage example in a
component's doc header, such as

* <the ES module keyword> { RecipeStyles } from "@/components/RecipeStyles";

is collected as a real dependency on `@/components` and sends the publish down
the rolldown path. That is what broke the "Preview or publish docs" job: the
failing component rotated between RecipeStyles, ReferenceStyles and
TerminalDemo depending on which one Fern reached first, and none of the three
had an actual npm dependency. Document the specifier in prose instead.

The regex and allowlist below mirror the Fern CLI's own implementation
(cli.cjs, the helper behind "Failed to bundle third-party imports"); keep them
in sync when the pinned CLI in fern.config.json moves.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Guard regex is a hand-copy of Fern's matcher and can drift with the pinned CLI

The docstring states the regex and allowlist mirror the pinned Fern CLI's implementation and must be re-synced when fern.config.json moves. There is no automated check for that drift, so a CLI bump that widens the allowlist or changes the scan (e.g. to a real parser that skips comments) would leave this hook either over- or under-reporting. Worth a follow-up note in the docs release process.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


Usage: python3 check_component_imports.py [files...]
With no arguments, checks every source file under docs/fern/components.
Run with --test to self-test the matcher.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1] # docs/fern
REPO = ROOT.parents[1] # repo root, for readable paths
COMPONENTS = ROOT / "components"

# Fern only scans these extensions.
SOURCE_SUFFIXES = (".js", ".jsx", ".ts", ".tsx")

# Specifiers Fern resolves itself; everything else is "third party".
ALLOWLIST = ("react", "react-dom", "@mdx-js/react", "next")

# Fern's own specifier regex, verbatim. Note it is applied to raw text, which is
# why comments count.
SPECIFIER = re.compile(
r"""(?:^|[^\w.])(?:import|export)\s+(?:[\w*\s{},$]*?from\s+)?["']([^"'\n]+)["']"""
r"""|import\(\s*["']([^"'\n]+)["']\s*\)"""
r"""|require\(\s*["']([^"'\n]+)["']\s*\)""",
re.M,
)


def package_root(specifier: str) -> str:
"""Reduce a specifier to the package Fern would have to install."""
parts = specifier.split("/")
if specifier.startswith("@"):
return "/".join(parts[:2])
return parts[0] if parts else specifier


def is_relative(specifier: str) -> bool:
return specifier.startswith(("./", "../")) or specifier in (".", "..")


def third_party(contents: str) -> list[tuple[int, str]]:
"""Return (line number, specifier) for everything Fern would bundle."""
found: list[tuple[int, str]] = []
seen: set[str] = set()
for match in SPECIFIER.finditer(contents):
specifier = match.group(1) or match.group(2) or match.group(3)
if specifier is None or is_relative(specifier):
continue
if package_root(specifier) in ALLOWLIST or specifier in seen:
continue
seen.add(specifier)
found.append((contents[: match.start()].count("\n") + 1, specifier))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Docs check reports the wrong line number for offending import statements

The reported location is computed from the character just before the statement (match.start() at docs/fern/scripts/check_component_imports.py:98), which is the newline ending the previous line whenever a statement begins at the start of a line, so the tool points contributors at the line above the real problem.
Impact: Someone fixing a failing docs check is sent to the wrong line, wasting time and potentially editing unrelated content.

Why the leading-character alternation shifts the line count

The pattern begins with (?:^|[^\w.]) (docs/fern/scripts/check_component_imports.py:68). For a statement at column 0 on any line after the first, the regex engine reaches the preceding \n before it reaches the line-start anchor position, so [^\w.] consumes that newline and match.start() points at it. contents[: match.start()].count("\n") + 1 therefore excludes that newline and yields the previous line's number.

Verified: for "header\nimport { X } from \"@/components/X\";\n" the function returns line 1 instead of 2. Indented comment examples (like the ones this PR removed) happen to be preceded by a space on the same line, which is why the author's validation showed correct numbers.

Using the specifier group's start position instead avoids the shift.

Suggested change
found.append((contents[: match.start()].count("\n") + 1, specifier))
start = match.start(1) if match.group(1) else match.start(2 if match.group(2) else 3)
found.append((contents[:start].count("\n") + 1, specifier))
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

return found


def display(path: Path) -> str:
"""Repo-relative when possible; a caller may pass a path from anywhere."""
try:
return str(path.resolve().relative_to(REPO))
except ValueError:
return str(path)


def check(path: Path) -> list[str]:
text = path.read_text()
return [
f"{display(path)}:{line}: {specifier}\n"
f" Fern reads this as a third-party dependency and bundles the file"
f" with rolldown, which fails the docs publish.\n"
f" If it is a usage example or a comment, describe the specifier in"
f" prose instead of writing a literal statement."
for line, specifier in third_party(text)
]


def selftest() -> int:
cases: list[tuple[str, bool]] = [
('import { useState } from "react";', False),
('import ReactDOM from "react-dom/client";', False),
('import { useMDXComponents } from "@mdx-js/react";', False),
('import { CURRENT_TAG } from "./releases.data";', False),
('import { X } from "../shared/x";', False),
# The regression this script exists for: an example inside a comment.
(' * import { RecipeStyles } from "@/components/RecipeStyles";', True),
('So instead of `import "asciinema-player"` we load it from a CDN.', True),
('export { Foo } from "@/components/Foo";', True),
('const mod = await import("chart.js");', True),
('const lodash = require("lodash");', True),
# Prose that merely contains the word must not trip the matcher.
('* fails with "Failed to bundle third-party imports" at publish.', False),
("* Every page must pull in the named export from `@/components/Foo`.", False),
]
failures = 0
for source, should_flag in cases:
flagged = bool(third_party(source))
if flagged != should_flag:
failures += 1
verb = "flagged" if flagged else "missed"
print(f"selftest: {verb} unexpectedly: {source!r}", file=sys.stderr)
if failures:
print(f"{failures} selftest case(s) failed", file=sys.stderr)
return 1
print(f"selftest: {len(cases)} case(s) passed")
return 0


def main() -> int:
if "--test" in sys.argv[1:]:
return selftest()

args = [Path(a) for a in sys.argv[1:]]
targets = args or sorted(
p for p in COMPONENTS.rglob("*") if p.suffix in SOURCE_SUFFIXES
)
targets = [t for t in targets if t.suffix in SOURCE_SUFFIXES and t.is_file()]

problems: list[str] = []
for target in targets:
problems.extend(check(target))

if problems:
print("module specifiers Fern cannot bundle:\n", file=sys.stderr)
for problem in problems:
print(f" - {problem}", file=sys.stderr)
print(
"\n Fern resolves only "
+ ", ".join(ALLOWLIST)
+ " and relative paths. A component that genuinely needs an npm"
" package also needs a package.json on the docs-website branch,"
" which does not exist -- load it from a CDN at runtime instead"
" (see docs/fern/components/TerminalDemo.tsx).",
file=sys.stderr,
)
return 1

print(f"checked {len(targets)} component source(s): no unbundleable specifiers")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading