diff --git a/.github/scripts/publish-docs.py b/.github/scripts/publish-docs.py new file mode 100644 index 0000000..5280f6c --- /dev/null +++ b/.github/scripts/publish-docs.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Publish a validated master commit. No content or credentials are printed.""" +import json +import os +import re +import time +import urllib.request + +BASE = 'https://invoiceshelf.com' +token = os.environ.get('CI_DOCS_TOKEN', '') +commit = os.environ.get('DOCS_COMMIT', '') +if not token or not re.fullmatch('[a-f0-9]{40}', commit): + raise SystemExit('Configure CI_DOCS_TOKEN and a full DOCS_COMMIT before publishing.') + +def request(path, data=None): + req = urllib.request.Request(BASE + path, + data=json.dumps(data).encode() if data is not None else None, + headers={'Authorization': 'Bearer ' + token, 'Accept': 'application/json', 'Content-Type': 'application/json'}) + with urllib.request.urlopen(req, timeout=30) as response: + return json.load(response) + +job = request('/api/docs/imports', {'commit': commit}) +if not isinstance(job.get('id'), int): + raise SystemExit('Unexpected import receipt.') +for _ in range(120): + state = request('/api/docs/imports/' + str(job['id'])) + if state['status'] in ('published', 'superseded'): + print('Documentation ' + state['status'] + ': ' + commit) + break + if state['status'] == 'failed': + raise SystemExit('Documentation import failed: ' + str(state.get('error', 'Check docs:status.'))) + time.sleep(5) +else: + raise SystemExit('Publication timed out. Inspect the docs worker and docs:status; the previous revision remains available.') diff --git a/.github/validator/ContentParser.php b/.github/validator/ContentParser.php new file mode 100644 index 0000000..1ce86fb --- /dev/null +++ b/.github/validator/ContentParser.php @@ -0,0 +1,351 @@ +read($root, 'docs.json'), true, flags: JSON_THROW_ON_ERROR); + $schema = $manifest['schema'] ?? null; + $catalog = $this->catalog($manifest); + $versioned = $schema === 2; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root.'/docs', \FilesystemIterator::SKIP_DOTS)); + $documents = $assets = $links = []; + foreach ($files as $file) { + if ($file->isLink()) { + throw new RuntimeException('Symlinks are not permitted in docs.'); + } + if ($file->getExtension() !== 'md') { + continue; + } + $path = substr($file->getPathname(), strlen($root) + 1); + $slug = substr($path, 5, -3); + $version = null; + if ($versioned) { + if (! preg_match('~^v([1-9][0-9]*)/(.+)$~', $slug, $match) || ! isset($catalog['versions'][$match[1]])) { + throw new RuntimeException("Document is outside a declared version: {$path}"); + } + $version = $match[1]; + $slug = $match[2]; + } + $documentKey = $this->key($slug, $version); + if (! preg_match('~^[a-z0-9][a-z0-9/-]*$~D', $slug) || str_contains($slug, '//') || in_array(explode('/', $slug)[0], ['search', 'ask', 'assets'], true)) { + throw new RuntimeException("Invalid document path: {$path}"); + } + [$meta, $body] = $this->frontMatter($this->read($root, $path)); + $versions = $meta['versions'] ?? []; + if (! is_array($versions) || array_diff($versions, ['2', '3']) || ($meta['lang'] ?? 'en') !== 'en') { + throw new RuntimeException("Invalid versions or language: {$path}"); + } + $versions = array_values(array_map('strval', $versions)); + sort($versions); + if ($versioned && ($versions !== [$version] || ($meta['reviewed_against'] ?? '') !== $catalog['versions'][$version]['source_commit'])) { + throw new RuntimeException("Document version/review baseline mismatch: {$path}"); + } + $hash = $this->reviewHash($body, $versions, (string) ($meta['title'] ?? '')); + $environment = new Environment(['html_input' => 'escape', 'allow_unsafe_links' => false, 'max_nesting_level' => 50]); + $environment->addExtension(new CommonMarkCoreExtension); + $environment->addExtension(new GithubFlavoredMarkdownExtension); + $ast = (new MarkdownParser($environment))->parse($body); + $headings = $seen = []; + $walker = $ast->walker(); + while ($event = $walker->next()) { + if (! $event->isEntering()) { + continue; + } + $node = $event->getNode(); + if ($node instanceof Heading) { + $label = $this->nodeText($node); + $base = $this->anchor($label); + $number = $seen[$base] ?? 0; + $seen[$base] = $number + 1; + $id = $base.($number ? '-'.$number : ''); + $node->data->set('attributes/id', $id); + $headings[] = ['id' => $id, 'text' => $label, 'level' => $node->getLevel()]; + } + if ($node instanceof Image || $node instanceof Link) { + $url = $node->getUrl(); + if (preg_match('~^https://docs\.invoiceshelf\.com(/.*)?$~', $url, $match)) { + $url = $match[1] ?? '/'; + if ($versioned && ! str_starts_with($url, '/images/')) { + $legacy = preg_replace('~\.(?:html|md)(?=#|$)~', '', ltrim($url, '/')); + [$legacySlug, $legacyAnchor] = array_pad(explode('#', $legacy, 2), 2, ''); + $url = '/docs/'.($catalog['redirects'][$legacySlug ?: 'index'] ?? 'v'.$catalog['default'].'/'.($legacySlug ?: 'index')).($legacyAnchor ? '#'.$legacyAnchor : ''); + } + } + if (preg_match('~^(?:https?://|mailto:)~i', $url)) { + if ($node instanceof Image) { + throw new RuntimeException("Store documentation images in the repository: {$path}"); + } + + continue; + } + if (preg_match('~^[a-z][a-z0-9+.-]*:|^//~i', $url)) { + throw new RuntimeException("Unsafe link in {$path}"); + } + [$target, $fragment] = array_pad(explode('#', $url, 2), 2, ''); + $target = explode('?', $target, 2)[0]; + $targetVersion = $version; + if ($versioned && preg_match('~^/docs/v([1-9][0-9]*)(?:/(.*))?$~', $target, $match)) { + $targetVersion = $match[1]; + $resolved = ($match[2] ?? '') ?: 'index'; + } else { + $resolved = $target === '' ? $slug.'.md' : $this->resolve($slug, rawurldecode($target)); + } + if ($node instanceof Image) { + if ($versioned && ! str_starts_with($resolved, 'images/v'.$version.'/')) { + throw new RuntimeException("Screenshot belongs outside v{$version}: {$resolved}"); + } + $source = str_starts_with($resolved, 'images/') ? 'docs/public/'.$resolved : 'docs/'.$resolved; + $assets[$resolved] ??= $this->asset($root, $source, $resolved); + if ($versioned) { + $capture = json_decode($this->read($root, $source.'.capture.json'), true, flags: JSON_THROW_ON_ERROR); + $book = $catalog['versions'][$version]; + if (($capture['app'] ?? '') !== 'v'.$version || ($capture['source_revision'] ?? '') !== $book['source_commit'] || ($capture['source_version'] ?? '') !== $book['release'] || ($capture['source_dirty'] ?? true)) { + throw new RuntimeException("Screenshot version or release does not match {$path}: {$source}"); + } + } + $node->setUrl('/docs/assets/'.$assets[$resolved]['hash']); + } else { + $targetSlug = preg_replace('~\.(?:md|html)$~', '', rtrim($resolved, '/')) ?: 'index'; + $links[] = [$documentKey, $this->key($targetSlug, $targetVersion), rawurldecode($fragment)]; + $node->setUrl($this->url($targetSlug, $targetVersion).($fragment !== '' ? '#'.$fragment : '')); + } + } + } + $title = $meta['title'] ?? ($headings[0]['text'] ?? null); + if (! is_string($title) || trim($title) === '' || mb_strlen($title) > 255) { + throw new RuntimeException("Missing or invalid title: {$path}"); + } + $renderer = new HtmlRenderer($environment); + $html = (string) $renderer->renderDocument($ast); + foreach (($meta['anchor_aliases'] ?? []) as $alias => $targetId) { + if (! preg_match('/^[a-z0-9_-]+$/D', $alias) || ! in_array($targetId, array_column($headings, 'id'), true) || in_array($alias, array_column($headings, 'id'), true)) { + throw new RuntimeException("Invalid heading alias: {$path}"); + } + $html = preg_replace('~(