Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
*.sh text eol=lf
docs/index.html linguist-generated=true
docs/guide/*.html linguist-generated=true
docs/assets/search-index.json linguist-generated=true
140 changes: 140 additions & 0 deletions .github/scripts/build-docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Build the dependency-free documentation site. Generated HTML is committed for easy preview."""
import argparse
import html
import hashlib
import importlib.util
import json
from pathlib import Path
import re

ROOT = Path(__file__).resolve().parents[2]
DOCS = ROOT / "docs"
spec = importlib.util.spec_from_file_location("documentation_content", DOCS / "_src/content.py")
content = importlib.util.module_from_spec(spec)
spec.loader.exec_module(content)


def slug(value):
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")


def highlight(code, kind):
if kind == "shell":
return html.escape(code)
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')
result, end = [], 0
for match in tokens.finditer(code):
result.append(html.escape(code[end:match.start()]))
value = match.group()
css = "comment" if value.startswith("//") else "string" if value.startswith('"') else "number" if value.isdigit() else "keyword"
result.append(f'<span class="syntax-{css}">{html.escape(value)}</span>')
end = match.end()
result.append(html.escape(code[end:]))
return ''.join(result)


def example_html(example, instance):
eid = slug(instance + "-" + example["id"])
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"
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"))
panels = []
for style in ("classic", "fluent"):
panels.append(f'''<section class="code-panel" id="{eid}-{style}" data-code-style="{style}">
<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>
<pre tabindex="0" aria-label="{html.escape(example['title'])}, {style}"><code id="{eid}-{style}-code">{highlight(example[style], example['kind'])}</code></pre></section>''')
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>'''


def header(prefix, guide=False):
return f'''<a class="skip" href="#main">Skip to content</a>
<header class="site-header"><div class="header-inner">
<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>
<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>
<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>
</div></header>'''


def footer(prefix):
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>'''


def document(title, description, body, prefix="", body_class=""):
css_version = hashlib.sha256((DOCS / "assets/site.css").read_text(encoding="utf-8").encode()).hexdigest()[:12]
js_version = hashlib.sha256((DOCS / "assets/site.js").read_text(encoding="utf-8").encode()).hexdigest()[:12]
return f'''<!doctype html>
<!-- Generated by .github/scripts/build-docs.py; edit docs/_src/ instead. -->
<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>
'''


def navigation(current):
groups = list(dict.fromkeys(page['group'] for page in content.PAGES))
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>']
for i, group in enumerate(groups):
result.append(f'<div class="chapter-group"><h2><span>{i+1:02}</span> {html.escape(group)}</h2><ul>')
for page in content.PAGES:
if page['group'] == group:
current_attr = ' aria-current="page"' if current == page["slug"] else ""
result.append(f'<li><a href="{page["slug"]}.html"{current_attr}>{html.escape(page["title"])}</a></li>')
result.append('</ul></div>')
return ''.join(result) + '</nav></details></aside>'


def render_page(page, index):
sections = []
for si, section in enumerate(page['sections']):
blocks = [example_html(block, f'{page["slug"]}-{si}-{bi}') if isinstance(block, dict) else block for bi, block in enumerate(section['blocks'])]
sections.append(f'<section id="{slug(section["title"])}"><h2>{html.escape(section["title"])}</h2>{"".join(blocks)}</section>')
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>'
links = []
for label, pos in (("Previous", index-1), ("Next", index+1)):
if 0 <= pos < len(content.PAGES):
target = content.PAGES[pos]
links.append(f'<a href="{target["slug"]}.html"><span>{label}</span>{html.escape(target["title"])} {"→" if label == "Next" else ""}</a>')
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>'''
return document(page['title'], page['summary'], header('../', True) + '<div class="docs-layout">' + navigation(page['slug']) + article + toc + '</div>' + footer('../'), '../', 'documentation')


def render_index():
groups = list(dict.fromkeys(page['group'] for page in content.PAGES))
items = []
for i, group in enumerate(groups):
items.append(f'<section class="manual-group"><h2><span>{i+1:02}</span>{html.escape(group)}</h2><ul>')
for page in content.PAGES:
if page['group'] == group:
items.append(f'<li><a href="{page["slug"]}.html">{html.escape(page["title"])} <span>↗</span></a><p>{html.escape(page["summary"])}</p></li>')
items.append('</ul></section>')
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>'
return document('The migration manual', 'Detailed C# database migration documentation with Classic and Fluent examples.', header('../', True) + main + footer('../'), '../', 'documentation')


def outputs():
result = {DOCS / 'guide/index.html': render_index()}
search = []
for i, page in enumerate(content.PAGES):
result[DOCS / 'guide' / (page['slug'] + '.html')] = render_page(page, i)
prose = ' '.join(block if isinstance(block, str) else block['title'] + ' ' + block['classic'] + ' ' + block['fluent'] for section in page['sections'] for block in section['blocks'])
search.append(dict(title=page['title'], group=page['group'], summary=page['summary'], url='guide/' + page['slug'] + '.html', text=html.unescape(re.sub('<[^>]+>', ' ', prose))))
result[DOCS / 'assets/search-index.json'] = json.dumps(search, ensure_ascii=False, indent=2) + '\n'
home = (DOCS / '_src/home.html').read_text(encoding='utf-8')
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():
home = home.replace('{{' + key + '}}', value)
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')
return result


if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--check', action='store_true', help='Fail if generated files are stale.')
args = parser.parse_args()
stale = []
for path, text in outputs().items():
if args.check:
if not path.exists() or path.read_text(encoding='utf-8') != text:
stale.append(str(path.relative_to(ROOT)))
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding='utf-8', newline='\n')
if stale:
raise SystemExit('Regenerate docs: ' + ', '.join(stale))
print(f'{"Checked" if args.check else "Built"} {len(content.PAGES)} chapters, documentation index, homepage and search index.')
32 changes: 32 additions & 0 deletions .github/scripts/test-docs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Regression checks for documentation paths on both Windows and Linux."""
import importlib.util
from pathlib import Path
import tempfile
import unittest

spec = importlib.util.spec_from_file_location("verify_docs", Path(__file__).with_name("verify-docs.py"))
verifier = importlib.util.module_from_spec(spec)
spec.loader.exec_module(verifier)


class ExactPathTests(unittest.TestCase):
def test_checks_file_and_directory_case_and_relative_links(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / "Mysql").mkdir()
(root / "Mysql/MySqlTransformationProvider.cs").write_text("", encoding="utf-8")
(root / "index.html").write_text("", encoding="utf-8")
for relative, expected in (
("Mysql/MySqlTransformationProvider.cs", True),
("Mysql/MysqlTransformationProvider.cs", False),
("mysql/MySqlTransformationProvider.cs", False),
("Mysql/Missing.cs", False),
("Mysql/../index.html", True),
("Mysql/../Index.html", False),
):
with self.subTest(path=relative):
self.assertEqual(verifier.exact_path_exists(root, relative), expected)


if __name__ == "__main__":
unittest.main()
Loading
Loading