diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b0e86d..85d6d1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,9 @@ jobs: os: [ubuntu-24.04, windows-2022] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 - name: Install Free Pascal on Ubuntu if: runner.os == 'Linux' diff --git a/CHANGELOG.md b/CHANGELOG.md index f858cf9..e2349e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.9.2] - 2026-08-22 + +### Documentation Experience + +- Redesigned the generated developer documentation with hierarchical navigation, breadcrumbs, on-page outlines, and sequential previous/next links. +- Added a responsive documentation shell with light/dark themes, accessible mobile navigation, improved typography, and focused Pascal code panels with copy support. +- Added a version selector, lightweight offline-safe client-side search, readable responsive tables, and GitHub-style NOTE, TIP, IMPORTANT, and WARNING callouts. +- Moved presentation assets out of the Python builder and strengthened built-site checks for assets, duplicate IDs, unsafe links, local paths, and version targets. +- Preserved generated offline ZIP/SHA-256 output and the v1.9.1 historical documentation path. + +### Compatibility + +- No intentional breaking API changes. + ## [1.9.1] - 2026-08-22 ### Documentation diff --git a/README.md b/README.md index 0148762..8470894 100644 --- a/README.md +++ b/README.md @@ -8,17 +8,17 @@ [![Lazarus](https://img.shields.io/badge/Lazarus-4.0+-blue.svg)](https://www.lazarus-ide.org/) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE.md) [![CI](https://github.com/ikelaiah/stringkit-fp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/ikelaiah/stringkit-fp/actions/workflows/ci.yml) -[![Version](https://img.shields.io/badge/Version-1.9.1-blueviolet.svg)](https://github.com/ikelaiah/stringkit-fp/releases/tag/v1.9.1) +[![Version](https://img.shields.io/badge/Version-1.9.2-blueviolet.svg)](https://github.com/ikelaiah/stringkit-fp/releases/tag/v1.9.2) Practical string handling for Free Pascal and Lazarus: cleaning, identifier case conversion, validation, encoding, regex extraction, approximate matching, and readability helpers. It has no third-party runtime dependencies. ## Learn StringKit-FP -- [Beginner Guide](docs/start/beginner-guide.md) — installation and the first useful calls. -- [Recipes](docs/start/recipes.md) — complete, compiled programs with expected output. -- [Cheat Sheet](docs/start/cheat-sheet.md) — compact API reminder. -- [API Overview](docs/reference/api-overview.md) — find the right area quickly. -- [Online documentation](https://ikelaiah.github.io/stringkit-fp/1.9.1/) — browsable, versioned HTML documentation. +- [Online Documentation](https://ikelaiah.github.io/stringkit-fp/) — browsable, versioned HTML documentation. +- [Beginner Guide](https://ikelaiah.github.io/stringkit-fp/1.9.2/start/beginner-guide.html) — installation and the first useful calls. +- [Recipes](https://ikelaiah.github.io/stringkit-fp/1.9.2/start/recipes.html) — complete, compiled programs with expected output. +- [Cheat Sheet](https://ikelaiah.github.io/stringkit-fp/1.9.2/start/cheat-sheet.html) — compact API reminder. +- [API Reference](https://ikelaiah.github.io/stringkit-fp/1.9.2/reference/api-overview.html) — find the right area quickly. The repository [documentation index](docs/index.md) links to all beginner guides, topic guides, contracts, and helper references. diff --git a/ROADMAP.md b/ROADMAP.md index 9a2857e..3dedd61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,11 @@ This roadmap describes direction rather than dates. Compatibility and beginner-friendly static APIs remain important release criteria. +## Completed in v1.9.2 + +- Redesigned the versioned documentation experience with a structured sidebar, responsive reading shell, light/dark themes, search, code-copy controls, and stronger built-site validation. +- Preserved the beginner-first documentation path, offline archive support, and the v1.9.1 historical release site. + ## Completed in v1.9.1 - Added progressive beginner documentation, executable task recipes, and concise API/contract guides. diff --git a/docs/guides/encoding.md b/docs/guides/encoding.md index 90c9a71..a4f22da 100644 --- a/docs/guides/encoding.md +++ b/docs/guides/encoding.md @@ -14,6 +14,9 @@ For the input `a b+c`: `PercentDecode` preserves a literal `+`. `FormURLDecode` and legacy `URLDecode` interpret `+` as a space. Use `PercentEncode` for URI components and `FormURLEncode` for `application/x-www-form-urlencoded` data. +> [!WARNING] +> `PercentDecode` and `FormURLDecode` do not have identical `+` semantics. Choose the decoder that matches the encoder and transport format. + The [URL encoding recipe](../start/recipes.md#form-url-encoding-and-percent-encoding) is compiled and checks both output lines. ## Hex and Base64 diff --git a/docs/index.md b/docs/index.md index a6aba65..09f3243 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ -# StringKit-FP documentation +# StringKit-FP -StringKit-FP is a lightweight string toolkit for Free Pascal and Lazarus. Start with a small task, then follow the guide that matches what you need to do. +StringKit-FP is a modern, lightweight string toolkit for Free Pascal and Lazarus. Start with a small task, then follow the guide that matches what you need to do. ## Getting started @@ -31,4 +31,4 @@ StringKit-FP is a lightweight string toolkit for Free Pascal and Lazarus. Start - [Roadmap](../ROADMAP.md) - [Contributing](../CONTRIBUTING.md) -The online site is versioned. The top-level site opens the current release; `/1.9.1/` keeps this release’s documentation at a stable path. Future entries in `versions.json` identify their immutable source ref, and the publishing builder regenerates every declared release path. +The online site is versioned. The top-level site opens the current release; `/1.9.2/` is the current documentation path and `/1.9.1/` remains a stable historical release. The version selector opens a release root when a page is not shared by every version. diff --git a/docs/layout.json b/docs/layout.json index 39f0a4f..31e6196 100644 --- a/docs/layout.json +++ b/docs/layout.json @@ -1,6 +1,8 @@ { - "schema_version": 1, - "release": "1.9.1", + "schema_version": 2, + "release": "1.9.2", + "site_title": "StringKit-FP documentation", + "description": "Practical StringKit-FP documentation for Free Pascal and Lazarus.", "required_pages": [ "index.md", "start/index.md", @@ -11,12 +13,56 @@ "guides/case-conversion.md", "guides/validation.md", "guides/encoding.md", - "guides/fuzzy-matching.md", "guides/parsing-and-try-apis.md", + "guides/fuzzy-matching.md", "guides/text-analysis.md", "reference/api-overview.md", - "reference/helper-feature-flags.md", "reference/contracts-and-limitations.md", + "reference/helper-feature-flags.md", "reference/helper-coverage.md" - ] + ], + "navigation": [ + { "title": "Getting Started", "pages": [ + { "path": "index.md", "title": "Introduction" }, + { "path": "start/index.md", "title": "Start Here" }, + { "path": "start/beginner-guide.md", "title": "Beginner Guide" }, + { "path": "start/recipes.md", "title": "Recipes" }, + { "path": "start/cheat-sheet.md", "title": "Cheat Sheet" } + ] }, + { "title": "Guides", "pages": [ + { "path": "guides/static-vs-helper.md", "title": "Static vs Helper" }, + { "path": "guides/case-conversion.md", "title": "Case Conversion" }, + { "path": "guides/validation.md", "title": "Validation" }, + { "path": "guides/encoding.md", "title": "Encoding" }, + { "path": "guides/parsing-and-try-apis.md", "title": "Parsing & Try APIs" }, + { "path": "guides/fuzzy-matching.md", "title": "Fuzzy Matching" }, + { "path": "guides/text-analysis.md", "title": "Text Analysis" } + ] }, + { "title": "Reference", "pages": [ + { "path": "reference/api-overview.md", "title": "API Overview" }, + { "path": "reference/contracts-and-limitations.md", "title": "Contracts & Limitations" }, + { "path": "reference/helper-feature-flags.md", "title": "Helper Feature Flags" }, + { "path": "reference/helper-coverage.md", "title": "Helper Coverage" } + ] } + ], + "project": [ + { "title": "GitHub repository", "url": "https://github.com/ikelaiah/stringkit-fp" }, + { "title": "Changelog", "project_path": "CHANGELOG.md" }, + { "title": "Roadmap", "project_path": "ROADMAP.md" }, + { "title": "Contributing", "project_path": "CONTRIBUTING.md" } + ], + "homepage": { + "tagline": "A modern string toolkit for Free Pascal and Lazarus.", + "actions": [ + { "label": "Get Started", "path": "start/beginner-guide.md" }, + { "label": "Browse Recipes", "path": "start/recipes.md" }, + { "label": "API Reference", "path": "reference/api-overview.md" } + ], + "cards": [ + { "eyebrow": "Get Started", "title": "Beginner Guide", "description": "Learn StringKit-FP from your first call to useful text processing.", "path": "start/beginner-guide.md" }, + { "eyebrow": "Recipes", "title": "Common Tasks", "description": "Copy tested, working Pascal examples.", "path": "start/recipes.md" }, + { "eyebrow": "Cheat Sheet", "title": "Quick Lookup", "description": "Find the operation you need quickly.", "path": "start/cheat-sheet.md" }, + { "eyebrow": "API Reference", "title": "Public API", "description": "Browse StringKit's static and helper interfaces.", "path": "reference/api-overview.md" } + ] + } } diff --git a/docs/start/beginner-guide.md b/docs/start/beginner-guide.md index 6ba87f2..6e29c67 100644 --- a/docs/start/beginner-guide.md +++ b/docs/start/beginner-guide.md @@ -6,6 +6,9 @@ Start with the static API. The optional string helper is a second spelling of most string-first calls, not a different implementation. +> [!TIP] +> Keep `StringKit` as your first import. Add `StringKitHelper` only when you specifically prefer instance-style calls. + ## 2. Add StringKit-FP to a project For an FPC project, put `src/` on the unit search path and add `StringKit` to `uses`. @@ -56,6 +59,9 @@ Use `ToCamelCase`, `ToPascalCase`, `ToSnakeCase`, or `ToKebabCase` when converti `IsValidEmail`, `IsValidURL`, `IsValidIPv4`, `IsValidIPv6`, and `IsValidDate` are practical syntax checks. They do not prove that an address exists, a URL is reachable, or input meets every RFC edge case. See [Validation](../guides/validation.md). +> [!IMPORTANT] +> `SubString` follows Pascal's 1-based indexing. Check [Contracts and limitations](../reference/contracts-and-limitations.md) before translating zero-based examples from another language. + ## 9. Encode text for the right context Use `HTMLEncode` before inserting text into HTML. Use `PercentEncode` for URI components and `FormURLEncode` for HTML form data. `URLEncode` is intentionally a legacy-compatible form alias. The [encoding guide](../guides/encoding.md) explains the difference. diff --git a/docs/versions.json b/docs/versions.json index 647eb79..1e08f94 100644 --- a/docs/versions.json +++ b/docs/versions.json @@ -1,9 +1,13 @@ { "schema_version": 1, - "current": "1.9.1", + "current": "1.9.2", "site_url": "https://ikelaiah.github.io/stringkit-fp", "repository_url": "https://github.com/ikelaiah/stringkit-fp", "versions": [ + { + "release": "1.9.2", + "source_ref": "v1.9.2" + }, { "release": "1.9.1", "source_ref": "v1.9.1" diff --git a/packages/lazarus/stringkit_fp.lpk b/packages/lazarus/stringkit_fp.lpk index d3aca90..447ae64 100644 --- a/packages/lazarus/stringkit_fp.lpk +++ b/packages/lazarus/stringkit_fp.lpk @@ -43,7 +43,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. "/> - + diff --git a/tools/build_docs.py b/tools/build_docs.py index 70d82e1..c7c4455 100644 --- a/tools/build_docs.py +++ b/tools/build_docs.py @@ -17,10 +17,13 @@ OUTPUT_MARKER = ".stringkit-fp-docs-output" +DOC_ASSETS = Path(__file__).resolve().parent / "docs_assets" LINK_PATTERN = re.compile(r"(? tuple[NavigationPage, ...]: + return tuple(page for section in self.navigation for page in section.pages) + + +@dataclass(frozen=True) +class RenderedDocument: + body: str + headings: tuple[tuple[int, str, str], ...] + text: str + + def load_config(versions_path: Path, release: str | None = None) -> SiteConfig: try: data = json.loads(versions_path.read_text(encoding="utf-8")) @@ -52,34 +96,58 @@ def load_config(versions_path: Path, release: str | None = None) -> SiteConfig: source_ref=str(entry["source_ref"]), repository_url=str(data["repository_url"]).rstrip("/"), site_url=str(data["site_url"]).rstrip("/"), - versions=[ - {"release": str(item["release"]), "source_ref": str(item["source_ref"])} - for item in versions - ], + versions=[{"release": str(item["release"]), "source_ref": str(item["source_ref"])} for item in versions], ) except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: raise ValueError(f"invalid version metadata {versions_path}: {exc}") from exc def slug(value: str) -> str: + value = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", value) value = re.sub(r"[`*_]", "", value).strip().lower() value = re.sub(r"[^a-z0-9]+", "-", value).strip("-") return value or "section" -def markdown_anchors(path: Path) -> set[str]: - anchors: set[str] = set() - for line in path.read_text(encoding="utf-8").splitlines(): +def plain_markdown(value: str) -> str: + return re.sub(r"[`*_]", "", LINK_PATTERN.sub(r"\1", value)).strip() + + +def unique_identifier(title: str, known: set[str]) -> str: + base = slug(title) + identifier = base + suffix = 2 + while identifier in known: + identifier = f"{base}-{suffix}" + suffix += 1 + known.add(identifier) + return identifier + + +def heading_entries(markdown: str) -> list[tuple[int, str, str]]: + entries: list[tuple[int, str, str]] = [] + identifiers: set[str] = set() + in_fence = False + for line in markdown.splitlines(): + if FENCE_PATTERN.match(line): + in_fence = not in_fence + continue + if in_fence: + continue match = HEADING_PATTERN.match(line) if match: - base = slug(match.group(2)) - anchor = base - suffix = 2 - while anchor in anchors: - anchor = f"{base}-{suffix}" - suffix += 1 - anchors.add(anchor) - return anchors + title = plain_markdown(match.group(2)) + entries.append((len(match.group(1)), title, unique_identifier(title, identifiers))) + return entries + + +def markdown_anchors(path: Path) -> set[str]: + return {identifier for _level, _title, identifier in heading_entries(path.read_text(encoding="utf-8"))} + + +def is_unsafe_url(target: str) -> bool: + scheme = urlsplit(target).scheme.lower() + return bool(scheme and scheme not in SAFE_EXTERNAL_SCHEMES) def is_external(target: str) -> bool: @@ -100,11 +168,120 @@ def relative_url(source: Path, target: Path) -> str: return os.path.relpath(target, source).replace(os.sep, "/") +def safe_document_path(value: object, label: str) -> str: + if not isinstance(value, str) or not value or "\\" in value: + raise ValueError(f"{label} must be a non-empty slash-separated path") + path = Path(value) + if path.is_absolute() or ".." in path.parts or path.suffix.lower() != ".md": + raise ValueError(f"{label} must name a Markdown file within docs") + return path.as_posix() + + +def legacy_navigation(source: Path) -> tuple[NavigationSection, ...]: + grouped: dict[str, list[NavigationPage]] = {"Getting Started": [], "Guides": [], "Reference": []} + for document in sorted(source.rglob("*.md")): + relative = document.relative_to(source).as_posix() + if relative == "index.md" or relative.startswith("start/"): + section = "Getting Started" + elif relative.startswith("guides/"): + section = "Guides" + elif relative.startswith("reference/"): + section = "Reference" + else: + section = "Documentation" + grouped.setdefault(section, []) + title = next((title for level, title, _anchor in heading_entries(document.read_text(encoding="utf-8")) if level == 1), document.stem) + grouped[section].append(NavigationPage(relative, title, section)) + return tuple(NavigationSection(title, tuple(pages)) for title, pages in grouped.items() if pages) + + +def load_layout(source: Path, config: SiteConfig) -> DocumentationLayout: + layout_path = source / "layout.json" + try: + data = json.loads(layout_path.read_text(encoding="utf-8")) + schema = data.get("schema_version") + if schema == 1: + if str(data.get("release")) != config.release: + raise ValueError("release must match the selected version") + required = data.get("required_pages", []) + if not isinstance(required, list): + raise ValueError("required_pages must be an array of paths") + missing = [str(page) for page in required if not (source / str(page)).is_file()] + if missing: + raise ValueError(f"missing required documentation page(s): {', '.join(missing)}") + return DocumentationLayout("StringKit-FP documentation", "Practical StringKit-FP documentation for Free Pascal and Lazarus.", legacy_navigation(source), tuple(), {}, legacy=True) + if schema != 2: + raise ValueError("schema_version must be 1 or 2") + if str(data.get("release")) != config.release: + raise ValueError("release must match the selected version") + site_title = str(data.get("site_title", "")).strip() + description = str(data.get("description", "")).strip() + if not site_title or not description: + raise ValueError("site_title and description are required") + raw_navigation = data.get("navigation") + if not isinstance(raw_navigation, list) or not raw_navigation: + raise ValueError("navigation must be a non-empty array") + navigation: list[NavigationSection] = [] + paths: set[str] = set() + for section in raw_navigation: + if not isinstance(section, dict) or not isinstance(section.get("title"), str): + raise ValueError("each navigation section needs a title") + title = section["title"].strip() + raw_pages = section.get("pages") + if not title or not isinstance(raw_pages, list) or not raw_pages: + raise ValueError(f"navigation section {title!r} needs pages") + pages: list[NavigationPage] = [] + for item in raw_pages: + if not isinstance(item, dict) or not isinstance(item.get("title"), str): + raise ValueError(f"navigation section {title!r} has an invalid page") + path = safe_document_path(item.get("path"), "navigation page path") + if path in paths: + raise ValueError(f"navigation page appears more than once: {path}") + if not (source / path).is_file(): + raise ValueError(f"navigation page does not exist: {path}") + paths.add(path) + pages.append(NavigationPage(path, item["title"].strip(), title)) + navigation.append(NavigationSection(title, tuple(pages))) + documents = {path.relative_to(source).as_posix() for path in source.rglob("*.md")} + if paths != documents: + missing = sorted(documents - paths) + extra = sorted(paths - documents) + detail = [f"missing navigation entries: {', '.join(missing)}" if missing else "", f"unknown navigation entries: {', '.join(extra)}" if extra else ""] + raise ValueError("; ".join(item for item in detail if item)) + required = data.get("required_pages", []) + if not isinstance(required, list): + raise ValueError("required_pages must be an array of paths") + required_paths = {safe_document_path(page, "required page") for page in required} + if required_paths != paths: + raise ValueError("required_pages must match the navigation pages") + project_links: list[ProjectLink] = [] + for item in data.get("project", []): + if not isinstance(item, dict) or not isinstance(item.get("title"), str): + raise ValueError("project links need a title") + url = item.get("url") + project_path = item.get("project_path") + if bool(url) == bool(project_path): + raise ValueError("project links need exactly one of url or project_path") + if url is not None and (not isinstance(url, str) or is_unsafe_url(url) or not is_external(url)): + raise ValueError("project link url must be a safe absolute URL") + if project_path is not None and (not isinstance(project_path, str) or not project_path or Path(project_path).is_absolute() or ".." in Path(project_path).parts): + raise ValueError("project_path must stay within the repository") + project_links.append(ProjectLink(item["title"].strip(), url, project_path)) + homepage = data.get("homepage", {}) + if not isinstance(homepage, dict): + raise ValueError("homepage must be an object") + return DocumentationLayout(site_title, description, tuple(navigation), tuple(project_links), homepage) + except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid documentation layout {layout_path}: {exc}") from exc + + def validate_source_links(source: Path, documents: list[Path], project_root: Path) -> None: document_set = {path.resolve() for path in documents} for document in documents: for _label, raw_target in LINK_PATTERN.findall(document.read_text(encoding="utf-8")): target = raw_target.strip() + if is_unsafe_url(target): + raise ValueError(f"unsafe link in {document}: {target}") if is_external(target): continue relative_path, fragment = split_target(target) @@ -115,82 +292,53 @@ def validate_source_links(source: Path, documents: list[Path], project_root: Pat raise ValueError(f"broken internal link in {document}: {target}") from exc if not candidate.is_file(): raise ValueError(f"broken internal link in {document}: {target}") - if ( - candidate.suffix.lower() == ".md" - and candidate.is_relative_to(source.resolve()) - and candidate not in document_set - ): + if candidate.suffix.lower() == ".md" and candidate.is_relative_to(source.resolve()) and candidate not in document_set: raise ValueError(f"broken internal link in {document}: {target}") - if ( - fragment - and candidate.suffix.lower() == ".md" - and candidate.is_relative_to(source.resolve()) - and fragment not in markdown_anchors(candidate) - ): + if fragment and candidate.suffix.lower() == ".md" and candidate.is_relative_to(source.resolve()) and fragment not in markdown_anchors(candidate): raise ValueError(f"broken internal link anchor in {document}: {target}") -def ensure_layout(source: Path, config: SiteConfig) -> None: - layout_path = source / "layout.json" - try: - layout = json.loads(layout_path.read_text(encoding="utf-8")) - if layout.get("schema_version") != 1: - raise ValueError("schema_version must be 1") - if str(layout.get("release")) != config.release: - raise ValueError("release must match versions.json current") - required = layout.get("required_pages", []) - if not isinstance(required, list) or not all(isinstance(item, str) for item in required): - raise ValueError("required_pages must be an array of paths") - missing = [page for page in required if not (source / page).is_file()] - if missing: - raise ValueError(f"missing required documentation page(s): {', '.join(missing)}") - except (OSError, ValueError, json.JSONDecodeError) as exc: - raise ValueError(f"invalid documentation layout {layout_path}: {exc}") from exc +def source_url(config: SiteConfig, project_path: str) -> str: + return f"{config.repository_url}/blob/{quote(config.source_ref, safe='')}/{quote(project_path.replace(os.sep, '/'), safe='/')}" -def link_resolver( - document: Path, - html_page: Path, - source: Path, - output: Path, - project_root: Path, - config: SiteConfig, -): +def link_resolver(document: Path, html_page: Path, source: Path, output: Path, project_root: Path, config: SiteConfig): def resolve(raw_target: str) -> str: target = raw_target.strip() + if is_unsafe_url(target): + return "#" if is_external(target): return target relative_path, fragment = split_target(target) candidate = (document.parent / relative_path).resolve() if relative_path else document.resolve() if candidate.suffix.lower() == ".md" and candidate.is_relative_to(source.resolve()): - generated = output / candidate.relative_to(source).with_suffix(".html") - href = relative_url(html_page.parent, generated) + href = relative_url(html_page.parent, output / candidate.relative_to(source).with_suffix(".html")) elif candidate == document.resolve() and not relative_path: href = "" else: - location = project_relative(candidate, project_root) - href = f"{config.repository_url}/blob/{quote(config.source_ref, safe='')}/{quote(location, safe='/')}" + href = source_url(config, project_relative(candidate, project_root)) return href + (f"#{fragment}" if fragment else "") - return resolve +def render_inline_plain(text: str) -> str: + result = html.escape(text) + result = re.sub(r"`([^`]+)`", r"\1", result) + result = re.sub(r"\*\*([^*]+)\*\*", r"\1", result) + return re.sub(r"(?\1", result) + + def render_inline(text: str, resolve_link) -> str: tokens: list[str] = [] - def stash(value: str) -> str: tokens.append(value) return f"\x00{len(tokens) - 1}\x00" - def render_link(match: re.Match[str]) -> str: label, target = match.groups() - return stash( - f'' - f"{render_inline_plain(label)}" - ) - - rendered = LINK_PATTERN.sub(render_link, text) - rendered = html.escape(rendered) + href = resolve_link(target) + external_class = ' class="external-link"' if is_external(href) and not href.startswith("#") else "" + return stash(f'{render_inline_plain(label)}') + rendered = html.escape(LINK_PATTERN.sub(render_link, text)) rendered = re.sub(r"`([^`]+)`", lambda match: f"{html.escape(match.group(1))}", rendered) rendered = re.sub(r"\*\*([^*]+)\*\*", r"\1", rendered) rendered = re.sub(r"(?\1", rendered) @@ -199,13 +347,6 @@ def render_link(match: re.Match[str]) -> str: return rendered -def render_inline_plain(text: str) -> str: - result = html.escape(text) - result = re.sub(r"`([^`]+)`", r"\1", result) - result = re.sub(r"\*\*([^*]+)\*\*", r"\1", result) - return result - - def is_table_separator(line: str) -> bool: cells = [cell.strip() for cell in line.strip().strip("|").split("|")] return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells) @@ -215,103 +356,175 @@ def table_cells(line: str, resolve_link) -> list[str]: return [render_inline(cell.strip(), resolve_link) for cell in line.strip().strip("|").split("|")] -def markdown_to_html(markdown: str, resolve_link) -> str: - lines = markdown.splitlines() - chunks: list[str] = [] - paragraph: list[str] = [] +def code_label(language: str) -> str: + return {"pascal": "Pascal", "text": "Expected output", "output": "Expected output", "console": "Console"}.get(language, language.upper() if language else "Code") + + +def markdown_to_html(markdown: str, resolve_link) -> RenderedDocument: + lines, chunks, search_text, paragraph = markdown.splitlines(), [], [], [] index = 0 heading_ids: set[str] = set() - + headings: list[tuple[int, str, str]] = [] def flush_paragraph() -> None: if paragraph: - chunks.append(f"

