diff --git a/src/doc_builder/commands/build.py b/src/doc_builder/commands/build.py index d42eb376..6067ed16 100644 --- a/src/doc_builder/commands/build.py +++ b/src/doc_builder/commands/build.py @@ -33,11 +33,21 @@ write_markdown_route_file, ) +# The kit (SvelteKit) build currently runs on Vite 7, which requires Node.js >= 20. +MIN_NODE_MAJOR_VERSION = 20 + +# Build artifacts and dependencies do not need to be staged: `npm ci` recreates +# node_modules and the build regenerates `.svelte-kit` and `build`. +KIT_COPY_IGNORE = shutil.ignore_patterns("node_modules", ".svelte-kit", "build") + def check_node_is_available(): + """ + Raises if Node.js is missing or older than `MIN_NODE_MAJOR_VERSION`. + """ try: p = subprocess.run( - ["node", "-v"], + [resolve_npm_sibling("node"), "-v"], capture_output=True, check=True, encoding="utf-8", @@ -45,17 +55,97 @@ def check_node_is_available(): version = p.stdout.strip() except Exception as e: raise OSError( - "Using the --html flag requires node v14 to be installed, but it was not found in your system." + f"This command requires Node.js v{MIN_NODE_MAJOR_VERSION} or newer, but node was not found in your system." ) from e - major = int(version[1:].split(".")[0]) - if major < 14: + major = int(version.removeprefix("v").split(".")[0]) + if major < MIN_NODE_MAJOR_VERSION: raise OSError( - "Using the --html flag requires node v14 to be installed, but the version in your system is lower " - f"({version[1:]})" + f"This command requires Node.js v{MIN_NODE_MAJOR_VERSION} or newer, but the version in your system is " + f"lower ({version.removeprefix('v')})." ) +def resolve_npm_sibling(executable): + """ + Resolves `node`/`npm` to a full path, which also works on Windows where + `npm` is `npm.cmd` (not directly runnable by `subprocess` without a shell). + """ + resolved = shutil.which(executable) + if resolved is None: + raise OSError(f"`{executable}` was not found in your system (PATH).") + return resolved + + +def run_npm(npm_args, cwd, env=None, quiet=True): + """ + Runs `npm ` in `cwd`. With `quiet=True`, stdout is captured + (stderr passes through so errors stay visible). + """ + subprocess.run( + [resolve_npm_sibling("npm"), *npm_args], + stdout=subprocess.PIPE if quiet else None, + check=True, + encoding="utf-8", + cwd=str(cwd), + env=env, + ) + + +def docs_node_env(library_name, version, language): + """ + Environment for the kit build/dev server: DOCS_* variables end up as compile-time + constants (see kit/vite.config.ts) and in the base path (see kit/svelte.config.js). + """ + env = os.environ.copy() + env["DOCS_LIBRARY"] = env.get("package_name") or library_name + env["DOCS_VERSION"] = version + env["DOCS_LANGUAGE"] = language + return env + + +def stage_kit_routes(kit_folder, tmp_dir, output_path): + """ + Stages the SvelteKit app in `tmp_dir` with the generated doc files as routes: + + 1. copies the kit folder (without dependencies/build artifacts), + 2. copies the generated files from `output_path` into `src/routes`, + 3. renames `.mdx` files to the SvelteKit routing convention (`foo.mdx` -> + `foo/+page.svelte`) and writes the markdown (`foo.md`) route files. + + Returns `(kit_dir, routes_dir, markdown_exports)` where `markdown_exports` is a + list of `(relative_posix_path, markdown_content)` tuples. + """ + kit_dir = Path(tmp_dir) / "kit" + routes_dir = kit_dir / "src" / "routes" + + shutil.copytree(kit_folder, kit_dir, ignore=KIT_COPY_IGNORE) + + # Manual copy and overwrite from output_path to src/routes: we don't use + # shutil.copytree as it exists and contains important files. + for f in Path(output_path).iterdir(): + dest = routes_dir / f.name + if f.is_dir(): + if dest.is_dir(): + shutil.rmtree(dest) + shutil.copytree(f, dest) + else: + shutil.copy(f, dest) + + # make mdx file paths comply with the sveltekit routing mechanism + # see more: https://svelte.dev/docs/kit/routing + markdown_exports = [] + for mdx_file_path in routes_dir.rglob("*.mdx"): + new_page_svelte = sveltify_file_route(mdx_file_path) + new_markdown = markdownify_file_route(mdx_file_path) + content = write_markdown_route_file(mdx_file_path, new_markdown) + markdown_exports.append((Path(new_markdown).relative_to(routes_dir).as_posix(), content)) + Path(new_page_svelte).parent.mkdir(parents=True, exist_ok=True) + shutil.move(mdx_file_path, new_page_svelte) + + return kit_dir, routes_dir, markdown_exports + + def build_command(args): read_doc_config(args.path_to_docs) if args.html: @@ -117,86 +207,37 @@ def build_command(args): ) # dev build should not update _versions.yml - package_doc_path = os.path.join(args.build_dir, args.library_name) - if "pr_" not in version and os.path.isfile(os.path.join(package_doc_path, "_versions.yml")): - update_versions_file(os.path.join(args.build_dir, args.library_name), version, args.path_to_docs) + package_doc_path = Path(args.build_dir) / args.library_name + if "pr_" not in version and (package_doc_path / "_versions.yml").is_file(): + update_versions_file(package_doc_path, version, args.path_to_docs) # If asked, convert the MDX files into HTML files. if args.html: with tempfile.TemporaryDirectory() as tmp_dir: - tmp_dir = Path(tmp_dir) - # Copy everything in a tmp dir - shutil.copytree(kit_folder, tmp_dir / "kit") - # Manual copy and overwrite from output_path to tmp_dir / "kit" / "src" / "routes" - # We don't use shutil.copytree as tmp_dir / "kit" / "src" / "routes" exists and contains important files. - svelte_kit_routes_dir = tmp_dir / "kit" / "src" / "routes" - for f in output_path.iterdir(): - dest = svelte_kit_routes_dir / f.name - if f.is_dir(): - # Remove the dest folder if it exists - if dest.is_dir(): - shutil.rmtree(dest) - shutil.copytree(f, dest) - else: - shutil.copy(f, dest) - # make mdx file paths comply with the sveltekit 1.0 routing mechanism - # see more: https://learn.svelte.dev/tutorial/pages - markdown_exports = [] - for mdx_file_path in svelte_kit_routes_dir.rglob("*.mdx"): - new_page_svelte = sveltify_file_route(mdx_file_path) - new_markdown = markdownify_file_route(mdx_file_path) - write_markdown_route_file(mdx_file_path, new_markdown) - markdown_exports.append((new_markdown, os.path.relpath(new_markdown, svelte_kit_routes_dir))) - parent_path = os.path.dirname(new_page_svelte) - os.makedirs(parent_path, exist_ok=True) - shutil.move(mdx_file_path, new_page_svelte) + kit_dir, _, markdown_exports = stage_kit_routes(kit_folder, tmp_dir, output_path) # Move the objects.inv file at the root if not args.not_python_module: - shutil.move(tmp_dir / "kit" / "src" / "routes" / "objects.inv", tmp_dir / "objects.inv") + shutil.move(kit_dir / "src" / "routes" / "objects.inv", Path(tmp_dir) / "objects.inv") # Build doc with node - working_dir = str(tmp_dir / "kit") print("Installing node dependencies") - subprocess.run( - ["npm", "ci"], - stdout=subprocess.PIPE, - check=True, - encoding="utf-8", - cwd=working_dir, - ) + run_npm(["ci"], cwd=kit_dir) - env = os.environ.copy() - env["DOCS_LIBRARY"] = ( - env["package_name"] or args.library_name if "package_name" in env else args.library_name - ) - env["DOCS_VERSION"] = version - env["DOCS_LANGUAGE"] = args.language print("Building HTML files. This will take a while :-)") - subprocess.run( - ["npm", "run", "build"], - stdout=subprocess.PIPE, - check=True, - encoding="utf-8", - cwd=working_dir, - env=env, - ) + run_npm(["run", "build"], cwd=kit_dir, env=docs_node_env(args.library_name, version, args.language)) # Copy result back in the build_dir. shutil.rmtree(output_path) - shutil.copytree(tmp_dir / "kit" / "build", output_path) - # copy markdown routes alongside the generated html output - markdown_data = [] - for markdown_file, relative_path in markdown_exports: - markdown_source = Path(markdown_file) + shutil.copytree(kit_dir / "build", output_path) + # write markdown routes alongside the generated html output + for relative_path, content in markdown_exports: markdown_dest = output_path / relative_path markdown_dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy(markdown_source, markdown_dest) - with open(markdown_source, encoding="utf-8") as f: - markdown_data.append((relative_path, f.read())) + markdown_dest.write_text(content, encoding="utf-8", newline="\n") write_llms_feeds( output_path, - markdown_data, + markdown_exports, package_name=args.library_name, version=version, language=args.language, @@ -204,7 +245,7 @@ def build_command(args): ) # Move the objects.inv file back if not args.not_python_module: - shutil.move(tmp_dir / "objects.inv", output_path / "objects.inv") + shutil.move(Path(tmp_dir) / "objects.inv", output_path / "objects.inv") def build_command_parser(subparsers=None): diff --git a/src/doc_builder/commands/preview.py b/src/doc_builder/commands/preview.py index a4850823..8c26c922 100644 --- a/src/doc_builder/commands/preview.py +++ b/src/doc_builder/commands/preview.py @@ -14,20 +14,18 @@ import argparse -import os -import platform import shutil -import subprocess import tempfile import time from pathlib import Path from threading import Thread from doc_builder import build_doc -from doc_builder.commands.build import check_node_is_available, locate_kit_folder +from doc_builder.commands.build import check_node_is_available, docs_node_env, run_npm, stage_kit_routes from doc_builder.commands.convert_doc_file import find_root_git from doc_builder.utils import ( is_watchdog_available, + locate_kit_folder, markdownify_file_route, read_doc_config, sveltify_file_route, @@ -74,12 +72,8 @@ def transform_path(self, event): if not event.is_directory: if src_path.endswith(".py") and src_path in self.source_files_mapping: src_path = self.source_files_mapping[src_path] - # if src_path.endswith(".md"): - # # src_path += "x" - # relative_path += "x" - if src_path.endswith(".mdx") or src_path.endswith(".md"): + if src_path.endswith((".mdx", ".md")): is_valid_file = True - return is_valid_file, src_path, relative_path return is_valid_file, src_path, relative_path def build(self, src_path, relative_path): @@ -88,32 +82,29 @@ def build(self, src_path, relative_path): """ print(f"Building: {src_path}") try: - # copy the built files into the actual build folder dawg - with tempfile.TemporaryDirectory() as tmp_input_dir: + with tempfile.TemporaryDirectory() as tmp_input_dir, tempfile.TemporaryDirectory() as tmp_out_dir: # copy the file into tmp_input_dir shutil.copy(src_path, tmp_input_dir) - with tempfile.TemporaryDirectory() as tmp_out_dir: - build_doc( - self.args.library_name, - tmp_input_dir, - tmp_out_dir, - version=self.args.version, - language=self.args.language, - is_python_module=not self.args.not_python_module, - watch_mode=True, - ) - - if str(src_path).endswith(".md"): - src_path += "x" - relative_path += "x" - src = Path(tmp_out_dir) / Path(src_path).name - svelte_dest = sveltify_file_route(self.kit_routes_folder / relative_path) - markdown_dest = markdownify_file_route(self.kit_routes_folder / relative_path) - write_markdown_route_file(src, markdown_dest) - parent_path = Path(svelte_dest).parent - parent_path.mkdir(parents=True, exist_ok=True) - shutil.move(src, svelte_dest) + build_doc( + self.args.library_name, + tmp_input_dir, + tmp_out_dir, + version=self.args.version, + language=self.args.language, + is_python_module=not self.args.not_python_module, + watch_mode=True, + ) + + if str(src_path).endswith(".md"): + src_path += "x" + relative_path += "x" + src = Path(tmp_out_dir) / Path(src_path).name + svelte_dest = sveltify_file_route(self.kit_routes_folder / relative_path) + markdown_dest = markdownify_file_route(self.kit_routes_folder / relative_path) + write_markdown_route_file(src, markdown_dest) + Path(svelte_dest).parent.mkdir(parents=True, exist_ok=True) + shutil.move(src, svelte_dest) except Exception as e: print(f"Error building: {src_path}\n{e}") @@ -134,30 +125,15 @@ def start_watcher(path, event_handler): observer.join() -def start_sveltekit_dev(tmp_dir, env, args): +def start_sveltekit_dev(kit_dir, env): """ - Installs sveltekit node dependencies & starts sveltekit in dev mode in a temp dir. + Installs sveltekit node dependencies & starts sveltekit in dev mode. """ - working_dir = str(tmp_dir / "kit") print("Installing node dependencies") - subprocess.run( - ["npm", "ci"], - stdout=subprocess.PIPE, - check=True, - encoding="utf-8", - cwd=working_dir, - shell=platform.system() == "Windows", - ) + run_npm(["ci"], cwd=kit_dir) # start sveltekit in dev mode - subprocess.run( - ["npm", "run", "dev"], - check=True, - encoding="utf-8", - cwd=working_dir, - env=env, - shell=platform.system() == "Windows", - ) + run_npm(["run", "dev"], cwd=kit_dir, env=env, quiet=False) def preview_command(args): @@ -191,38 +167,16 @@ def preview_command(args): is_python_module=not args.not_python_module, ) - # convert the MDX files into HTML files. - tmp_dir = Path(tmp_dir) - # Copy everything in a tmp dir - shutil.copytree(kit_folder, tmp_dir / "kit") - # Manual copy and overwrite from output_path to tmp_dir / "kit" / "src" / "routes" - # We don't use shutil.copytree as tmp_dir / "kit" / "src" / "routes" exists and contains important files. - kit_routes_folder = tmp_dir / "kit" / "src" / "routes" # files/folders cannot have a name that starts with `__` since it is a reserved Sveltekit keyword for p in output_path.glob("**/*__*"): - if p.exists(): - p.rmdir if p.is_dir() else p.unlink() - for f in output_path.iterdir(): - dest = kit_routes_folder / f.name - if f.is_dir(): - # Remove the dest folder if it exists - if dest.is_dir(): - shutil.rmtree(dest) - shutil.copytree(f, dest) + if not p.exists(): + continue + if p.is_dir(): + shutil.rmtree(p) else: - shutil.copy(f, dest) + p.unlink() - # make mdx file paths comply with the sveltekit 1.0 routing mechanism - # see more: https://learn.svelte.dev/tutorial/pages - markdown_exports = [] - for mdx_file_path in kit_routes_folder.rglob("*.mdx"): - new_page_svelte = sveltify_file_route(mdx_file_path) - new_markdown = markdownify_file_route(mdx_file_path) - content = write_markdown_route_file(mdx_file_path, new_markdown) - markdown_exports.append((Path(new_markdown).relative_to(kit_routes_folder).as_posix(), content)) - parent_path = os.path.dirname(new_page_svelte) - os.makedirs(parent_path, exist_ok=True) - shutil.move(mdx_file_path, new_page_svelte) + kit_dir, kit_routes_folder, markdown_exports = stage_kit_routes(kit_folder, tmp_dir, output_path) write_llms_feeds( kit_routes_folder, @@ -233,12 +187,9 @@ def preview_command(args): is_python_module=not args.not_python_module, ) - # Node - env = os.environ.copy() - env["DOCS_LIBRARY"] = env["package_name"] or args.library_name if "package_name" in env else args.library_name - env["DOCS_VERSION"] = args.version - env["DOCS_LANGUAGE"] = args.language - Thread(target=start_sveltekit_dev, args=(tmp_dir, env, args)).start() + # Node dev server (daemon: it must not keep the process alive after Ctrl-C) + env = docs_node_env(args.library_name, args.version, args.language) + Thread(target=start_sveltekit_dev, args=(kit_dir, env), daemon=True).start() git_folder = find_root_git(args.path_to_docs) event_handler = WatchEventHandler(args, source_files_mapping, kit_routes_folder)