-
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))} Copy
{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"")
- index -= 1
+ chunks.append(f''); 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'{kind.title()}
{render_inline(content, resolve_link)}
')
+ 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"{tag}>")
- 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"{tag}>"); 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'{"".join(sections)} '
+
+
+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'On this page
{items} '
+
+
+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'Docs {html.escape(item.section)} {html.escape(item.title)} '
+
+
+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'{previous_html}{next_html} '
+
+
+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'' 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'{html.escape(label)} ')
+ 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
-
-
-{body}
-
-
-"""
-
-
-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
+Skip to content
+
+{breadcrumbs}{body} {pagination} {f'
' if toc else ''}