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' '
+
+ 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"{inline_markdown(cell, current_slug)} " for cell in headers)
+ output.append(" ")
+ for row in rows:
+ padded = row + [""] * max(0, len(headers) - len(row))
+ output.append("")
+ output.extend(f"{inline_markdown(cell, current_slug)} " for cell in padded[: len(headers)])
+ output.append(" ")
+ output.append("
")
+ 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"{tag}>")
+ 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 / Docs / {html.escape(breadcrumb_label)}
+
+ {body}
+
+
+
+
+
+
+
+
+'''
+
+
+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 Installer Publisher + Subscriber + shortcuts + uninstaller
- Download
-
-
-
- EXE
- Publisher Portable Self-contained single-file application
- Download
-
-
-
- EXE
- Subscriber Portable Self-contained single-file application
- Download
-
-
-
- ZIP
- Portable Suite Both applications, docs, notices, and samples
- Download
-
+
+
01 Install the suite, select Publisher or Subscriber, and begin with an authorized dry-run or isolated link.
+
02 Review current Sampled Values profile support, packing behavior, and explicit technical boundaries.
+
03 Map analog records into repeatable Sampled Values publishing scenarios with documented scaling assumptions.
+
04 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
Source
- Docs
+ Docs
Issues
License