""" 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…

This page has moved to {url}.

""" 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' \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)