diff --git a/.editorconfig b/.editorconfig index 7382bb8eb572..4d8854436cb8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,3 +13,7 @@ trim_trailing_whitespace = true [*.{yml,yaml}] indent_size = 2 + +# Two trailing spaces are a hard line break in the converted pages. +[docs/**.md] +trim_trailing_whitespace = false diff --git a/.gitattributes b/.gitattributes index 0ec474bf866d..ed268a11ca73 100644 --- a/.gitattributes +++ b/.gitattributes @@ -36,5 +36,7 @@ AGENTS.md export-ignore # source user guide user_guide_src/ export-ignore +docs/ export-ignore +mkdocs.yml export-ignore .nojekyll export-ignore phpdoc.dist.xml export-ignore diff --git a/.github/workflows/test-userguide.yml b/.github/workflows/test-userguide.yml index b7ef79e0170d..6cbc34d341a8 100644 --- a/.github/workflows/test-userguide.yml +++ b/.github/workflows/test-userguide.yml @@ -1,13 +1,18 @@ # When a Pull Request is opened that modifies # the User Guide source, build the User Guide -# with Sphinx and let the contributor know of -# any errors. +# with Sphinx or MkDocs and let the contributor +# know of any errors. name: Test User Guide on: pull_request: paths: - 'user_guide_src/**' + - 'docs/**' + - 'mkdocs.yml' + - 'admin/anchors.py' + - 'admin/llms.py' + - 'admin/update-docs-nav.php' - '.github/workflows/test-userguide.yml' concurrency: @@ -42,3 +47,42 @@ jobs: - name: Build Docs with Sphinx run: make html SPHINXOPTS="-W --keep-going -w /tmp/sphinx-log" working-directory: user_guide_src + + markdown_check: + name: Check Markdown User Guide + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup PHP + uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '8.2' + coverage: none + + - name: Setup Python + uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: '3.12' + + - name: Install MkDocs + run: | + python -m pip install --upgrade pip + pip install -r docs/requirements.txt + + - name: Check that every page is in the nav + run: php admin/update-docs-nav.php --check + + - name: Build Docs with MkDocs + env: + NO_MKDOCS_2_WARNING: '1' + run: mkdocs build --strict + + - name: Write the llms files and the anchor aliases + run: | + python admin/llms.py + python admin/anchors.py diff --git a/.gitignore b/.gitignore index 3e4a7c1d18f7..418eb60af297 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,9 @@ php_errors.log # User Guide Temp Files #------------------------- /user_guide_src/build/* +/site/ +/venv/ +__pycache__/ #------------------------- # Test Files diff --git a/admin/anchors.py b/admin/anchors.py new file mode 100644 index 000000000000..37a533abfca5 --- /dev/null +++ b/admin/anchors.py @@ -0,0 +1,42 @@ +"""Adds the legacy anchor ids listed in the docs' .anchors.json to the headings of the built site.""" + +from __future__ import annotations + +import html +import json +import re +import sys +from pathlib import Path + +from llms import load + + +def main(config_path: Path) -> None: + config = load(config_path) + base = config_path.parent + docs = base / config.get("docs_dir", "docs") + site = base / config.get("site_dir", "site") + aliases = json.loads((docs / ".anchors.json").read_text(encoding="utf-8")) + + for page, headings in aliases.items(): + target = site / (page.removesuffix(".md") + ".html") + + if not target.is_file(): + print(f"WARNING - {page} has aliases but was not built", file=sys.stderr) + continue + + content = target.read_text(encoding="utf-8") + + for heading, ids in headings.items(): + opening = re.compile(rf'(]*\bid="{re.escape(html.escape(heading))}"[^>]*>)') + anchors = "".join(f'' for id_ in ids) + content, count = opening.subn(lambda match: match.group(1) + anchors, content, count=1) + + if count == 0: + print(f"WARNING - {page} has no heading #{heading} for its aliases", file=sys.stderr) + + target.write_text(content, encoding="utf-8") + + +if __name__ == "__main__": + main(Path(sys.argv[1] if len(sys.argv) > 1 else "mkdocs.yml")) diff --git a/admin/llms.py b/admin/llms.py new file mode 100644 index 000000000000..5dae2c1dd465 --- /dev/null +++ b/admin/llms.py @@ -0,0 +1,95 @@ +"""Writes llms.txt, llms-full.txt and each page's Markdown beside its HTML in the built site.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +import yaml + + +class Loader(yaml.SafeLoader): + pass + + +Loader.add_multi_constructor("!", lambda loader, suffix, node: None) + + +def load(path: Path) -> dict: + return yaml.load(path.read_text(encoding="utf-8"), Loader=Loader) or {} + + +def title_of(markdown: Path) -> str: + fenced = False + + for line in markdown.read_text(encoding="utf-8").splitlines(): + if line.startswith("```"): + fenced = not fenced + elif not fenced and (match := re.match(r"# (.+?)\s*#*\s*$", line)): + return match.group(1) + + return markdown.stem + + +def index_lines(items: list, docs: Path, depth: int = 0) -> list[str]: + indent = " " * max(depth - 1, 0) + lines: list[str] = [] + + for item in items: + title, target = next(iter(item.items())) if isinstance(item, dict) else (None, item) + + if isinstance(target, list): + lines += ["", f"## {title}", ""] if depth == 0 else [f"{indent}- {title}"] + lines += index_lines(target, docs, depth + 1) + elif isinstance(target, str) and target.endswith(".md"): + lines.append(f"{indent}- [{title or title_of(docs / target)}]({target})") + + return lines + + +def nav_pages(items: list) -> list[str]: + pages: list[str] = [] + + for item in items: + target = next(iter(item.values())) if isinstance(item, dict) else item + + if isinstance(target, list): + pages += nav_pages(target) + elif isinstance(target, str) and target.endswith(".md"): + pages.append(target) + + return pages + + +def main(config_path: Path) -> None: + config = load(config_path) + base = config_path.parent + nav = config.get("nav") or load(base / config["INHERIT"]).get("nav", []) + docs = base / config.get("docs_dir", "docs") + site = base / config.get("site_dir", "site") + + in_nav = nav_pages(nav) + outside_nav = sorted( + path.relative_to(docs).as_posix() for path in docs.rglob("*.md") if path.relative_to(docs).as_posix() not in in_nav + ) + pages = in_nav + outside_nav + + for page in pages: + (site / page).write_text((docs / page).read_text(encoding="utf-8"), encoding="utf-8") + + index = [f"# {config['site_name']}", "", f"> {config['site_description']}", "", *index_lines(nav, docs)] + + if outside_nav: + index += ["", "## Other pages", ""] + index += [f"- [{title_of(docs / page)}]({page})" for page in outside_nav] + + (site / "llms.txt").write_text("\n".join(index) + "\n", encoding="utf-8") + (site / "llms-full.txt").write_text( + "\n\n".join((docs / page).read_text(encoding="utf-8").strip() for page in pages) + "\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main(Path(sys.argv[1] if len(sys.argv) > 1 else "mkdocs.yml")) diff --git a/admin/update-docs-nav.php b/admin/update-docs-nav.php new file mode 100644 index 000000000000..33ecfa89613b --- /dev/null +++ b/admin/update-docs-nav.php @@ -0,0 +1,246 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +/** + * Adds the pages of the user guide that are missing from its nav (docs/.nav.yml). + * + * A page goes into the section whose first page links to it, after the page + * that precedes it in that list. With --check, nothing is changed, and the + * script exits with 1 when a page is missing or a nav entry has no file. + * + * Usage: php admin/update-docs-nav.php [--check] [--docs=] + */ + +/** + * @return list Markdown pages under $docs, relative to it + */ +function docs_pages(string $docs): array +{ + $pages = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($docs, FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + $page = str_replace('\\', '/', substr($file->getPathname(), strlen($docs) + 1)); + + if ($file->getExtension() === 'md' && ! str_starts_with($page, 'overrides/')) { + $pages[] = $page; + } + } + + sort($pages); + + return $pages; +} + +/** + * @param list $lines + * + * @return array{indent: int, page: string|null, section: bool}|null + */ +function parse_nav_line(array $lines, int $i): ?array +{ + if (preg_match('/^(\s*)- (?:(?:"[^"]*"|[^:]+): )?([^\s:]+\.md)\s*$/', $lines[$i], $matches) === 1) { + return ['indent' => strlen($matches[1]), 'page' => $matches[2], 'section' => false]; + } + + if (preg_match('/^(\s*)- (?:"[^"]*"|[^:]+):\s*$/', $lines[$i], $matches) === 1) { + $head = isset($lines[$i + 1]) ? parse_nav_line($lines, $i + 1) : null; + + return ['indent' => strlen($matches[1]), 'page' => $head['page'] ?? null, 'section' => true]; + } + + return null; +} + +/** + * @param list $lines + * + * @return list + */ +function nav_pages(array $lines): array +{ + $pages = []; + + foreach (array_keys($lines) as $i) { + $entry = parse_nav_line($lines, $i); + + if ($entry !== null && ! $entry['section']) { + $pages[] = $entry['page']; + } + } + + return $pages; +} + +/** + * @return list Pages that $page links to, in order, as paths relative to $docs + */ +function linked_pages(string $docs, string $page): array +{ + preg_match_all('/\]\(([^)#\s]+\.md)(?:#[^)]*)?\)/', (string) file_get_contents($docs . '/' . $page), $matches); + + $links = []; + + foreach ($matches[1] as $link) { + if (str_contains($link, '://')) { + continue; + } + + $parts = []; + + foreach (explode('/', dirname($page) . '/' . $link) as $part) { + if ($part === '..') { + array_pop($parts); + } elseif ($part !== '.' && $part !== '') { + $parts[] = $part; + } + } + + $links[] = implode('/', $parts); + } + + return array_values(array_unique($links)); +} + +/** + * The index of the last line of the entry at $i, including the entries nested in it. + * + * @param list $lines + */ +function entry_end(array $lines, int $i): int +{ + $indent = parse_nav_line($lines, $i)['indent']; + $end = $i; + + for ($j = $i + 1; $j < count($lines); $j++) { + $entry = parse_nav_line($lines, $j); + + if ($entry === null || $entry['indent'] <= $indent) { + break; + } + + $end = $j; + } + + return $end; +} + +/** + * Inserts $page into $lines, returning the title of the section it went into, or null when no section links to it. + * + * @param list $lines + */ +function insert_page(array &$lines, string $docs, string $page): ?string +{ + $target = null; + + foreach (array_keys($lines) as $i) { + $section = parse_nav_line($lines, $i); + + if ($section === null || ! $section['section'] || $section['page'] === null) { + continue; + } + + if (! in_array($page, linked_pages($docs, $section['page']), true)) { + continue; + } + + // The index of an outer section also lists the pages of the sections nested in it. + if ($target === null || $section['indent'] > $target['indent']) { + $target = $section + ['line' => $i]; + } + } + + if ($target === null) { + return null; + } + + $order = linked_pages($docs, $target['page']); + $position = array_search($page, $order, true); + $after = $target['line'] + 1; + $childIndent = $target['indent'] + 4; + $end = entry_end($lines, $target['line']); + + for ($j = $target['line'] + 1; $j <= $end; $j++) { + $entry = parse_nav_line($lines, $j); + + if ($entry === null || $entry['indent'] !== $childIndent || $entry['page'] === null) { + continue; + } + + $before = array_search($entry['page'], $order, true); + + if ($j === $target['line'] + 1 || ($before !== false && $before < $position)) { + $after = entry_end($lines, $j); + } + } + + array_splice($lines, $after + 1, 0, [str_repeat(' ', $childIndent) . '- ' . $page]); + + return $target['page']; +} + +$check = in_array('--check', $argv, true); +$docs = 'docs'; + +foreach ($argv as $argument) { + if (str_starts_with($argument, '--docs=')) { + $docs = rtrim(substr($argument, strlen('--docs=')), '/'); + } +} + +$navFile = $docs . '/.nav.yml'; + +if (! is_file($navFile)) { + echo "{$navFile} is not found.\n"; + + exit(1); +} + +$lines = explode("\n", rtrim((string) file_get_contents($navFile), "\n")); +$pages = docs_pages($docs); +$missing = array_values(array_diff($pages, nav_pages($lines))); +$stale = array_values(array_diff(nav_pages($lines), $pages)); + +foreach ($stale as $page) { + echo "The nav lists {$page}, which does not exist.\n"; +} + +foreach ($missing as $page) { + if ($check) { + echo "{$page} is missing from the nav.\n"; + + continue; + } + + $section = insert_page($lines, $docs, $page); + + if ($section === null) { + $lines[] = ' - ' . $page; + echo "Added {$page} at the end of the nav, since no section links to it. Move it by hand.\n"; + } else { + echo "Added {$page} to the section of {$section}.\n"; + } +} + +if (! $check && $missing !== []) { + file_put_contents($navFile, implode("\n", $lines) . "\n"); +} + +if ($check && ($missing !== [] || $stale !== [])) { + echo "Run \"php admin/update-docs-nav.php\" to update docs/.nav.yml.\n"; +} + +exit($check && ($missing !== [] || $stale !== []) ? 1 : 0); diff --git a/docs/overrides/assets/css/code.css b/docs/overrides/assets/css/code.css new file mode 100644 index 000000000000..f9ec92f50951 --- /dev/null +++ b/docs/overrides/assets/css/code.css @@ -0,0 +1,98 @@ +/* The theme turns ligatures off in code. */ +.md-typeset code, +.md-typeset pre { + font-variant-ligatures: normal; +} + +/* Pygments marks most PHP identifiers Name.Other, which the theme leaves at body color. */ +.highlight .nx { + color: var(--md-code-hl-function-color); +} + +/* Light-scheme code colors matched to VS Code's Light Modern token palette, on the theme's own background. */ +[data-md-color-scheme="codeigniter"] { + --md-code-fg-color: #3b3b3b; + --md-code-hl-keyword-color: #0000ff; + --md-code-hl-function-color: #795e26; + --md-code-hl-string-color: #a31515; + --md-code-hl-number-color: #098658; + --md-code-hl-comment-color: #008000; + --md-code-hl-variable-color: #001080; + --md-code-hl-operator-color: #3b3b3b; + --md-code-hl-punctuation-color: #3b3b3b; + --md-code-hl-constant-color: #0070c1; + --md-code-hl-name-color: #3b3b3b; + --md-code-hl-special-color: #811f3f; +} + +[data-md-color-scheme="codeigniter"] .highlight .nc, +[data-md-color-scheme="codeigniter"] .highlight .nn, +[data-md-color-scheme="codeigniter"] .highlight .nx.nx-class { + color: #267f99; +} + +[data-md-color-scheme="codeigniter"] .highlight .si { + color: #001080; +} + +[data-md-color-scheme="codeigniter"] .highlight .k.k-control { + color: #af00db; +} + +[data-md-color-scheme="codeigniter"] .highlight .nv.nv-this { + color: #0000ff; +} + +[data-md-color-scheme="codeigniter"] .highlight .nx.nx-constant { + color: #0070c1; +} + +[data-md-color-scheme="codeigniter"] .highlight .na.na-call { + color: #795e26; +} + +/* Dark-scheme code colors matched to VS Code's Dark Modern token palette. */ +[data-md-color-scheme="slate"] { + --md-code-bg-color: #1f1f1f; + --md-code-fg-color: #cccccc; + --md-code-hl-keyword-color: #569cd6; + --md-code-hl-function-color: #dcdcaa; + --md-code-hl-string-color: #ce9178; + --md-code-hl-number-color: #b5cea8; + --md-code-hl-comment-color: #6a9955; + --md-code-hl-variable-color: #9cdcfe; + --md-code-hl-operator-color: #d4d4d4; + --md-code-hl-punctuation-color: #d4d4d4; + --md-code-hl-constant-color: #569cd6; + --md-code-hl-name-color: #cccccc; + --md-code-hl-special-color: #d16969; +} + +[data-md-color-scheme="slate"] .highlight .nc, +[data-md-color-scheme="slate"] .highlight .nn { + color: #4ec9b0; +} + +[data-md-color-scheme="slate"] .highlight .si { + color: #9cdcfe; +} + +[data-md-color-scheme="slate"] .highlight .k.k-control { + color: #c586c0; +} + +[data-md-color-scheme="slate"] .highlight .nv.nv-this { + color: #569cd6; +} + +[data-md-color-scheme="slate"] .highlight .nx.nx-class { + color: #4ec9b0; +} + +[data-md-color-scheme="slate"] .highlight .nx.nx-constant { + color: #9cdcfe; +} + +[data-md-color-scheme="slate"] .highlight .na.na-call { + color: #dcdcaa; +} diff --git a/docs/overrides/assets/css/codeigniter.css b/docs/overrides/assets/css/codeigniter.css new file mode 100644 index 000000000000..c63734fefead --- /dev/null +++ b/docs/overrides/assets/css/codeigniter.css @@ -0,0 +1,172 @@ +[data-md-color-scheme="codeigniter"] { + --md-primary-fg-color: #dd4814; + --md-primary-fg-color--light: #ECB7B7; + --md-primary-fg-color--dark: #90030C; + + --md-default-bg-color: #fcfcfc; + + --md-typeset-a-color: #e74c3c; + --md-accent-fg-color: #97310e; + + --md-accent-fg-color--transparent: #ECB7B7; + + /* Enhanced heading styles for better visibility */ + .md-typeset h1 { + font-weight: 700; + color: #1a202c; + border-bottom: 2px solid #dd4814; + padding-bottom: 0.3em; + } + + .md-typeset h2 { + font-weight: 600; + color: #2d3748; + border-bottom: 1px solid #cbd5e0; + padding-bottom: 0.2em; + } + + .md-typeset h3 { + font-weight: 600; + color: #1a202c; + } + + .md-typeset h4 { + font-weight: 600; + color: #2d3748; + } + + .md-typeset h5 { + text-transform: none; + font-weight: 600; + color: #1a202c; + } + + .md-typeset h6 { + font-weight: 600; + color: #2d3748; + } + + /* Inline code styling - orange color like links */ + .md-typeset p code, + .md-typeset li code, + .md-typeset td code, + .md-typeset th code { + border: 1px solid #e1e4e5; + color: #e74c3c; /* Same orange color as links */ + } + + /* Code blocks should keep default styling */ + .md-typeset pre code { + border: none; + } + + .md-typeset .admonition.important, + .md-typeset details.important { + border-color: #dd4814; + } + + .md-typeset .important > .admonition-title, + .md-typeset .important > summary { + background-color: #dd4814; + color: white; + } + + .md-typeset .important > .admonition-title:before, + .md-typeset .important > summary:before { + background-color: white; + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-image: var(--md-admonition-icon--warning); + } + + /* Note admonition styling */ + .md-typeset .admonition.note, + .md-typeset details.note { + border-color: #448aff; + } + + .md-typeset .note > .admonition-title, + .md-typeset .note > summary { + background-color: #448aff; + color: white; + } + + .md-typeset .note > .admonition-title:before, + .md-typeset .note > summary:before { + background-color: white; + -webkit-mask-image: var(--md-admonition-icon--note); + mask-image: var(--md-admonition-icon--note); + } + + /* Warning admonition styling */ + .md-typeset .admonition.warning, + .md-typeset details.warning { + border-color: #ff9800; + } + + .md-typeset .warning > .admonition-title, + .md-typeset .warning > summary { + background-color: #ff9800; + color: white; + } + + .md-typeset .warning > .admonition-title:before, + .md-typeset .warning > summary:before { + background-color: white; + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-image: var(--md-admonition-icon--warning); + } + + /* Image styling for better visibility in light mode */ + .md-typeset img { + border-color: #d1d4d8; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); + } + + /* Remove the shadow from inline images or icons */ + .md-typeset p img[src*="icon"], + .md-typeset p img[width="16"], + .md-typeset p img[width="24"], + .md-typeset p img[height="16"], + .md-typeset p img[height="24"] { + box-shadow: none; + } + + /* Table of Contents list styling */ + .md-typeset ul li { + margin-bottom: 0; /* Reduce vertical spacing between list items */ + margin-top: 0; + } + + /* Nested list bullet styles */ + .md-typeset ul { + list-style-type: disc; /* Top level uses solid disc */ + } + + .md-typeset ul ul { + list-style-type: circle; /* Second level uses outline circle */ + } + + .md-typeset ul ul ul { + list-style-type: square; /* Third level uses square */ + } + + /* Additional spacing adjustments for nested lists */ + .md-typeset ul ul { + margin-top: 0; + margin-bottom: 0; + } + + /* Navigation adjustments for better spacing in dark mode */ + + .md-nav[aria-expanded="true"] { + margin-bottom: 0.5rem !important; + } + + .md-nav .md-nav .md-nav__list { + padding-left: 1.2rem !important; + } + + .md-nav__item { + line-height: 1.4 !important; + } +} diff --git a/docs/overrides/assets/css/codeigniter_dark_mode.css b/docs/overrides/assets/css/codeigniter_dark_mode.css new file mode 100644 index 000000000000..8300abecae2d --- /dev/null +++ b/docs/overrides/assets/css/codeigniter_dark_mode.css @@ -0,0 +1,150 @@ +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #b13a10; + --md-primary-fg-color--light: #8d7474; + --md-primary-fg-color--dark: #6d554d; + + --md-default-bg-color: #1e2129; + + --md-typeset-a-color: #ed6436; + --md-accent-fg-color: #f18a67; + + --md-accent-fg-color--transparent: #625151; + + /* Enhanced heading styles for better visibility in dark mode */ + .md-typeset h1 { + font-weight: 700; + color: #f8f9fa; + border-bottom: 2px solid #ed6436; + padding-bottom: 0.3em; + } + + .md-typeset h2 { + font-weight: 600; + color: #e9ecef; + border-bottom: 1px solid #495057; + padding-bottom: 0.2em; + } + + .md-typeset h3 { + font-weight: 600; + color: #f8f9fa; + } + + .md-typeset h4 { + font-weight: 600; + color: #e9ecef; + } + + .md-typeset h5 { + text-transform: none; + font-weight: 600; + color: #f8f9fa; + } + + .md-typeset h6 { + font-weight: 600; + color: #e9ecef; + } + + /* Inline code styling - orange color like links */ + .md-typeset p code, + .md-typeset li code, + .md-typeset td code, + .md-typeset th code { + border: 1px solid #3f4547; + color: #ed6436; /* Same orange color as links */ + } + + /* Code blocks should keep default styling */ + .md-typeset pre code { + border: none; + } + + .md-typeset .admonition.note, + .md-typeset details.note { + border-color: #2c5293; + } + + .md-typeset .note > .admonition-title:before, + .md-typeset .note > summary:before { + background-color: #2c5293; + -webkit-mask-image: var(--md-admonition-icon--note); + mask-image: var(--md-admonition-icon--note); + } + + .md-typeset .admonition.warning, + .md-typeset details.warning { + border-color: #97631e; + } + + .md-typeset .warning > .admonition-title:before, + .md-typeset .warning > summary:before { + background-color: #97631e; + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-image: var(--md-admonition-icon--warning); + } + + .md-typeset .admonition.important, + .md-typeset details.important { + border-color: #8a3111; + } + + .md-typeset .important > .admonition-title:before, + .md-typeset .important > summary:before { + background-color: #8a3111; + -webkit-mask-image: var(--md-admonition-icon--warning); + mask-image: var(--md-admonition-icon--warning); + } + + .md-typeset .admonition.success, + .md-typeset details.success { + border-color: #026f2f; + } + + .md-typeset .success > .admonition-title:before, + .md-typeset .success > summary:before { + background-color: #026f2f; + -webkit-mask-image: var(--md-admonition-icon--success); + mask-image: var(--md-admonition-icon--success); + } + + /* Table of Contents list styling for dark mode */ + .md-typeset ul li { + margin-bottom: 0; /* Reduce vertical spacing between list items */ + margin-top: 0; + } + + /* Nested list bullet styles */ + .md-typeset ul { + list-style-type: disc; /* Top level uses solid disc */ + } + + .md-typeset ul ul { + list-style-type: circle; /* Second level uses outline circle */ + } + + .md-typeset ul ul ul { + list-style-type: square; /* Third level uses square */ + } + + /* Additional spacing adjustments for nested lists */ + .md-typeset ul ul { + margin-top: 0; + margin-bottom: 0; + } + + /* Navigation adjustments for better spacing in dark mode */ + + .md-nav[aria-expanded="true"] { + margin-bottom: 0.5rem !important; + } + + .md-nav .md-nav .md-nav__list { + padding-left: 1.2rem !important; + /* background-color: rgba(245,245,255,0.01) !important; */ + } + + .md-nav__item { + line-height: 1.4 !important; + } +} diff --git a/docs/overrides/assets/css/fonts.css b/docs/overrides/assets/css/fonts.css new file mode 100644 index 000000000000..045da829045d --- /dev/null +++ b/docs/overrides/assets/css/fonts.css @@ -0,0 +1,94 @@ +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url("../fonts/Inter/inter-latin-300-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Inter"; + font-style: italic; + font-weight: 300; + font-display: swap; + src: url("../fonts/Inter/inter-latin-300-italic.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/Inter/inter-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Inter"; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url("../fonts/Inter/inter-latin-400-italic.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Inter"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/Inter/inter-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "Inter"; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url("../fonts/Inter/inter-latin-700-italic.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url("../fonts/JetBrainsMono/jetbrains-mono-latin-400-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: italic; + font-weight: 400; + font-display: swap; + src: url("../fonts/JetBrainsMono/jetbrains-mono-latin-400-italic.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url("../fonts/JetBrainsMono/jetbrains-mono-latin-700-normal.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: "JetBrains Mono"; + font-style: italic; + font-weight: 700; + font-display: swap; + src: url("../fonts/JetBrainsMono/jetbrains-mono-latin-700-italic.woff2") format("woff2"); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +:root { + --md-text-font: "Inter"; + --md-code-font: "JetBrains Mono"; +} diff --git a/docs/overrides/assets/css/layout.css b/docs/overrides/assets/css/layout.css new file mode 100644 index 000000000000..e276fd18d2e3 --- /dev/null +++ b/docs/overrides/assets/css/layout.css @@ -0,0 +1,153 @@ +/* Search field and its shortcut hint */ +.md-search__form { + border-radius: .4rem; +} + +[data-md-toggle="search"]:checked ~ .md-header .md-search__form { + border-radius: .4rem .4rem 0 0; +} + +.md-search__output { + border-radius: 0 0 .4rem .4rem; +} + +@media screen and (min-width: 60em) { + .md-search__form { + background-color: #ffffff1f; + } + + .md-search__form:hover { + background-color: #00000042; + } +} + +.md-search__form[data-shortcut]::after { + content: attr(data-shortcut); + position: absolute; + top: 50%; + right: .6rem; + z-index: 2; + padding: 0 .3rem; + border: .05rem solid currentColor; + border-radius: .2rem; + color: var(--md-primary-bg-color--light); + font-size: .6rem; + line-height: 1.4; + pointer-events: none; + transform: translateY(-50%); +} + +[data-md-toggle="search"]:checked ~ .md-header .md-search__form::after { + display: none; +} + +/* Wider content column */ +.md-grid { + max-width: 71.4rem; +} + +.md-sidebar--secondary { + width: 13rem; +} + +/* Translucent header */ +.md-header { + background-color: color-mix(in srgb, var(--md-primary-fg-color) 85%, transparent); + -webkit-backdrop-filter: blur(.4rem); + backdrop-filter: blur(.4rem); +} + +/* Labelled edit button */ +.md-typeset .md-content__button--labelled { + display: inline-flex; + gap: .3rem; + align-items: center; + padding: .2rem .6rem; + border-radius: .4rem; + background-color: var(--md-default-fg-color--lightest); + color: var(--md-default-fg-color--light); + font-size: .7rem; +} + +.md-typeset .md-content__button--labelled:hover { + background-color: var(--md-accent-fg-color--transparent); + color: var(--md-accent-fg-color); +} + +/* Smaller previous and next links */ +.md-footer__title { + font-size: .8rem; +} + +.md-footer__direction { + font-size: .6rem; +} + +.md-sidebar--secondary .md-nav .md-nav .md-nav__list { + padding-left: .6rem !important; +} + +/* The version list opens on click, not on the theme's hover or focus. */ +.md-version:hover .md-version__list, +.md-version:focus-within .md-version__list { + animation: none; + max-height: 0; + opacity: 0; +} + +.md-version { + font-size: .7rem; + position: relative; +} + +.md-version__item { + line-height: 1.5rem; +} + +/* Lets the title shrink on narrow screens instead of pushing the header's buttons off screen. */ +.md-header__title { + min-width: 0; +} + +.md-version__current { + line-height: 2.4rem; + margin: 0 .4rem; + white-space: nowrap; +} + +.md-version__list { + margin: 0; + right: 0; + top: 2.2rem; + visibility: hidden; +} + +.md-version.md-version--open .md-version__list { + max-height: 10rem; + opacity: 1; + visibility: visible; +} + +/* A transparent border keeps images the same size in the scheme without a visible one. */ +.md-typeset img { + border: 1px solid transparent; + border-radius: 6px; + height: auto; + margin: .5rem 0; + max-width: 100%; +} + +.md-typeset p img[src*="icon"], +.md-typeset p img[width="16"], +.md-typeset p img[width="24"], +.md-typeset p img[height="16"], +.md-typeset p img[height="24"] { + border: none; + border-radius: 0; + margin: 0; +} + +/* Keeps the footer below the first screen, so short and long pages scroll alike. */ +.md-main { + min-height: 100vh; +} diff --git a/docs/overrides/assets/favicon.ico b/docs/overrides/assets/favicon.ico new file mode 100644 index 000000000000..a725c10e7251 Binary files /dev/null and b/docs/overrides/assets/favicon.ico differ diff --git a/docs/overrides/assets/flame.svg b/docs/overrides/assets/flame.svg new file mode 100644 index 000000000000..964782602e10 --- /dev/null +++ b/docs/overrides/assets/flame.svg @@ -0,0 +1 @@ + diff --git a/docs/overrides/assets/fonts/Inter/inter-latin-300-italic.woff2 b/docs/overrides/assets/fonts/Inter/inter-latin-300-italic.woff2 new file mode 100644 index 000000000000..dd92d3b0bbaf Binary files /dev/null and b/docs/overrides/assets/fonts/Inter/inter-latin-300-italic.woff2 differ diff --git a/docs/overrides/assets/fonts/Inter/inter-latin-300-normal.woff2 b/docs/overrides/assets/fonts/Inter/inter-latin-300-normal.woff2 new file mode 100644 index 000000000000..ece952cf7ef6 Binary files /dev/null and b/docs/overrides/assets/fonts/Inter/inter-latin-300-normal.woff2 differ diff --git a/docs/overrides/assets/fonts/Inter/inter-latin-400-italic.woff2 b/docs/overrides/assets/fonts/Inter/inter-latin-400-italic.woff2 new file mode 100644 index 000000000000..9e98286ff4db Binary files /dev/null and b/docs/overrides/assets/fonts/Inter/inter-latin-400-italic.woff2 differ diff --git a/docs/overrides/assets/fonts/Inter/inter-latin-400-normal.woff2 b/docs/overrides/assets/fonts/Inter/inter-latin-400-normal.woff2 new file mode 100644 index 000000000000..f15b025d6662 Binary files /dev/null and b/docs/overrides/assets/fonts/Inter/inter-latin-400-normal.woff2 differ diff --git a/docs/overrides/assets/fonts/Inter/inter-latin-700-italic.woff2 b/docs/overrides/assets/fonts/Inter/inter-latin-700-italic.woff2 new file mode 100644 index 000000000000..48b6e5b1ca83 Binary files /dev/null and b/docs/overrides/assets/fonts/Inter/inter-latin-700-italic.woff2 differ diff --git a/docs/overrides/assets/fonts/Inter/inter-latin-700-normal.woff2 b/docs/overrides/assets/fonts/Inter/inter-latin-700-normal.woff2 new file mode 100644 index 000000000000..a68fb101cd92 Binary files /dev/null and b/docs/overrides/assets/fonts/Inter/inter-latin-700-normal.woff2 differ diff --git a/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-400-italic.woff2 b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-400-italic.woff2 new file mode 100644 index 000000000000..3d3c5d77ef4a Binary files /dev/null and b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-400-italic.woff2 differ diff --git a/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-400-normal.woff2 b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-400-normal.woff2 new file mode 100644 index 000000000000..585887339efd Binary files /dev/null and b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-400-normal.woff2 differ diff --git a/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-700-italic.woff2 b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-700-italic.woff2 new file mode 100644 index 000000000000..69e029cda341 Binary files /dev/null and b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-700-italic.woff2 differ diff --git a/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-700-normal.woff2 b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-700-normal.woff2 new file mode 100644 index 000000000000..3a4e333fc931 Binary files /dev/null and b/docs/overrides/assets/fonts/JetBrainsMono/jetbrains-mono-latin-700-normal.woff2 differ diff --git a/docs/overrides/assets/js/code.js b/docs/overrides/assets/js/code.js new file mode 100644 index 000000000000..6c61d334fb72 --- /dev/null +++ b/docs/overrides/assets/js/code.js @@ -0,0 +1,38 @@ +/* Splits Pygments' single keyword class the way VS Code's Modern themes do: + control-flow keywords and $this get their own classes for code.css to color. */ +const controlKeywords = new Set([ + 'if', 'else', 'elseif', 'switch', 'case', 'default', 'for', 'foreach', + 'while', 'do', 'break', 'continue', 'return', 'try', 'catch', 'finally', + 'throw', 'match', 'yield', 'goto', +]); + +// Material's document$ emits on the first load and after every instant navigation. +document$.subscribe(() => { + for (const span of document.querySelectorAll('.highlight .k')) { + if (controlKeywords.has(span.textContent)) { + span.classList.add('k-control'); + } + } + + for (const span of document.querySelectorAll('.highlight .nv')) { + if (span.textContent === '$this') { + span.classList.add('nv-this'); + } + } + + for (const span of document.querySelectorAll('.highlight .nx')) { + if (/^[A-Z][A-Z0-9_]*$/.test(span.textContent)) { + span.classList.add('nx-constant'); + } else if (/^[A-Z]/.test(span.textContent)) { + span.classList.add('nx-class'); + } + } + + for (const span of document.querySelectorAll('.highlight .na')) { + const next = span.nextElementSibling; + + if (next !== null && next.classList.contains('p') && next.textContent.startsWith('(')) { + span.classList.add('na-call'); + } + } +}); diff --git a/docs/overrides/assets/js/shortcuts.js b/docs/overrides/assets/js/shortcuts.js new file mode 100644 index 000000000000..8d17b9a5170b --- /dev/null +++ b/docs/overrides/assets/js/shortcuts.js @@ -0,0 +1,17 @@ +const isMac = /Mac|iPhone|iPad/.test(navigator.userAgentData?.platform ?? navigator.platform); + +document.querySelector('.md-search__form')?.setAttribute('data-shortcut', isMac ? '⌘K' : 'Ctrl K'); + +document.addEventListener('keydown', (event) => { + if (event.key.toLowerCase() !== 'k' || !(isMac ? event.metaKey : event.ctrlKey)) { + return; + } + + event.preventDefault(); + + const toggle = document.querySelector('[data-md-toggle="search"]'); + + toggle.checked = true; + toggle.dispatchEvent(new CustomEvent('change')); + document.querySelector('.md-search__input')?.focus(); +}); diff --git a/docs/overrides/assets/js/sidebar.js b/docs/overrides/assets/js/sidebar.js new file mode 100644 index 000000000000..90d99a6efa59 --- /dev/null +++ b/docs/overrides/assets/js/sidebar.js @@ -0,0 +1,24 @@ +const navigationWrap = '.md-sidebar--primary .md-sidebar__scrollwrap'; +const navigationScrollKey = 'navigation.scroll'; +const scrollElementTo = Element.prototype.scrollTo; + +// The theme centers the active link on every page load, with no option to turn that off. +Element.prototype.scrollTo = function (...position) { + const saved = this.matches(navigationWrap) ? __md_get(navigationScrollKey, sessionStorage) : null; + const active = saved === null ? null : this.querySelector('.md-nav__link--active[href]'); + + if (active === null) { + return scrollElementTo.apply(this, position); + } + + const activeTop = active.getBoundingClientRect().top - this.getBoundingClientRect().top + this.scrollTop; + const isVisible = activeTop >= saved && activeTop + active.offsetHeight <= saved + this.clientHeight; + + return isVisible ? scrollElementTo.call(this, { top: saved }) : scrollElementTo.apply(this, position); +}; + +document.addEventListener('scroll', (event) => { + if (event.target.matches?.(navigationWrap)) { + __md_set(navigationScrollKey, event.target.scrollTop, sessionStorage); + } +}, { capture: true, passive: true }); diff --git a/docs/overrides/assets/js/versions.js b/docs/overrides/assets/js/versions.js new file mode 100644 index 000000000000..8936b5e39b60 --- /dev/null +++ b/docs/overrides/assets/js/versions.js @@ -0,0 +1,129 @@ +const root = new URL(JSON.parse(document.querySelector('#__config').textContent).base, location).href; +const isLocal = ['localhost', '127.0.0.1', ''].includes(location.hostname); +const versionsUrl = isLocal ? new URL('assets/versions.json', root) : 'https://codeigniter4.github.io/CodeIgniter4/versions.json'; + +const releaseUrl = 'https://api.github.com/repos/codeigniter4/CodeIgniter4/releases/latest'; +const releaseKey = 'version.latest'; + +const latestTag = async () => { + try { + const cached = sessionStorage.getItem(releaseKey); + + if (cached !== null) { + return cached; + } + } catch {} + + const response = await fetch(releaseUrl); + + if (!response.ok) { + throw new Error(response.status); + } + + const { tag_name: tag } = await response.json(); + + try { + sessionStorage.setItem(releaseKey, tag); + } catch {} + + return tag; +}; + +const withLatestTag = async (versions) => { + if (!versions.some((version) => version.title.includes('{tag}'))) { + return versions; + } + + const tag = await latestTag().catch(() => null); + + return versions.map((version) => ({ + ...version, + title: tag === null ? version.title.replace(' ({tag})', '') : version.title.replace('{tag}', tag), + })); +}; + +const samePageIn =(version) => version.url + location.pathname.slice(new URL(root).pathname.length) + location.hash; + +const goTo = async (version) => { + const target = samePageIn(version); + + try { + const response = await fetch(target, { method: 'HEAD' }); + + location.assign(response.status === 404 ? version.url : target); + } catch { + // A host without CORS headers hides the status, so the page is assumed to exist. + location.assign(target); + } +}; + +const renderVersions = (deployed) => { + const local = { title: 'Local build', url: root }; + const current = deployed.find((version) => root === version.url) ?? local; + const versions = current === local ? [local, ...deployed] : deployed; + const selector = document.createElement('div'); + const button = document.createElement('button'); + const list = document.createElement('ul'); + + selector.className = 'md-version'; + button.className = 'md-version__current'; + button.setAttribute('aria-label', 'Select version'); + button.setAttribute('aria-expanded', 'false'); + button.textContent = current.title; + list.className = 'md-version__list'; + + const setOpen = (open) => { + selector.classList.toggle('md-version--open', open); + button.setAttribute('aria-expanded', String(open)); + }; + + button.addEventListener('click', () => setOpen(!selector.classList.contains('md-version--open'))); + document.addEventListener('click', (event) => { + if (!selector.contains(event.target)) { + setOpen(false); + } + }); + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + setOpen(false); + } + }); + + const links = versions.map((version) => { + const item = document.createElement('li'); + const link = document.createElement('a'); + + item.className = 'md-version__item'; + link.className = 'md-version__link'; + link.textContent = version.title; + link.addEventListener('click', (event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + goTo(version); + }); + + item.append(link); + list.append(item); + + return [link, version]; + }); + + selector.append(button, list); + document.querySelector('.md-header__title').after(selector); + + document$.subscribe(() => { + links.forEach(([link, version]) => { + link.href = samePageIn(version); + }); + }); +}; + +fetch(versionsUrl) + .then((response) => (response.ok ? response.json() : Promise.reject(response.status))) + .then(withLatestTag) + .then(renderVersions) + .catch(() => {}); diff --git a/docs/overrides/assets/js/year.js b/docs/overrides/assets/js/year.js new file mode 100644 index 000000000000..aa79e11111b1 --- /dev/null +++ b/docs/overrides/assets/js/year.js @@ -0,0 +1,3 @@ +document.querySelectorAll('[data-current-year]').forEach((element) => { + element.textContent = new Date().getFullYear(); +}); diff --git a/docs/overrides/assets/versions.json b/docs/overrides/assets/versions.json new file mode 100644 index 000000000000..f7d2d103dbe5 --- /dev/null +++ b/docs/overrides/assets/versions.json @@ -0,0 +1,5 @@ +[ + {"title": "Stable ({tag})", "url": "https://codeigniter.com/user_guide/"}, + {"title": "Latest (develop)", "url": "https://codeigniter4.github.io/CodeIgniter4/develop/"}, + {"title": "Next minor (4.8)", "url": "https://codeigniter4.github.io/CodeIgniter4/next/"} +] diff --git a/docs/overrides/partials/actions.html b/docs/overrides/partials/actions.html new file mode 100644 index 000000000000..2e15c408ac3e --- /dev/null +++ b/docs/overrides/partials/actions.html @@ -0,0 +1,21 @@ +{# Material's edit button, with the "Edit this page" label shown next to its icon. #} +{% if page.edit_url %} + {% if "content.action.edit" in features %} + + {% set icon = config.theme.icon.edit or "material/file-edit-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + {{ lang.t('action.edit') }} + + {% endif %} + {% if "content.action.view" in features %} + {% if "/blob/" in page.edit_url %} + {% set part = "blob" %} + {% else %} + {% set part = "edit" %} + {% endif %} + + {% set icon = config.theme.icon.view or "material/file-eye-outline" %} + {% include ".icons/" ~ icon ~ ".svg" %} + + {% endif %} +{% endif %} diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000000..b5a84edda6cd --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +mkdocs-material==9.8.0 +pygments==2.21.0 diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 000000000000..cc73418bb32b --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,105 @@ +# The nav is docs/.nav.yml, kept up to date by admin/update-docs-nav.php. +INHERIT: docs/.nav.yml + +site_name: CodeIgniter +site_description: Main user guide for the CodeIgniter 4 framework +# Set per deployment, since instant navigation and the sitemap need the site's own URL. +site_url: !ENV SITE_URL +use_directory_urls: false + +# Files in docs/ that are not pages. +exclude_docs: | + overrides/ + requirements.txt + +theme: + name: material + custom_dir: docs/overrides + logo: assets/flame.svg + favicon: assets/favicon.ico + font: false + icon: + repo: fontawesome/brands/github + palette: + - media: "(prefers-color-scheme: light)" + scheme: codeigniter + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.instant.prefetch + - navigation.instant.progress + - content.code.copy + - navigation.footer + - content.action.edit + - navigation.top + - search.suggest + - search.highlight + - search.share + - navigation.path + - toc.follow + +extra: + homepage: https://codeigniter.com + generator: false + + social: + - icon: material/github + link: https://github.com/codeigniter4/CodeIgniter4 + name: GitHub + - icon: material/twitter + link: https://twitter.com/CodeIgniterPhp + name: X + - icon: material/forum + link: https://forum.codeigniter.com + name: Forum Codeigniter + - icon: material/slack + link: https://join.slack.com/t/codeigniterchat/shared_invite/zt-244xrrslc-l_I69AJSi5y2a2RVN~xIdQ + name: Slack + +repo_name: codeigniter4/CodeIgniter4 +repo_url: https://github.com/codeigniter4/CodeIgniter4 +edit_uri: edit/develop/docs/ +copyright: Copyright © 2019-2026 CodeIgniter Foundation. + +validation: + anchors: warn + +markdown_extensions: + - admonition + - pymdownx.superfences + - pymdownx.highlight: + extend_pygments_lang: + # PHP samples have no opening tag. + - name: php + lang: php + options: + startinline: true + - toc: + permalink: true + toc_depth: 3 + - tables + +extra_css: + - assets/css/fonts.css + - assets/css/codeigniter.css + - assets/css/codeigniter_dark_mode.css + - assets/css/layout.css + - assets/css/code.css + +extra_javascript: + - assets/js/shortcuts.js + - assets/js/code.js + - assets/js/versions.js + - assets/js/sidebar.js + - assets/js/year.js diff --git a/tests/system/AutoReview/UpdateDocsNavTest.php b/tests/system/AutoReview/UpdateDocsNavTest.php new file mode 100644 index 000000000000..837604ee8193 --- /dev/null +++ b/tests/system/AutoReview/UpdateDocsNavTest.php @@ -0,0 +1,181 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\AutoReview; + +use FilesystemIterator; +use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\TestCase; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +/** + * @internal + */ +#[CoversNothing] +#[Group('AutoReview')] +final class UpdateDocsNavTest extends TestCase +{ + private const NAV = <<<'YAML' + nav: + - Home: index.md + - "Installation": + - installation/index.md + - "Change Logs": + - changelogs/index.md + - changelogs/v1.2.0.md + - changelogs/v1.0.0.md + - "Upgrading": + - installation/upgrading.md + - installation/upgrade_100.md + YAML; + + private string $docs; + + protected function setUp(): void + { + parent::setUp(); + + $this->docs = sys_get_temp_dir() . '/docs-nav-' . bin2hex(random_bytes(4)); + + $this->write('.nav.yml', self::NAV . "\n"); + $this->write('index.md', "# Home\n"); + $this->write('installation/index.md', "- [v1.2.0](../changelogs/v1.2.0.md)\n- [Upgrade](upgrade_100.md)\n"); + $this->write('changelogs/index.md', "- [v1.3.0](v1.3.0.md)\n- [v1.2.0](v1.2.0.md)\n- [v1.1.0](v1.1.0.md)\n- [v1.0.0](v1.0.0.md)\n"); + $this->write('installation/upgrading.md', "- [Upgrade 1.0.0](upgrade_100.md)\n- [Upgrade 1.1.0](upgrade_110.md)\n"); + + foreach (['changelogs/v1.2.0.md', 'changelogs/v1.0.0.md', 'installation/upgrade_100.md'] as $page) { + $this->write($page, "# Page\n"); + } + } + + protected function tearDown(): void + { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->docs, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + + rmdir($this->docs); + + parent::tearDown(); + } + + public function testErrorWhenNavIsMissing(): void + { + unlink($this->docs . '/.nav.yml'); + + $result = $this->runScript(''); + + $this->assertSame(1, $result['exitCode']); + $this->assertStringContainsString('is not found', $result['output']); + } + + public function testCheckPassesWhenNavIsComplete(): void + { + $this->write('changelogs/v1.3.0.md', "# Page\n"); + $this->write('changelogs/v1.1.0.md', "# Page\n"); + $this->write('installation/upgrade_110.md', "# Page\n"); + $this->write('.nav.yml', str_replace( + " - changelogs/v1.2.0.md\n - changelogs/v1.0.0.md\n", + " - changelogs/v1.3.0.md\n - changelogs/v1.2.0.md\n - changelogs/v1.1.0.md\n - changelogs/v1.0.0.md\n", + self::NAV . "\n - installation/upgrade_110.md\n", + )); + + $result = $this->runScript('--check'); + + $this->assertSame(0, $result['exitCode']); + $this->assertSame('', $result['output']); + } + + public function testCheckReportsMissingAndStalePagesWithoutChangingTheNav(): void + { + $this->write('changelogs/v1.3.0.md', "# Page\n"); + unlink($this->docs . '/installation/upgrade_100.md'); + + $result = $this->runScript('--check'); + + $this->assertSame(1, $result['exitCode']); + $this->assertStringContainsString('changelogs/v1.3.0.md is missing from the nav', $result['output']); + $this->assertStringContainsString('The nav lists installation/upgrade_100.md, which does not exist', $result['output']); + $this->assertSame(self::NAV . "\n", file_get_contents($this->docs . '/.nav.yml')); + } + + public function testMissingPagesAreInsertedInTheOrderOfTheSectionIndex(): void + { + $this->write('changelogs/v1.3.0.md', "# Page\n"); + $this->write('changelogs/v1.1.0.md', "# Page\n"); + $this->write('installation/upgrade_110.md', "# Page\n"); + + $result = $this->runScript(''); + + $this->assertSame(0, $result['exitCode']); + $this->assertSame( + <<<'YAML' + nav: + - Home: index.md + - "Installation": + - installation/index.md + - "Change Logs": + - changelogs/index.md + - changelogs/v1.3.0.md + - changelogs/v1.2.0.md + - changelogs/v1.1.0.md + - changelogs/v1.0.0.md + - "Upgrading": + - installation/upgrading.md + - installation/upgrade_100.md + - installation/upgrade_110.md + + YAML, + file_get_contents($this->docs . '/.nav.yml'), + ); + } + + public function testPageThatNoSectionLinksToIsAddedAtTheEnd(): void + { + $this->write('extra.md', "# Page\n"); + + $result = $this->runScript(''); + + $this->assertSame(0, $result['exitCode']); + $this->assertStringContainsString('Move it by hand', $result['output']); + $this->assertStringEndsWith(" - extra.md\n", (string) file_get_contents($this->docs . '/.nav.yml')); + } + + /** + * @return array{exitCode: int, output: string} + */ + private function runScript(string $options): array + { + exec(sprintf('php ./admin/update-docs-nav.php %s --docs=%s 2>&1', $options, escapeshellarg($this->docs)), $output, $exitCode); + + return ['exitCode' => $exitCode, 'output' => implode("\n", $output)]; + } + + private function write(string $path, string $contents): void + { + $file = $this->docs . '/' . $path; + + if (! is_dir(dirname($file))) { + mkdir(dirname($file), 0777, true); + } + + file_put_contents($file, $contents); + } +}