{render_inline(' '.join(paragraph), resolve_link)}

") + raw = " ".join(paragraph) + chunks.append(f"

{render_inline(raw, resolve_link)}

") + search_text.append(plain_markdown(raw)) paragraph.clear() - while index < len(lines): line = lines[index] - fence = FENCE_PATTERN.match(line) - heading = HEADING_PATTERN.match(line) - list_match = LIST_PATTERN.match(line) + fence, heading, list_match = FENCE_PATTERN.match(line), HEADING_PATTERN.match(line), LIST_PATTERN.match(line) if fence: flush_paragraph() - language = fence.group(1).strip().lower() + language = re.sub(r"[^a-z0-9_-]", "", fence.group(1).strip().lower()) index += 1 code: list[str] = [] while index < len(lines) and not FENCE_PATTERN.match(lines[index]): - code.append(lines[index]) - index += 1 + code.append(lines[index]); index += 1 if index == len(lines): raise ValueError("unclosed code fence") language_class = f' class="language-{html.escape(language, quote=True)}"' if language else "" - chunks.append(f"
{html.escape(chr(10).join(code))}
") + kind = " code-output" if language in {"text", "output", "console"} else "" + chunks.append(f'
{html.escape(code_label(language))}
{html.escape(chr(10).join(code))}
') + search_text.extend(code) elif heading: flush_paragraph() level, title = len(heading.group(1)), heading.group(2) - base = slug(title) - identifier = base - suffix = 2 - while identifier in heading_ids: - identifier = f"{base}-{suffix}" - suffix += 1 - heading_ids.add(identifier) - chunks.append(f"{render_inline(title, resolve_link)}") + text_title = plain_markdown(title) + identifier = unique_identifier(text_title, heading_ids) + headings.append((level, text_title, identifier)); search_text.append(text_title) + anchor = f'#' if level >= 2 else "" + chunks.append(f'{render_inline(title, resolve_link)}{anchor}') elif line.strip().startswith("|") and index + 1 < len(lines) and is_table_separator(lines[index + 1]): - flush_paragraph() - headers = table_cells(line, resolve_link) - index += 2 - rows: list[list[str]] = [] + flush_paragraph(); headers = table_cells(line, resolve_link); index += 2; rows: list[list[str]] = [] while index < len(lines) and lines[index].strip().startswith("|"): - rows.append(table_cells(lines[index], resolve_link)) - index += 1 - header_html = "".join(f"{cell}" for cell in headers) + rows.append(table_cells(lines[index], resolve_link)); search_text.extend(plain_markdown(cell) for cell in lines[index].strip().strip("|").split("|")); index += 1 + header_html = "".join(f'{cell}' for cell in headers) body_html = "".join("" + "".join(f"{cell}" for cell in row) + "" for row in rows) - chunks.append(f"{header_html}{body_html}
") - index -= 1 + chunks.append(f'
{header_html}{body_html}
'); index -= 1 + elif line.lstrip().startswith(">"): + flush_paragraph(); quoted: list[str] = [] + while index < len(lines) and lines[index].lstrip().startswith(">"): + quoted.append(re.sub(r"^\s*>\s?", "", lines[index])); index += 1 + marker = ADMONITION_PATTERN.match(quoted[0].strip()) if quoted else None + content = " ".join(item.strip() for item in quoted[1 if marker else 0:] if item.strip()) + if marker: + kind = marker.group(1).lower() + chunks.append(f'') + else: + chunks.append(f"

