mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
Merge pull request #661 from iflytek/docs/issue-659-weekly-pages-v2
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
Some checks are pending
Deploy Docs / build (push) Waiting to run
Deploy Docs / Deploy (push) Blocked by required conditions
Security / Dependency Review (push) Waiting to run
Security / CodeQL (java-kotlin) (push) Waiting to run
Security / CodeQL (javascript-typescript) (push) Waiting to run
Security / CodeQL (python) (push) Waiting to run
docs(weekly): mirror reports into project Pages
This commit is contained in:
commit
1525698ff0
14 changed files with 6933 additions and 0 deletions
11
.github/workflows/deploy-docs.yml
vendored
11
.github/workflows/deploy-docs.yml
vendored
|
|
@ -5,6 +5,8 @@ on:
|
|||
branches: [main]
|
||||
paths:
|
||||
- 'docs/skillhub/**'
|
||||
- 'weekly/**'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
|
@ -34,8 +36,17 @@ jobs:
|
|||
uses: actions/configure-pages@v4
|
||||
- name: Install dependencies
|
||||
run: cd docs/skillhub && npm ci
|
||||
- name: Build and validate weekly reports
|
||||
run: |
|
||||
cd weekly
|
||||
python3 scripts/build_site.py
|
||||
python3 scripts/validate_site.py _site
|
||||
- name: Build with VitePress
|
||||
run: cd docs/skillhub && npm run build
|
||||
- name: Add weekly reports to Pages artifact
|
||||
run: |
|
||||
mkdir -p docs/skillhub/.vitepress/dist/weekly
|
||||
cp -R weekly/_site/. docs/skillhub/.vitepress/dist/weekly/
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
|
|
|
|||
17
.github/workflows/pr-tests.yml
vendored
17
.github/workflows/pr-tests.yml
vendored
|
|
@ -88,7 +88,9 @@ jobs:
|
|||
filters: |
|
||||
docs:
|
||||
- 'docs/skillhub/**'
|
||||
- 'weekly/**'
|
||||
- '.github/workflows/pr-tests.yml'
|
||||
- '.github/workflows/deploy-docs.yml'
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
|
|
@ -105,3 +107,18 @@ jobs:
|
|||
- name: Build VitePress site
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
run: cd docs/skillhub && npm run build
|
||||
|
||||
- name: Build and validate weekly reports
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
run: |
|
||||
cd weekly
|
||||
python3 scripts/build_site.py
|
||||
python3 scripts/validate_site.py _site
|
||||
|
||||
- name: Assemble Pages artifact layout
|
||||
if: steps.changed.outputs.docs == 'true'
|
||||
run: |
|
||||
mkdir -p docs/skillhub/.vitepress/dist/weekly
|
||||
cp -R weekly/_site/. docs/skillhub/.vitepress/dist/weekly/
|
||||
test -f docs/skillhub/.vitepress/dist/weekly/index.html
|
||||
test -f docs/skillhub/.vitepress/dist/weekly/archive.html
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -69,6 +69,7 @@ package-lock.json
|
|||
.tmp/
|
||||
tmp/
|
||||
__pycache__/
|
||||
weekly/_site/
|
||||
|
||||
# Git worktrees
|
||||
.worktrees/
|
||||
|
|
@ -84,6 +85,9 @@ docs/superpowers/
|
|||
# Local workspace metadata
|
||||
CLAUDE.md
|
||||
|
||||
# Local report-generation skill
|
||||
.agents/skills/generate-skillhub-weekly-report/
|
||||
|
||||
# Helm chart dependencies
|
||||
charts/skillhub/charts/*.tgz
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export default defineConfig({
|
|||
{ text: '首页', link: '/' },
|
||||
{ text: '快速开始', link: '/quickstart' },
|
||||
{ text: '功能指南', link: '/guide/skill-publish' },
|
||||
{ text: '开源周报', link: 'https://iflytek.github.io/skillhub/weekly/' },
|
||||
{ text: 'FAQ', link: '/faq' },
|
||||
],
|
||||
sidebar: [
|
||||
|
|
@ -69,6 +70,7 @@ export default defineConfig({
|
|||
{ text: 'Home', link: '/en/' },
|
||||
{ text: 'Quick Start', link: '/en/quickstart' },
|
||||
{ text: 'Guide', link: '/en/guide/skill-publish' },
|
||||
{ text: 'Weekly Reports', link: 'https://iflytek.github.io/skillhub/weekly/' },
|
||||
{ text: 'FAQ', link: '/en/faq' },
|
||||
],
|
||||
sidebar: [
|
||||
|
|
|
|||
26
weekly/README.md
Normal file
26
weekly/README.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# SkillHub Weekly Mirror
|
||||
|
||||
This directory is the reviewed mirror of the public
|
||||
[`XiaoSeS/skillhub-weekly`](https://github.com/XiaoSeS/skillhub-weekly) site.
|
||||
The standalone repository remains the authoritative content source.
|
||||
|
||||
The SkillHub documentation workflow builds this directory and places the result
|
||||
under the existing VitePress Pages artifact:
|
||||
|
||||
- Latest report: `https://iflytek.github.io/skillhub/weekly/`
|
||||
- Archive: `https://iflytek.github.io/skillhub/weekly/archive.html`
|
||||
- Report: `https://iflytek.github.io/skillhub/weekly/reports/<week>/`
|
||||
|
||||
## Local validation
|
||||
|
||||
```bash
|
||||
cd weekly
|
||||
python3 scripts/sync_report_theme.py site/reports/*/index.html
|
||||
python3 scripts/build_site.py
|
||||
python3 scripts/validate_site.py _site
|
||||
```
|
||||
|
||||
Do not edit `_site/`; it is ignored and rebuilt by CI. Update reports in the
|
||||
standalone repository first, then copy `site/`, `assets/`, and `scripts/`
|
||||
byte-for-byte into this directory so both published sites keep the same report
|
||||
HTML, Notion-light theme, charts, and Tab behavior.
|
||||
1176
weekly/assets/notion-light.css
Normal file
1176
weekly/assets/notion-light.css
Normal file
File diff suppressed because it is too large
Load diff
193
weekly/scripts/build_site.py
Executable file
193
weekly/scripts/build_site.py
Executable file
|
|
@ -0,0 +1,193 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Build the SkillHub weekly report site from self-contained report files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from html import escape
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_manifest(source: Path) -> tuple[str, list[dict[str, str]]]:
|
||||
manifest_path = source / "reports.json"
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
latest = payload.get("latest")
|
||||
reports = payload.get("reports")
|
||||
if not isinstance(latest, str) or not latest:
|
||||
raise ValueError("reports.json must define a non-empty latest week")
|
||||
if not isinstance(reports, list) or not reports:
|
||||
raise ValueError("reports.json must contain at least one report")
|
||||
|
||||
required = {"week", "title", "period", "snapshot", "path"}
|
||||
normalized: list[dict[str, str]] = []
|
||||
for index, report in enumerate(reports):
|
||||
if not isinstance(report, dict) or not required.issubset(report):
|
||||
missing = required - set(report) if isinstance(report, dict) else required
|
||||
raise ValueError(f"report #{index + 1} is missing fields: {sorted(missing)}")
|
||||
normalized.append({key: str(report[key]) for key in required})
|
||||
|
||||
weeks = {report["week"] for report in normalized}
|
||||
if latest not in weeks:
|
||||
raise ValueError(f"latest week {latest!r} is not present in reports")
|
||||
return latest, sorted(normalized, key=lambda item: item["week"], reverse=True)
|
||||
|
||||
|
||||
def render_archive(latest: str, reports: list[dict[str, str]]) -> str:
|
||||
rows = "\n".join(
|
||||
f""" <li>
|
||||
<a class="report-link" href="./{escape(report['path'], quote=True)}">
|
||||
<span class="report-copy">
|
||||
<span class="report-kicker">
|
||||
<span>{escape(report['week'])}</span>
|
||||
{'<span class="latest">最新</span>' if report['week'] == latest else ''}
|
||||
</span>
|
||||
<strong>{escape(report['title'])}</strong>
|
||||
<span class="report-meta">{escape(report['period'])} · 快照 {escape(report['snapshot'])}</span>
|
||||
</span>
|
||||
<span class="arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
</li>"""
|
||||
for report in reports
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>SkillHub 开源周报归档</title>
|
||||
<style data-site-theme="notion-light">
|
||||
:root {{
|
||||
color-scheme: light;
|
||||
--page:#fff;
|
||||
--warm:#f6f5f4;
|
||||
--ink:#0d0d0d;
|
||||
--ink-soft:#31302e;
|
||||
--muted:#615d59;
|
||||
--faint:#76716c;
|
||||
--line:#e5e3e1;
|
||||
--blue:#0075de;
|
||||
--blue-active:#005bab;
|
||||
--green:#147a33;
|
||||
--green-soft:#e9f7ec;
|
||||
--focus:#097fe8;
|
||||
}}
|
||||
* {{ box-sizing: border-box; }}
|
||||
body {{
|
||||
margin:0;
|
||||
background:var(--page);
|
||||
color:var(--ink);
|
||||
font:15px/1.65 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
}}
|
||||
a {{ color:var(--blue); text-decoration:none; text-underline-offset:3px; }}
|
||||
a:hover {{ color:var(--blue-active); }}
|
||||
:focus-visible {{ outline:2px solid var(--focus); outline-offset:3px; }}
|
||||
main {{ width:min(920px,100%); min-height:100vh; margin:0 auto; padding:44px 28px 56px; }}
|
||||
.topline {{ display:flex; align-items:center; justify-content:space-between; gap:18px; margin-bottom:44px; }}
|
||||
.brand {{ display:flex; align-items:center; gap:10px; color:var(--ink); }}
|
||||
.brand-mark {{
|
||||
display:inline-flex;
|
||||
width:34px;
|
||||
height:34px;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
border-radius:6px;
|
||||
background:var(--ink-soft);
|
||||
color:#fff;
|
||||
font-size:14px;
|
||||
font-weight:700;
|
||||
letter-spacing:.04em;
|
||||
}}
|
||||
.brand-name {{ font-size:17px; font-weight:700; letter-spacing:-.01em; }}
|
||||
.brand-tag {{ padding:4px 10px; border-radius:999px; background:#f1f0ef; color:var(--muted); font-size:12px; font-weight:600; }}
|
||||
.utility {{ display:flex; flex-wrap:wrap; gap:16px; font-size:13px; }}
|
||||
h1 {{ margin:0; font-size:clamp(32px,5vw,44px); line-height:1.15; letter-spacing:-.025em; }}
|
||||
.intro {{ margin:9px 0 30px; color:var(--muted); }}
|
||||
.archive-summary {{ margin-bottom:18px; padding:18px 20px; border-radius:8px; background:var(--warm); color:var(--ink-soft); }}
|
||||
.archive-summary strong {{ color:var(--ink); }}
|
||||
ul {{ margin:0; padding:0; overflow:hidden; border:1px solid var(--line); border-radius:12px; list-style:none; }}
|
||||
li + li {{ border-top:1px solid var(--line); }}
|
||||
.report-link {{ display:flex; align-items:center; justify-content:space-between; gap:24px; padding:20px 22px; color:var(--ink); }}
|
||||
.report-link:hover {{ background:#faf9f8; text-decoration:none; }}
|
||||
.report-copy {{ display:flex; min-width:0; flex-direction:column; gap:4px; }}
|
||||
.report-kicker {{ display:flex; align-items:center; gap:8px; color:var(--faint); font-size:12px; font-weight:600; }}
|
||||
.report-copy strong {{ color:var(--ink); font-size:17px; line-height:1.4; }}
|
||||
.report-meta {{ color:var(--muted); font-size:12.5px; }}
|
||||
.latest {{ padding:2px 8px; border-radius:999px; background:var(--green-soft); color:var(--green); font-size:11px; font-weight:700; }}
|
||||
.arrow {{ flex:0 0 auto; color:var(--faint); font-size:20px; transition:transform .15s ease; }}
|
||||
.report-link:hover .arrow {{ transform:translateX(3px); color:var(--ink); }}
|
||||
.back {{ display:inline-block; margin-top:24px; font-size:13px; font-weight:600; }}
|
||||
@media (max-width:600px) {{
|
||||
main {{ padding:28px 18px 40px; }}
|
||||
.topline {{ align-items:flex-start; flex-direction:column; gap:12px; margin-bottom:34px; }}
|
||||
.report-link {{ align-items:flex-start; padding:18px; }}
|
||||
.report-copy strong {{ font-size:15px; }}
|
||||
}}
|
||||
@media (prefers-reduced-motion:reduce) {{ .arrow {{ transition:none; }} }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="topline">
|
||||
<div class="brand">
|
||||
<span class="brand-mark" aria-hidden="true">SH</span>
|
||||
<span class="brand-name">SkillHub</span>
|
||||
<span class="brand-tag">开源周报</span>
|
||||
</div>
|
||||
<nav class="utility" aria-label="站点链接">
|
||||
<a href="https://iflytek.github.io/skillhub/">项目文档</a>
|
||||
<a href="https://github.com/iflytek/skillhub">GitHub 仓库</a>
|
||||
</nav>
|
||||
</div>
|
||||
<h1>SkillHub 开源周报归档</h1>
|
||||
<p class="intro">按统计周期倒序查看历期开源周报。</p>
|
||||
<div class="archive-summary">当前共收录 <strong>{len(reports)} 期</strong>,最新一期为 <strong>{escape(latest)}</strong>。</div>
|
||||
<ul>
|
||||
{rows}
|
||||
</ul>
|
||||
<a class="back" href="./">返回最新周报</a>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def build(source: Path, output: Path) -> None:
|
||||
latest, reports = load_manifest(source)
|
||||
latest_report = next(report for report in reports if report["week"] == latest)
|
||||
latest_source = source / latest_report["path"] / "index.html"
|
||||
if not latest_source.is_file():
|
||||
raise FileNotFoundError(f"latest report not found: {latest_source}")
|
||||
|
||||
for report in reports:
|
||||
report_file = source / report["path"] / "index.html"
|
||||
if not report_file.is_file():
|
||||
raise FileNotFoundError(f"report not found: {report_file}")
|
||||
|
||||
if output.exists():
|
||||
shutil.rmtree(output)
|
||||
shutil.copytree(source, output)
|
||||
|
||||
latest_html = latest_source.read_text(encoding="utf-8")
|
||||
latest_html = latest_html.replace('href="../../archive.html"', 'href="./archive.html"')
|
||||
(output / "index.html").write_text(latest_html, encoding="utf-8")
|
||||
(output / "archive.html").write_text(
|
||||
render_archive(latest, reports),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(output / ".nojekyll").write_text("", encoding="utf-8")
|
||||
print(f"Built {len(reports)} report(s); latest={latest}; output={output}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--source", type=Path, default=Path("site"))
|
||||
parser.add_argument("--output", type=Path, default=Path("_site"))
|
||||
args = parser.parse_args()
|
||||
build(args.source.resolve(), args.output.resolve())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
55
weekly/scripts/sync_report_theme.py
Executable file
55
weekly/scripts/sync_report_theme.py
Executable file
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Inline the canonical Notion-light theme into self-contained weekly reports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
THEME_PATTERN = re.compile(
|
||||
r'(?P<open><style data-report-theme="notion-light">\n)'
|
||||
r".*?"
|
||||
r"(?P<close>\n[ \t]*</style>)",
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def sync_theme(theme_path: Path, report_paths: list[Path]) -> None:
|
||||
theme = theme_path.read_text(encoding="utf-8").rstrip()
|
||||
for report_path in report_paths:
|
||||
source = report_path.read_text(encoding="utf-8")
|
||||
updated, replacements = THEME_PATTERN.subn(
|
||||
lambda match: f"{match.group('open')}{theme}{match.group('close')}",
|
||||
source,
|
||||
)
|
||||
if replacements != 1:
|
||||
raise ValueError(
|
||||
f"{report_path}: expected one notion-light theme block, "
|
||||
f"found {replacements}"
|
||||
)
|
||||
report_path.write_text(updated, encoding="utf-8")
|
||||
print(f"Synced theme: {report_path}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"reports",
|
||||
type=Path,
|
||||
nargs="+",
|
||||
help="HTML report files containing a notion-light theme block",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--theme",
|
||||
type=Path,
|
||||
default=Path("assets/notion-light.css"),
|
||||
help="canonical CSS file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
sync_theme(args.theme.resolve(), [path.resolve() for path in args.reports])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
264
weekly/scripts/validate_site.py
Executable file
264
weekly/scripts/validate_site.py
Executable file
|
|
@ -0,0 +1,264 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Validate built routes and basic accessibility hooks for the weekly site."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VOID_ELEMENTS = {
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
}
|
||||
NON_CONTENT_ELEMENTS = {"caption", "h1", "h2", "h3", "h4", "h5", "h6", "th"}
|
||||
ALLOWED_PANELS = {"panel-overview", "panel-health", "panel-flow", "panel-method"}
|
||||
|
||||
|
||||
class PageParser(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.h1_count = 0
|
||||
self.tabs = 0
|
||||
self.panels = 0
|
||||
self.tab_controls: set[str] = set()
|
||||
self.panel_ids: set[str] = set()
|
||||
self.panel_modules: dict[str, int] = {}
|
||||
self.current_panel: str | None = None
|
||||
self.panel_depth = 0
|
||||
self.module_stack: list[dict[str, object]] = []
|
||||
self.module_counts: dict[str, int] = {}
|
||||
self.module_panels: dict[str, set[str]] = {}
|
||||
self.module_names: set[str] = set()
|
||||
self.empty_modules: set[str] = set()
|
||||
self.table_stack: list[dict[str, int]] = []
|
||||
self.empty_table_count = 0
|
||||
self.ids: set[str] = set()
|
||||
self.duplicate_ids: set[str] = set()
|
||||
self.external_assets: list[str] = []
|
||||
self.non_content_depth = 0
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
is_void = tag in VOID_ELEMENTS
|
||||
if self.current_panel is not None and not is_void:
|
||||
self.panel_depth += 1
|
||||
if not is_void:
|
||||
for module in self.module_stack:
|
||||
module["depth"] = int(module["depth"]) + 1
|
||||
for table in self.table_stack:
|
||||
table["depth"] += 1
|
||||
if tag in NON_CONTENT_ELEMENTS:
|
||||
self.non_content_depth += 1
|
||||
values = dict(attrs)
|
||||
element_id = values.get("id")
|
||||
if element_id:
|
||||
if element_id in self.ids:
|
||||
self.duplicate_ids.add(element_id)
|
||||
self.ids.add(element_id)
|
||||
if tag == "h1":
|
||||
self.h1_count += 1
|
||||
if values.get("role") == "tab":
|
||||
self.tabs += 1
|
||||
controls = values.get("aria-controls")
|
||||
if controls:
|
||||
self.tab_controls.add(controls)
|
||||
if values.get("role") == "tabpanel":
|
||||
self.panels += 1
|
||||
panel_id = values.get("id") or ""
|
||||
if panel_id:
|
||||
self.panel_ids.add(panel_id)
|
||||
self.panel_modules.setdefault(panel_id, 0)
|
||||
self.current_panel = panel_id
|
||||
self.panel_depth = 1
|
||||
if tag == "table":
|
||||
self.table_stack.append({"depth": 1, "data_cells": 0})
|
||||
elif tag == "td":
|
||||
for table in self.table_stack:
|
||||
table["data_cells"] += 1
|
||||
module_name = values.get("data-module")
|
||||
if module_name:
|
||||
self.module_names.add(module_name)
|
||||
self.module_counts[module_name] = self.module_counts.get(module_name, 0) + 1
|
||||
if self.current_panel:
|
||||
self.module_panels.setdefault(module_name, set()).add(self.current_panel)
|
||||
if is_void:
|
||||
self.empty_modules.add(module_name)
|
||||
else:
|
||||
self.module_stack.append(
|
||||
{"depth": 1, "name": module_name, "has_meaningful_content": False}
|
||||
)
|
||||
if self.current_panel:
|
||||
self.panel_modules[self.current_panel] = self.panel_modules.get(self.current_panel, 0) + 1
|
||||
if tag == "script" and values.get("src"):
|
||||
self.external_assets.append(values["src"] or "")
|
||||
if tag == "link" and "stylesheet" in (values.get("rel") or ""):
|
||||
self.external_assets.append(values.get("href") or "")
|
||||
|
||||
def handle_startendtag(
|
||||
self, tag: str, attrs: list[tuple[str, str | None]]
|
||||
) -> None:
|
||||
self.handle_starttag(tag, attrs)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if data.strip() and not self.non_content_depth:
|
||||
for module in self.module_stack:
|
||||
module["has_meaningful_content"] = True
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag in NON_CONTENT_ELEMENTS and self.non_content_depth:
|
||||
self.non_content_depth -= 1
|
||||
for module in self.module_stack:
|
||||
module["depth"] = int(module["depth"]) - 1
|
||||
while self.module_stack and int(self.module_stack[-1]["depth"]) == 0:
|
||||
module = self.module_stack.pop()
|
||||
if not module["has_meaningful_content"]:
|
||||
self.empty_modules.add(str(module["name"]))
|
||||
for table in self.table_stack:
|
||||
table["depth"] -= 1
|
||||
while self.table_stack and self.table_stack[-1]["depth"] == 0:
|
||||
table = self.table_stack.pop()
|
||||
if table["data_cells"] == 0:
|
||||
self.empty_table_count += 1
|
||||
if self.current_panel is not None:
|
||||
self.panel_depth -= 1
|
||||
if self.panel_depth == 0:
|
||||
self.current_panel = None
|
||||
|
||||
|
||||
def validate(root: Path) -> list[str]:
|
||||
errors: list[str] = []
|
||||
required = (root / "index.html", root / "archive.html", root / ".nojekyll")
|
||||
for path in required:
|
||||
if not path.exists():
|
||||
errors.append(f"missing built route: {path}")
|
||||
|
||||
manifest_path = root / "reports.json"
|
||||
if not manifest_path.is_file():
|
||||
errors.append(f"missing manifest: {manifest_path}")
|
||||
return errors
|
||||
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
latest = payload.get("latest")
|
||||
report_paths: list[Path] = []
|
||||
for report in payload.get("reports", []):
|
||||
report_file = root / str(report.get("path", "")) / "index.html"
|
||||
if not report_file.is_file():
|
||||
errors.append(f"missing report: {report_file}")
|
||||
else:
|
||||
report_paths.append(report_file)
|
||||
if not latest:
|
||||
errors.append("manifest latest is empty")
|
||||
|
||||
archive_path = root / "archive.html"
|
||||
if archive_path.is_file():
|
||||
archive_source = archive_path.read_text(encoding="utf-8")
|
||||
archive_parser = PageParser()
|
||||
archive_parser.feed(archive_source)
|
||||
if archive_parser.h1_count != 1:
|
||||
errors.append(
|
||||
f"{archive_path}: expected one h1, found {archive_parser.h1_count}"
|
||||
)
|
||||
if 'data-site-theme="notion-light"' not in archive_source:
|
||||
errors.append(f"{archive_path}: missing notion-light theme marker")
|
||||
report_link_count = archive_source.count('class="report-link"')
|
||||
if report_link_count != len(payload.get("reports", [])):
|
||||
errors.append(
|
||||
f"{archive_path}: expected one archive link per report, "
|
||||
f"found {report_link_count}"
|
||||
)
|
||||
if archive_parser.external_assets:
|
||||
errors.append(
|
||||
f"{archive_path}: external assets are not allowed: "
|
||||
f"{archive_parser.external_assets}"
|
||||
)
|
||||
|
||||
pages_to_validate = [root / "index.html", *report_paths]
|
||||
for index_path in pages_to_validate:
|
||||
if not index_path.is_file():
|
||||
continue
|
||||
parser = PageParser()
|
||||
parser.feed(index_path.read_text(encoding="utf-8"))
|
||||
if parser.h1_count != 1:
|
||||
errors.append(f"{index_path}: expected one h1, found {parser.h1_count}")
|
||||
if not 2 <= parser.tabs <= 4 or parser.tabs != parser.panels:
|
||||
errors.append(
|
||||
f"{index_path}: expected two to four matching report tabs/panels, "
|
||||
f"found {parser.tabs}/{parser.panels}"
|
||||
)
|
||||
if parser.tab_controls != parser.panel_ids:
|
||||
errors.append(f"{index_path}: tab aria-controls values do not match panel ids")
|
||||
if not {"panel-overview", "panel-method"}.issubset(parser.panel_ids):
|
||||
errors.append(f"{index_path}: overview and data panels are required")
|
||||
unexpected_panels = sorted(parser.panel_ids - ALLOWED_PANELS)
|
||||
if unexpected_panels:
|
||||
errors.append(f"{index_path}: unexpected panels: {unexpected_panels}")
|
||||
if "repository-summary" not in parser.module_names:
|
||||
errors.append(f"{index_path}: missing required repository-summary module")
|
||||
elif parser.module_panels.get("repository-summary") != {"panel-overview"}:
|
||||
errors.append(
|
||||
f"{index_path}: repository-summary must appear in panel-overview"
|
||||
)
|
||||
duplicate_modules = sorted(
|
||||
name for name, count in parser.module_counts.items() if count > 1
|
||||
)
|
||||
if duplicate_modules:
|
||||
errors.append(f"{index_path}: duplicate module names: {duplicate_modules}")
|
||||
if parser.empty_modules:
|
||||
errors.append(
|
||||
f"{index_path}: modules without meaningful content: "
|
||||
f"{sorted(parser.empty_modules)}"
|
||||
)
|
||||
if parser.empty_table_count:
|
||||
errors.append(
|
||||
f"{index_path}: empty tables without data cells: "
|
||||
f"{parser.empty_table_count}"
|
||||
)
|
||||
if parser.module_stack:
|
||||
errors.append(f"{index_path}: unclosed data-module element")
|
||||
if parser.table_stack:
|
||||
errors.append(f"{index_path}: unclosed table element")
|
||||
empty_panels = sorted(
|
||||
panel_id for panel_id, module_count in parser.panel_modules.items() if module_count == 0
|
||||
)
|
||||
if empty_panels:
|
||||
errors.append(f"{index_path}: panels without modules: {empty_panels}")
|
||||
if parser.duplicate_ids:
|
||||
errors.append(f"{index_path}: duplicate ids: {sorted(parser.duplicate_ids)}")
|
||||
if parser.external_assets:
|
||||
errors.append(f"{index_path}: external assets are not allowed: {parser.external_assets}")
|
||||
if "{{" in index_path.read_text(encoding="utf-8"):
|
||||
errors.append(f"{index_path}: unresolved template placeholder")
|
||||
return errors
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("site", type=Path, nargs="?", default=Path("_site"))
|
||||
args = parser.parse_args()
|
||||
errors = validate(args.site.resolve())
|
||||
for error in errors:
|
||||
print(f"ERROR: {error}")
|
||||
if errors:
|
||||
print(f"FAIL: {len(errors)} error(s)")
|
||||
return 1
|
||||
print(f"PASS: {args.site.resolve()}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
26
weekly/site/reports.json
Normal file
26
weekly/site/reports.json
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"latest": "2026-W31",
|
||||
"reports": [
|
||||
{
|
||||
"week": "2026-W31",
|
||||
"title": "SkillHub 开源周报|2026 年第 31 周",
|
||||
"period": "2026-07-23—2026-07-30",
|
||||
"snapshot": "2026-07-30 16:36 Asia/Shanghai",
|
||||
"path": "reports/2026-W31/"
|
||||
},
|
||||
{
|
||||
"week": "2026-W30",
|
||||
"title": "SkillHub 开源周报|2026 年第 30 周",
|
||||
"period": "2026-07-16—2026-07-23",
|
||||
"snapshot": "2026-07-23 22:00 Asia/Shanghai",
|
||||
"path": "reports/2026-W30/"
|
||||
},
|
||||
{
|
||||
"week": "2026-W29",
|
||||
"title": "SkillHub 开源周报|2026 年第 29 周",
|
||||
"period": "2026-07-09—2026-07-16",
|
||||
"snapshot": "2026-07-23 22:00 Asia/Shanghai",
|
||||
"path": "reports/2026-W29/"
|
||||
}
|
||||
]
|
||||
}
|
||||
1719
weekly/site/reports/2026-W29/index.html
Normal file
1719
weekly/site/reports/2026-W29/index.html
Normal file
File diff suppressed because it is too large
Load diff
1713
weekly/site/reports/2026-W30/index.html
Normal file
1713
weekly/site/reports/2026-W30/index.html
Normal file
File diff suppressed because it is too large
Load diff
1723
weekly/site/reports/2026-W31/index.html
Normal file
1723
weekly/site/reports/2026-W31/index.html
Normal file
File diff suppressed because it is too large
Load diff
4
weekly/source.json
Normal file
4
weekly/source.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"repository": "https://github.com/XiaoSeS/skillhub-weekly.git",
|
||||
"commit": "81cabe5ba7a73a938e06bb986d20aaed28fa9695"
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue