Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Docs

on:
push:
branches: [main]
paths:
- 'user-docs/**'
- 'website/**'
- 'scripts/build_site.py'
- '.github/workflows/docs.yml'
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

# Allow one concurrent deployment; don't cancel an in-progress publish.
concurrency:
group: pages
cancel-in-progress: false

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '20'
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Assemble VitePress source
run: python3 scripts/build_site.py
- name: Install & build
working-directory: website
run: |
npm install
npm run build
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: website/dist

deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,12 @@ internal/ui/embedded/export/export.js

node_modules/
tmp/
__pycache__/

.requirements/

# VitePress docs site: generated srcDir, build output, cache, and locale config.
website/src/
website/dist/
website/.vitepress/cache/
website/.vitepress/locales.generated.json
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build setup frontend-setup go-setup root-setup frontend-build frontend-test frontend-knip frontend-lint frontend-format-check extension-test memory-test go-test install-test vet test check clean dev release-patch release-minor release-major release-beta e2e e2e-setup
.PHONY: build setup frontend-setup go-setup root-setup frontend-build frontend-test frontend-knip frontend-lint frontend-format-check extension-test memory-test go-test install-test vet test check clean dev docs docs-dev release-patch release-minor release-major release-beta e2e e2e-setup

BINARY ?= pi-web
WEB_DIR := web
Expand Down Expand Up @@ -88,6 +88,16 @@ clean:
rm -f $(BINARY)
rm -rf $(WEB_DIR)/dist

# Docs site (VitePress). Assemble the generated srcDir from user-docs + authored
# pages, then build/preview. Not part of `make check` — never gates the app.
docs:
python3 scripts/build_site.py
cd website && npm install && npm run build

docs-dev:
python3 scripts/build_site.py
cd website && npm install && npm run dev

# Release helpers — bump package.json, commit, tag, and push.
# Uses npm version which auto-creates a vX.Y.Z git tag.
release-patch:
Expand Down
195 changes: 195 additions & 0 deletions scripts/build_site.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""Assemble the VitePress source tree (website/src) for the docs site.