{render_inline(content, resolve_link)}

") + search_text.append(plain_markdown(content)); index -= 1 elif list_match: - flush_paragraph() - ordered = list_match.group(1).endswith(".") - tag = "ol" if ordered else "ul" - items: list[str] = [] + flush_paragraph(); ordered = list_match.group(1).endswith("."); tag = "ol" if ordered else "ul"; items: list[str] = [] while index < len(lines): item_match = LIST_PATTERN.match(lines[index]) if not item_match or item_match.group(1).endswith(".") != ordered: break - items.append(f"
  • {render_inline(item_match.group(2), resolve_link)}
  • ") - index += 1 - chunks.append(f"<{tag}>" + "".join(items) + f"") - index -= 1 + item = item_match.group(2); items.append(f"
  • {render_inline(item, resolve_link)}
  • "); search_text.append(plain_markdown(item)); index += 1 + chunks.append(f"<{tag}>" + "".join(items) + f""); index -= 1 elif not line.strip(): flush_paragraph() else: paragraph.append(line.strip()) index += 1 flush_paragraph() - return "\n".join(chunks) + return RenderedDocument("\n".join(chunks), tuple(headings), re.sub(r"\s+", " ", " ".join(search_text)).strip()) + + +def nav_href(page: Path, output: Path, document_path: str) -> str: + return relative_url(page.parent, output / Path(document_path).with_suffix(".html")) + + +def render_navigation(layout: DocumentationLayout, current_path: str, page: Path, output: Path, config: SiteConfig) -> str: + sections: list[str] = [] + for section in layout.navigation: + links = [] + for item in section.pages: + current = ' aria-current="page"' if item.path == current_path else "" + current_class = " is-current" if item.path == current_path else "" + links.append(f'
  • {html.escape(item.title)}
  • ') + sections.append(f'') + if layout.project_links: + links = [] + for item in layout.project_links: + href = item.url if item.url else source_url(config, str(item.project_path)) + links.append(f'
  • {html.escape(item.title)}
  • ') + sections.append(f'') + return f'' + + +def render_toc(headings: tuple[tuple[int, str, str], ...]) -> str: + entries = [(level, title, identifier) for level, title, identifier in headings if level in {2, 3}] + if len(entries) < 2: + return "" + items = "".join(f'
  • {html.escape(title)}
  • ' for level, title, identifier in entries) + return f'' + + +def render_breadcrumbs(item: NavigationPage | None, page: Path, output: Path) -> str: + if item is None or item.path == "index.md": + return "" + root = html.escape(relative_url(page.parent, output / "index.html"), quote=True) + return f'' + + +def render_page_navigation(pages: tuple[NavigationPage, ...], current: NavigationPage | None, page: Path, output: Path) -> str: + if current is None: + return "" + index = pages.index(current); previous = pages[index - 1] if index else None; following = pages[index + 1] if index + 1 < len(pages) else None + if not previous and not following: + return "" + previous_html = f'Previous← {html.escape(previous.title)}' if previous else "" + next_html = f'Next{html.escape(following.title)} →' if following else "" + return f'' + + +def homepage_content(layout: DocumentationLayout, page: Path, output: Path) -> str: + tagline = html.escape(str(layout.homepage.get("tagline", layout.description))) + actions, cards = [], [] + for action in layout.homepage.get("actions", []): + if isinstance(action, dict) and isinstance(action.get("label"), str) and isinstance(action.get("path"), str): + try: + actions.append(f'{html.escape(action["label"])}') + except ValueError: + continue + for card in layout.homepage.get("cards", []): + if isinstance(card, dict) and all(isinstance(card.get(key), str) for key in ("eyebrow", "title", "description", "path")): + try: + href = nav_href(page, output, safe_document_path(card["path"], "homepage card")) + except ValueError: + continue + cards.append(f'{html.escape(card["eyebrow"])}{html.escape(card["title"])}

    {html.escape(card["description"])}

    ') + hero = f'

    Documentation

    {html.escape(layout.site_title.replace(" documentation", ""))}

    {tagline}

    {"".join(actions)}
    ' + return hero + (f'
    {"".join(cards)}
    ' if cards else "") + + +def remove_first_heading(body: str) -> str: + return re.sub(r"^]*>.*?\n?", "", body, count=1, flags=re.DOTALL) -def page_shell(title: str, body: str, config: SiteConfig, page: Path, output: Path) -> str: - version_links = [] +def page_shell(title: str, rendered: RenderedDocument, config: SiteConfig, layout: DocumentationLayout, current: NavigationPage | None, page: Path, output: Path, relative: str) -> str: + stylesheet = relative_url(page.parent, output / "assets" / "site.css"); script = relative_url(page.parent, output / "assets" / "site.js"); search_script = relative_url(page.parent, output / "search-index.js") + home = relative == "index.md"; body = remove_first_heading(rendered.body) if home else rendered.body + if home: + body = homepage_content(layout, page, output) + body + navigation, toc = render_navigation(layout, relative, page, output, config), ("" if home else render_toc(rendered.headings)) + breadcrumbs, pagination = ("" if home else render_breadcrumbs(current, page, output)), render_page_navigation(layout.pages, current, page, output) + root = relative_url(page.parent, output / "index.html") + options = [] for item in config.versions: - release = item["release"] - target = output.parent / release / "index.html" - href = relative_url(page.parent, target) - label = f"{release} (current)" if release == config.release else release - version_links.append(f'{html.escape(label)}') - stylesheet = relative_url(page.parent, output / "assets" / "site.css") + release = item["release"]; label = f"v{release}" + (" (current)" if release == config.current else ""); selected = " selected" if release == config.release else "" + options.append(f'') + canonical = f"{config.site_url}/{config.release}/{page.relative_to(output).as_posix()}" + current_release = ' Current' if config.release == config.current else "" return f""" - - -{html.escape(title)} — StringKit-FP - -
    StringKit-FP docsv{html.escape(config.release)}
    -
    {body}
    -

    Generated from the StringKit-FP documentation source.

    - -""" - - -SITE_CSS = """*{box-sizing:border-box}body{margin:0;background:#f5f7fb;color:#1c2733;font:16px/1.6 system-ui,-apple-system,Segoe UI,sans-serif}header,main,footer{max-width:72rem;margin:auto;padding:1rem 1.4rem}header{display:flex;gap:1rem;align-items:center;border-bottom:1px solid #d9e1eb;background:#fff}.brand{font-weight:750;color:#063e70;text-decoration:none}header span{color:#506274}main{max-width:60rem;background:#fff;margin-top:2rem;margin-bottom:2rem;padding:clamp(1.25rem,4vw,3rem);border:1px solid #d9e1eb;border-radius:.6rem;box-shadow:0 8px 30px #102a4310}h1,h2,h3{line-height:1.2;color:#102a43;margin-top:1.8em}h1{margin-top:0}a{color:#0868ae}code{background:#edf2f7;padding:.1em .3em;border-radius:.2em}pre{overflow:auto;background:#102a43;color:#f7fafc;padding:1rem;border-radius:.45rem}pre code{padding:0;background:transparent}table{border-collapse:collapse;width:100%;margin:1rem 0}th,td{border:1px solid #cfd8e3;padding:.55rem;text-align:left;vertical-align:top}th{background:#edf3f8}li+li{margin-top:.35rem}footer{color:#52606d;font-size:.9rem}@media(max-width:640px){header,main,footer{padding-left:1rem;padding-right:1rem}main{margin-top:0;border:0;border-radius:0;box-shadow:none}}""" + + +{html.escape(title)} — StringKit-FP + + +
    {breadcrumbs}
    {body}
    {pagination}
    StringKit-FP v{html.escape(config.release)}{current_release}Free Pascal / LazarusGitHubMIT License
    {f'' if toc else ''}
    \n""" def prepare_output(output: Path, release: str) -> None: @@ -320,77 +533,58 @@ def prepare_output(output: Path, release: str) -> None: if not marker.is_file(): raise ValueError(f"refusing to replace unmarked documentation output: {output}") shutil.rmtree(output) - output.mkdir(parents=True, exist_ok=True) - marker.write_text(release + "\n", encoding="utf-8") + output.mkdir(parents=True, exist_ok=True); marker.write_text(release + "\n", encoding="utf-8") + + +def copy_assets(output: Path) -> None: + assets = output / "assets"; assets.mkdir() + for name in ("site.css", "site.js"): + source = DOC_ASSETS / name + if not source.is_file(): + raise ValueError(f"missing documentation asset: {source}") + shutil.copy2(source, assets / name) def write_landing_page(site_root: Path, config: SiteConfig) -> None: - target = f"{config.current}/index.html" - site_root.mkdir(parents=True, exist_ok=True) - (site_root / "index.html").write_text( - f"""StringKit-FP documentation

    Opening StringKit-FP {html.escape(config.release)} documentation.

    \n""", - encoding="utf-8", - ) + target = f"{config.current}/index.html"; site_root.mkdir(parents=True, exist_ok=True) + (site_root / "index.html").write_text(f"""StringKit-FP documentation

    Opening StringKit-FP {html.escape(config.current)} documentation.

    \n""", encoding="utf-8") (site_root / ".nojekyll").write_text("", encoding="utf-8") - (site_root / "versions.json").write_text( - json.dumps({"schema_version": 1, "current": config.release, "versions": config.versions}, indent=2) + "\n", - encoding="utf-8", - ) + (site_root / "versions.json").write_text(json.dumps({"schema_version": 1, "current": config.current, "versions": config.versions}, indent=2) + "\n", encoding="utf-8") def write_offline_archive(site_root: Path, archive: Path, release: str) -> str: - archive.parent.mkdir(parents=True, exist_ok=True) - root_name = f"stringkit-fp-docs-{release}" + archive.parent.mkdir(parents=True, exist_ok=True); root_name = f"stringkit-fp-docs-{release}" with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as bundle: for path in sorted(path for path in site_root.rglob("*") if path.is_file()): if path.resolve() == archive.resolve() or path.name == OUTPUT_MARKER: continue - info = zipfile.ZipInfo(f"{root_name}/{path.relative_to(site_root).as_posix()}") - info.date_time = (1980, 1, 1, 0, 0, 0) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o644 << 16 - bundle.writestr(info, path.read_bytes()) - digest = hashlib.sha256(archive.read_bytes()).hexdigest() - archive.with_name(archive.name + ".sha256").write_text(f"{digest} {archive.name}\n", encoding="ascii") + info = zipfile.ZipInfo(f"{root_name}/{path.relative_to(site_root).as_posix()}"); info.date_time = (1980, 1, 1, 0, 0, 0); info.compress_type = zipfile.ZIP_DEFLATED; info.external_attr = 0o644 << 16; bundle.writestr(info, path.read_bytes()) + digest = hashlib.sha256(archive.read_bytes()).hexdigest(); archive.with_name(archive.name + ".sha256").write_text(f"{digest} {archive.name}\n", encoding="ascii") return digest -def build_site( - source: Path, - output: Path, - site_root: Path, - versions_path: Path, - offline_archive: Path | None = None, - release: str | None = None, -) -> int: - source = source.resolve() - output = output.resolve() - site_root = site_root.resolve() - config = load_config(versions_path.resolve(), release) - ensure_layout(source, config) +def build_site(source: Path, output: Path, site_root: Path, versions_path: Path, offline_archive: Path | None = None, release: str | None = None) -> int: + source, output, site_root = source.resolve(), output.resolve(), site_root.resolve() + config = load_config(versions_path.resolve(), release); layout = load_layout(source, config) if output.name != config.release or output.parent != site_root: raise ValueError("versioned output must be site-root/") documents = sorted(source.rglob("*.md")) if not documents: raise ValueError(f"no Markdown documents found in {source}") - validate_source_links(source, documents, source.parent) - prepare_output(output, config.release) - (output / "assets").mkdir() - (output / "assets" / "site.css").write_text(SITE_CSS + "\n", encoding="utf-8") - search_entries: list[dict[str, str]] = [] + validate_source_links(source, documents, source.parent); prepare_output(output, config.release); copy_assets(output) + search_entries: list[dict[str, object]] = []; by_path = {item.path: item for item in layout.pages} for document in documents: - relative = document.relative_to(source) - page = output / relative.with_suffix(".html") - page.parent.mkdir(parents=True, exist_ok=True) - markdown = document.read_text(encoding="utf-8") - resolver = link_resolver(document, page, source, output, source.parent, config) - body = markdown_to_html(markdown, resolver) - title_match = HEADING_PATTERN.search(markdown) - title = title_match.group(2) if title_match else relative.stem - page.write_text(page_shell(title, body, config, page, output), encoding="utf-8") - search_entries.append({"title": title, "url": relative.with_suffix(".html").as_posix(), "text": re.sub(r"\s+", " ", markdown)}) + relative = document.relative_to(source); relative_path = relative.as_posix(); page = output / relative.with_suffix(".html"); page.parent.mkdir(parents=True, exist_ok=True) + rendered = markdown_to_html(document.read_text(encoding="utf-8"), link_resolver(document, page, source, output, source.parent, config)) + fallback = by_path.get(relative_path, NavigationPage(relative_path, relative.stem, "Documentation")) + title = next((text for level, text, _identifier in rendered.headings if level == 1), fallback.title) + page.write_text(page_shell(title, rendered, config, layout, by_path.get(relative_path), page, output, relative_path), encoding="utf-8") + item = by_path.get(relative_path) + search_entries.append({"title": title, "section": item.section if item else "Documentation", "headings": [text for level, text, _identifier in rendered.headings if level >= 2], "url": relative.with_suffix(".html").as_posix(), "text": rendered.text}) + search_json = json.dumps(search_entries, ensure_ascii=False, separators=(",", ":")) (output / "search-index.json").write_text(json.dumps(search_entries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - (output / "release.json").write_text(json.dumps({"schema_version": 1, "release": config.release, "source_ref": config.source_ref, "page_count": len(documents)}, indent=2) + "\n", encoding="utf-8") + (output / "search-index.js").write_text("globalThis.StringKitSearchIndex=" + search_json.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026") + ";\n", encoding="utf-8") + (output / "release.json").write_text(json.dumps({"schema_version": 2, "release": config.release, "source_ref": config.source_ref, "page_count": len(documents)}, indent=2) + "\n", encoding="utf-8") write_landing_page(site_root, config) if offline_archive: write_offline_archive(site_root, offline_archive.resolve(), config.release) @@ -399,17 +593,8 @@ def build_site( def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--source", type=Path, default=Path("docs")) - parser.add_argument("--versions", type=Path, default=Path("docs/versions.json")) - parser.add_argument("--output", type=Path) - parser.add_argument("--site-root", type=Path) - parser.add_argument("--offline-archive", type=Path) - parser.add_argument("--release", help="build this release from its matching documentation source") - args = parser.parse_args() - config = load_config(args.versions.resolve(), args.release) - site_root = args.site_root or Path("build/docs-site") - output = args.output or site_root / config.release + parser = argparse.ArgumentParser(description=__doc__); parser.add_argument("--source", type=Path, default=Path("docs")); parser.add_argument("--versions", type=Path, default=Path("docs/versions.json")); parser.add_argument("--output", type=Path); parser.add_argument("--site-root", type=Path); parser.add_argument("--offline-archive", type=Path); parser.add_argument("--release", help="build this release from its matching documentation source") + args = parser.parse_args(); config = load_config(args.versions.resolve(), args.release); site_root = args.site_root or Path("build/docs-site"); output = args.output or site_root / config.release build_site(args.source, output, site_root, args.versions, args.offline_archive, args.release) return 0 diff --git a/tools/check_built_docs.py b/tools/check_built_docs.py index 4b7cf0a..16019a7 100644 --- a/tools/check_built_docs.py +++ b/tools/check_built_docs.py @@ -5,6 +5,7 @@ import argparse import json +import re from functools import lru_cache from html.parser import HTMLParser from pathlib import Path @@ -16,18 +17,28 @@ def __init__(self) -> None: super().__init__() self.links: list[str] = [] self.identifiers: set[str] = set() + self.duplicate_identifiers: set[str] = set() self.release_values: list[str] = [] + self.version_targets: list[str] = [] + self.title_count = 0 def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: values = dict(attrs) if values.get("id"): - self.identifiers.add(str(values["id"])) + identifier = str(values["id"]) + if identifier in self.identifiers: + self.duplicate_identifiers.add(identifier) + self.identifiers.add(identifier) if tag in {"a", "link"} and values.get("href"): self.links.append(str(values["href"])) if tag in {"img", "script"} and values.get("src"): self.links.append(str(values["src"])) if tag == "meta" and values.get("name") == "stringkit-release" and values.get("content"): self.release_values.append(str(values["content"])) + if tag == "option" and values.get("value"): + self.version_targets.append(str(values["value"])) + if tag == "title": + self.title_count += 1 @lru_cache(maxsize=None) @@ -58,9 +69,20 @@ def local_target(page: Path, raw_link: str, site: Path) -> tuple[Path | None, st def check_page(page: Path, site: Path, release: str) -> list[str]: errors: list[str] = [] parsed = page_data(page) + source = page.read_text(encoding="utf-8") if parsed.release_values != [release]: errors.append(f"{page}: release metadata {parsed.release_values!r}, expected [{release!r}]") + if parsed.title_count != 1: + errors.append(f"{page}: expected exactly one page title") + for identifier in sorted(parsed.duplicate_identifiers): + errors.append(f"{page}: duplicate id: {identifier}") + if re.search(r"(?:^|[\"'\s])(?:file://|[A-Za-z]:[\\/])", source, re.IGNORECASE): + errors.append(f"{page}: contains an absolute local filesystem path") for link in parsed.links: + split = urlsplit(link) + if split.scheme.lower() not in {"", "http", "https", "mailto"}: + errors.append(f"{page}: unsafe link: {link}") + continue target, fragment = local_target(page, link, site) if target is None: if fragment == "escapes built site": @@ -71,6 +93,10 @@ def check_page(page: Path, site: Path, release: str) -> list[str]: continue if fragment and target.suffix.lower() == ".html" and fragment not in page_data(target).identifiers: errors.append(f"{page}: missing link anchor: {link}") + for target_value in parsed.version_targets: + target, _fragment = local_target(page, target_value, site) + if target is None or not target.is_file(): + errors.append(f"{page}: missing version target: {target_value}") return errors @@ -108,6 +134,9 @@ def check_site(site: Path) -> list[str]: except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: errors.append(f"{directory}: invalid release output: {exc}") continue + for asset in ("assets/site.css", "assets/site.js", "search-index.js"): + if not (directory / asset).is_file(): + errors.append(f"{directory}: missing required asset: {asset}") pages = sorted(directory.rglob("*.html")) if not pages: errors.append(f"{directory}: no HTML pages") diff --git a/tools/docs_assets/site.css b/tools/docs_assets/site.css new file mode 100644 index 0000000..e6cfa85 --- /dev/null +++ b/tools/docs_assets/site.css @@ -0,0 +1,393 @@ +:root { + color-scheme: light; + --bg: #f5f7f8; + --surface: #ffffff; + --surface-muted: #edf2f3; + --surface-strong: #dfe8ea; + --text: #1d2a2e; + --text-muted: #58676d; + --heading: #102b31; + --border: #d5dfe1; + --border-strong: #b6c6c9; + --accent: #087c83; + --accent-hover: #05656b; + --accent-soft: #d8eff0; + --header: #11373b; + --header-text: #f5fbfb; + --code-bg: #14272b; + --code-text: #edf7f7; + --code-output: #eef4f4; + --focus: #0c9ca5; + --warning: #b76a16; + --important: #8d3d65; + --shadow: 0 0.75rem 2rem rgb(21 47 51 / 8%); + --radius: 0.5rem; + --header-height: 4rem; + --site-width: 100rem; + --reading-width: 76ch; +} + +:root[data-theme="dark"] { + color-scheme: dark; + --bg: #0c1517; + --surface: #111d20; + --surface-muted: #19292d; + --surface-strong: #24383d; + --text: #dce8e9; + --text-muted: #9cb1b4; + --heading: #f0f8f8; + --border: #2c4145; + --border-strong: #476066; + --accent: #65cbd0; + --accent-hover: #9be4e6; + --accent-soft: #173b3f; + --header: #0b282c; + --header-text: #f4fbfb; + --code-bg: #091316; + --code-text: #e5f2f2; + --code-output: #162529; + --focus: #7de0e4; + --warning: #e9a84a; + --important: #e794bd; + --shadow: 0 0.75rem 2rem rgb(0 0 0 / 24%); +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + color-scheme: dark; + --bg: #0c1517; + --surface: #111d20; + --surface-muted: #19292d; + --surface-strong: #24383d; + --text: #dce8e9; + --text-muted: #9cb1b4; + --heading: #f0f8f8; + --border: #2c4145; + --border-strong: #476066; + --accent: #65cbd0; + --accent-hover: #9be4e6; + --accent-soft: #173b3f; + --header: #0b282c; + --header-text: #f4fbfb; + --code-bg: #091316; + --code-text: #e5f2f2; + --code-output: #162529; + --focus: #7de0e4; + --warning: #e9a84a; + --important: #e794bd; + --shadow: 0 0.75rem 2rem rgb(0 0 0 / 24%); + } +} + +* { box-sizing: border-box; } + +html { + scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + 1.5rem); +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 16px/1.65 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-rendering: optimizeLegibility; +} + +a { color: var(--accent); text-decoration-thickness: 0.08em; text-underline-offset: 0.15em; } +a:hover { color: var(--accent-hover); } + +a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible, .table-wrap:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 3px; +} + +.skip-link { + position: fixed; + z-index: 100; + top: 0.75rem; + left: 0.75rem; + padding: 0.55rem 0.8rem; + transform: translateY(-160%); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + font-weight: 700; +} +.skip-link:focus { transform: translateY(0); } + +.site-header { + position: sticky; + z-index: 20; + top: 0; + border-bottom: 1px solid rgb(255 255 255 / 16%); + background: var(--header); + color: var(--header-text); +} + +.topbar { + display: flex; + min-height: var(--header-height); + max-width: var(--site-width); + margin: 0 auto; + padding: 0 1.25rem; + align-items: center; + gap: 1rem; +} + +.brand { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + gap: 0.55rem; + color: var(--header-text); + font-size: 1rem; + font-weight: 750; + letter-spacing: -0.025em; + text-decoration: none; +} +.brand:hover { color: #fff; } +.brand-mark { + display: grid; + width: 1.9rem; + height: 1.9rem; + place-items: center; + border: 1px solid rgb(155 224 225 / 62%); + border-radius: 0.375rem; + background: rgb(255 255 255 / 10%); + color: #b8eff0; + font: 750 0.85rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.top-links { display: flex; gap: 0.75rem; font-size: 0.86rem; } +.top-links a { color: #d7eeee; text-decoration: none; } +.top-links a:hover { color: #fff; text-decoration: underline; } + +.search-box { position: relative; flex: 1 1 20rem; max-width: 30rem; } +.search-box label, .version-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; +} +.search-box input { + width: 100%; + min-height: 2.4rem; + padding: 0.45rem 2.55rem 0.45rem 0.75rem; + border: 1px solid rgb(196 235 235 / 45%); + border-radius: 0.375rem; + background: rgb(255 255 255 / 10%); + color: #fff; + font: inherit; +} +.search-box input::placeholder { color: #c6dada; } +.search-box input:focus { border-color: #a4eded; background: rgb(255 255 255 / 15%); } +.search-box kbd { + position: absolute; + top: 50%; + right: 0.6rem; + padding: 0.03rem 0.35rem; + transform: translateY(-50%); + border: 1px solid rgb(255 255 255 / 28%); + border-radius: 0.2rem; + color: #d5e8e8; + font: 0.72rem/1.3 ui-monospace, SFMono-Regular, Consolas, monospace; +} + +.header-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 0.5rem; } +.header-actions select, #theme-toggle { + min-height: 2.3rem; + border: 1px solid rgb(196 235 235 / 45%); + border-radius: 0.375rem; + background: rgb(255 255 255 / 10%); + color: var(--header-text); + font: 600 0.8rem/1 system-ui, sans-serif; +} +.header-actions select { max-width: 9.5rem; padding: 0.35rem 0.4rem; } +.header-actions option { background: var(--surface); color: var(--text); } +#theme-toggle { display: inline-flex; padding: 0.35rem 0.6rem; align-items: center; gap: 0.35rem; cursor: pointer; } +#theme-toggle:hover { background: rgb(255 255 255 / 17%); } + +.search-results { + position: absolute; + z-index: 30; + top: calc(100% + 0.45rem); + left: 50%; + width: min(42rem, calc(100vw - 2rem)); + max-height: min(30rem, 70vh); + overflow: auto; + transform: translateX(-50%); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); + color: var(--text); +} +.search-results[hidden] { display: none; } +.search-results ul { margin: 0; padding: 0; list-style: none; } +.search-results li { margin: 0; } +.search-results a { display: block; padding: 0.75rem 0.9rem; border-bottom: 1px solid var(--border); color: var(--text); text-decoration: none; } +.search-results a:hover { background: var(--surface-muted); color: var(--accent); } +.search-results strong, .search-results span { display: block; } +.search-results span { margin-top: 0.15rem; color: var(--text-muted); font-size: 0.82rem; } +.search-empty { margin: 0; padding: 0.9rem; color: var(--text-muted); } + +.mobile-navigation { display: none; } + +.doc-shell { + display: grid; + min-height: calc(100vh - var(--header-height)); + max-width: var(--site-width); + margin: 0 auto; + grid-template-columns: 16rem minmax(0, 1fr) 14rem; +} +.doc-shell.no-toc { grid-template-columns: 16rem minmax(0, 1fr); } +.doc-sidebar { border-right: 1px solid var(--border); background: var(--bg); } +.sidebar-sticky, .toc-sticky { + position: sticky; + top: var(--header-height); + max-height: calc(100vh - var(--header-height)); + overflow: auto; + padding: 1.75rem 1rem 2rem; +} +.sidebar-section + .sidebar-section { margin-top: 1.5rem; } +.sidebar-section h2, .on-page > p { + margin: 0 0 0.45rem; + color: var(--text-muted); + font-size: 0.7rem; + font-weight: 750; + letter-spacing: 0.09em; + text-transform: uppercase; +} +.sidebar-section ul, .on-page ol { margin: 0; padding: 0; list-style: none; } +.sidebar-section li, .on-page li { margin: 0; } +.nav-link, .on-page a { + display: block; + padding: 0.34rem 0.55rem; + border-left: 2px solid transparent; + color: var(--text-muted); + font-size: 0.84rem; + line-height: 1.35; + text-decoration: none; +} +.nav-link:hover, .on-page a:hover { border-left-color: var(--accent); background: var(--surface-muted); color: var(--accent); } +.nav-link.is-current { border-left-color: var(--accent); background: var(--accent-soft); color: var(--heading); font-weight: 700; } + +.doc-content { min-width: 0; padding: clamp(2rem, 4vw, 4rem) clamp(1.25rem, 4vw, 4.25rem) 4rem; background: var(--surface); } +.doc-content:focus { outline: none; } +.doc-prose { max-width: 78rem; } +.doc-prose > p, .doc-prose > ul, .doc-prose > ol, .doc-prose > blockquote, .doc-prose > .admonition { max-width: var(--reading-width); } + +.breadcrumbs { margin: 0 0 1.4rem; } +.breadcrumbs ol { display: flex; margin: 0; padding: 0; flex-wrap: wrap; gap: 0.35rem; color: var(--text-muted); font-size: 0.82rem; list-style: none; } +.breadcrumbs li + li::before { margin-right: 0.35rem; color: var(--border-strong); content: "/"; } + +h1, h2, h3, h4 { color: var(--heading); line-height: 1.2; text-wrap: balance; } +h1 { max-width: 25ch; margin: 0 0 1.35rem; font-size: clamp(2.15rem, 5vw, 3.25rem); letter-spacing: -0.04em; } +h2 { margin: 3.1rem 0 0.9rem; padding-top: 0.2rem; font-size: clamp(1.45rem, 3vw, 1.9rem); letter-spacing: -0.025em; } +h3 { margin: 2.2rem 0 0.65rem; font-size: 1.22rem; letter-spacing: -0.015em; } +h4 { margin: 1.8rem 0 0.55rem; font-size: 1.05rem; } +h2, h3 { display: flex; align-items: baseline; gap: 0.35rem; } +.heading-anchor { color: var(--text-muted); font-size: 0.8em; font-weight: 500; opacity: 0; text-decoration: none; } +h2:hover .heading-anchor, h3:hover .heading-anchor, .heading-anchor:focus { opacity: 1; } +p { margin: 0 0 1rem; } +ul, ol { padding-left: 1.35rem; } +li + li { margin-top: 0.35rem; } + +code { padding: 0.12em 0.3em; border-radius: 0.25rem; background: var(--surface-muted); font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; font-size: 0.9em; } +.code-block { max-width: 100%; margin: 1.4rem 0; overflow: hidden; border: 1px solid var(--border-strong); border-radius: var(--radius); background: var(--code-bg); box-shadow: var(--shadow); } +.code-toolbar { display: flex; min-height: 2.45rem; padding: 0.4rem 0.5rem 0.4rem 0.85rem; align-items: center; justify-content: space-between; border-bottom: 1px solid rgb(255 255 255 / 16%); color: #c9dddd; } +.code-language { font: 700 0.7rem/1 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: 0.08em; text-transform: uppercase; } +.copy-code { min-height: 1.8rem; padding: 0.25rem 0.5rem; border: 1px solid rgb(220 246 246 / 38%); border-radius: 0.3rem; background: transparent; color: #e5f3f3; cursor: pointer; font: 600 0.75rem/1 system-ui, sans-serif; } +.copy-code:hover { background: rgb(255 255 255 / 12%); } +pre { max-width: 100%; margin: 0; overflow: auto; padding: 1rem 1.15rem 1.15rem; color: var(--code-text); font: 0.88rem/1.6 ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; } +pre code { padding: 0; background: transparent; color: inherit; font: inherit; } +.code-output { border-color: var(--border); background: var(--code-output); box-shadow: none; } +.code-output .code-toolbar { border-bottom-color: var(--border); color: var(--text-muted); } +.code-output pre { color: var(--text); } +.code-output .copy-code { border-color: var(--border-strong); color: var(--text); } + +.table-wrap { max-width: 100%; margin: 1.4rem 0; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); } +table { width: 100%; min-width: 38rem; border-collapse: collapse; font-size: 0.9rem; } +th, td { padding: 0.7rem 0.8rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; } +th { background: var(--surface-strong); color: var(--heading); font-size: 0.72rem; letter-spacing: 0.06em; text-transform: uppercase; } +tbody tr:nth-child(even) { background: var(--surface-muted); } +tbody tr:last-child td { border-bottom: 0; } +td code { white-space: nowrap; } + +blockquote, .admonition { margin: 1.4rem 0; padding: 0.85rem 1rem; border-left: 4px solid var(--accent); background: var(--surface-muted); } +blockquote { color: var(--text-muted); } +blockquote p, .admonition p { margin: 0; } +.admonition-title { margin-bottom: 0.25rem !important; color: var(--heading); font-size: 0.82rem; font-weight: 750; letter-spacing: 0.04em; text-transform: uppercase; } +.admonition-tip { border-left-color: var(--accent); } +.admonition-important { border-left-color: var(--important); } +.admonition-warning { border-left-color: var(--warning); } + +.doc-toc { border-left: 1px solid var(--border); background: var(--bg); } +.on-page a { font-size: 0.8rem; } +.toc-level-3 a { padding-left: 1.2rem; font-size: 0.76rem; } + +.page-navigation { display: grid; max-width: 78rem; margin-top: 3.5rem; border-top: 1px solid var(--border); grid-template-columns: repeat(2, minmax(0, 1fr)); } +.page-navigation a { display: flex; min-height: 5rem; padding: 1rem 0; flex-direction: column; gap: 0.2rem; text-decoration: none; } +.page-navigation a + a { padding-left: 1rem; text-align: right; } +.page-navigation span { color: var(--text-muted); font-size: 0.78rem; } +.page-navigation strong { color: var(--heading); } +.page-navigation a:hover strong { color: var(--accent); } + +.site-footer { display: flex; max-width: 78rem; margin-top: 2.5rem; padding-top: 1rem; flex-wrap: wrap; gap: 0.6rem 1rem; border-top: 1px solid var(--border); color: var(--text-muted); font-size: 0.8rem; } +.site-footer a { color: var(--text-muted); } +.current-release { color: var(--accent); font-weight: 700; } + +.home-hero { max-width: 48rem; padding: clamp(0.5rem, 3vw, 2rem) 0 2.5rem; } +.eyebrow, .home-card > span { color: var(--accent); font-size: 0.73rem; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; } +.home-hero > p:not(.eyebrow) { max-width: 44rem; color: var(--text-muted); font-size: 1.14rem; } +.hero-actions { display: flex; margin-top: 1.5rem; flex-wrap: wrap; gap: 0.65rem; } +.button-link { padding: 0.55rem 0.75rem; border: 1px solid var(--accent); border-radius: 0.35rem; background: var(--accent); color: #fff; font-size: 0.88rem; font-weight: 700; text-decoration: none; } +.button-link:hover { border-color: var(--accent-hover); background: var(--accent-hover); color: #fff; } +.home-grid { display: grid; max-width: 72rem; margin: 0 0 2.5rem; grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); gap: 0.75rem; } +.home-card { display: block; padding: 1rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text); text-decoration: none; } +.home-card:hover { border-color: var(--accent); background: var(--surface-muted); color: var(--text); } +.home-card strong { display: block; margin-top: 0.3rem; color: var(--heading); font-size: 1rem; } +.home-card p { margin: 0.35rem 0 0; color: var(--text-muted); font-size: 0.86rem; } +.homepage > h2 { margin-top: 2.5rem; } + +.external-link::after { margin-left: 0.18em; font-size: 0.72em; content: "↗"; } + +@media (max-width: 78rem) { + .doc-shell { grid-template-columns: 15rem minmax(0, 1fr); } + .doc-toc { display: none; } + .top-links { display: none; } +} + +@media (max-width: 58rem) { + :root { --header-height: auto; } + .site-header { position: relative; } + .topbar { min-height: auto; padding: 0.75rem 1rem; flex-wrap: wrap; } + .search-box { flex-basis: calc(100% - 3.5rem); max-width: none; order: 3; } + .header-actions { margin-left: auto; } + #theme-toggle span:last-child { display: none; } + .doc-shell { display: block; } + .doc-sidebar { display: none; } + .mobile-navigation { display: block; max-width: var(--site-width); margin: 0 auto; border-top: 1px solid rgb(255 255 255 / 16%); background: var(--header); } + .mobile-navigation summary { padding: 0.65rem 1rem; cursor: pointer; font-size: 0.85rem; font-weight: 700; } + .mobile-navigation .docs-navigation { padding: 0 1rem 1rem; background: var(--surface); } + .mobile-navigation .sidebar-section { padding-top: 1rem; } + .mobile-navigation .sidebar-section + .sidebar-section { margin-top: 0; } + .doc-content { padding: 2rem 1.1rem 3rem; } +} + +@media (max-width: 34rem) { + .brand { font-size: 0.92rem; } + .header-actions select { max-width: 6.7rem; } + .search-box { flex-basis: 100%; } + .header-actions { margin-left: auto; } + h1 { font-size: 2.15rem; } + table { min-width: 32rem; } + .page-navigation { grid-template-columns: 1fr; } + .page-navigation a + a { padding-left: 0; text-align: left; } +} + +@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } } diff --git a/tools/docs_assets/site.js b/tools/docs_assets/site.js new file mode 100644 index 0000000..08ca54b --- /dev/null +++ b/tools/docs_assets/site.js @@ -0,0 +1,144 @@ +(() => { + "use strict"; + + const search = document.querySelector("#search"); + const results = document.querySelector("#search-results"); + const root = document.body.dataset.docRoot || ""; + const themeToggle = document.querySelector("#theme-toggle"); + const versionSelect = document.querySelector("#version-select"); + const items = Array.isArray(globalThis.StringKitSearchIndex) ? globalThis.StringKitSearchIndex : []; + const colorPreference = window.matchMedia("(prefers-color-scheme: dark)"); + + function storedTheme() { + try { + const value = localStorage.getItem("stringkit-theme"); + return value === "light" || value === "dark" ? value : null; + } catch (_error) { + return null; + } + } + + function currentTheme() { + return document.documentElement.dataset.theme || (colorPreference.matches ? "dark" : "light"); + } + + function updateThemeControl() { + const next = currentTheme() === "dark" ? "light" : "dark"; + themeToggle?.setAttribute("aria-label", `Switch to ${next} theme`); + themeToggle?.setAttribute("title", `Switch to ${next} theme`); + const icon = themeToggle?.querySelector("span[aria-hidden='true']"); + if (icon) icon.textContent = currentTheme() === "dark" ? "☀" : "☾"; + } + + updateThemeControl(); + themeToggle?.addEventListener("click", () => { + const next = currentTheme() === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { localStorage.setItem("stringkit-theme", next); } catch (_error) { /* Session-only theme is still useful. */ } + updateThemeControl(); + }); + colorPreference.addEventListener?.("change", () => { if (!storedTheme()) updateThemeControl(); }); + + versionSelect?.addEventListener("change", () => { window.location.assign(versionSelect.value); }); + + function closeResults() { + if (!results || !search) return; + results.hidden = true; + results.replaceChildren(); + search.setAttribute("aria-expanded", "false"); + } + + function score(item, query) { + const title = String(item.title || "").toLowerCase(); + const headings = Array.isArray(item.headings) ? item.headings.join(" ").toLowerCase() : ""; + const body = String(item.text || "").toLowerCase(); + if (title.includes(query)) return 3; + if (headings.includes(query)) return 2; + return body.includes(query) ? 1 : 0; + } + + function renderResults() { + if (!search || !results) return; + const query = search.value.trim().toLowerCase(); + if (!query) return closeResults(); + const matches = items.map((item) => ({ item, score: score(item, query) })) + .filter((match) => match.score > 0) + .sort((a, b) => b.score - a.score || String(a.item.title).localeCompare(String(b.item.title))) + .slice(0, 12); + results.replaceChildren(); + if (matches.length) { + const list = document.createElement("ul"); + for (const { item } of matches) { + const row = document.createElement("li"); + const link = document.createElement("a"); + link.href = `${root}/${item.url}`.replace(/^\.\//, ""); + const title = document.createElement("strong"); + title.textContent = item.title; + const detail = document.createElement("span"); + detail.textContent = item.section || "Documentation"; + link.append(title, detail); + row.append(link); + list.append(row); + } + results.append(list); + } else { + const empty = document.createElement("p"); + empty.className = "search-empty"; + empty.textContent = `No documentation found for “${search.value.trim()}”.`; + results.append(empty); + } + results.hidden = false; + search.setAttribute("aria-expanded", "true"); + } + + search?.addEventListener("input", renderResults); + search?.addEventListener("keydown", (event) => { + if (event.key === "Escape") { closeResults(); search.blur(); } + }); + document.addEventListener("keydown", (event) => { + const target = event.target; + const typing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable; + if (event.key === "/" && !typing) { event.preventDefault(); search?.focus(); } + }); + document.addEventListener("click", (event) => { + if (results && search && !results.contains(event.target) && event.target !== search) closeResults(); + }); + + function fallbackCopy(text) { + const area = document.createElement("textarea"); + area.value = text; + area.setAttribute("readonly", ""); + area.style.position = "fixed"; + area.style.opacity = "0"; + document.body.append(area); + area.select(); + const copied = document.execCommand("copy"); + area.remove(); + if (!copied) throw new Error("Clipboard copy was unavailable."); + } + + document.querySelectorAll(".copy-code").forEach((button) => { + button.addEventListener("click", async () => { + const code = button.closest(".code-block")?.querySelector("code"); + if (!code) return; + const original = button.textContent; + try { + const text = code.textContent || ""; + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopy(text); + } + button.textContent = "Copied"; + } catch (_error) { + try { + fallbackCopy(code.textContent || ""); + button.textContent = "Copied"; + } catch (_fallbackError) { + button.textContent = "Copy failed"; + } + } + window.setTimeout(() => { button.textContent = original; }, 1600); + }); + }); +})(); diff --git a/tools/test_build_docs.py b/tools/test_build_docs.py index beae7ba..b9dbeb7 100644 --- a/tools/test_build_docs.py +++ b/tools/test_build_docs.py @@ -29,13 +29,42 @@ def write_fixture(self, root: Path) -> tuple[Path, Path, Path]: ) (source / "start" / "guide.md").write_text( "# A tiny guide\n\n" + "> [!NOTE]\n" + "> This guide keeps Pascal's familiar 1-based indexing.\n\n" + "## Repeat\n\n" "```pascal\n" "Writeln('Hello');\n" - "```\n", + "```\n\n" + "### Details\n\n" + "The call writes one line.\n\n" + "## Repeat\n\n" + "The stable duplicate heading uses a distinct anchor.\n", encoding="utf-8", ) (source / "layout.json").write_text( - json.dumps({"schema_version": 1, "release": "1.9.1"}), + json.dumps( + { + "schema_version": 2, + "release": "1.9.1", + "site_title": "StringKit-FP documentation", + "description": "Practical StringKit-FP documentation.", + "required_pages": ["index.md", "start/guide.md"], + "navigation": [ + { + "title": "Getting Started", + "pages": [ + {"path": "index.md", "title": "Introduction"}, + {"path": "start/guide.md", "title": "Beginner Guide"}, + ], + } + ], + "project": [{"title": "GitHub repository", "url": "https://github.com/example/stringkit-fp"}], + "homepage": { + "tagline": "A modern string toolkit for Free Pascal and Lazarus.", + "actions": [{"label": "Get Started", "path": "start/guide.md"}], + }, + } + ), encoding="utf-8", ) versions = source / "versions.json" @@ -53,7 +82,7 @@ def write_fixture(self, root: Path) -> tuple[Path, Path, Path]: ) return source, output, site_root - def test_builds_versioned_navigation_and_pascal_code(self) -> None: + def test_builds_documentation_shell_navigation_and_pascal_code(self) -> None: with tempfile.TemporaryDirectory() as directory: source, output, site_root = self.write_fixture(Path(directory)) @@ -63,7 +92,23 @@ def test_builds_versioned_navigation_and_pascal_code(self) -> None: guide = (output / "start" / "guide.html").read_text(encoding="utf-8") landing = (site_root / "index.html").read_text(encoding="utf-8") self.assertIn('href="start/guide.html"', index) + self.assertIn('class="doc-sidebar"', guide) + self.assertIn('aria-label="Breadcrumb"', guide) + self.assertIn('class="page-navigation"', guide) + self.assertIn('class="on-page"', guide) + self.assertIn('class="copy-code"', guide) + self.assertIn('class="admonition admonition-note"', guide) + self.assertIn('class="heading-anchor"', guide) + self.assertIn('id="repeat-2"', guide) + self.assertIn('id="version-select"', guide) self.assertIn('
    ', guide)
    +            self.assertTrue((output / "assets" / "site.css").is_file())
    +            self.assertTrue((output / "assets" / "site.js").is_file())
    +            self.assertTrue((output / "search-index.json").is_file())
    +            self.assertTrue((output / "search-index.js").is_file())
    +            self.assertIn(':root[data-theme="dark"]', (output / "assets" / "site.css").read_text(encoding="utf-8"))
    +            self.assertIn("StringKitSearchIndex", (output / "assets" / "site.js").read_text(encoding="utf-8"))
    +            self.assertEqual("Getting Started", json.loads((output / "search-index.json").read_text(encoding="utf-8"))[1]["section"])
                 self.assertIn("StringKit-FP documentation", landing)
     
         def test_rejects_a_broken_internal_link(self) -> None:
    @@ -77,6 +122,17 @@ def test_rejects_a_broken_internal_link(self) -> None:
                 with self.assertRaisesRegex(ValueError, "broken internal link"):
                     build_site(source, output, site_root, source / "versions.json")
     
    +    def test_rejects_an_unsafe_markdown_url(self) -> None:
    +        with tempfile.TemporaryDirectory() as directory:
    +            source, output, site_root = self.write_fixture(Path(directory))
    +            (source / "index.md").write_text(
    +                "# StringKit-FP documentation\n\n[Unsafe](javascript:alert(1))\n",
    +                encoding="utf-8",
    +            )
    +
    +            with self.assertRaisesRegex(ValueError, "unsafe link"):
    +                build_site(source, output, site_root, source / "versions.json")
    +
         def test_links_project_markdown_to_its_repository_source(self) -> None:
             with tempfile.TemporaryDirectory() as directory:
                 root = Path(directory)
    diff --git a/tools/test_check_built_docs.py b/tools/test_check_built_docs.py
    index 61742b4..e8d7441 100644
    --- a/tools/test_check_built_docs.py
    +++ b/tools/test_check_built_docs.py
    @@ -52,6 +52,28 @@ def test_reports_a_missing_generated_link_target(self) -> None:
                 page.write_text(page.read_text(encoding="utf-8").replace('guide.html', 'missing.html'), encoding="utf-8")
                 self.assertTrue(any("missing link target" in error for error in check_site(site)))
     
    +    def test_reports_duplicate_ids_and_unsafe_links(self) -> None:
    +        with tempfile.TemporaryDirectory() as directory:
    +            site = self.build_fixture(Path(directory))
    +            page = site / "1.9.1" / "guide.html"
    +            page.write_text(
    +                page.read_text(encoding="utf-8").replace(
    +                    "",
    +                    '

    Duplicate identifier

    Unsafe', + ), + encoding="utf-8", + ) + errors = check_site(site) + self.assertTrue(any("duplicate id" in error for error in errors)) + self.assertTrue(any("unsafe link" in error for error in errors)) + + def test_requires_the_documentation_assets_and_version_targets(self) -> None: + with tempfile.TemporaryDirectory() as directory: + site = self.build_fixture(Path(directory)) + (site / "1.9.1" / "assets" / "site.js").unlink() + errors = check_site(site) + self.assertTrue(any("missing required asset" in error for error in errors)) + if __name__ == "__main__": unittest.main()