Skip to content

Commit 8dd30d1

Browse files
Add GitHub Pages autopublishing workflow (#1165)
Build all bikeshed specs, convert markdown documents to HTML, generate an index page with symlinks, and deploy to GitHub Pages on each push to main. Relates to w3c/csswg-drafts#12054
1 parent 0b7adca commit 8dd30d1

File tree

4 files changed

+398
-0
lines changed

4 files changed

+398
-0
lines changed

.github/workflows/build-specs.yml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Spec Deployment
2+
on:
3+
push:
4+
branches: [ "main" ]
5+
# Allows you to run this workflow manually from the Actions tab
6+
workflow_dispatch:
7+
8+
permissions:
9+
contents: read
10+
pages: write
11+
id-token: write
12+
13+
# Allow one concurrent deployment
14+
concurrency:
15+
group: "pages"
16+
cancel-in-progress: true
17+
18+
jobs:
19+
build-specs:
20+
runs-on: ubuntu-latest
21+
22+
environment:
23+
name: github-pages
24+
url: ${{ steps.deployment.outputs.page_url }}
25+
26+
steps:
27+
- uses: actions/checkout@v3
28+
with:
29+
fetch-depth: 0
30+
31+
- uses: actions/setup-python@v4
32+
with:
33+
python-version: "3.14"
34+
cache: 'pip'
35+
36+
- run: pip install bikeshed markdown
37+
- run: bikeshed update
38+
39+
- name: Build specs
40+
run: |
41+
set -e
42+
# Handle non-bikeshed specs.
43+
for file in ./**/Overview.html; do
44+
cp "$file" "$(dirname "$file")/index.html"
45+
done
46+
# Handle bikeshed specs.
47+
for file in ./**/Overview.bs; do
48+
# We use `date` to build a YYYY-MM-DD date rather than using git's
49+
# `--format=%as` because we want the date not to depend on the
50+
# committer's timezone. We use UTC to avoid depending on the build
51+
# runner's timezone as well.
52+
echo "==============================================================="
53+
echo "Building $file"
54+
TIMESTAMP="$(git log -1 --format=%at "$file")"
55+
SHORT_DATE="$(date --date=@"$TIMESTAMP" --utc +%F)"
56+
bikeshed -f spec "$file" "${file%Overview.bs}index.html" --md-date="$SHORT_DATE"
57+
done
58+
- name: Build markdown
59+
run: python ./bin/build-markdown.py
60+
- name: Build index & symlinks
61+
run: python ./bin/build-index.py
62+
- run: rm -rf ./.git{,attributes,ignore}
63+
64+
- name: Setup Pages
65+
uses: actions/configure-pages@v2
66+
- name: Upload artifact
67+
uses: actions/upload-pages-artifact@v3
68+
with:
69+
# Upload entire repository
70+
path: '.'
71+
- name: Deploy to GitHub Pages
72+
id: deployment
73+
uses: actions/deploy-pages@v4

bin/build-index.py

Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
"""
2+
All the drafts are built by the build-specs workflow itself.
3+
This handles the rest of the work:
4+
5+
* creates an index page listing all specs
6+
* creates symlinks for unlevelled urls, linking to the appropriate levelled folder
7+
* builds timestamps.json, which provides metadata about the specs
8+
"""
9+
10+
import json
11+
import os
12+
import os.path
13+
import re
14+
import subprocess
15+
from collections import defaultdict
16+
from datetime import datetime, timezone
17+
18+
import bikeshed
19+
from html.parser import HTMLParser
20+
21+
22+
def title_from_html(file):
23+
class HTMLTitleParser(HTMLParser):
24+
def __init__(self):
25+
super().__init__()
26+
self.in_title = False
27+
self.title = ""
28+
self.done = False
29+
30+
def handle_starttag(self, tag, attrs):
31+
if tag == "title":
32+
self.in_title = True
33+
34+
def handle_data(self, data):
35+
if self.in_title:
36+
self.title += data
37+
38+
def handle_endtag(self, tag):
39+
if tag == "title" and self.in_title:
40+
self.in_title = False
41+
self.done = True
42+
self.reset()
43+
44+
parser = HTMLTitleParser()
45+
with open(file, encoding="UTF-8") as f:
46+
for line in f:
47+
parser.feed(line)
48+
if parser.done:
49+
break
50+
if not parser.done:
51+
parser.close()
52+
53+
return parser.title if parser.done else None
54+
55+
56+
def get_date_authored_timestamp_from_git(path):
57+
source = os.path.realpath(path)
58+
proc = subprocess.run(["git", "log", "-1", "--format=%at", source],
59+
capture_output=True, encoding="utf_8")
60+
return int(proc.stdout.splitlines()[-1])
61+
62+
63+
def get_bs_spec_metadata(folder_name, path):
64+
spec = bikeshed.Spec(path)
65+
spec.assembleDocument()
66+
67+
level = int(spec.md.level) if spec.md.level else 0
68+
shortname = spec.md.shortname
69+
70+
return {
71+
"timestamp": get_date_authored_timestamp_from_git(path),
72+
"shortname": shortname,
73+
"level": level,
74+
"title": spec.md.title,
75+
"workStatus": spec.md.workStatus
76+
}
77+
78+
79+
def get_html_spec_metadata(folder_name, path):
80+
match = re.match("^([a-z0-9-]+)-([0-9]+)$", folder_name)
81+
shortname = match.group(1) if match else folder_name
82+
title = title_from_html(path)
83+
84+
return {
85+
"shortname": shortname,
86+
"level": int(match.group(2)) if match else 0,
87+
"title": title,
88+
"workStatus": "completed" # It's a good heuristic
89+
}
90+
91+
92+
def create_symlink(shortname, spec_folder):
93+
"""Creates a <shortname> symlink pointing to the given <spec_folder>."""
94+
95+
if spec_folder in timestamps:
96+
timestamps[shortname] = timestamps[spec_folder]
97+
98+
try:
99+
os.symlink(spec_folder, shortname)
100+
except OSError:
101+
pass
102+
103+
104+
def format_timestamp(ts):
105+
"""Format a Unix timestamp as a human-readable date string."""
106+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
107+
return dt.strftime("%Y-%m-%d")
108+
109+
110+
def escape_html(text):
111+
"""Escape HTML special characters."""
112+
return (text
113+
.replace("&", "&amp;")
114+
.replace("<", "&lt;")
115+
.replace(">", "&gt;")
116+
.replace('"', "&quot;"))
117+
118+
119+
CURRENT_WORK_EXCEPTIONS = {}
120+
121+
# ------------------------------------------------------------------------------
122+
123+
124+
bikeshed.messages.state.dieOn = "nothing"
125+
126+
specgroups = defaultdict(list)
127+
timestamps = defaultdict(list)
128+
129+
for entry in os.scandir("."):
130+
if entry.is_dir(follow_symlinks=False):
131+
bs_file = os.path.join(entry.path, "Overview.bs")
132+
html_file = os.path.join(entry.path, "Overview.html")
133+
if os.path.exists(bs_file):
134+
metadata = get_bs_spec_metadata(entry.name, bs_file)
135+
timestamps[entry.name] = metadata["timestamp"]
136+
elif os.path.exists(html_file):
137+
metadata = get_html_spec_metadata(entry.name, html_file)
138+
else:
139+
# Not a spec
140+
continue
141+
142+
metadata["dir"] = entry.name
143+
metadata["currentWork"] = False
144+
specgroups[metadata["shortname"]].append(metadata)
145+
146+
# Reorder the specs with common shortname based on their level,
147+
# and determine which spec is the current work.
148+
for shortname, specgroup in specgroups.items():
149+
if len(specgroup) == 1:
150+
if shortname != specgroup[0]["dir"]:
151+
create_symlink(shortname, specgroup[0]["dir"])
152+
else:
153+
specgroup.sort(key=lambda spec: spec["level"])
154+
155+
for spec in specgroup:
156+
if shortname in CURRENT_WORK_EXCEPTIONS:
157+
if CURRENT_WORK_EXCEPTIONS[shortname] == spec["level"]:
158+
spec["currentWork"] = True
159+
currentWorkDir = spec["dir"]
160+
break
161+
elif spec["workStatus"] != "completed":
162+
spec["currentWork"] = True
163+
currentWorkDir = spec["dir"]
164+
break
165+
else:
166+
specgroup[-1]["currentWork"] = True
167+
currentWorkDir = specgroup[-1]["dir"]
168+
169+
if shortname != currentWorkDir:
170+
create_symlink(shortname, currentWorkDir)
171+
172+
with open('./timestamps.json', 'w') as f:
173+
json.dump(timestamps, f, indent=2, sort_keys=True)
174+
175+
# Build the index page
176+
rows = []
177+
for shortname in sorted(specgroups.keys()):
178+
specgroup = specgroups[shortname]
179+
for spec in specgroup:
180+
title = escape_html(spec["title"] or spec["dir"])
181+
level_suffix = f" Level {spec['level']}" if spec["level"] else ""
182+
current_label = ' <span class="current-work">(Current Work)</span>' if spec["currentWork"] else ""
183+
dir_name = spec["dir"]
184+
185+
ts = timestamps.get(dir_name)
186+
date_str = format_timestamp(ts) if ts else ""
187+
188+
rows.append(
189+
f' <tr>\n'
190+
f' <td><a href="./{dir_name}/">{title}</a>{current_label}</td>\n'
191+
f' <td>{date_str}</td>\n'
192+
f' </tr>'
193+
)
194+
195+
rows_html = "\n".join(rows)
196+
197+
with open("./index.html", mode='w', encoding="UTF-8") as f:
198+
f.write(f"""\
199+
<!doctype html>
200+
<html lang="en">
201+
<head>
202+
<meta charset="utf-8">
203+
<title>CSS Houdini Task Force Editor Drafts</title>
204+
<style>
205+
body {{
206+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
207+
max-width: 900px;
208+
margin: 2em auto;
209+
padding: 0 1em;
210+
color: #333;
211+
}}
212+
h1 {{
213+
border-bottom: 1px solid #ccc;
214+
padding-bottom: 0.3em;
215+
}}
216+
table {{
217+
width: 100%;
218+
border-collapse: collapse;
219+
margin-top: 1em;
220+
}}
221+
th, td {{
222+
text-align: left;
223+
padding: 0.5em 0.75em;
224+
border-bottom: 1px solid #eee;
225+
}}
226+
th {{
227+
border-bottom: 2px solid #ccc;
228+
font-weight: 600;
229+
}}
230+
td:last-child {{
231+
white-space: nowrap;
232+
color: #666;
233+
}}
234+
a {{
235+
color: #0366d6;
236+
text-decoration: none;
237+
}}
238+
a:hover {{
239+
text-decoration: underline;
240+
}}
241+
.current-work {{
242+
color: #080;
243+
font-size: 0.9em;
244+
}}
245+
</style>
246+
</head>
247+
<body>
248+
<h1>CSS Houdini Task Force Editor Drafts</h1>
249+
<table>
250+
<thead>
251+
<tr>
252+
<th>Specification</th>
253+
<th>Last Update</th>
254+
</tr>
255+
</thead>
256+
<tbody>
257+
{rows_html}
258+
</tbody>
259+
</table>
260+
</body>
261+
</html>
262+
""")

bin/build-markdown.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
#!/usr/bin/env python3
2+
"""Convert markdown files in spec directories to HTML."""
3+
4+
import glob
5+
import os
6+
import re
7+
8+
import markdown
9+
10+
TEMPLATE = """\
11+
<!doctype html>
12+
<meta charset="utf-8">
13+
<title>{title}</title>
14+
<style>
15+
body {{ max-width: 50em; margin: 2em auto; padding: 0 1em; font-family: sans-serif; line-height: 1.6; }}
16+
code {{ background: #f4f4f4; padding: .1em .3em; border-radius: 3px; }}
17+
pre {{ background: #f4f4f4; padding: 1em; overflow: auto; border-radius: 3px; }}
18+
pre code {{ background: none; padding: 0; }}
19+
img {{ max-width: 100%; }}
20+
</style>
21+
{body}
22+
"""
23+
24+
25+
def extract_title(text):
26+
m = re.search(r"^#\s+(.+)", text, re.MULTILINE)
27+
return m.group(1).strip() if m else "Untitled"
28+
29+
30+
def main():
31+
md = markdown.Markdown(extensions=["fenced_code", "tables"])
32+
33+
for md_file in sorted(glob.glob("*/*.md")):
34+
if md_file.startswith("."):
35+
continue
36+
37+
html_file = os.path.splitext(md_file)[0] + ".html"
38+
if os.path.exists(html_file):
39+
continue
40+
41+
with open(md_file, encoding="utf-8") as f:
42+
text = f.read()
43+
44+
title = extract_title(text)
45+
body = md.convert(text)
46+
md.reset()
47+
48+
with open(html_file, "w", encoding="utf-8") as f:
49+
f.write(TEMPLATE.format(title=title, body=body))
50+
51+
print(f" {html_file}")
52+
53+
54+
if __name__ == "__main__":
55+
main()

0 commit comments

Comments
 (0)