Source of truth stays in user-docs/ — prose in user-docs/<lang>/*.md and the
hero in user-docs/<lang>/hero.json (translated by build_userdocs.py). This
script produces the generated, gitignored srcDir VitePress builds from, so docs
are never duplicated by hand.

For every language it:
1. Renders a localized hero home page (index.md) from user-docs/<lang>/hero.json
(falling back to the English user-docs/en/hero.json if a locale lacks one).
2. Transforms each user-docs/<lang>/*.md into a localized VitePress page
(README -> guide), copies shared images into public/.
3. Emits .vitepress/locales.generated.json (labels + per-locale sidebars)
consumed by config.js to wire up the language switcher.

The language list is imported from build_userdocs so a new language is added in
one place (its LANGS list), exactly like the rest of the docs pipeline.
"""

import json
import re
import shutil
from pathlib import Path

from build_userdocs import LANGS

ROOT = Path(__file__).resolve().parent.parent
SITE = ROOT / "website"
SRC = SITE / "src"
USER_DOCS = ROOT / "user-docs"
ASSETS = USER_DOCS / "assets"
LOCALES_JSON = SITE / ".vitepress" / "locales.generated.json"

PACKAGE_URL = "https://pi.dev/packages/@ygncode/pi-web?name=pi-web"

# Browser/SEO <title> for the home page (titleTemplate:false keeps it verbatim).
HOME_TITLE = "pi-web - Web UI for Pi (Access pi via Remote, Mobile)"

# Reading order for the sidebar. README becomes the "guide" page per locale.
DOC_ORDER = [
"README",
"why",
"install",
"personal-assistant",
"keyboard-shortcuts",
"llm-debug",
"roadmap",
]

# BCP-47 tags for the <html lang> attribute, keyed by user-docs dir name.
LANG_TAGS = {
"en": "en-US",
"es": "es",
"fr": "fr",
"de": "de",
"zh": "zh-Hans",
"ja": "ja",
"id": "id",
"ms": "ms",
"vi": "vi",
"th": "th",
"fil": "fil",
"my": "my",
"km": "km",
"lo": "lo",
}

# Sidebar title for docs with no Markdown heading to derive one from.
FALLBACK_TITLES = {"llm-debug": "LLM Debugging"}

# The language-switcher nav block authored for GitHub rendering: the only
# <div align="center"> in a README, recognizable by its README.md) links.
NAV_BLOCK_RE = re.compile(
r'<div align="center">\s*\n.*?README\.md\).*?</div>\n*',
re.DOTALL,
)


def slug(code: str, doc: str) -> str:
name = "guide" if doc == "README" else doc
return f"/{name}" if code == "en" else f"/{code}/{name}"


def transform(text: str, doc: str, code: str) -> str:
if doc == "README":
text = NAV_BLOCK_RE.sub("", text, count=1)
# Shared images: ../assets/x -> /assets/x (served from public/, base-prefixed).
text = text.replace("](../assets/", "](/assets/")
# The lone cross-doc link to the README (in roadmap). English slugs match the
# English headings; translated headings slugify differently, so drop the
# fragment for locales to avoid a dead anchor.
if code == "en":
text = re.sub(r"\]\(README\.md#([^)]*)\)", r"](/guide#\1)", text)
else:
text = re.sub(r"\]\(README\.md#[^)]*\)", f"](/{code}/guide)", text)
return re.sub(r"\n{3,}", "\n\n", text).lstrip("\n")


def page_title(text: str, doc: str) -> str:
match = re.search(r"^#\s+(.+)$", text, re.MULTILINE)
if match:
return match.group(1).strip()
return FALLBACK_TITLES.get(doc, doc.replace("-", " ").title())


def yaml_scalar(value: str) -> str:
# JSON strings are valid double-quoted YAML scalars (escaping + emoji safe).
return json.dumps(value, ensure_ascii=False)


def render_hero(code: str) -> str:
path = USER_DOCS / code / "hero.json"
if not path.exists():
path = USER_DOCS / "en" / "hero.json"
hero = json.loads(path.read_text())

lines = [
"---",
"layout: home",
f"title: {yaml_scalar(HOME_TITLE)}",
"titleTemplate: false",
"",
"hero:",
' name: "pi-web"',
f" text: {yaml_scalar(hero['text'])}",
f" tagline: {yaml_scalar(hero['tagline'])}",
" actions:",
]
for action in hero["actions"]:
link = action.get("link") or (
f"/{action['to']}" if code == "en" else f"/{code}/{action['to']}"
)
lines += [
f" - theme: {action.get('theme', 'brand')}",
f" text: {yaml_scalar(action['text'])}",
f" link: {yaml_scalar(link)}",
]
# The hero image (demo GIF + caption) is rendered for every locale by the
# home-hero-image theme slot (HeroImage.vue), not via per-locale frontmatter.
lines += ["", "features:"]
for feature in hero["features"]:
lines += [
f" - icon: {yaml_scalar(feature['icon'])}",
f" title: {yaml_scalar(feature['title'])}",
f" details: {yaml_scalar(feature['details'])}",
]
lines += ["---", ""]
return "\n".join(lines)


def main() -> None:
if SRC.exists():
shutil.rmtree(SRC)
SRC.mkdir(parents=True)

dest_assets = SRC / "public" / "assets"
dest_assets.mkdir(parents=True, exist_ok=True)
for path in ASSETS.glob("*"):
if path.is_file():
shutil.copy2(path, dest_assets / path.name)

locales = {}
for code, label in LANGS:
out_dir = SRC if code == "en" else SRC / code
out_dir.mkdir(parents=True, exist_ok=True)

(out_dir / "index.md").write_text(render_hero(code))

sidebar = []
for doc in DOC_ORDER:
text = transform((USER_DOCS / code / f"{doc}.md").read_text(), doc, code)
name = "guide" if doc == "README" else doc
(out_dir / f"{name}.md").write_text(text)
sidebar.append({"text": page_title(text, doc), "link": slug(code, doc)})

theme = {
"nav": [
{"text": "Guide", "link": slug(code, "README")},
{"text": "pi.dev", "link": PACKAGE_URL},
],
"sidebar": sidebar,
}
key = "root" if code == "en" else code
entry = {"label": label, "lang": LANG_TAGS[code], "themeConfig": theme}
if code != "en":
entry["link"] = f"/{code}/"
locales[key] = entry

LOCALES_JSON.write_text(json.dumps(locales, ensure_ascii=False, indent=2) + "\n")
print(f"Assembled VitePress src at {SRC.relative_to(ROOT)} ({len(LANGS)} locales)")


if __name__ == "__main__":
main()
65 changes: 64 additions & 1 deletion scripts/build_userdocs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#!/usr/bin/env python3
import json
import re
import subprocess
import sys
Expand Down Expand Up @@ -116,6 +117,66 @@ def build_doc(doc: str, code: str) -> str:
return translate(src, code) + "\n"


# hero.json drives the docs-site home page (see scripts/build_site.py). Only its
# string values are translated; keys, links, icons, and structure are preserved.
HERO_PROMPT = """Translate the string VALUES of this JSON object into {target}.

Rules:
- Output ONLY valid JSON with the exact same keys. No preamble, no commentary, no code fence.
- Translate every value into {target}.
- Keep these terms in English / as-is: pi, pi-web, PWA, SSE, Tailscale, GitHub, Gist, macOS, Linux, Windows, npm, Claude, Cowork, Termius.
- Preserve emoji, punctuation, and the em dash (—).

{payload}
"""


def hero_strings(hero: dict) -> list[tuple]:
paths = [("text",), ("tagline",)]
paths += [("actions", i, "text") for i in range(len(hero["actions"]))]
for i in range(len(hero["features"])):
paths += [("features", i, "title"), ("features", i, "details")]
return paths


def build_hero(code: str) -> str:
hero = json.loads((EN_DIR / "hero.json").read_text())
if code == "en":
return json.dumps(hero, ensure_ascii=False, indent=2) + "\n"
paths = hero_strings(hero)
payload = json.dumps(
{str(i): _at(hero, p) for i, p in enumerate(paths)}, ensure_ascii=False, indent=2
)
prompt = HERO_PROMPT.format(target=TRANSLATE_TARGET[code], payload=payload)
result = subprocess.run(
["pi", "-p", "--model", "opencode-go/deepseek-v4-pro", "--no-session", prompt],
capture_output=True,
text=True,
timeout=600,
)
if result.returncode != 0:
raise RuntimeError(f"pi failed for {code} hero: {result.stderr}")
out = re.sub(r"\n```$", "", re.sub(r"^```[a-zA-Z]*\n", "", result.stdout.strip()))
data = json.loads(out)
if len(data) != len(paths):
raise RuntimeError(f"{code} hero: expected {len(paths)} strings, got {len(data)}")
for i, path in enumerate(paths):
_set(hero, path, data[str(i)])
return json.dumps(hero, ensure_ascii=False, indent=2) + "\n"


def _at(obj, path):
for key in path:
obj = obj[key]
return obj


def _set(obj, path, value):
for key in path[:-1]:
obj = obj[key]
obj[path[-1]] = value


def main():
only = sys.argv[1:] # optional list of lang codes
for code, name in LANGS:
Expand All @@ -132,7 +193,9 @@ def main():
else:
content = build_doc(doc, code)
(out_dir / f"{doc}.md").write_text(content)
print(f"[{code}] done ({len(DOCS)} docs)", flush=True)
print(f"[{code}] hero…", flush=True)
(out_dir / "hero.json").write_text(build_hero(code))
print(f"[{code}] done ({len(DOCS)} docs + hero)", flush=True)


if __name__ == "__main__":
Expand Down
21 changes: 21 additions & 0 deletions user-docs/assets/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added user-docs/assets/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added user-docs/assets/pi-web-demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading