diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07c3c22..f7bd294 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,12 @@ jobs: with: dotnet-version: 8.0.4xx + - name: Build public site and HTML documentation + run: python scripts/build-public-site.py --output artifacts/public-site + - name: Validate public site shell: pwsh - run: .\scripts\validate-public-site.ps1 + run: .\scripts\validate-public-site.ps1 -SiteRoot artifacts/public-site - name: Restore Publisher run: dotnet restore src/ARSVIN/ARSVIN.csproj diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index db53968..099c23b 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -6,6 +6,7 @@ on: paths: - 'site/**' - 'docs/**' + - 'scripts/build-public-site.py' - 'scripts/validate-public-site.ps1' - '.github/workflows/pages.yml' workflow_dispatch: @@ -29,15 +30,12 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - name: Build public site and HTML documentation + run: python3 scripts/build-public-site.py --output artifacts/public-site + - name: Validate public site shell: pwsh - run: ./scripts/validate-public-site.ps1 - - - name: Prepare site - run: | - mkdir -p site/docs - cp -R docs/* site/docs/ - touch site/.nojekyll + run: ./scripts/validate-public-site.ps1 -SiteRoot artifacts/public-site - name: Configure Pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6 @@ -45,7 +43,7 @@ jobs: - name: Upload Pages artifact uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: - path: site + path: artifacts/public-site - name: Deploy id: deployment diff --git a/CHANGELOG.md b/CHANGELOG.md index db901e3..ead379d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,23 @@ All notable ARSVIN changes are documented here using a lightweight Keep a Change ## Unreleased +### Added + +- Added a repository-owned, dependency-free public-site builder that converts every `docs/*.md` guide into a dedicated HTML page. +- Added compact documentation navigation, topic filtering, breadcrumbs, source links, responsive styling, and a generated search index. +- Added unique canonical URLs, descriptions, Open Graph metadata, Twitter Card metadata, and `TechArticle` JSON-LD for engineering documentation pages. +- Added a generated multi-page sitemap containing the product homepage and every published documentation page. +- Added recursive validation for all HTML metadata, structured data, canonical uniqueness, local references, search-index targets, sitemap coverage, web-manifest icons, and robots metadata. + +### Changed + +- GitHub Pages and Windows CI now build and validate the same staged public-site artifact instead of copying raw Markdown into the deployment directory. +- The product landing page now links directly to Quick Start, SV Profile Support, COMTRADE Replay, Subscriber Verification, Safety Boundaries, and the complete documentation index. + ### Planned - Move the engine source directory physically under `src/ARSVIN.Engine` after the shared-assembly transition has proven stable. - Expand protocol regression tests and raise the whole-engine coverage baseline progressively. -- Publish search-indexable HTML engineering documentation with a multi-page sitemap. - Add Windows Authenticode signing when a trusted certificate becomes available. ## 0.3.1 — 2026-07-12 diff --git a/scripts/build-public-site.py b/scripts/build-public-site.py new file mode 100644 index 0000000..afeef8d --- /dev/null +++ b/scripts/build-public-site.py @@ -0,0 +1,469 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import html +import json +import re +import shutil +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import urlsplit, urlunsplit + +BASE_URL = "https://masarray.github.io/arsvin/" +REPO_URL = "https://github.com/masarray/arsvin" + + +@dataclass(frozen=True) +class DocPage: + source: Path + slug: str + title: str + description: str + + +def slugify(value: str) -> str: + value = re.sub(r"<[^>]+>", "", value) + value = re.sub(r"[`*_~]", "", value) + value = value.lower().strip() + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") or "section" + + +def strip_markdown(value: str) -> str: + value = re.sub(r"`([^`]+)`", r"\1", value) + value = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", value) + value = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", value) + value = re.sub(r"[*_~>#]", "", value) + return re.sub(r"\s+", " ", value).strip() + + +def page_metadata(path: Path) -> tuple[str, str]: + lines = path.read_text(encoding="utf-8").splitlines() + title = path.stem.replace("-", " ").title() + title_index = -1 + for index, line in enumerate(lines): + match = re.match(r"^#\s+(.+?)\s*$", line) + if match: + title = strip_markdown(match.group(1)) + title_index = index + break + + paragraph: list[str] = [] + for line in lines[title_index + 1 :]: + stripped = line.strip() + if not stripped: + if paragraph: + break + continue + if stripped.startswith(("#", "- ", "* ", ">", "```", "|")): + if paragraph: + break + continue + paragraph.append(stripped) + + description = strip_markdown(" ".join(paragraph)) + if not description: + description = f"ARSVIN engineering documentation for {title}." + if len(description) > 158: + description = description[:155].rsplit(" ", 1)[0] + "..." + return title, description + + +def rewrite_link(target: str, current_slug: str) -> str: + target = html.unescape(target.strip()) + if not target or target.startswith("#"): + return target + if re.match(r"^(?:https?:|mailto:|tel:|data:)", target, re.IGNORECASE): + return target + + parts = urlsplit(target) + path = parts.path.replace("\\", "/") + fragment = parts.fragment + query = parts.query + + if path.endswith(".md"): + normalized = path + while normalized.startswith("./"): + normalized = normalized[2:] + if normalized.startswith("../"): + github_target = f"{REPO_URL}/blob/main/{normalized[3:]}" + return urlunsplit(("https", "github.com", github_target.split("github.com/", 1)[1], query, fragment)) + + stem = Path(normalized).stem + parent = Path(normalized).parent.as_posix() + if parent not in (".", ""): + github_target = f"{REPO_URL}/blob/main/docs/{normalized}" + return urlunsplit(("https", "github.com", github_target.split("github.com/", 1)[1], query, fragment)) + + if current_slug == "index": + href = "./" if stem == "index" else f"{stem}/" + else: + href = "../" if stem == "index" else f"../{stem}/" + if query: + href += f"?{query}" + if fragment: + href += f"#{fragment}" + return href + + return target + + +def inline_markdown(text: str, current_slug: str) -> str: + code_values: list[str] = [] + + def code_repl(match: re.Match[str]) -> str: + token = f"@@ARSVINCODE{len(code_values)}@@" + code_values.append(f"{html.escape(match.group(1), quote=False)}") + return token + + text = re.sub(r"`([^`]+)`", code_repl, text) + text = html.escape(text, quote=False) + + def image_repl(match: re.Match[str]) -> str: + alt = match.group(1) + src = rewrite_link(html.unescape(match.group(2)), current_slug) + return f'{alt}' + + def link_repl(match: re.Match[str]) -> str: + label = match.group(1) + target = rewrite_link(html.unescape(match.group(2)), current_slug) + external = bool(re.match(r"^https?://", target, re.IGNORECASE)) + attrs = ' target="_blank" rel="noopener noreferrer"' if external else "" + return f'{label}' + + text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image_repl, text) + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link_repl, text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"__([^_]+)__", r"\1", text) + text = re.sub(r"(?\1", text) + + for index, value in enumerate(code_values): + text = text.replace(f"@@ARSVINCODE{index}@@", value) + return text + + +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 or "") for cell in cells) + + +def markdown_to_html(markdown: str, current_slug: str) -> str: + lines = markdown.splitlines() + output: list[str] = [] + used_ids: dict[str, int] = {} + first_h1_seen = False + index = 0 + + def unique_id(text: str) -> str: + base = slugify(strip_markdown(text)) + count = used_ids.get(base, 0) + used_ids[base] = count + 1 + return base if count == 0 else f"{base}-{count + 1}" + + while index < len(lines): + line = lines[index] + stripped = line.strip() + + if not stripped: + index += 1 + continue + + fence = re.match(r"^```\s*([A-Za-z0-9_+.-]*)\s*$", stripped) + if fence: + language = fence.group(1) + code_lines: list[str] = [] + index += 1 + while index < len(lines) and not re.match(r"^```\s*$", lines[index].strip()): + code_lines.append(lines[index]) + index += 1 + if index < len(lines): + index += 1 + class_attr = f' class="language-{html.escape(language, quote=True)}"' if language else "" + output.append(f"
{html.escape(chr(10).join(code_lines), quote=False)}
") + continue + + heading = re.match(r"^(#{1,6})\s+(.+?)\s*#*\s*$", stripped) + if heading: + level = len(heading.group(1)) + text = heading.group(2) + if level == 1: + if first_h1_seen: + level = 2 + else: + first_h1_seen = True + anchor = unique_id(text) + output.append(f'{inline_markdown(text, current_slug)}') + index += 1 + continue + + if index + 1 < len(lines) and "|" in stripped and is_table_separator(lines[index + 1]): + headers = [cell.strip() for cell in stripped.strip("|").split("|")] + index += 2 + rows: list[list[str]] = [] + while index < len(lines): + candidate = lines[index].strip() + if not candidate or "|" not in candidate: + break + rows.append([cell.strip() for cell in candidate.strip("|").split("|")]) + index += 1 + output.append('
') + output.extend(f"" for cell in headers) + output.append("") + for row in rows: + padded = row + [""] * max(0, len(headers) - len(row)) + output.append("") + output.extend(f"" for cell in padded[: len(headers)]) + output.append("") + output.append("
{inline_markdown(cell, current_slug)}
{inline_markdown(cell, current_slug)}
") + continue + + unordered = re.match(r"^[-*+]\s+(.+)$", stripped) + ordered = re.match(r"^\d+[.)]\s+(.+)$", stripped) + if unordered or ordered: + tag = "ul" if unordered else "ol" + output.append(f"<{tag}>") + while index < len(lines): + candidate = lines[index].strip() + match = re.match(r"^[-*+]\s+(.+)$", candidate) if tag == "ul" else re.match(r"^\d+[.)]\s+(.+)$", candidate) + if not match: + break + output.append(f"
  • {inline_markdown(match.group(1), current_slug)}
  • ") + index += 1 + output.append(f"") + continue + + if stripped.startswith(">"): + quoted: list[str] = [] + while index < len(lines) and lines[index].strip().startswith(">"): + quoted.append(lines[index].strip()[1:].lstrip()) + index += 1 + output.append(f"

    {inline_markdown(' '.join(quoted), current_slug)}

    ") + continue + + if re.fullmatch(r"(?:-{3,}|\*{3,}|_{3,})", stripped): + output.append("
    ") + index += 1 + continue + + paragraph: list[str] = [] + while index < len(lines): + candidate = lines[index].strip() + if not candidate: + break + if re.match(r"^(?:#{1,6}\s|```|[-*+]\s+|\d+[.)]\s+|>|(?:-{3,}|\*{3,}|_{3,})$)", candidate): + break + if index + 1 < len(lines) and "|" in candidate and is_table_separator(lines[index + 1]): + break + paragraph.append(candidate) + index += 1 + if paragraph: + output.append(f"

    {inline_markdown(' '.join(paragraph), current_slug)}

    ") + else: + output.append(f"

    {inline_markdown(stripped, current_slug)}

    ") + index += 1 + + if not first_h1_seen: + output.insert(0, '

    Documentation

    ') + return "\n".join(output) + + +def docs_order(docs_dir: Path, pages_by_name: dict[str, DocPage]) -> list[DocPage]: + ordered: list[DocPage] = [] + seen: set[str] = set() + index_path = docs_dir / "index.md" + if index_path.exists(): + for match in re.finditer(r"\[[^\]]+\]\(([^)#?]+\.md)(?:#[^)]+)?\)", index_path.read_text(encoding="utf-8")): + name = Path(match.group(1)).name + page = pages_by_name.get(name) + if page and name not in seen: + ordered.append(page) + seen.add(name) + for name in sorted(pages_by_name): + if name not in seen: + ordered.append(pages_by_name[name]) + return ordered + + +def nav_html(pages: list[DocPage], current_slug: str) -> str: + items: list[str] = [] + for page in pages: + if current_slug == "index": + href = "./" if page.slug == "index" else f"{page.slug}/" + else: + href = "../" if page.slug == "index" else f"../{page.slug}/" + active = ' class="active" aria-current="page"' if page.slug == current_slug else "" + items.append(f'
  • {html.escape(page.title)}
  • ') + return "\n".join(items) + + +def render_page(page: DocPage, pages: list[DocPage]) -> str: + is_index = page.slug == "index" + prefix = "../" if is_index else "../../" + canonical = f"{BASE_URL}docs/" if is_index else f"{BASE_URL}docs/{page.slug}/" + source_url = f"{REPO_URL}/blob/main/docs/{page.source.name}" + body = markdown_to_html(page.source.read_text(encoding="utf-8"), page.slug) + nav = nav_html(pages, page.slug) + structured = { + "@context": "https://schema.org", + "@type": "TechArticle", + "headline": page.title, + "description": page.description, + "url": canonical, + "isPartOf": {"@type": "WebSite", "name": "ARSVIN", "url": BASE_URL}, + "author": {"@type": "Person", "name": "Ari Sulistiono", "url": "https://github.com/masarray"}, + "license": "https://www.apache.org/licenses/LICENSE-2.0", + "about": ["IEC 61850", "Sampled Values", "Digital substation", "Process bus"], + } + json_ld = json.dumps(structured, ensure_ascii=False, separators=(",", ":")) + breadcrumb_label = "Documentation" if is_index else page.title + + return f''' + + + + + {html.escape(page.title)} | ARSVIN Documentation + + + + + + + + + + + + + + + + + + + +
    + + + ARSVIN + + + GitHub +
    + +
    + + +
    + + {body} +
    + Apache-2.0 engineering documentation. + Edit or review this page on GitHub → +
    +
    +
    + + + + + +''' + + +def build(repo_root: Path, output: Path) -> None: + site_dir = repo_root / "site" + docs_dir = repo_root / "docs" + if not site_dir.is_dir() or not docs_dir.is_dir(): + raise SystemExit("Expected site/ and docs/ directories under the repository root.") + + if output.exists(): + shutil.rmtree(output) + shutil.copytree(site_dir, output, ignore=shutil.ignore_patterns("docs")) + + docs_output = output / "docs" + docs_output.mkdir(parents=True, exist_ok=True) + + pages_by_name: dict[str, DocPage] = {} + for source in sorted(docs_dir.glob("*.md")): + title, description = page_metadata(source) + slug = "index" if source.name == "index.md" else source.stem + pages_by_name[source.name] = DocPage(source=source, slug=slug, title=title, description=description) + + if "index.md" not in pages_by_name: + raise SystemExit("docs/index.md is required for the documentation site.") + + pages = docs_order(docs_dir, pages_by_name) + for page in pages: + target = docs_output / "index.html" if page.slug == "index" else docs_output / page.slug / "index.html" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(render_page(page, pages), encoding="utf-8", newline="\n") + + search_index = [ + { + "title": page.title, + "description": page.description, + "url": "./" if page.slug == "index" else f"{page.slug}/", + "source": page.source.name, + } + for page in pages + ] + (docs_output / "search-index.json").write_text( + json.dumps(search_index, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n" + ) + + urls = [BASE_URL] + [BASE_URL + "docs/" if page.slug == "index" else BASE_URL + f"docs/{page.slug}/" for page in pages] + sitemap_lines = [ + '', + '', + ] + for url in urls: + sitemap_lines.extend([" ", f" {html.escape(url)}", " "]) + sitemap_lines.append("") + (output / "sitemap.xml").write_text("\n".join(sitemap_lines) + "\n", encoding="utf-8", newline="\n") + (output / ".nojekyll").write_text("", encoding="utf-8") + + print(f"Built public site: {output}") + print(f"Generated documentation pages: {len(pages)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Build the ARSVIN public site and searchable HTML engineering documentation.") + parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parents[1]) + parser.add_argument("--output", type=Path, default=Path("artifacts/public-site")) + args = parser.parse_args() + repo_root = args.repo_root.resolve() + output = args.output if args.output.is_absolute() else (repo_root / args.output) + build(repo_root, output.resolve()) + + +if __name__ == "__main__": + main() diff --git a/scripts/validate-public-site.ps1 b/scripts/validate-public-site.ps1 index 5df7e19..6a75d34 100644 --- a/scripts/validate-public-site.ps1 +++ b/scripts/validate-public-site.ps1 @@ -1,3 +1,4 @@ +[CmdletBinding()] param( [string] $SiteRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'site') ) @@ -5,118 +6,20 @@ param( $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest -function Assert-True { - param( - [Parameter(Mandatory)] - [bool] $Condition, - [Parameter(Mandatory)] - [string] $Message - ) - - if (-not $Condition) { - throw $Message - } -} - -$sitePath = (Resolve-Path $SiteRoot).Path -$indexPath = Join-Path $sitePath 'index.html' -$manifestPath = Join-Path $sitePath 'site.webmanifest' -$sitemapPath = Join-Path $sitePath 'sitemap.xml' -$robotsPath = Join-Path $sitePath 'robots.txt' - -$requiredFiles = @( - $indexPath, - (Join-Path $sitePath 'styles.css'), - $manifestPath, - $sitemapPath, - $robotsPath -) - -foreach ($file in $requiredFiles) { - Assert-True (Test-Path $file -PathType Leaf) "Required public-site file is missing: $file" +$validator = Join-Path $PSScriptRoot 'validate-public-site.py' +if (-not (Test-Path $validator -PathType Leaf)) { + throw "Public-site validator was not found: $validator" } -$html = Get-Content $indexPath -Raw - -$requiredHtmlPatterns = @( - ']*>(.*?)' -$jsonLdMatches = [regex]::Matches( - $html, - $jsonLdPattern, - [Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [Text.RegularExpressions.RegexOptions]::Singleline -) -Assert-True ($jsonLdMatches.Count -gt 0) 'No JSON-LD structured data blocks were found.' - -foreach ($match in $jsonLdMatches) { - $null = $match.Groups[1].Value | ConvertFrom-Json +$pythonCommand = Get-Command python -ErrorAction SilentlyContinue +if (-not $pythonCommand) { + $pythonCommand = Get-Command python3 -ErrorAction SilentlyContinue } - -$attributeMatches = [regex]::Matches( - $html, - '(?:src|href)=["'']([^"'']+)["'']', - [Text.RegularExpressions.RegexOptions]::IgnoreCase -) - -$missingLocalReferences = [System.Collections.Generic.List[string]]::new() -foreach ($match in $attributeMatches) { - $reference = $match.Groups[1].Value.Trim() - if ( - [string]::IsNullOrWhiteSpace($reference) -or - $reference.StartsWith('#') -or - $reference -match '^(?i:https?:|mailto:|data:|javascript:)' - ) { - continue - } - - $relativePath = ($reference -split '[?#]', 2)[0] - if ([string]::IsNullOrWhiteSpace($relativePath)) { - continue - } - - $normalizedPath = $relativePath.Replace('/', [IO.Path]::DirectorySeparatorChar) - $resolvedPath = Join-Path $sitePath $normalizedPath - if (-not (Test-Path $resolvedPath -PathType Leaf)) { - $missingLocalReferences.Add($reference) - } +if (-not $pythonCommand) { + throw 'Python 3 is required to validate the public site.' } -Assert-True ($missingLocalReferences.Count -eq 0) "Missing local site references: $($missingLocalReferences -join ', ')" - -$manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json -Assert-True (-not [string]::IsNullOrWhiteSpace($manifest.name)) 'The web manifest name is missing.' -Assert-True (-not [string]::IsNullOrWhiteSpace($manifest.short_name)) 'The web manifest short_name is missing.' -Assert-True ($manifest.icons.Count -gt 0) 'The web manifest does not declare any icons.' - -foreach ($icon in $manifest.icons) { - $iconPath = Join-Path $sitePath ($icon.src.Replace('/', [IO.Path]::DirectorySeparatorChar)) - Assert-True (Test-Path $iconPath -PathType Leaf) "Web-manifest icon is missing: $($icon.src)" +& $pythonCommand.Source $validator --site-root $SiteRoot +if ($LASTEXITCODE -ne 0) { + throw "Public-site validation failed with exit code $LASTEXITCODE." } - -[xml] $sitemap = Get-Content $sitemapPath -Raw -$location = $sitemap.urlset.url.loc -Assert-True ($location -eq 'https://masarray.github.io/arsvin/') "Unexpected sitemap canonical URL: $location" - -$robots = Get-Content $robotsPath -Raw -Assert-True ($robots -match 'Sitemap:\s*https://masarray\.github\.io/arsvin/sitemap\.xml') 'robots.txt does not reference the public sitemap.' - -Write-Host "Public site validation passed: $sitePath" diff --git a/scripts/validate-public-site.py b/scripts/validate-public-site.py new file mode 100644 index 0000000..6503081 --- /dev/null +++ b/scripts/validate-public-site.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from urllib.parse import unquote, urlsplit + +BASE_URL = "https://masarray.github.io/arsvin/" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def local_target(site_root: Path, page: Path, reference: str) -> Path | None: + reference = reference.strip() + if not reference or reference.startswith("#"): + return None + if re.match(r"^(?:https?:|mailto:|tel:|data:|javascript:)", reference, re.IGNORECASE): + return None + + parsed = urlsplit(reference) + path = unquote(parsed.path) + if not path: + return None + + if path.startswith("/arsvin/"): + candidate = site_root / path[len("/arsvin/") :] + elif path.startswith("/"): + candidate = site_root / path.lstrip("/") + else: + candidate = page.parent / path + + candidate = candidate.resolve() + try: + candidate.relative_to(site_root) + except ValueError as exc: + raise RuntimeError(f"Local reference escapes the public-site root: {page}: {reference}") from exc + + if candidate.is_dir() or path.endswith("/"): + candidate = candidate / "index.html" + return candidate + + +def validate_html(site_root: Path, page: Path) -> str: + text = read_text(page) + relative = page.relative_to(site_root).as_posix() + + require(re.search(r']*href=["\']([^"\']+)', text, re.IGNORECASE) + if canonical_match is None: + canonical_match = re.search(r']*rel=["\']canonical["\']', text, re.IGNORECASE) + require(canonical_match is not None, f"Missing canonical URL: {relative}") + + h1_count = len(re.findall(r"]*>(.*?)', + text, + re.IGNORECASE | re.DOTALL, + ) + require(json_ld_blocks, f"No JSON-LD structured data found: {relative}") + for block in json_ld_blocks: + json.loads(block) + + references = re.findall(r'(?:src|href)=["\']([^"\']+)["\']', text, re.IGNORECASE) + missing: list[str] = [] + for reference in references: + target = local_target(site_root, page, reference) + if target is not None and not target.is_file(): + missing.append(reference) + require(not missing, f"Missing local references in {relative}: {', '.join(sorted(set(missing)))}") + + return canonical_match.group(1) + + +def validate(site_root: Path) -> None: + site_root = site_root.resolve() + required = [ + "index.html", + "styles.css", + "docs.css", + "site.webmanifest", + "sitemap.xml", + "robots.txt", + ".nojekyll", + "docs/index.html", + "docs/search-index.json", + ] + for relative in required: + require((site_root / relative).is_file(), f"Required public-site file is missing: {relative}") + + landing = read_text(site_root / "index.html") + landing_patterns = [ + r'= 6, f"Expected landing page plus multiple documentation pages; found {len(html_pages)} HTML files.") + canonicals: dict[str, str] = {} + for page in html_pages: + canonical = validate_html(site_root, page) + relative = page.relative_to(site_root).as_posix() + require(canonical not in canonicals, f"Duplicate canonical URL in {relative} and {canonicals.get(canonical)}: {canonical}") + canonicals[canonical] = relative + + docs_pages = [page for page in html_pages if page.relative_to(site_root).as_posix().startswith("docs/")] + require(len(docs_pages) >= 5, f"Expected at least five generated documentation pages; found {len(docs_pages)}.") + + manifest = json.loads(read_text(site_root / "site.webmanifest")) + require(bool(manifest.get("name")), "The web manifest name is missing.") + require(bool(manifest.get("short_name")), "The web manifest short_name is missing.") + icons = manifest.get("icons") or [] + require(bool(icons), "The web manifest does not declare any icons.") + for icon in icons: + icon_path = site_root / str(icon.get("src", "")).lstrip("/") + require(icon_path.is_file(), f"Web-manifest icon is missing: {icon.get('src')}") + + search_index = json.loads(read_text(site_root / "docs/search-index.json")) + require(isinstance(search_index, list) and len(search_index) >= 5, "Documentation search index is missing expected entries.") + for entry in search_index: + require(bool(entry.get("title")) and bool(entry.get("url")), "Documentation search index contains an incomplete entry.") + target = site_root / "docs" / str(entry["url"]) + if target.is_dir() or str(entry["url"]).endswith("/"): + target = target / "index.html" + require(target.is_file(), f"Documentation search-index target is missing: {entry['url']}") + + sitemap_root = ET.fromstring(read_text(site_root / "sitemap.xml")) + sitemap_urls = [element.text.strip() for element in sitemap_root.findall("{*}url/{*}loc") if element.text] + require(BASE_URL in sitemap_urls, f"Sitemap does not contain the product homepage: {BASE_URL}") + require(BASE_URL + "docs/" in sitemap_urls, "Sitemap does not contain the documentation index.") + for canonical, relative in canonicals.items(): + require(canonical in sitemap_urls, f"Sitemap does not contain canonical URL for {relative}: {canonical}") + + robots = read_text(site_root / "robots.txt") + require( + re.search(r"Sitemap:\s*https://masarray\.github\.io/arsvin/sitemap\.xml", robots) is not None, + "robots.txt does not reference the public sitemap.", + ) + + print(f"Public site validation passed: {site_root}") + print(f"HTML pages: {len(html_pages)}") + print(f"Documentation pages: {len(docs_pages)}") + print(f"Sitemap URLs: {len(sitemap_urls)}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate the staged ARSVIN public site and generated HTML documentation.") + parser.add_argument("--site-root", type=Path, default=Path(__file__).resolve().parents[1] / "site") + args = parser.parse_args() + try: + validate(args.site_root) + except Exception as exc: + print(f"Public site validation failed: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + +if __name__ == "__main__": + main() diff --git a/site/docs.css b/site/docs.css new file mode 100644 index 0000000..2f74ffe --- /dev/null +++ b/site/docs.css @@ -0,0 +1,318 @@ +.docs-body { + background: #f5f8fc; +} + +.docs-topbar nav a[aria-current="page"] { + color: var(--blue); +} + +.docs-shell { + display: grid; + grid-template-columns: 250px minmax(0, 820px); + gap: 34px; + width: min(1140px, calc(100% - 36px)); + margin: 0 auto; + padding: 34px 0 64px; + align-items: start; +} + +.docs-sidebar { + position: sticky; + top: 78px; + max-height: calc(100vh - 96px); + overflow: auto; + padding: 14px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(255, 255, 255, 0.88); + box-shadow: 0 8px 24px rgba(20, 32, 51, 0.05); +} + +.docs-sidebar-head { + display: grid; + gap: 9px; + margin-bottom: 10px; +} + +.docs-sidebar-head strong { + font-size: 12px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.docs-sidebar input { + width: 100%; + min-height: 34px; + padding: 6px 9px; + border: 1px solid var(--line); + border-radius: 7px; + background: #fff; + color: var(--ink); + font: inherit; + font-size: 12px; + outline: none; +} + +.docs-sidebar input:focus { + border-color: #8aa9d4; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.08); +} + +.docs-sidebar ul { + display: grid; + gap: 2px; + margin: 0; + padding: 0; + list-style: none; +} + +.docs-sidebar li { + margin: 0; +} + +.docs-sidebar a { + display: block; + padding: 6px 8px; + border-radius: 6px; + color: #526177; + font-size: 12px; + line-height: 1.35; + text-decoration: none; +} + +.docs-sidebar a:hover { + background: var(--soft); + color: var(--blue-dark); +} + +.docs-sidebar a.active { + background: #e8f0ff; + color: #1748b6; + font-weight: 650; +} + +.docs-article { + min-width: 0; + padding: 34px 42px 40px; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + box-shadow: var(--shadow); +} + +.breadcrumbs { + display: flex; + flex-wrap: wrap; + gap: 7px; + align-items: center; + margin-bottom: 26px; + color: #748198; + font-size: 11px; +} + +.breadcrumbs a { + color: #4d607b; + text-decoration: none; +} + +.breadcrumbs a:hover { + color: var(--blue); + text-decoration: underline; +} + +.docs-article h1 { + max-width: none; + margin-bottom: 16px; + font-size: clamp(30px, 4vw, 43px); + font-weight: 640; + letter-spacing: -0.035em; + line-height: 1.08; +} + +.docs-article h2 { + margin-top: 38px; + padding-top: 6px; + border-top: 1px solid #e8eef6; + font-size: 25px; + font-weight: 630; + letter-spacing: -0.025em; +} + +.docs-article h3 { + margin-top: 28px; + font-size: 18px; +} + +.docs-article h4, +.docs-article h5, +.docs-article h6 { + margin: 24px 0 8px; + font-size: 15px; +} + +.docs-article p, +.docs-article li { + color: #3e4d63; + font-size: 14px; + line-height: 1.72; +} + +.docs-article p { + margin-bottom: 15px; +} + +.docs-article ul, +.docs-article ol { + margin: 0 0 18px; + padding-left: 23px; +} + +.docs-article li + li { + margin-top: 5px; +} + +.docs-article a { + color: var(--blue-dark); + text-underline-offset: 2px; +} + +.docs-article code { + padding: 2px 5px; + border: 1px solid #dce6f2; + border-radius: 5px; + background: #f4f7fb; + color: #173a66; + font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace; + font-size: 0.88em; +} + +.docs-article pre { + overflow: auto; + margin: 18px 0; + padding: 15px 17px; + border: 1px solid #263752; + border-radius: 9px; + background: #111d2f; + box-shadow: 0 10px 24px rgba(10, 20, 36, 0.09); +} + +.docs-article pre code { + padding: 0; + border: 0; + background: transparent; + color: #e8eef7; + font-size: 12px; + line-height: 1.65; +} + +.docs-article blockquote { + margin: 18px 0; + padding: 13px 16px; + border-left: 3px solid var(--cyan); + background: #f1f8fa; +} + +.docs-article blockquote p { + margin: 0; + color: #2e5260; +} + +.table-wrap { + overflow-x: auto; + margin: 18px 0 22px; + border: 1px solid var(--line); + border-radius: 9px; +} + +.docs-article table { + width: 100%; + border-collapse: collapse; + background: #fff; + font-size: 12px; +} + +.docs-article th, +.docs-article td { + padding: 9px 11px; + border-bottom: 1px solid #e5ecf4; + text-align: left; + vertical-align: top; +} + +.docs-article th { + background: #f3f7fc; + color: #26364d; + font-weight: 650; +} + +.docs-article tr:last-child td { + border-bottom: 0; +} + +.docs-article hr { + height: 1px; + margin: 30px 0; + border: 0; + background: var(--line); +} + +.docs-source { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px 18px; + margin-top: 44px; + padding-top: 16px; + border-top: 1px solid var(--line); + color: #718097; + font-size: 11px; +} + +.docs-source a { + font-weight: 650; + text-decoration: none; +} + +@media (max-width: 900px) { + .docs-shell { + grid-template-columns: 1fr; + gap: 16px; + padding-top: 20px; + } + + .docs-sidebar { + position: static; + max-height: none; + } + + .docs-sidebar ul { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .docs-topbar nav { + display: none; + } + + .docs-shell { + width: min(100% - 20px, 1140px); + } + + .docs-sidebar ul { + grid-template-columns: 1fr; + } + + .docs-article { + padding: 24px 18px 30px; + border-radius: 9px; + } + + .docs-article h1 { + font-size: 31px; + } + + .docs-article h2 { + font-size: 22px; + } +} diff --git a/site/index.html b/site/index.html index 95f5d08..0947593 100644 --- a/site/index.html +++ b/site/index.html @@ -111,6 +111,7 @@ Products Screenshots Capabilities + Docs Downloads Safety @@ -128,7 +129,7 @@

    Sampled Values publishing and analysis, without a black box.

    @@ -224,42 +225,61 @@

    Useful visibility from configuration to evidence.

    -
    +
    -

    Windows release options

    -

    Installer or direct portable applications.

    -

    All public release files use stable names so the latest download links remain predictable.

    +

    Search-indexable engineering documentation

    +

    Understand the workflow before sending a frame.

    +

    Every repository guide is published as a dedicated HTML page with canonical metadata, structured data, internal navigation, and sitemap discovery.

    -
    - - EXE - ARSVIN Suite InstallerPublisher + Subscriber + shortcuts + uninstaller - Download - - - - EXE - Publisher PortableSelf-contained single-file application - Download - - - - EXE - Subscriber PortableSelf-contained single-file application - Download - - - - ZIP - Portable SuiteBoth applications, docs, notices, and samples - Download - +
    +
    01

    Quick Start

    Install the suite, select Publisher or Subscriber, and begin with an authorized dry-run or isolated link.

    +
    02

    SV Profile Support

    Review current Sampled Values profile support, packing behavior, and explicit technical boundaries.

    +
    03

    COMTRADE Replay

    Map analog records into repeatable Sampled Values publishing scenarios with documented scaling assumptions.

    +
    04

    Subscriber Verification

    Inspect live or recorded streams, continuity, decoded values, waveform, phasor, RMS, and receiver evidence.

    -

    Current binaries are unsigned and may trigger a Windows SmartScreen warning. Npcap is not bundled; install it separately from the official Npcap website for live network features. Verify downloads with SHA256SUMS.txt.

    + +
    +
    + +
    +
    +

    Windows release options

    +

    Installer or direct portable applications.

    +

    All public release files use stable names so the latest download links remain predictable.

    + + + +

    Current binaries are unsigned and may trigger a Windows SmartScreen warning. Npcap is not bundled; install it separately from the official Npcap website for live network features. Verify downloads with SHA256SUMS.txt.

    @@ -271,7 +291,7 @@

    Designed for controlled laboratory use.

    ARSVIN can transmit and capture raw Ethernet frames. Use isolated networks, point-to-point links, or networks where you have explicit authorization.

    It is not a certified protection test set, calibrated merging unit, deterministic real-time platform, production process-bus monitor, or IEC 61850 conformance tool.

    - Read safety boundaries → + Read safety boundaries →
    @@ -319,7 +339,7 @@

    Open engineering for IEC 61850 Sampled Values.

    ARSVIN · Apache-2.0 · © 2026 Ari Sulistiono