Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions fern/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,9 @@ supports React's `<style precedence>` hoisting.

The only checked-in standalone CSS is `styles/local-preview.css`, used by
`make serve-fern-docs-locally` only when the Fern global theme is inaccessible.
It is not referenced from `docs.yml`.
It is not referenced from `docs.yml`. The fallback applies a deterministic
NVIDIA-style local theme and mirrors source changes into Fern's temporary
preview tree so live reload continues to work without theme access.

## Common commands

Expand Down Expand Up @@ -184,4 +186,4 @@ Raw Fern CLI commands, normally wrapped by Make:

| Symptom | Fix |
|---------|-----|
| Local preview uses the local fallback theme | Sign in to https://dashboard.buildwithfern.com, run `cd fern && npx -y fern-api@$(jq -r .version fern.config.json) login`, then retry. If it still falls back, export a privileged `DOCS_FERN_TOKEN` as `FERN_TOKEN`. |
| Local preview uses the deterministic local fallback theme | The fallback supports live reload without authentication. To use the canonical global theme, sign in to https://dashboard.buildwithfern.com, run `cd fern && npx -y fern-api@$(jq -r .version fern.config.json) login`, then retry. If it still falls back, export a privileged `DOCS_FERN_TOKEN` as `FERN_TOKEN`. |
148 changes: 117 additions & 31 deletions fern/scripts/serve-local-docs-preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import argparse
import shutil
import signal
import subprocess
import sys
Expand Down Expand Up @@ -41,6 +42,11 @@
}

COMPONENT_EXTENSIONS = {".js", ".jsx", ".ts", ".tsx"}
DOCS_CONFIG_PATH = Path("docs.yml")
POLL_INTERVAL_SECONDS = 0.25

FileState = tuple[int, int, int]
SourceState = dict[Path, FileState]


def parse_args() -> argparse.Namespace:
Expand All @@ -57,6 +63,8 @@ def parse_args() -> argparse.Namespace:

def write_local_docs_config(root: Path, preview_root: Path) -> None:
config = yaml.safe_load((root / "docs.yml").read_text(encoding="utf-8"))
if not isinstance(config, dict):
raise yaml.YAMLError("docs.yml must contain a mapping")
config.pop("global-theme", None)

for key, value in LOCAL_THEME_CONFIG.items():
Expand All @@ -81,43 +89,121 @@ def write_local_docs_config(root: Path, preview_root: Path) -> None:
)


def link_components(root: Path, preview_root: Path) -> None:
components_root = root / "components"
preview_components_root = preview_root / "components"
preview_components_root.mkdir()

for source in sorted(components_root.rglob("*")):
target = preview_components_root / source.relative_to(components_root)
if source.is_dir():
target.mkdir()
def snapshot_source(root: Path) -> SourceState:
state = {}
for source in root.rglob("*"):
try:
if not source.is_file():
continue
stat = source.stat()
except OSError:
continue
state[source.relative_to(root)] = (stat.st_mtime_ns, stat.st_ino, stat.st_size)
return state

target.symlink_to(source)
if source.suffix in COMPONENT_EXTENSIONS:
alias = target.with_suffix("")
if not alias.exists():
alias.symlink_to(source)


def build_preview_root(root: Path, preview_root: Path) -> None:
for child in root.iterdir():
if child.name == "docs.yml":
continue
target = preview_root / child.name
if child.name == "components":
link_components(root, preview_root)
def component_aliases(state: SourceState) -> dict[Path, Path]:
aliases = {}
for source in sorted(state):
if source.parts[:1] != ("components",) or source.suffix not in COMPONENT_EXTENSIONS:
continue
target.symlink_to(child, target_is_directory=child.is_dir())
write_local_docs_config(root, preview_root)


def run_command(command: list[str], cwd: Path) -> int:
process = subprocess.Popen(command, cwd=cwd)
alias = source.with_suffix("")
if alias not in state and alias not in aliases:
aliases[alias] = source
return aliases


def copy_preview_file(root: Path, preview_root: Path, source: Path, target: Path | None = None) -> None:
target = preview_root / (source if target is None else target)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(root / source, target)


def remove_preview_file(preview_root: Path, relative_path: Path) -> None:
target = preview_root / relative_path
target.unlink(missing_ok=True)
parent = target.parent
while parent != preview_root:
try:
parent.rmdir()
except OSError:
break
parent = parent.parent


def build_preview_root(root: Path, preview_root: Path) -> SourceState:
state = snapshot_source(root)
for relative_path in sorted(state):
if relative_path == DOCS_CONFIG_PATH:
write_local_docs_config(root, preview_root)
else:
copy_preview_file(root, preview_root, relative_path)
for alias, source in component_aliases(state).items():
copy_preview_file(root, preview_root, source, alias)
return state


def sync_preview_root(root: Path, preview_root: Path, previous_state: SourceState) -> SourceState:
current_state = snapshot_source(root)
previous_aliases = component_aliases(previous_state)
current_aliases = component_aliases(current_state)
removed_paths = previous_state.keys() - current_state.keys()
removed_aliases = previous_aliases.keys() - current_aliases.keys()
for relative_path in sorted(removed_paths | removed_aliases):
remove_preview_file(preview_root, relative_path)

changed_paths = {
relative_path
for relative_path, file_state in current_state.items()
if previous_state.get(relative_path) != file_state
}
failed_paths = set()
for relative_path in sorted(changed_paths):
try:
if relative_path == DOCS_CONFIG_PATH:
write_local_docs_config(root, preview_root)
else:
copy_preview_file(root, preview_root, relative_path)
except (OSError, yaml.YAMLError):
failed_paths.add(relative_path)

changed_aliases = {
alias
for alias, source in current_aliases.items()
if previous_aliases.get(alias) != source or source in changed_paths
}
for alias in sorted(changed_aliases):
source = current_aliases[alias]
try:
copy_preview_file(root, preview_root, source, alias)
except OSError:
failed_paths.add(source)
if alias in previous_aliases:
failed_paths.add(previous_aliases[alias])

for relative_path in failed_paths:
if relative_path in previous_state:
current_state[relative_path] = previous_state[relative_path]
else:
current_state.pop(relative_path, None)
return current_state


def run_command(command: list[str], preview_root: Path, root: Path, source_state: SourceState) -> int:
process = subprocess.Popen(command, cwd=preview_root)
try:
return process.wait()
while True:
try:
return process.wait(timeout=POLL_INTERVAL_SECONDS)
except subprocess.TimeoutExpired:
source_state = sync_preview_root(root, preview_root, source_state)
except KeyboardInterrupt:
process.send_signal(signal.SIGINT)
return process.wait()
except Exception:
process.send_signal(signal.SIGINT)
process.wait()
raise


def main() -> int:
Expand All @@ -126,9 +212,9 @@ def main() -> int:
with tempfile.TemporaryDirectory(prefix="fern-local-preview-") as temp_dir:
preview_root = Path(temp_dir) / "fern"
preview_root.mkdir()
build_preview_root(root, preview_root)
source_state = build_preview_root(root, preview_root)
print(f"Using local Fern preview config at {preview_root / 'docs.yml'}", file=sys.stderr)
return run_command(args.command, preview_root)
return run_command(args.command, preview_root, root, source_state)


if __name__ == "__main__":
Expand Down
Loading
Loading