Repository navigation
fix(docs): stop Fern bundling commented-out component imports #12753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||||||||
|
|
||||||||
| 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)) | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 ( Why the leading-character alternation shifts the line countThe pattern begins with Verified: for Using the specifier group's start position instead avoids the shift.
Suggested change
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()) | ||||||||
There was a problem hiding this comment.
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.jsonmoves. 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.Was this helpful? React with 👍 or 👎 to provide feedback.