|
| 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.') |
0 commit comments