"""
All the drafts are built by the build-specs workflow itself.
This handles the rest of the work:
* creates an index page listing all specs
* creates symlinks for unlevelled urls, linking to the appropriate levelled folder
* builds timestamps.json, which provides metadata about the specs
"""
import glob
import json
import os
import os.path
import re
import subprocess
from collections import defaultdict
from datetime import datetime, timezone
import bikeshed
from html.parser import HTMLParser
def title_from_html(file):
class HTMLTitleParser(HTMLParser):
def __init__(self):
super().__init__()
self.in_title = False
self.title = ""
self.done = False
def handle_starttag(self, tag, attrs):
if tag == "title":
self.in_title = True
def handle_data(self, data):
if self.in_title:
self.title += data
def handle_endtag(self, tag):
if tag == "title" and self.in_title:
self.in_title = False
self.done = True
self.reset()
parser = HTMLTitleParser()
with open(file, encoding="UTF-8") as f:
for line in f:
parser.feed(line)
if parser.done:
break
if not parser.done:
parser.close()
return parser.title if parser.done else None
def get_date_authored_timestamp_from_git(path):
source = os.path.realpath(path)
proc = subprocess.run(["git", "log", "-1", "--format=%at", source],
capture_output = True, encoding = "utf_8")
return int(proc.stdout.splitlines()[-1])
def get_bs_spec_metadata(folder_name, path):
spec = bikeshed.Spec(path)
spec.assembleDocument()
level = int(spec.md.level) if spec.md.level else 0
if spec.md.shortname == "css-animations-2":
shortname = "css-animations"
elif spec.md.shortname == "css-gcpm-4":
shortname = "css-gcpm"
elif spec.md.shortname == "css-transitions-2":
shortname = "css-transitions"
elif spec.md.shortname == "scroll-animations-1":
shortname = "scroll-animations"
else:
# Fix CSS snapshots (e.g. "css-2022")
snapshot_match = re.match(
"^css-(20[0-9]{2})$", spec.md.shortname)
if snapshot_match:
shortname = "css-snapshot"
level = int(snapshot_match.group(1))
else:
shortname = spec.md.shortname
return {
"timestamp": get_date_authored_timestamp_from_git(path),
"shortname": shortname,
"level": level,
"title": spec.md.title,
"workStatus": spec.md.workStatus
}
def get_html_spec_metadata(folder_name, path):
match = re.match("^([a-z0-9-]+)-([0-9]+)$", folder_name)
if match and match.group(1) == "css":
shortname = "css-snapshot"
title = f"CSS Snapshot {match.group(2)}"
else:
shortname = match.group(1) if match else folder_name
title = title_from_html(path)
return {
"shortname": shortname,
"level": int(match.group(2)) if match else 0,
"title": title,
"workStatus": "completed" # It's a good heuristic
}
def create_symlink(shortname, spec_folder):
"""Creates a symlink pointing to the given .
"""
if spec_folder in timestamps:
timestamps[shortname] = timestamps[spec_folder]
try:
os.symlink(spec_folder, shortname)
except OSError:
pass
def format_timestamp(ts):
"""Format a Unix timestamp as a human-readable date string."""
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
def escape_html(text):
"""Escape HTML special characters."""
return (text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """))
CURRENT_WORK_EXCEPTIONS = {
"compositing": 2,
"css-conditional": 5,
"css-easing": 2,
"css-grid": 2,
"css-snapshot": None, # always choose the last spec
"css-values": 4,
"css-writing-modes": 4,
"web-animations": 2
}
# ------------------------------------------------------------------------------
bikeshed.messages.state.dieOn = "nothing"
specgroups = defaultdict(list)
timestamps = defaultdict(list)
for entry in os.scandir("."):
if entry.is_dir(follow_symlinks=False):
# Not actual specs, just examples.
if entry.name in ["css-module"]:
continue
bs_file = os.path.join(entry.path, "Overview.bs")
html_file = os.path.join(entry.path, "Overview.html")
if os.path.exists(bs_file):
metadata = get_bs_spec_metadata(entry.name, bs_file)
timestamps[entry.name] = metadata["timestamp"]
elif os.path.exists(html_file):
metadata = get_html_spec_metadata(entry.name, html_file)
else:
# Not a spec
continue
metadata["dir"] = entry.name
metadata["currentWork"] = False
issues_files = sorted(f for f in glob.glob(os.path.join(entry.path, "issues-*.html"))
if not f.endswith(".bsi.html"))
metadata["issues"] = [os.path.basename(f) for f in issues_files]
specgroups[metadata["shortname"]].append(metadata)
# Reorder the specs with common shortname based on their level (or year, for
# CSS snapshots), and determine which spec is the current work.
for shortname, specgroup in specgroups.items():
if len(specgroup) == 1:
if shortname != specgroup[0]["dir"]:
create_symlink(shortname, specgroup[0]["dir"])
else:
specgroup.sort(key=lambda spec: spec["level"])
# TODO: This algorithm for determining which spec is the current work
# is wrong in a number of cases. Try and come up with a better
# algorithm, rather than maintaining a list of exceptions.
for spec in specgroup:
if shortname in CURRENT_WORK_EXCEPTIONS:
if CURRENT_WORK_EXCEPTIONS[shortname] == spec["level"]:
spec["currentWork"] = True
currentWorkDir = spec["dir"]
break
elif spec["workStatus"] != "completed":
spec["currentWork"] = True
currentWorkDir = spec["dir"]
break
else:
specgroup[-1]["currentWork"] = True
currentWorkDir = specgroup[-1]["dir"]
if shortname != currentWorkDir:
create_symlink(shortname, currentWorkDir)
if shortname == "css-snapshot":
create_symlink("css", currentWorkDir)
with open('./timestamps.json', 'w') as f:
json.dump(timestamps, f, indent = 2, sort_keys=True)
# Legacy path redirects
# These handle old URLs that were previously served by Apache RewriteRule redirects.
# For local targets, we use symlinks. For cross-origin targets (css-houdini.org),
# we create HTML files with meta refresh.
LEGACY_REDIRECTS = {
# Old name -> new name (local redirects via symlink)
"css-anchor-1": "css-anchor-position-1",
"css3-grid-align": "css-grid-1",
"css3-text-layout": "css-writing-modes-3",
"css3-2d-transforms": "css-transforms",
"css3-3d-transforms": "css-transforms",
"mediaqueries3": "mediaqueries-3",
"mediaqueries4": "mediaqueries-4",
"css-namespaces-1": "css-namespaces",
"css-snappoints-1": "css-scroll-snap-1",
"css-snappoints": "css-scroll-snap",
"css-snap-size-1": "css-rhythm-1",
"css-snap-size": "css-rhythm",
"css-step-sizing": "css-rhythm",
"css-containment": "css-contain",
"css-logical-props": "css-logical",
"css-device-adapt-1": "css-viewport-1",
"css-device-adapt": "css-viewport",
"css-overscroll-behavior-1": "css-overscroll-1",
"css-overscroll-behavior": "css-overscroll",
"css-shared-element-transitions-1": "css-view-transitions-1",
"css-shared-element-transitions": "css-view-transitions",
"css-timing-1": "css-easing-1",
"css-timing": "css-easing",
"css-scoping-2": "css-cascade-6",
}
# Cross-origin redirects need HTML meta refresh files
CROSS_ORIGIN_REDIRECTS = {
"cssom-values-1": "https://drafts.css-houdini.org/css-typed-om-1/",
"cssom-values": "https://drafts.css-houdini.org/css-typed-om/",
}
REDIRECT_HTML_TEMPLATE = """
Redirecting…
"""
for old_path, new_path in LEGACY_REDIRECTS.items():
if os.path.exists(new_path) and not os.path.exists(old_path):
try:
os.symlink(new_path, old_path)
except OSError:
pass
for old_path, url in CROSS_ORIGIN_REDIRECTS.items():
if not os.path.exists(old_path):
os.makedirs(old_path, exist_ok=True)
index_file = os.path.join(old_path, "index.html")
if not os.path.exists(index_file):
with open(index_file, "w", encoding="utf-8") as f:
f.write(REDIRECT_HTML_TEMPLATE.format(url=url))
# css2 subpage redirects: old CSS 2.1 chapter URLs redirect to the spec root
CSS2_SUBPAGES = [
"about.html", "aural.html", "box.html", "cascade.html", "changes.html",
"colors.html", "conform.html", "cover.html", "fonts.html", "generate.html",
"grammar.html", "indexlist.html", "intro.html", "leftblank.html", "media.html",
"page.html", "propidx.html", "refs.html", "sample.html", "selector.html",
"syndata.html", "tables.html", "text.html", "ui.html", "visudet.html",
"visufx.html", "visuren.html", "zindex.html",
]
for subpage in CSS2_SUBPAGES:
subpage_path = os.path.join("css2", subpage)
if not os.path.exists(subpage_path):
with open(subpage_path, "w", encoding="utf-8") as f:
f.write(REDIRECT_HTML_TEMPLATE.format(url="./"))
# Build the index page
# Flatten all specs into a single list with full metadata
all_specs = []
for shortname, specgroup in specgroups.items():
group_size = len(specgroup)
for spec in specgroup:
dir_name = spec["dir"]
ts = timestamps.get(dir_name, 0)
title = spec["title"] or dir_name
doc_links = []
for fname in spec.get("issues", []):
label = fname.replace("issues-", "").replace(".html", "")
doc_links.append((f"./{dir_name}/{fname}", label))
all_specs.append({
"shortname": shortname,
"dir": dir_name,
"title": title,
"ts": ts if isinstance(ts, int) else 0,
"currentWork": spec["currentWork"],
"level": spec["level"],
"group_size": group_size,
"doc_links": doc_links,
})
# Sort by timestamp descending (most recent first) for default view
all_specs.sort(key=lambda s: s["ts"], reverse=True)
# Generate HTML for each spec
REPO = "https://github.com/w3c/csswg-drafts"
spec_items = []
for spec in all_specs:
t = escape_html(spec["title"]).replace("Level ", "Level\u00a0")
d = spec["dir"]
sn = spec["shortname"]
ts = spec["ts"]
lv = spec["level"]
gs = spec["group_size"]
date_str = format_timestamp(ts) if ts else ""
iso_date = datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if ts else ""
cw = ' Current Work' if spec["currentWork"] else ""
links = [
f'Issues',
f'PRs',
f'History',
]
for url, label in spec["doc_links"]:
links.append(f'DoC: {escape_html(label)}')
links_html = ' \u00b7 '.join(links)
spec_items.append(
f'
\n'
f'
\n'
f' \n'
f' {t}{cw}\n'
f' {escape_html(d)}\n'
f' \n'
f' ›\n'
f'
\n'
f'
\n'
f'
{links_html}
\n'
f'
\n'
f'
'
)
specs_html = "\n".join(spec_items)
HTML_START = """\
CSS Working Group Editor Drafts
CSS Working Group Editor Drafts
"""
HTML_END = """\
No matching specifications.
"""
with open("./index.html", mode='w', encoding="UTF-8") as f:
f.write(HTML_START)
f.write(specs_html)
f.write(HTML_END)