feat: rename to paperspec and add the website

- Rename manuspec to paperspec everywhere outside pixi.toml: prose, the
  tools, provenance tool names and the fixture key derivations, so every
  fixture and vector is rebuilt.
- Move the namespace addresses under the site, mirroring the repository:
  schema $id https://paperspec.scient.ing/schemas/<file>, the JSON-LD
  context https://paperspec.scient.ing/context/scienting-1.jsonld, and
  term IRIs https://paperspec.scient.ing/vocab#<term>. Format
  identifiers (scienting-*) and URNs (urn:scienting:) are unchanged.
- tools/site.py (standard library only) builds _site/: every file of the
  repository at its own path, each Markdown file rendered as a page with
  sidebar navigation, heading anchors matching the forge's, folder
  listings, a vocabulary page for the context's IRIs, a heading search
  and a 404 page; light and dark themes.
- check.py builds the site and checks that every Markdown file has a
  page, every internal link and fragment resolves, no Markdown is left
  unrendered, and every $id, the context address and the vocabulary IRIs
  are files of the site.
- deploy/: a Caddyfile with the media types and CORS the data needs, and
  a guide; .gitea/workflows/site.yml builds on main and publishes with
  rsync when the SITE_* secrets are set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
alexmandClaude committed 2026-10-14 21:27:21 -04:00
1 parent ea0a7efa78
commit 76d76353d9
213 files changed
+1233 -386

No files matched your search

+85 -2
View File
@@ -37,7 +37,7 @@ from tools.oracle.tree import Refusal # noqa: E402
FAILS = []
# Folders that are not part of the repository: version control, environments and caches.
SKIP_DIRS = {".git", ".pixi", "node_modules", "__pycache__"}
SKIP_DIRS = {".git", ".pixi", "node_modules", "__pycache__", "_site", ".claude"}
COUNTS = {}
@@ -852,7 +852,7 @@ COUNT_PATTERNS = [
(NUM + r" cases in all\b", "cases"),
]
# Where the prose names the specification's own version; other standards' versions are not checked.
VERSION_PATTERN = r"\b(?:manuspec|the formats are|the specification is at|this is|it is|is at|at) version (\d+\.\d+\.\d+)\b|\bmanuspec (\d+\.\d+\.\d+)\b"
VERSION_PATTERN = r"\b(?:paperspec|the formats are|the specification is at|this is|it is|is at|at) version (\d+\.\d+\.\d+)\b|\bpaperspec (\d+\.\d+\.\d+)\b"
def check_counts():
@@ -907,6 +907,88 @@ def check_counts():
% (rel, i, word, what, have[what]))
def check_site():
"""The website builds, every Markdown file has a page, every internal link and fragment resolves, no Markdown
is left unrendered, and the namespace addresses the formats name are files the site serves."""
from html.parser import HTMLParser
from tools import site
class Page(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=True)
self.ids, self.links, self.text, self.skip = set(), [], [], 0
def handle_starttag(self, tag, attrs):
a = dict(attrs)
if "id" in a:
self.ids.add(a["id"])
for k in ("href", "src"):
if a.get(k):
self.links.append(a[k])
if tag in ("code", "pre", "script", "style"):
self.skip += 1
def handle_endtag(self, tag):
if tag in ("code", "pre", "script", "style"):
self.skip -= 1
def handle_data(self, data):
if not self.skip:
self.text.append(data)
with tempfile.TemporaryDirectory() as tmp:
out = os.path.join(tmp, "site")
site.build(out)
pages = {}
for d, _, fs in os.walk(out):
for f in fs:
if f.endswith(".html"):
pg = Page()
with open(os.path.join(d, f), encoding="utf-8") as fh:
pg.feed(fh.read())
pages[os.path.relpath(os.path.join(d, f), out)] = pg
for d, dirs, fs in os.walk(ROOT):
dirs[:] = [x for x in dirs if x not in SKIP_DIRS]
for f in fs:
if f.endswith(".md"):
md = os.path.relpath(os.path.join(d, f), ROOT)
ok("site", site.page_path(md) in pages, "%s has no page" % md)
for name, pg in sorted(pages.items()):
if name == "404.html":
continue
# Markdown syntax left in a single text node means a construct the renderer did not recognize.
bad = [m.group(0) for chunk in pg.text
for m in re.finditer(r"\]\(|\*\*|`|\n *(?:\||: |#{1,6} |[-*] |\d+\. )", chunk)]
ok("site", not bad, "%s: unrendered Markdown %r" % (name, sorted(set(bad))[:3]))
for link in pg.links:
if re.match(r"^[a-z][a-z0-9+.-]*:", link) or link.startswith("//"):
continue
path, _, frag = link.partition("#")
target = os.path.normpath(os.path.join(os.path.dirname(name), path)) if path else name
full = os.path.join(out, target)
if os.path.isdir(full):
target = os.path.join(target, "index.html")
full = os.path.join(out, target)
ok("site", os.path.exists(full), "%s: link to a missing file %s" % (name, link))
if frag and target in pages:
ok("site", frag in pages[target].ids, "%s: no #%s in %s" % (name, frag, target))
# The addresses the formats name are files of the site.
for f in SCHEMA_FILES:
sid = STORE.load(f)["$id"]
ok("site", sid.startswith(site.BASE_URL + "/") and
os.path.exists(os.path.join(out, sid[len(site.BASE_URL) + 1:])), "%s is not served at its $id" % f)
ctx_url = jl(os.path.join(p("fixtures", "records", "layer"), "annotations.jsonl"))[0]["@context"][1]
ok("site", ctx_url.startswith(site.BASE_URL + "/") and
os.path.exists(os.path.join(out, ctx_url[len(site.BASE_URL) + 1:])), "the context is not served at %s" % ctx_url)
vocab = js(p("context", "scienting-1.jsonld"))["@context"]["sct"]
ok("site", vocab == site.BASE_URL + "/vocab#", "the vocabulary IRI %s is not the site's vocab page" % vocab)
terms = [v if isinstance(v, str) else v["@id"] for k, v in js(p("context", "scienting-1.jsonld"))["@context"].items()
if not k.startswith("@")]
for t in terms:
if t.startswith("sct:"):
ok("site", t[4:] in pages["vocab/index.html"].ids, "the vocabulary page has no #%s" % t[4:])
def main():
import argparse
ap = argparse.ArgumentParser(
@@ -932,6 +1014,7 @@ def main():
check_counts()
check_prose()
check_links()
check_site()
for k in sorted(COUNTS):
print("%-17s %5d checks" % (k, COUNTS[k]))
if FAILS: