Skip to content

Commit d3a3cf9

Browse files
authored
Merge pull request #191 from dotnetprojects/codex/documentation-site
Build detailed Classic and Fluent documentation and redesign homepage
2 parents a56b6e7 + de356a9 commit d3a3cf9

57 files changed

Lines changed: 4701 additions & 1372 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.gitattributes‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
11
*.sh text eol=lf
2+
docs/index.html linguist-generated=true
3+
docs/guide/*.html linguist-generated=true
4+
docs/assets/search-index.json linguist-generated=true

‎.github/scripts/build-docs.py‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Build the dependency-free documentation site. Generated HTML is committed for easy preview."""
2+
import argparse
3+
import html
4+
import hashlib
5+
import importlib.util
6+
import json
7+
from pathlib import Path
8+
import re
9+
10+
ROOT = Path(__file__).resolve().parents[2]
11+
DOCS = ROOT / "docs"
12+
spec = importlib.util.spec_from_file_location("documentation_content", DOCS / "_src/content.py")
13+
content = importlib.util.module_from_spec(spec)
14+
spec.loader.exec_module(content)
15+
16+
17+
def slug(value):
18+
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
19+
20+
21+
def highlight(code, kind):
22+
if kind == "shell":
23+
return html.escape(code)
24+
tokens = re.compile(r'//[^\n]*|"(?:\\.|[^"\\])*"|\b(?:using|var|new|public|class|static|override|void|if|foreach|in|return|throw|false|true|null|typeof|params|string|object|int)\b|\b\d+\b')
25+
result, end = [], 0
26+
for match in tokens.finditer(code):
27+
result.append(html.escape(code[end:match.start()]))
28+
value = match.group()
29+
css = "comment" if value.startswith("//") else "string" if value.startswith('"') else "number" if value.isdigit() else "keyword"
30+
result.append(f'<span class="syntax-{css}">{html.escape(value)}</span>')
31+
end = match.end()
32+
result.append(html.escape(code[end:]))
33+
return ''.join(result)
34+
35+
36+
def example_html(example, instance):
37+
eid = slug(instance + "-" + example["id"])
38+
label = "Inside Up() / BuildUp(MigrationBuilder migration)" if example["kind"] == "body" else "Shared commands · both styles" if example["kind"] == "shell" else "Shared host · both styles" if example["classic"] == example["fluent"] else "Choose one authoring style"
39+
controls = ''.join(f'<button type="button" id="{eid}-{style}-tab" data-style="{style}" aria-controls="{eid}-{style}">{style.title()}</button>' for style in ("classic", "fluent"))
40+
panels = []
41+
for style in ("classic", "fluent"):
42+
panels.append(f'''<section class="code-panel" id="{eid}-{style}" data-code-style="{style}">
43+
<div class="code-label"><h3>{style.title()}</h3><button type="button" data-copy="{eid}-{style}-code" hidden aria-label="Copy {style} example">Copy</button></div>
44+
<pre tabindex="0" aria-label="{html.escape(example['title'])}, {style}"><code id="{eid}-{style}-code">{highlight(example[style], example['kind'])}</code></pre></section>''')
45+
return f'''<div class="code-example" data-example="{example['id']}"><div class="code-toolbar"><span>{html.escape(example['title'])}</span><div class="code-tabs" aria-label="Code style" hidden>{controls}</div></div>{''.join(panels)}<p class="code-caption">{label}</p></div>'''
46+
47+
48+
def header(prefix, guide=False):
49+
return f'''<a class="skip" href="#main">Skip to content</a>
50+
<header class="site-header"><div class="header-inner">
51+
<a class="brand" href="{prefix}index.html" aria-label="Migrator.NET home"><span class="brand-mark" aria-hidden="true">m<span>↗</span></span>Migrator<span class="brand-suffix">.NET</span></a>
52+
<nav aria-label="Main navigation"><a href="{prefix}guide/index.html"{' aria-current="true"' if guide else ''}>Documentation</a><a href="{prefix}index.html#sqlite">SQLite</a><a href="{prefix}index.html#compare">Compare</a><a href="https://github.com/dotnetprojects/Migrator.NET">GitHub ↗</a></nav>
53+
<div class="search-wrap" data-search-index="{prefix}assets/search-index.json" hidden><form role="search"><label class="sr-only" for="doc-search">Search documentation</label><input id="doc-search" type="search" placeholder="Search the manual…" autocomplete="off" aria-controls="search-results" aria-describedby="search-status"><button aria-label="Search" type="submit">↵</button></form><div class="search-popover" hidden><p id="search-status" role="status"></p><ul id="search-results"></ul></div></div>
54+
</div></header>'''
55+
56+
57+
def footer(prefix):
58+
return f'''<footer class="site-footer"><a class="brand" href="{prefix}index.html">Migrator.NET</a><p>Explicit changes. A lasting record.<br>DotNetProjects · MPL-1.1</p><a href="{prefix}guide/quick-start.html">Write your first migration ↗</a></footer><span id="copy-status" class="sr-only" role="status" aria-live="polite"></span>'''
59+
60+
61+
def document(title, description, body, prefix="", body_class=""):
62+
css_version = hashlib.sha256((DOCS / "assets/site.css").read_text(encoding="utf-8").encode()).hexdigest()[:12]
63+
js_version = hashlib.sha256((DOCS / "assets/site.js").read_text(encoding="utf-8").encode()).hexdigest()[:12]
64+
return f'''<!doctype html>
65+
<!-- Generated by .github/scripts/build-docs.py; edit docs/_src/ instead. -->
66+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="{html.escape(description, quote=True)}"><meta name="theme-color" content="#f4f1e9"><title>{html.escape(title)} · Migrator.NET</title><link rel="icon" href="{prefix}assets/favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="{prefix}assets/site.css?v={css_version}"><script src="{prefix}assets/site.js?v={js_version}" defer></script></head><body class="{body_class}">{body}</body></html>
67+
'''
68+
69+
70+
def navigation(current):
71+
groups = list(dict.fromkeys(page['group'] for page in content.PAGES))
72+
result = ['<aside class="docs-sidebar"><details class="chapter-menu" open><summary>Browse the manual</summary><nav aria-label="Documentation chapters"><a class="manual-index" href="index.html">The migration manual</a>']
73+
for i, group in enumerate(groups):
74+
result.append(f'<div class="chapter-group"><h2><span>{i+1:02}</span> {html.escape(group)}</h2><ul>')
75+
for page in content.PAGES:
76+
if page['group'] == group:
77+
current_attr = ' aria-current="page"' if current == page["slug"] else ""
78+
result.append(f'<li><a href="{page["slug"]}.html"{current_attr}>{html.escape(page["title"])}</a></li>')
79+
result.append('</ul></div>')
80+
return ''.join(result) + '</nav></details></aside>'
81+
82+
83+
def render_page(page, index):
84+
sections = []
85+
for si, section in enumerate(page['sections']):
86+
blocks = [example_html(block, f'{page["slug"]}-{si}-{bi}') if isinstance(block, dict) else block for bi, block in enumerate(section['blocks'])]
87+
sections.append(f'<section id="{slug(section["title"])}"><h2>{html.escape(section["title"])}</h2>{"".join(blocks)}</section>')
88+
toc = '<aside class="page-toc"><p>On this page</p><nav aria-label="On this page">' + ''.join(f'<a href="#{slug(s["title"])}">{html.escape(s["title"])}</a>' for s in page['sections']) + '</nav></aside>'
89+
links = []
90+
for label, pos in (("Previous", index-1), ("Next", index+1)):
91+
if 0 <= pos < len(content.PAGES):
92+
target = content.PAGES[pos]
93+
links.append(f'<a href="{target["slug"]}.html"><span>{label}</span>{html.escape(target["title"])} {"→" if label == "Next" else ""}</a>')
94+
article = f'''<main class="docs-article" id="main"><div class="breadcrumb"><a href="index.html">Manual</a><span>/</span>{html.escape(page['group'])}</div><h1>{html.escape(page['title'])}</h1><p class="lead">{html.escape(page['summary'])}</p><p class="sample-note">Examples use <strong>Classic</strong> and <strong>Fluent</strong> tabs. Your choice follows you through the manual.</p>{''.join(sections)}<div class="source-link"><a href="https://github.com/dotnetprojects/Migrator.NET/blob/master/{page['source']}">Implementation reference ↗</a><a href="https://github.com/dotnetprojects/Migrator.NET/blob/master/docs/_src/content.py">Improve this page ↗</a></div><nav class="page-turn" aria-label="Adjacent chapters">{''.join(links)}</nav></main>'''
95+
return document(page['title'], page['summary'], header('../', True) + '<div class="docs-layout">' + navigation(page['slug']) + article + toc + '</div>' + footer('../'), '../', 'documentation')
96+
97+
98+
def render_index():
99+
groups = list(dict.fromkeys(page['group'] for page in content.PAGES))
100+
items = []
101+
for i, group in enumerate(groups):
102+
items.append(f'<section class="manual-group"><h2><span>{i+1:02}</span>{html.escape(group)}</h2><ul>')
103+
for page in content.PAGES:
104+
if page['group'] == group:
105+
items.append(f'<li><a href="{page["slug"]}.html">{html.escape(page["title"])} <span>↗</span></a><p>{html.escape(page["summary"])}</p></li>')
106+
items.append('</ul></section>')
107+
main = f'<main id="main" class="manual-home"><p class="eyebrow">DOTNETPROJECTS / THE MIGRATION MANUAL</p><h1>Know what changes.<br><em>Know how it runs.</em></h1><p class="lead">From your first table to deployment locks and SQLite reconstruction. Practical guides with a Classic and Fluent example for every authoring task.</p><a class="button primary" href="quick-start.html">Start with a working example ↗</a><div class="manual-directory">{"".join(items)}</div></main>'
108+
return document('The migration manual', 'Detailed C# database migration documentation with Classic and Fluent examples.', header('../', True) + main + footer('../'), '../', 'documentation')
109+
110+
111+
def outputs():
112+
result = {DOCS / 'guide/index.html': render_index()}
113+
search = []
114+
for i, page in enumerate(content.PAGES):
115+
result[DOCS / 'guide' / (page['slug'] + '.html')] = render_page(page, i)
116+
prose = ' '.join(block if isinstance(block, str) else block['title'] + ' ' + block['classic'] + ' ' + block['fluent'] for section in page['sections'] for block in section['blocks'])
117+
search.append(dict(title=page['title'], group=page['group'], summary=page['summary'], url='guide/' + page['slug'] + '.html', text=html.unescape(re.sub('<[^>]+>', ' ', prose))))
118+
result[DOCS / 'assets/search-index.json'] = json.dumps(search, ensure_ascii=False, indent=2) + '\n'
119+
home = (DOCS / '_src/home.html').read_text(encoding='utf-8')
120+
for key, value in dict(header=header(''), footer=footer(''), hero=example_html(content.CREATE_USERS, 'hero'), install=example_html(content.INSTALL, 'install'), sqlite=example_html(content.SQLITE_ALTER, 'sqlite'), comparison=(DOCS / '_src/comparison.html').read_text(encoding='utf-8')).items():
121+
home = home.replace('{{' + key + '}}', value)
122+
result[DOCS / 'index.html'] = document('Database changes, written in C#', 'Classic and Fluent C# migrations. Automatic SQLite schema reconstruction. A complete manual for your next database change.', home, body_class='homepage')
123+
return result
124+
125+
126+
if __name__ == '__main__':
127+
parser = argparse.ArgumentParser()
128+
parser.add_argument('--check', action='store_true', help='Fail if generated files are stale.')
129+
args = parser.parse_args()
130+
stale = []
131+
for path, text in outputs().items():
132+
if args.check:
133+
if not path.exists() or path.read_text(encoding='utf-8') != text:
134+
stale.append(str(path.relative_to(ROOT)))
135+
else:
136+
path.parent.mkdir(parents=True, exist_ok=True)
137+
path.write_text(text, encoding='utf-8', newline='\n')
138+
if stale:
139+
raise SystemExit('Regenerate docs: ' + ', '.join(stale))
140+
print(f'{"Checked" if args.check else "Built"} {len(content.PAGES)} chapters, documentation index, homepage and search index.')

‎.github/scripts/test-docs.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""Regression checks for documentation paths on both Windows and Linux."""
2+
import importlib.util
3+
from pathlib import Path
4+
import tempfile
5+
import unittest
6+
7+
spec = importlib.util.spec_from_file_location("verify_docs", Path(__file__).with_name("verify-docs.py"))
8+
verifier = importlib.util.module_from_spec(spec)
9+
spec.loader.exec_module(verifier)
10+
11+
12+
class ExactPathTests(unittest.TestCase):
13+
def test_checks_file_and_directory_case_and_relative_links(self):
14+
with tempfile.TemporaryDirectory() as directory:
15+
root = Path(directory)
16+
(root / "Mysql").mkdir()
17+
(root / "Mysql/MySqlTransformationProvider.cs").write_text("", encoding="utf-8")
18+
(root / "index.html").write_text("", encoding="utf-8")
19+
for relative, expected in (
20+
("Mysql/MySqlTransformationProvider.cs", True),
21+
("Mysql/MysqlTransformationProvider.cs", False),
22+
("mysql/MySqlTransformationProvider.cs", False),
23+
("Mysql/Missing.cs", False),
24+
("Mysql/../index.html", True),
25+
("Mysql/../Index.html", False),
26+
):
27+
with self.subTest(path=relative):
28+
self.assertEqual(verifier.exact_path_exists(root, relative), expected)
29+
30+
31+
if __name__ == "__main__":
32+
unittest.main()

0 commit comments

Comments
 (0)