From 314329aafcbb5fb85e6f9b3ebdb4b937183b24ac Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 12 Aug 2026 11:51:09 +0800 Subject: [PATCH 01/11] test: freeze PRD 4 navigation and palette contracts --- .github/workflows/ci.yml | 3 + .gitignore | 4 +- ...rd4-navigation-command-palette-contract.md | 164 ++++ scripts/check-prd4-contract.py | 873 ++++++++++++++++++ tests/fixtures/prd4/contract.json | 161 ++++ tests/fixtures/prd4/current.json | 353 +++++++ tests/fixtures/prd4/site/content/_index.md | 5 + tests/fixtures/prd4/site/content/_index.zh.md | 5 + .../fixtures/prd4/site/content/blog/_index.md | 6 + .../prd4/site/content/blog/_index.zh.md | 6 + tests/fixtures/prd4/site/content/blog/post.md | 7 + .../prd4/site/content/blog/post.zh.md | 7 + .../fixtures/prd4/site/content/docs/_index.md | 7 + .../prd4/site/content/docs/_index.zh.md | 7 + .../prd4/site/content/docs/guides/_index.md | 6 + .../site/content/docs/guides/_index.zh.md | 6 + .../prd4/site/content/docs/guides/tutorial.md | 10 + .../site/content/docs/guides/tutorial.zh.md | 10 + .../prd4/site/content/docs/reference.md | 6 + .../prd4/site/content/docs/reference.zh.md | 6 + .../prd4/site/content/project/_index.md | 6 + .../prd4/site/content/project/_index.zh.md | 6 + 22 files changed, 1663 insertions(+), 1 deletion(-) create mode 100644 docs/prd4-navigation-command-palette-contract.md create mode 100644 scripts/check-prd4-contract.py create mode 100644 tests/fixtures/prd4/contract.json create mode 100644 tests/fixtures/prd4/current.json create mode 100644 tests/fixtures/prd4/site/content/_index.md create mode 100644 tests/fixtures/prd4/site/content/_index.zh.md create mode 100644 tests/fixtures/prd4/site/content/blog/_index.md create mode 100644 tests/fixtures/prd4/site/content/blog/_index.zh.md create mode 100644 tests/fixtures/prd4/site/content/blog/post.md create mode 100644 tests/fixtures/prd4/site/content/blog/post.zh.md create mode 100644 tests/fixtures/prd4/site/content/docs/_index.md create mode 100644 tests/fixtures/prd4/site/content/docs/_index.zh.md create mode 100644 tests/fixtures/prd4/site/content/docs/guides/_index.md create mode 100644 tests/fixtures/prd4/site/content/docs/guides/_index.zh.md create mode 100644 tests/fixtures/prd4/site/content/docs/guides/tutorial.md create mode 100644 tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md create mode 100644 tests/fixtures/prd4/site/content/docs/reference.md create mode 100644 tests/fixtures/prd4/site/content/docs/reference.zh.md create mode 100644 tests/fixtures/prd4/site/content/project/_index.md create mode 100644 tests/fixtures/prd4/site/content/project/_index.zh.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac3d4226..46760a0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,9 @@ jobs: - name: Check content primitive contract run: python3 scripts/check-content-primitives-contract.py + - name: Check PRD 4 contracts and characterization + run: python3 scripts/check-prd4-contract.py + - name: Build the example site without warnings working-directory: exampleSite run: hugo --printPathWarnings --panicOnWarning diff --git a/.gitignore b/.gitignore index 32134212..fe1742ee 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ .DS_Store +__pycache__/ +*.py[cod] .hugo_build.lock public/ resources/ @@ -6,4 +8,4 @@ resources/ .codex/ .cursor/ .claude/ -tmp/ \ No newline at end of file +tmp/ diff --git a/docs/prd4-navigation-command-palette-contract.md b/docs/prd4-navigation-command-palette-contract.md new file mode 100644 index 00000000..1794669c --- /dev/null +++ b/docs/prd4-navigation-command-palette-contract.md @@ -0,0 +1,164 @@ +# PRD 4 navigation and Command Palette contract + +Status: accepted implementation contract + +Contract version: 1 + +Tracking issue: [pgsty/oink#11](https://github.com/pgsty/oink/issues/11) + +This document freezes the public decisions that later PRD 4 changes must +preserve. The machine-readable companion is +tests/fixtures/prd4/contract.json; CI checks that the two stay aligned. + +The contract deliberately separates the target behavior from the current +characterization. scripts/check-prd4-contract.py records today's rendered +behavior as observations, including known gaps. A feature PR moves an +observation to its target only when it also updates the relevant assertions. + +## Authority boundaries + +PRD 4 does not add another information architecture: + +- Hugo Menu is the global-navigation authority. +- The Hugo content tree and current docs navigation are the sidebar authority. +- The existing root switcher owns product or documentation-domain switching. +- The per-language local search index is the shared discovery and command + source. + +docs.json, navigation.yaml, or another parallel navigation tree is outside +this contract. + +## Navigation contract + +Only one child level is interactive. A parent with children renders a +navigable label and a separate disclosure button on desktop and mobile. No +operation depends on hover. + +Desktop disclosure supports click, Enter, Space, and ArrowDown. ArrowDown opens +the dropdown and focuses its first actionable item. Escape closes it and +restores focus to the disclosure. Outside click closes it. The button owns +aria-expanded and aria-controls. + +Mobile uses the same split link/button model as an accordion. Multiple sections +may be open by default. params.ui.navbar_accordion_single_open may opt into +single-open behavior. + +The parent is active when it or any descendant is current. External links have +an explicit visual affordance and include noopener noreferrer when opened in a +new browsing context. + +Deeper menu input emits a build warning and degrades to content under a group +heading. It never creates a third-level flyout. + +## Sidebar icon contract + +params.ui.sidebar_icon_policy accepts: + +- all: every eligible item shows its resolved icon; +- groups: roots and nodes with children show icons, ordinary leaves do not; +- none: sidebar item icons are omitted. + +An absent setting remains all until a future major release. New starter sites +explicitly choose groups. Invalid input warns and falls back to the +compatibility default. + +## Search schema and ranking contract + +Canonical front matter: + +- search_keywords: additional query terms; +- search_boost: a finite positive multiplier, default 1.0; +- search_exclude: the canonical exclusion flag. + +exclude_search and excludeSearch remain compatibility aliases through the next +major release. If any canonical or compatibility exclusion flag is true, the +page is excluded. A false canonical value does not override a true legacy +alias. + +Every indexed document keeps the existing fields and adds root, section, type, +keywords, boost, breadcrumb, and icon. Missing metadata uses deterministic +fallbacks derived from Hugo's page and section tree. + +The final score is text match score multiplied by search_boost. The multiplier +and keywords apply to Lunr and the CJK substring fallback. Indexes remain +separated by language and all references remain correct under subpath +deployment. Each language index has a 2 MiB uncompressed and 512 KiB gzip +budget. The search-metadata implementation issue may tighten these ceilings, +but cannot remove the measured budget gate. + +## Command registry contract + +The built-in action IDs are copy_markdown, view_markdown, edit_page, +create_issue, print, switch_theme, switch_language, switch_version, and +open_github. + +Existing page-action controls and the Command Palette call the same internal +registry entry. Availability, title, description, icon, keywords, execution +kind, URL, and disabled reason are data rather than duplicated click handlers. + +Configured commands may reference a built-in action ID or a URL. Configuration +cannot inject a JavaScript callback. Localized command titles and keywords live +under languages..params.ui.command_palette.commands, with the site's +normal language fallback. + +## Palette modes + +The current local-search dialog becomes the Command Palette; PRD 4 does not add +a second modal. + +- Empty query: quick links, context-aware page actions, theme, language, + version, and configured commands. +- Text query: page fields, page keywords, and commands, grouped by content root + and Actions. +- A greater-than prefix: commands only. + +The first version does not add @docs or @blog scopes. + +Cmd/Ctrl-K, keyboard result navigation, Escape, focus restoration, live-region +announcements, reduced motion, and mobile interaction remain part of the +existing dialog's accessibility contract. + +## Runtime contract + +The local Palette capability is true only when all of these conditions hold: + +1. params.offlineSearch is enabled; +2. the page is home or uses a shell surface; +3. the current output is not print. + +When the capability is false, the build omits the dialog, local index, Lunr, +and Palette controller. Print follows the same omission rule. + +No default telemetry request is allowed. + +The current characterization intentionally exposes places where source assets +are still gated by params.offlineSearch alone. Issue +[pgsty/oink#13](https://github.com/pgsty/oink/issues/13) owns closing that gap. + +## Compatibility and non-goals + +Existing flat Hugo menus remain valid without configuration changes. PRD 4 +does not include AI or semantic search, a remote search replacement, browsing +history, personalized recommendations, arbitrary-depth flyouts, a second +navigation authority, or default query upload. + +## Characterization matrix + +scripts/check-prd4-contract.py builds temporary bilingual sites with local +search off and on. It covers both root deployment and a /preview/ subpath. It normalizes the +desktop and mobile menu into link records and records runtime markers for these +surfaces: + +| Surface | Representative output | +| --- | --- | +| Home | /preview/en/ and /preview/zh/ | +| Docs shell | localized tutorial page | +| Blog shell | localized blog root | +| Plain project | localized non-shell page | +| Print | localized docs print output | + +The flat menu snapshot is a compatibility guard. The nested entries also +characterize the pre-PRD 4 gap: Hugo recognizes the parent/child structure, but +the current navbar renders only the top-level parent. The deep fixture exists +so the navbar implementation can add and test its warning/degradation contract +without inventing a new test site later. diff --git a/scripts/check-prd4-contract.py b/scripts/check-prd4-contract.py new file mode 100644 index 00000000..25b9df3e --- /dev/null +++ b/scripts/check-prd4-contract.py @@ -0,0 +1,873 @@ +#!/usr/bin/env python3 +"""Validate PRD 4 contracts and characterize the pre-implementation theme. + +The script builds an isolated bilingual fixture with local search disabled and +enabled. It compares normalized navigation and runtime observations with a +checked-in snapshot. Use --write-current only when an intentional behavior +change is reviewed together with its owning PRD 4 issue. +""" + +from __future__ import annotations + +import argparse +import gzip +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE_ROOT = ROOT / "tests" / "fixtures" / "prd4" +CONTRACT_PATH = FIXTURE_ROOT / "contract.json" +CURRENT_PATH = FIXTURE_ROOT / "current.json" +CONTRACT_DOC_PATH = ROOT / "docs" / "prd4-navigation-command-palette-contract.md" +SITE_FIXTURE_PATH = FIXTURE_ROOT / "site" + +LANGUAGES = ("en", "zh") +VARIANTS = ("flat", "nested", "deep") +SURFACES = { + "home": "{lang}/index.html", + "docs": "{lang}/docs/guides/tutorial/index.html", + "blog": "{lang}/blog/index.html", + "plain": "{lang}/project/index.html", + "print": "{lang}/_print/docs/index.html", +} + +REQUIRED_DOC_HEADINGS = ( + "## Authority boundaries", + "## Navigation contract", + "## Sidebar icon contract", + "## Search schema and ranking contract", + "## Command registry contract", + "## Palette modes", + "## Runtime contract", + "## Compatibility and non-goals", + "## Characterization matrix", +) + + +class ContractError(RuntimeError): + """A deterministic contract or characterization failure.""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--write-current", + action="store_true", + help="replace the checked-in current characterization snapshot", + ) + return parser.parse_args() + + +def load_json(path: Path) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise ContractError(f"missing required file: {path.relative_to(ROOT)}") from exc + except json.JSONDecodeError as exc: + raise ContractError( + f"invalid JSON in {path.relative_to(ROOT)}: {exc}" + ) from exc + if not isinstance(value, dict): + raise ContractError(f"{path.relative_to(ROOT)} must contain a JSON object") + return value + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ContractError(message) + + +def validate_contract(contract: dict[str, Any]) -> None: + require(contract.get("version") == 1, "contract.version must be 1") + + authorities = contract.get("authorities", {}) + require( + authorities + == { + "global_navigation": "hugo_menu", + "sidebar": "hugo_content_tree", + "root_switcher": "existing_root_switcher", + "discovery_and_commands": "local_search_index", + }, + "authority boundaries changed; update the PRD and contract together", + ) + + navigation = contract.get("navigation", {}) + require( + navigation.get("interactive_child_levels") == 1, + "navigation supports exactly one interactive child level", + ) + require( + navigation.get("desktop_control") == "separate_link_and_button" + and navigation.get("mobile_control") == "separate_link_and_button", + "desktop and mobile parent links must be separate from disclosure buttons", + ) + require( + navigation.get("hover_required") is False, + "navigation must not require hover", + ) + require( + navigation.get("mobile_single_open") + == { + "parameter": "params.ui.navbar_accordion_single_open", + "default": False, + }, + "mobile accordion single-open contract changed", + ) + require( + navigation.get("external_rel") == ["noopener", "noreferrer"], + "external-link rel contract changed", + ) + deep_menu = navigation.get("deep_menu", {}) + require( + deep_menu.get("warning") is True + and deep_menu.get("rendering") == "flatten_under_group_heading" + and deep_menu.get("third_level_flyout") is False, + "deep menus must warn and degrade without a third-level flyout", + ) + + sidebar = contract.get("sidebar_icons", {}) + require( + sidebar.get("parameter") == "params.ui.sidebar_icon_policy" + and + sidebar.get("values") == ["all", "groups", "none"], + "sidebar icon policy values must be all, groups, none", + ) + require( + sidebar.get("compatibility_default") == "all" + and sidebar.get("starter_default") == "groups", + "sidebar compatibility and starter defaults are distinct", + ) + + search = contract.get("search", {}) + require( + search.get("canonical_front_matter") + == ["search_keywords", "search_boost", "search_exclude"], + "canonical search front matter changed", + ) + require( + search.get("exclude_aliases") == ["exclude_search", "excludeSearch"], + "legacy exclude aliases must remain supported", + ) + require( + search.get("exclude_resolution") == "any_true_excludes", + "search exclusion precedence must be any-true-excludes", + ) + require( + search.get("default_boost") == 1.0 + and search.get("invalid_boost") == "warn_and_use_default" + and search.get("score_formula") == "text_score_times_search_boost", + "search boost contract changed", + ) + require( + search.get("keywords_in_latin") is True + and search.get("keywords_in_cjk") is True + and search.get("language_separated_indexes") is True, + "keyword or language-separated search contract changed", + ) + size_budget = search.get("size_budget", {}) + require( + isinstance(size_budget.get("uncompressed_bytes"), int) + and size_budget["uncompressed_bytes"] == 2_097_152 + and isinstance(size_budget.get("gzip_bytes"), int) + and size_budget["gzip_bytes"] == 524_288, + "search index byte budgets changed", + ) + require( + search.get("index_fields") + == [ + "ref", + "title", + "categories", + "tags", + "excerpt", + "headings", + "description", + "body", + "root", + "section", + "type", + "keywords", + "boost", + "breadcrumb", + "icon", + ], + "search index field contract changed", + ) + + commands = contract.get("commands", {}) + require( + set(commands.get("site_command_kinds", [])) + == {"url", "builtin_action_id"}, + "configured commands may only use URLs or built-in action IDs", + ) + require( + commands.get("javascript_callbacks_allowed") is False, + "configured JavaScript callbacks must remain forbidden", + ) + require( + commands.get("builtin_action_ids") + == [ + "copy_markdown", + "view_markdown", + "edit_page", + "create_issue", + "print", + "switch_theme", + "switch_language", + "switch_version", + "open_github", + ], + "built-in action ID contract changed", + ) + require( + commands.get("localized_parameter") + == "languages..params.ui.command_palette.commands", + "localized command parameter contract changed", + ) + + palette = contract.get("palette", {}) + require( + palette.get("dialog_owner") == "existing_local_search_dialog", + "the Palette must extend the existing local-search dialog", + ) + require( + palette.get("modes") == ["empty", "text", "commands"] + and palette.get("command_prefix") == ">", + "Palette mode contract changed", + ) + require( + palette.get("first_version_scopes") == [], + "first-version Palette scopes must remain empty", + ) + require( + palette.get("default_telemetry") is False, + "default telemetry must remain disabled", + ) + + runtime = contract.get("runtime", {}) + expected_omissions = {"dialog", "local_index", "lunr", "palette_controller"} + require( + runtime.get("enabled_when") + == ["params.offlineSearch", "home_or_shell_surface", "not_print"], + "runtime capability predicate contract changed", + ) + require( + set(runtime.get("disabled_must_omit", [])) == expected_omissions, + "disabled runtime omission contract changed", + ) + require( + set(runtime.get("print_must_omit", [])) == expected_omissions, + "print runtime omission contract changed", + ) + matrix = contract.get("characterization_matrix", {}) + require( + matrix.get("menu_variants") == ["flat", "nested", "deep"] + and matrix.get("search_states") == ["disabled", "enabled"] + and matrix.get("languages") == ["en", "zh"] + and matrix.get("deployments") == ["root", "subpath"] + and matrix.get("surfaces") == list(SURFACES), + "the required characterization matrix changed", + ) + require( + contract.get("non_goals") + == [ + "ai_or_semantic_search", + "remote_search_replacement", + "recent_history", + "personalized_recommendations", + "arbitrary_depth_flyouts", + "second_navigation_authority", + "default_query_upload", + ], + "PRD 4 non-goals changed", + ) + + +def validate_contract_doc(contract: dict[str, Any]) -> None: + try: + doc = CONTRACT_DOC_PATH.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ContractError( + f"missing contract documentation: {CONTRACT_DOC_PATH.relative_to(ROOT)}" + ) from exc + for heading in REQUIRED_DOC_HEADINGS: + require(heading in doc, f"contract document is missing heading: {heading}") + + required_literals = ( + "params.ui.sidebar_icon_policy", + "search_keywords", + "search_boost", + "search_exclude", + "exclude_search", + "excludeSearch", + "copy_markdown", + "view_markdown", + "noopener noreferrer", + "No default telemetry request", + ) + lower_doc = doc.lower() + for literal in required_literals: + require( + literal.lower() in lower_doc, + f"contract document is missing required term: {literal}", + ) + + require( + contract["search"]["exclude_resolution"] == "any_true_excludes" + and "any canonical or compatibility exclusion flag is true" in doc, + "exclude alias precedence is not documented", + ) + + +def config_text( + variant: str, offline_search: bool, subpath: bool = True +) -> str: + if variant not in VARIANTS: + raise ContractError(f"unknown fixture variant: {variant}") + + lines = [ + ( + "baseURL: https://example.org/preview/" + if subpath + else "baseURL: https://example.org/" + ), + "title: PRD 4 Contract Fixture", + f"theme: {ROOT.name}", + "defaultContentLanguage: en", + "defaultContentLanguageInSubdir: true", + "disableKinds: [RSS, sitemap, taxonomy, term]", + "params:", + f" offlineSearch: {'true' if offline_search else 'false'}", + " github_project_repo: https://github.com/pgsty/oink", + " ui:", + " shell_types: [docs, blog, swagger]", + " docs_section: docs", + " blog_section: blog", + " feedback:", + " enable: false", + "languages:", + " en:", + " label: English", + " locale: en-US", + " weight: 1", + " zh:", + " label: 简体中文", + " locale: zh-CN", + " weight: 2", + "menus:", + " main:", + " - identifier: docs", + " name: Docs", + " pageRef: /docs", + " weight: 10", + ] + if variant == "flat": + lines.extend( + [ + " - identifier: blog", + " name: Blog", + " pageRef: /blog", + " weight: 20", + " - identifier: project", + " name: Project", + " pageRef: /project", + " weight: 30", + " - identifier: status", + " name: Status", + " url: https://status.example.com/", + " weight: 40", + " params:", + " icon: fa-solid fa-signal", + ] + ) + else: + lines.extend( + [ + " - identifier: guides", + " parent: docs", + " name: Guides", + " pageRef: /docs/guides", + " weight: 10", + " params:", + " icon: fa-solid fa-route", + " description: Task-oriented tutorials", + " - identifier: tutorial", + " parent: guides", + " name: First Tutorial", + " pageRef: /docs/guides/tutorial", + " weight: 10", + " - identifier: reference", + " parent: docs", + " name: Reference", + " pageRef: /docs/reference", + " weight: 20", + " - identifier: blog", + " name: Blog", + " pageRef: /blog", + " weight: 20", + " - identifier: project", + " name: Project", + " pageRef: /project", + " weight: 30", + " - identifier: status", + " name: Status", + " url: https://status.example.com/", + " weight: 40", + " params:", + " icon: fa-solid fa-signal", + ] + ) + if variant == "deep": + lines.extend( + [ + " - identifier: advanced", + " parent: tutorial", + " name: Advanced Topic", + " pageRef: /docs/guides/tutorial", + " weight: 10", + ] + ) + return "\n".join(lines) + "\n" + + +def copy_fixture_site( + target: Path, variant: str, offline_search: bool, subpath: bool = True +) -> None: + shutil.copytree(SITE_FIXTURE_PATH, target) + (target / "hugo.yaml").write_text( + config_text(variant, offline_search, subpath), encoding="utf-8" + ) + + +def run_hugo(site: Path, output: Path, cache: Path) -> str: + command = [ + "hugo", + "--source", + str(site), + "--themesDir", + str(ROOT.parent), + "--destination", + str(output), + "--cacheDir", + str(cache), + "--cleanDestinationDir", + "--printPathWarnings", + ] + environment = dict(os.environ) + environment["HUGO_ENVIRONMENT"] = "development" + result = subprocess.run( + command, + cwd=site, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + if result.returncode: + raise ContractError( + f"Hugo fixture build failed ({site.name}):\n{result.stdout.rstrip()}" + ) + return result.stdout + + +def read_output(output: Path, pattern: str, lang: str) -> str: + path = output / pattern.format(lang=lang) + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + raise ContractError( + f"fixture output is missing {path.relative_to(output)}" + ) from exc + + +def normalize_navigation(html: str) -> dict[str, list[dict[str, Any]]]: + anchors = re.findall( + r'[^>]*\bclass="[^"]*' + r'(?:nav-link|mobile-menu-link)[^"]*"[^>]*)>' + r'(?P.*?)', + html, + flags=re.DOTALL, + ) + ignored = { + "GitHub", + "Language", + "语言", + "语言切换", + "Theme", + "主题", + "Search", + "搜索", + } + normalized: dict[str, list[dict[str, Any]]] = {"desktop": [], "mobile": []} + for attrs_text, body in anchors: + attrs = { + name.lower(): value + for name, _, value in re.findall( + r'([:\w-]+)\s*=\s*(["\x27])(.*?)\2', attrs_text, re.DOTALL + ) + } + classes = set(attrs.get("class", "").split()) + region = "desktop" if "nav-link" in classes else "mobile" + text = re.sub(r"<[^>]+>", " ", body) + text = " ".join(text.split()) + if text in ignored: + continue + normalized[region].append( + { + "href": attrs.get("href", ""), + "active": "active" in classes, + "target": attrs.get("target", ""), + "rel": sorted(attrs.get("rel", "").split()), + "text": text, + } + ) + return normalized + + +def runtime_markers(html: str, palette_controller_in_bundle: bool) -> dict[str, bool]: + script_sources = re.findall(r']*\bsrc="([^"]+)"', html) + return { + "dialog": 'id="td-shell-search"' in html, + "opener": "data-td-shell-search-open" in html, + "lunr": any("lunr" in source for source in script_sources), + "index_reference": "data-index-src=" in html, + "palette_controller": palette_controller_in_bundle, + } + + +def index_sizes(output: Path, lang: str) -> dict[str, int]: + candidates = sorted(output.glob(f"offline-search-index.{lang}*.json")) + if not candidates: + return {"uncompressed_bytes": 0, "gzip_bytes": 0} + payload = candidates[0].read_bytes() + return { + "uncompressed_bytes": len(payload), + "gzip_bytes": len(gzip.compress(payload, mtime=0)), + } + + +def observe_variant( + workspace: Path, + variant: str, + offline_search: bool, + subpath: bool = True, +) -> tuple[dict[str, Any], str]: + deployment = "subpath" if subpath else "root" + site = workspace / ( + f"{variant}-{'on' if offline_search else 'off'}-{deployment}" + ) + output = workspace / ( + f"public-{variant}-{'on' if offline_search else 'off'}-{deployment}" + ) + cache = workspace / "cache" + copy_fixture_site(site, variant, offline_search, subpath) + log = run_hugo(site, output, cache) + main_bundles = sorted((output / "js").glob("main-*.js")) + palette_bundles = { + str(bundle.relative_to(output)) + for bundle in main_bundles + if "td-shell-search" in bundle.read_text(encoding="utf-8") + } + + observation: dict[str, Any] = { + "navigation": {}, + "runtime": {}, + "indexes": {}, + "index_sizes": {}, + } + for lang in LANGUAGES: + navbar_html = read_output(output, SURFACES["plain"], lang) + observation["navigation"][lang] = normalize_navigation(navbar_html) + observation["runtime"][lang] = {} + observation["indexes"][lang] = any( + output.glob(f"offline-search-index.{lang}*.json") + ) + observation["index_sizes"][lang] = index_sizes(output, lang) + for surface, pattern in SURFACES.items(): + html = read_output(output, pattern, lang) + script_sources = re.findall(r']*\bsrc="([^"]+)"', html) + palette_controller_in_bundle = any( + any(source.endswith(bundle) for bundle in palette_bundles) + for source in script_sources + ) + observation["runtime"][lang][surface] = runtime_markers( + html, palette_controller_in_bundle + ) + return observation, log + + +def build_observation() -> dict[str, Any]: + result: dict[str, Any] = { + "version": 1, + "deployments": {}, + "index_sizes": {}, + } + with tempfile.TemporaryDirectory(prefix="oink-prd4-") as temp_dir: + workspace = Path(temp_dir) + for deployment, subpath in (("root", False), ("subpath", True)): + result["deployments"][deployment] = {} + for variant in VARIANTS: + result["deployments"][deployment][variant] = {} + for offline_search in (False, True): + state = "search_on" if offline_search else "search_off" + observation, log = observe_variant( + workspace, variant, offline_search, subpath=subpath + ) + require( + "WARN" not in log, + f"current {deployment}/{variant}/{state} fixture " + f"emitted an unexpected warning:\n{log}", + ) + result["deployments"][deployment][variant][state] = { + "navigation": observation["navigation"], + "runtime": observation["runtime"], + "indexes": observation["indexes"], + } + if ( + deployment == "subpath" + and variant == "flat" + and offline_search + ): + result["index_sizes"] = observation["index_sizes"] + return result + + +def validate_observation( + observation: dict[str, Any], contract: dict[str, Any] +) -> None: + deployments = observation["deployments"] + for deployment in ("root", "subpath"): + for variant in VARIANTS: + for state in ("search_off", "search_on"): + build = deployments[deployment][variant][state] + for lang in LANGUAGES: + for region in ("desktop", "mobile"): + links = build["navigation"][lang][region] + require( + [link["text"] for link in links] + == ["Docs", "Blog", "Project", "Status"], + f"{deployment}/{variant}/{state}/{lang}/{region} " + "navigation characterization changed", + ) + prefix = ( + f"/preview/{lang}/" + if deployment == "subpath" + else f"/{lang}/" + ) + require( + all( + link["href"].startswith(prefix) + for link in links[:3] + ), + f"{deployment}/{variant}/{state}/{lang}/{region} " + "internal menu prefix changed", + ) + external = links[-1] + require( + external["href"] == "https://status.example.com/" + and external["target"] == "_blank" + and external["rel"] == ["noopener", "noreferrer"], + f"{deployment}/{variant}/{state}/{lang}/{region} " + "external-link safety changed", + ) + require( + build["indexes"][lang] is (state == "search_on"), + f"{deployment}/{variant}/{state}/{lang} index emission changed", + ) + for surface in SURFACES: + runtime = build["runtime"][lang][surface] + if state == "search_off": + require( + not runtime["dialog"] + and not runtime["opener"] + and not runtime["lunr"] + and not runtime["index_reference"], + f"{deployment}/{variant}/{lang}/{surface} emits " + "search runtime while disabled", + ) + require( + runtime["palette_controller"] + is (surface in ("docs", "blog")), + f"{deployment}/{variant}/{lang}/{surface} " + "current controller characterization changed", + ) + elif surface in ("home", "docs", "blog"): + require( + runtime["dialog"] + and runtime["opener"] + and runtime["lunr"] + and runtime["index_reference"], + f"{deployment}/{variant}/{lang}/{surface} " + "search-enabled characterization changed", + ) + require( + runtime["palette_controller"] is True, + f"{deployment}/{variant}/{lang}/{surface} " + "Palette controller is missing", + ) + else: + require( + not runtime["dialog"] + and not runtime["opener"] + and runtime["lunr"] + and not runtime["index_reference"], + f"{deployment}/{variant}/{lang}/{surface} " + "non-Palette surface characterization changed", + ) + require( + runtime["palette_controller"] is False, + f"{deployment}/{variant}/{lang}/{surface} " + "unexpected Palette controller", + ) + + flat = deployments[deployment]["flat"]["search_off"]["navigation"] + for variant in ("nested", "deep"): + require( + flat + == deployments[deployment][variant]["search_off"]["navigation"], + f"the current {deployment}/{variant} menu gap changed; move " + "this assertion with the navbar implementation issue", + ) + + for lang in LANGUAGES: + sizes = observation["index_sizes"][lang] + budget = contract["search"]["size_budget"] + require( + sizes["uncompressed_bytes"] <= budget["uncompressed_bytes"], + f"{lang} local index exceeds the uncompressed byte budget", + ) + require( + sizes["gzip_bytes"] <= budget["gzip_bytes"], + f"{lang} local index exceeds the gzip byte budget", + ) + + +def validate_runtime_source_contract() -> None: + head = (ROOT / "layouts" / "_partials" / "head.html").read_text( + encoding="utf-8" + ) + scripts = (ROOT / "layouts" / "_partials" / "scripts.html").read_text( + encoding="utf-8" + ) + require( + 'resources.Get "js/third_party/lunr.min.js"' in head, + "current Lunr source marker changed", + ) + require( + 'resources.Get "js/docs-shell.js"' in scripts, + "current docs-shell controller source marker changed", + ) + + +def format_json(value: dict[str, Any]) -> str: + return json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True) + "\n" + + +def compact_snapshot(observation: dict[str, Any]) -> dict[str, Any]: + profiles: list[dict[str, Any]] = [] + assignments: dict[str, str] = {} + for deployment, variants in observation["deployments"].items(): + for variant, states in variants.items(): + for state, build in states.items(): + runtime_patterns: dict[str, list[str]] = {} + for surface, markers in build["runtime"][LANGUAGES[0]].items(): + require( + all( + build["runtime"][lang][surface] == markers + for lang in LANGUAGES[1:] + ), + f"{deployment}/{variant}/{state}/{surface} runtime " + "markers differ by language", + ) + key = json.dumps(markers, sort_keys=True) + runtime_patterns.setdefault(key, []).append(surface) + navigation = build["navigation"][LANGUAGES[0]] + for lang in LANGUAGES[1:]: + normalized = json.loads(format_json(build["navigation"][lang])) + for region in ("desktop", "mobile"): + for link in normalized[region]: + link["href"] = link["href"].replace( + f"/{lang}/", f"/{LANGUAGES[0]}/" + ) + require( + normalized == navigation, + f"{deployment}/{variant}/{state} navigation differs " + "beyond the expected language prefix", + ) + profile = { + "navigation_en": navigation, + "indexes": sorted( + lang for lang, present in build["indexes"].items() if present + ), + "runtime_patterns": runtime_patterns, + } + profile_text = json.dumps(profile, ensure_ascii=False, sort_keys=True) + profile_id = "" + for index, existing in enumerate(profiles, start=1): + if ( + json.dumps(existing, ensure_ascii=False, sort_keys=True) + == profile_text + ): + profile_id = f"profile_{index}" + break + if not profile_id: + profiles.append(profile) + profile_id = f"profile_{len(profiles)}" + assignments[f"{deployment}/{variant}/{state}"] = profile_id + return { + "version": observation["version"], + "profiles": { + f"profile_{index}": profile + for index, profile in enumerate(profiles, start=1) + }, + "matrix": assignments, + } + + +def compare_or_write(observation: dict[str, Any], write_current: bool) -> None: + snapshot = compact_snapshot(observation) + rendered = format_json(snapshot) + if write_current: + CURRENT_PATH.write_text(rendered, encoding="utf-8") + print(f"updated {CURRENT_PATH.relative_to(ROOT)}") + return + + current = load_json(CURRENT_PATH) + if current != snapshot: + raise ContractError( + "PRD 4 characterization changed.\n" + "Review the rendered behavior, then run the check with " + "--write-current only with the owning PRD 4 implementation issue.\n\n" + f"Observed:\n{rendered}" + ) + + +def main() -> int: + args = parse_args() + try: + contract = load_json(CONTRACT_PATH) + validate_contract(contract) + validate_contract_doc(contract) + validate_runtime_source_contract() + observation = build_observation() + validate_observation(observation, contract) + compare_or_write(observation, args.write_current) + except ContractError as exc: + print(f"PRD 4 contract check failed: {exc}", file=sys.stderr) + return 1 + print("PRD 4 contract and characterization checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/prd4/contract.json b/tests/fixtures/prd4/contract.json new file mode 100644 index 00000000..555bdea5 --- /dev/null +++ b/tests/fixtures/prd4/contract.json @@ -0,0 +1,161 @@ +{ + "version": 1, + "authorities": { + "global_navigation": "hugo_menu", + "sidebar": "hugo_content_tree", + "root_switcher": "existing_root_switcher", + "discovery_and_commands": "local_search_index" + }, + "navigation": { + "interactive_child_levels": 1, + "desktop_control": "separate_link_and_button", + "mobile_control": "separate_link_and_button", + "hover_required": false, + "deep_menu": { + "warning": true, + "rendering": "flatten_under_group_heading", + "third_level_flyout": false + }, + "mobile_single_open": { + "parameter": "params.ui.navbar_accordion_single_open", + "default": false + }, + "external_rel": [ + "noopener", + "noreferrer" + ] + }, + "sidebar_icons": { + "parameter": "params.ui.sidebar_icon_policy", + "values": [ + "all", + "groups", + "none" + ], + "compatibility_default": "all", + "starter_default": "groups" + }, + "search": { + "canonical_front_matter": [ + "search_keywords", + "search_boost", + "search_exclude" + ], + "exclude_aliases": [ + "exclude_search", + "excludeSearch" + ], + "exclude_resolution": "any_true_excludes", + "default_boost": 1.0, + "invalid_boost": "warn_and_use_default", + "score_formula": "text_score_times_search_boost", + "keywords_in_latin": true, + "keywords_in_cjk": true, + "language_separated_indexes": true, + "size_budget": { + "uncompressed_bytes": 2097152, + "gzip_bytes": 524288 + }, + "index_fields": [ + "ref", + "title", + "categories", + "tags", + "excerpt", + "headings", + "description", + "body", + "root", + "section", + "type", + "keywords", + "boost", + "breadcrumb", + "icon" + ] + }, + "commands": { + "builtin_action_ids": [ + "copy_markdown", + "view_markdown", + "edit_page", + "create_issue", + "print", + "switch_theme", + "switch_language", + "switch_version", + "open_github" + ], + "site_command_kinds": [ + "url", + "builtin_action_id" + ], + "javascript_callbacks_allowed": false, + "localized_parameter": "languages..params.ui.command_palette.commands" + }, + "palette": { + "dialog_owner": "existing_local_search_dialog", + "modes": [ + "empty", + "text", + "commands" + ], + "command_prefix": ">", + "first_version_scopes": [], + "default_telemetry": false + }, + "runtime": { + "enabled_when": [ + "params.offlineSearch", + "home_or_shell_surface", + "not_print" + ], + "disabled_must_omit": [ + "dialog", + "local_index", + "lunr", + "palette_controller" + ], + "print_must_omit": [ + "dialog", + "local_index", + "lunr", + "palette_controller" + ] + }, + "characterization_matrix": { + "menu_variants": [ + "flat", + "nested", + "deep" + ], + "search_states": [ + "disabled", + "enabled" + ], + "languages": [ + "en", + "zh" + ], + "deployments": [ + "root", + "subpath" + ], + "surfaces": [ + "home", + "docs", + "blog", + "plain", + "print" + ] + }, + "non_goals": [ + "ai_or_semantic_search", + "remote_search_replacement", + "recent_history", + "personalized_recommendations", + "arbitrary_depth_flyouts", + "second_navigation_authority", + "default_query_upload" + ] +} diff --git a/tests/fixtures/prd4/current.json b/tests/fixtures/prd4/current.json new file mode 100644 index 00000000..f9fe392b --- /dev/null +++ b/tests/fixtures/prd4/current.json @@ -0,0 +1,353 @@ +{ + "matrix": { + "root/deep/search_off": "profile_1", + "root/deep/search_on": "profile_2", + "root/flat/search_off": "profile_1", + "root/flat/search_on": "profile_2", + "root/nested/search_off": "profile_1", + "root/nested/search_on": "profile_2", + "subpath/deep/search_off": "profile_3", + "subpath/deep/search_on": "profile_4", + "subpath/flat/search_off": "profile_3", + "subpath/flat/search_on": "profile_4", + "subpath/nested/search_off": "profile_3", + "subpath/nested/search_on": "profile_4" + }, + "profiles": { + "profile_1": { + "indexes": [], + "navigation_en": { + "desktop": [ + { + "active": false, + "href": "/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ], + "mobile": [ + { + "active": false, + "href": "/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ] + }, + "runtime_patterns": { + "{\"dialog\": false, \"index_reference\": false, \"lunr\": false, \"opener\": false, \"palette_controller\": false}": [ + "home", + "plain", + "print" + ], + "{\"dialog\": false, \"index_reference\": false, \"lunr\": false, \"opener\": false, \"palette_controller\": true}": [ + "docs", + "blog" + ] + } + }, + "profile_2": { + "indexes": [ + "en", + "zh" + ], + "navigation_en": { + "desktop": [ + { + "active": false, + "href": "/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ], + "mobile": [ + { + "active": false, + "href": "/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ] + }, + "runtime_patterns": { + "{\"dialog\": false, \"index_reference\": false, \"lunr\": true, \"opener\": false, \"palette_controller\": false}": [ + "plain", + "print" + ], + "{\"dialog\": true, \"index_reference\": true, \"lunr\": true, \"opener\": true, \"palette_controller\": true}": [ + "home", + "docs", + "blog" + ] + } + }, + "profile_3": { + "indexes": [], + "navigation_en": { + "desktop": [ + { + "active": false, + "href": "/preview/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/preview/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/preview/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ], + "mobile": [ + { + "active": false, + "href": "/preview/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/preview/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/preview/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ] + }, + "runtime_patterns": { + "{\"dialog\": false, \"index_reference\": false, \"lunr\": false, \"opener\": false, \"palette_controller\": false}": [ + "home", + "plain", + "print" + ], + "{\"dialog\": false, \"index_reference\": false, \"lunr\": false, \"opener\": false, \"palette_controller\": true}": [ + "docs", + "blog" + ] + } + }, + "profile_4": { + "indexes": [ + "en", + "zh" + ], + "navigation_en": { + "desktop": [ + { + "active": false, + "href": "/preview/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/preview/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/preview/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ], + "mobile": [ + { + "active": false, + "href": "/preview/en/docs/", + "rel": [], + "target": "", + "text": "Docs" + }, + { + "active": false, + "href": "/preview/en/blog/", + "rel": [], + "target": "", + "text": "Blog" + }, + { + "active": true, + "href": "/preview/en/project/", + "rel": [], + "target": "", + "text": "Project" + }, + { + "active": false, + "href": "https://status.example.com/", + "rel": [ + "noopener", + "noreferrer" + ], + "target": "_blank", + "text": "Status" + } + ] + }, + "runtime_patterns": { + "{\"dialog\": false, \"index_reference\": false, \"lunr\": true, \"opener\": false, \"palette_controller\": false}": [ + "plain", + "print" + ], + "{\"dialog\": true, \"index_reference\": true, \"lunr\": true, \"opener\": true, \"palette_controller\": true}": [ + "home", + "docs", + "blog" + ] + } + } + }, + "version": 1 +} diff --git a/tests/fixtures/prd4/site/content/_index.md b/tests/fixtures/prd4/site/content/_index.md new file mode 100644 index 00000000..0c037ff7 --- /dev/null +++ b/tests/fixtures/prd4/site/content/_index.md @@ -0,0 +1,5 @@ +--- +title: Home +--- + +OINK PRD 4 fixture home. diff --git a/tests/fixtures/prd4/site/content/_index.zh.md b/tests/fixtures/prd4/site/content/_index.zh.md new file mode 100644 index 00000000..5b8fe01e --- /dev/null +++ b/tests/fixtures/prd4/site/content/_index.zh.md @@ -0,0 +1,5 @@ +--- +title: 首页 +--- + +OINK PRD 4 中文测试首页。 diff --git a/tests/fixtures/prd4/site/content/blog/_index.md b/tests/fixtures/prd4/site/content/blog/_index.md new file mode 100644 index 00000000..2e94e2e0 --- /dev/null +++ b/tests/fixtures/prd4/site/content/blog/_index.md @@ -0,0 +1,6 @@ +--- +title: Blog +linkTitle: Blog +--- + +Blog fixture root. diff --git a/tests/fixtures/prd4/site/content/blog/_index.zh.md b/tests/fixtures/prd4/site/content/blog/_index.zh.md new file mode 100644 index 00000000..711a1347 --- /dev/null +++ b/tests/fixtures/prd4/site/content/blog/_index.zh.md @@ -0,0 +1,6 @@ +--- +title: 博客 +linkTitle: 博客 +--- + +博客测试根节点。 diff --git a/tests/fixtures/prd4/site/content/blog/post.md b/tests/fixtures/prd4/site/content/blog/post.md new file mode 100644 index 00000000..85a40fa3 --- /dev/null +++ b/tests/fixtures/prd4/site/content/blog/post.md @@ -0,0 +1,7 @@ +--- +title: Release Post +date: 2026-08-12 +description: A release post used by the runtime matrix. +--- + +Release notes. diff --git a/tests/fixtures/prd4/site/content/blog/post.zh.md b/tests/fixtures/prd4/site/content/blog/post.zh.md new file mode 100644 index 00000000..f41de6c3 --- /dev/null +++ b/tests/fixtures/prd4/site/content/blog/post.zh.md @@ -0,0 +1,7 @@ +--- +title: 发布文章 +date: 2026-08-12 +description: 用于运行时矩阵的发布文章。 +--- + +发布说明。 diff --git a/tests/fixtures/prd4/site/content/docs/_index.md b/tests/fixtures/prd4/site/content/docs/_index.md new file mode 100644 index 00000000..c87cd523 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/_index.md @@ -0,0 +1,7 @@ +--- +title: Documentation +linkTitle: Docs +outputs: [HTML, print] +--- + +Documentation fixture root. diff --git a/tests/fixtures/prd4/site/content/docs/_index.zh.md b/tests/fixtures/prd4/site/content/docs/_index.zh.md new file mode 100644 index 00000000..91e4bbdc --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/_index.zh.md @@ -0,0 +1,7 @@ +--- +title: 文档 +linkTitle: 文档 +outputs: [HTML, print] +--- + +文档测试根节点。 diff --git a/tests/fixtures/prd4/site/content/docs/guides/_index.md b/tests/fixtures/prd4/site/content/docs/guides/_index.md new file mode 100644 index 00000000..824ebf80 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/guides/_index.md @@ -0,0 +1,6 @@ +--- +title: Guides +linkTitle: Guides +--- + +Task-oriented guides. diff --git a/tests/fixtures/prd4/site/content/docs/guides/_index.zh.md b/tests/fixtures/prd4/site/content/docs/guides/_index.zh.md new file mode 100644 index 00000000..a90220e2 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/guides/_index.zh.md @@ -0,0 +1,6 @@ +--- +title: 指南 +linkTitle: 指南 +--- + +面向任务的指南。 diff --git a/tests/fixtures/prd4/site/content/docs/guides/tutorial.md b/tests/fixtures/prd4/site/content/docs/guides/tutorial.md new file mode 100644 index 00000000..3d13e501 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/guides/tutorial.md @@ -0,0 +1,10 @@ +--- +title: First Tutorial +description: A tutorial used for active-path and search checks. +tags: [postgres] +outputs: [HTML, print] +--- + +## Install PostgreSQL + +Follow the tutorial to install PostgreSQL. diff --git a/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md b/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md new file mode 100644 index 00000000..9edb91c7 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md @@ -0,0 +1,10 @@ +--- +title: 第一个教程 +description: 用于活动路径与中文搜索检查的教程。 +tags: [数据库] +outputs: [HTML, print] +--- + +## 安装 PostgreSQL + +按照教程安装 PostgreSQL 数据库。 diff --git a/tests/fixtures/prd4/site/content/docs/reference.md b/tests/fixtures/prd4/site/content/docs/reference.md new file mode 100644 index 00000000..7a9c8235 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/reference.md @@ -0,0 +1,6 @@ +--- +title: Reference +description: Configuration and API reference. +--- + +Reference material. diff --git a/tests/fixtures/prd4/site/content/docs/reference.zh.md b/tests/fixtures/prd4/site/content/docs/reference.zh.md new file mode 100644 index 00000000..eddd7717 --- /dev/null +++ b/tests/fixtures/prd4/site/content/docs/reference.zh.md @@ -0,0 +1,6 @@ +--- +title: 参考 +description: 配置与 API 参考。 +--- + +参考资料。 diff --git a/tests/fixtures/prd4/site/content/project/_index.md b/tests/fixtures/prd4/site/content/project/_index.md new file mode 100644 index 00000000..088fe1d3 --- /dev/null +++ b/tests/fixtures/prd4/site/content/project/_index.md @@ -0,0 +1,6 @@ +--- +title: Project +linkTitle: Project +--- + +Non-shell project root. diff --git a/tests/fixtures/prd4/site/content/project/_index.zh.md b/tests/fixtures/prd4/site/content/project/_index.zh.md new file mode 100644 index 00000000..59863fcf --- /dev/null +++ b/tests/fixtures/prd4/site/content/project/_index.zh.md @@ -0,0 +1,6 @@ +--- +title: 项目 +linkTitle: 项目 +--- + +非文档壳层的项目根节点。 From e42ece52d91ac842596d958e73eec415ee9de01d Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 12 Aug 2026 12:32:43 +0800 Subject: [PATCH 02/11] refactor: isolate command palette runtime --- .github/workflows/ci.yml | 3 + assets/js/base.js | 40 +- assets/js/command-palette.js | 389 ++++++++++++++++++ assets/js/docs-shell.js | 356 +--------------- assets/js/surface-coordinator.js | 55 +++ ...rd4-navigation-command-palette-contract.md | 7 +- layouts/_partials/head.html | 2 +- .../_partials/navbar-version-selector.html | 2 +- layouts/_partials/scripts.html | 19 +- layouts/_partials/search-input.html | 4 +- layouts/_partials/shell/search-enabled.html | 3 +- scripts/check-prd4-contract.py | 52 ++- scripts/check-prd4-runtime.py | 106 +++++ tests/fixtures/prd4/current.json | 16 +- 14 files changed, 672 insertions(+), 382 deletions(-) create mode 100644 assets/js/command-palette.js create mode 100644 assets/js/surface-coordinator.js create mode 100644 scripts/check-prd4-runtime.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46760a0c..90adb563 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,9 @@ jobs: - name: Check PRD 4 contracts and characterization run: python3 scripts/check-prd4-contract.py + - name: Check PRD 4 runtime isolation + run: python3 scripts/check-prd4-runtime.py + - name: Build the example site without warnings working-directory: exampleSite run: hugo --printPathWarnings --panicOnWarning diff --git a/assets/js/base.js b/assets/js/base.js index f53dd2d9..d3621ab6 100644 --- a/assets/js/base.js +++ b/assets/js/base.js @@ -36,12 +36,20 @@ limitations under the License. if (!toggle || !menu) return; function setOpen(open) { + if (open && window.OinkSurfaceCoordinator) { + window.OinkSurfaceCoordinator.closeOthers('mobile-menu'); + } menu.classList.toggle('active', open); toggle.classList.toggle('active', open); toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); const label = open ? toggle.dataset.labelClose : toggle.dataset.labelOpen; if (label) toggle.setAttribute('aria-label', label); } + if (window.OinkSurfaceCoordinator) { + window.OinkSurfaceCoordinator.register('mobile-menu', function() { + setOpen(false); + }); + } toggle.addEventListener('click', function() { setOpen(!menu.classList.contains('active')); @@ -65,15 +73,23 @@ limitations under the License. } function initLanguageMenus() { - document.querySelectorAll('.td-language-selector--menu').forEach(function(menu) { + document.querySelectorAll('.td-language-selector--menu').forEach(function(menu, index) { const trigger = menu.querySelector('.td-language-selector__trigger'); + const surfaceName = 'language-menu-' + index; let closeTimer = 0; function open() { + if (window.OinkSurfaceCoordinator) { + const keep = menu.closest('#td-shell-sidebar') ? ['drawer'] : []; + window.OinkSurfaceCoordinator.closeOthers(surfaceName, keep); + } window.clearTimeout(closeTimer); menu.classList.add('is-open'); if (trigger) trigger.setAttribute('aria-expanded', 'true'); } + if (window.OinkSurfaceCoordinator) { + window.OinkSurfaceCoordinator.register(surfaceName, close); + } function close() { menu.classList.remove('is-open'); @@ -98,8 +114,30 @@ limitations under the License. }); } + function initVersionMenus() { + document.querySelectorAll('[data-td-version-menu]').forEach(function(menu, index) { + const trigger = menu.querySelector('[data-bs-toggle="dropdown"]'); + const surfaceName = 'version-menu-' + index; + if (!trigger || !window.bootstrap || !bootstrap.Dropdown) return; + const dropdown = bootstrap.Dropdown.getOrCreateInstance(trigger); + + menu.addEventListener('show.bs.dropdown', function() { + if (window.OinkSurfaceCoordinator) { + const keep = menu.closest('#td-shell-sidebar') ? ['drawer'] : []; + window.OinkSurfaceCoordinator.closeOthers(surfaceName, keep); + } + }); + if (window.OinkSurfaceCoordinator) { + window.OinkSurfaceCoordinator.register(surfaceName, function() { + dropdown.hide(); + }); + } + }); + } + initHeaderScroll(); initMobileMenu(); initLanguageMenus(); + initVersionMenus(); }()); diff --git a/assets/js/command-palette.js b/assets/js/command-palette.js new file mode 100644 index 00000000..cb50437d --- /dev/null +++ b/assets/js/command-palette.js @@ -0,0 +1,389 @@ +/** + * command-palette.js — local search dialog and keyboard interaction. + * + * This controller is bundled only when shell/search-enabled.html returns true. + * Search metadata and commands extend this controller in later PRD 4 issues. + */ +(function () { + 'use strict'; + + var html = document.documentElement; + var FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), ' + + 'select:not([disabled]), textarea:not([disabled]), ' + + '[tabindex]:not([tabindex="-1"])'; + + function focusable(container) { + return Array.prototype.filter.call( + container.querySelectorAll(FOCUSABLE), + function (el) { + return el.offsetParent !== null || el === document.activeElement; + }, + ); + } + + function tabTrap(container, isActive) { + return function (event) { + if (event.key !== 'Tab' || !isActive()) return; + var items = focusable(container); + if (!items.length) return; + var first = items[0]; + var last = items[items.length - 1]; + var active = document.activeElement; + if (event.shiftKey && (active === first || !container.contains(active))) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + }; + } + + function initSearch() { + var root = document.getElementById('td-shell-search'); + if (!root) return; + var input = root.querySelector('.td-shell-search__input'); + var list = root.querySelector('.td-shell-search__list'); + var panel = root.querySelector('.td-shell-search__panel'); + var status = root.querySelector('[data-td-shell-search-status]'); + if (!input || !list || !panel || !status) return; + + var CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿]/; + var index = null; + var docs = null; + var docByRef = new Map(); + var loading = false; + var results = []; + var selected = 0; + var hideTimer = 0; + var maxResults = parseInt(root.dataset.maxResults, 10); + if (!Number.isFinite(maxResults) || maxResults < 1) maxResults = 10; + var openers = document.querySelectorAll('[data-td-shell-search-open]'); + var lastOpener = null; + + function isOpen() { + return root.classList.contains('is-open'); + } + + function open(event) { + var opener = + event && event.currentTarget + ? event.currentTarget + : document.activeElement; + if ( + opener && + opener.closest && + opener.closest('#td-shell-sidebar') && + html.hasAttribute('data-td-shell-drawer') + ) { + var drawerOpeners = document.querySelectorAll( + '[data-td-shell-drawer-open]', + ); + opener = + Array.prototype.find.call(drawerOpeners, function (candidate) { + return candidate.offsetParent !== null; + }) || drawerOpeners[0] || opener; + } + lastOpener = opener; + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.closeOthers('palette'); + window.clearTimeout(hideTimer); + root.hidden = false; + html.setAttribute('data-td-shell-lock', ''); + openers.forEach(function (el) { + el.setAttribute('aria-expanded', 'true'); + }); + input.setAttribute('aria-expanded', 'true'); + window.requestAnimationFrame(function () { + root.classList.add('is-open'); + }); + input.focus(); + input.select(); + ensureIndex(); + } + function close(restoreFocus) { + var shouldRestore = restoreFocus !== false; + root.classList.remove('is-open'); + html.removeAttribute('data-td-shell-lock'); + openers.forEach(function (el) { + el.setAttribute('aria-expanded', 'false'); + }); + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + if (shouldRestore && lastOpener && root.contains(document.activeElement)) + lastOpener.focus(); + hideTimer = window.setTimeout(function () { + root.hidden = true; + }, 240); + } + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.register('palette', close); + + function announce(text) { + status.textContent = ''; + window.requestAnimationFrame(function () { + status.textContent = text; + }); + } + + function message(text) { + list.textContent = ''; + input.removeAttribute('aria-activedescendant'); + var el = document.createElement('div'); + el.className = 'td-shell-search__empty'; + el.textContent = text; + list.appendChild(el); + announce(text); + } + + function ensureIndex() { + if (index || loading) return; + loading = true; + list.setAttribute('aria-busy', 'true'); + if (!docs) message(root.dataset.tLoading || '…'); + fetch(root.dataset.indexSrc) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + docs = data; + data.forEach(function (d) { + docByRef.set(d.ref, d); + }); + index = lunr(function () { + this.ref('ref'); + this.field('title', { boost: 5 }); + this.field('categories', { boost: 3 }); + this.field('tags', { boost: 3 }); + this.field('headings', { boost: 3 }); + this.field('description', { boost: 2 }); + this.field('body'); + data.forEach(function (d) { + this.add(d); + }, this); + }); + loading = false; + list.removeAttribute('aria-busy'); + render(input.value); + }) + .catch(function () { + loading = false; + list.removeAttribute('aria-busy'); + message(root.dataset.tEmpty || 'No results'); + }); + } + + // lunr cannot tokenize CJK reliably, so scan the indexed text for substrings. + function queryCjk(q) { + var hits = []; + var needle = q.toLowerCase(); + docs.forEach(function (d) { + var titleAt = (d.title || '').toLowerCase().indexOf(needle); + var headingAt = (d.headings || '').toLowerCase().indexOf(needle); + var descAt = (d.description || '').toLowerCase().indexOf(needle); + var bodyAt = (d.body || '').toLowerCase().indexOf(needle); + var score = + (titleAt >= 0 ? 100 : 0) + + (headingAt >= 0 ? 50 : 0) + + (descAt >= 0 ? 30 : 0) + + (bodyAt >= 0 ? 10 : 0); + if (!score) return; + var excerpt = d.excerpt || ''; + if (bodyAt >= 0) { + var start = Math.max(0, bodyAt - 24); + excerpt = + (start > 0 ? '…' : '') + d.body.slice(start, bodyAt + 56) + '…'; + } else if (descAt >= 0) { + excerpt = d.description; + } + hits.push({ doc: d, score: score, excerpt: excerpt }); + }); + hits.sort(function (a, b) { + return b.score - a.score; + }); + return hits.slice(0, maxResults); + } + + // Latin queries retain exact, wildcard, and edit-distance matching. + function queryLatin(q) { + var found = index.query(function (builder) { + lunr.tokenizer(q.toLowerCase()).forEach(function (token) { + var term = token.toString(); + builder.term(term, { boost: 100 }); + builder.term(term, { + wildcard: + lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING, + boost: 10, + }); + builder.term(term, { editDistance: 2 }); + }); + }); + return found + .slice(0, maxResults) + .map(function (r) { + var doc = docByRef.get(r.ref); + return doc + ? { doc: doc, excerpt: doc.excerpt || doc.description || '' } + : null; + }) + .filter(Boolean); + } + + function highlight(text, q) { + var fragment = document.createDocumentFragment(); + var at = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1; + if (at < 0) { + fragment.appendChild(document.createTextNode(text)); + return fragment; + } + fragment.appendChild(document.createTextNode(text.slice(0, at))); + var mark = document.createElement('mark'); + mark.textContent = text.slice(at, at + q.length); + fragment.appendChild(mark); + fragment.appendChild(document.createTextNode(text.slice(at + q.length))); + return fragment; + } + + function select(i) { + var options = Array.prototype.slice.call( + list.querySelectorAll('[role="option"]'), + ); + if (!options.length) { + input.removeAttribute('aria-activedescendant'); + return; + } + selected = Math.max(0, Math.min(i, options.length - 1)); + options.forEach(function (row, n) { + row.setAttribute('aria-selected', n === selected ? 'true' : 'false'); + }); + var row = options[selected]; + input.setAttribute('aria-activedescendant', row.id); + row.scrollIntoView({ block: 'nearest' }); + } + + function render(q) { + q = (q || '').trim(); + if (!docs || !index) return; + list.textContent = ''; + results = []; + selected = 0; + input.removeAttribute('aria-activedescendant'); + if (!q) { + status.textContent = ''; + return; + } + + try { + results = CJK.test(q) ? queryCjk(q) : queryLatin(q); + } catch (e) { + results = []; + } + if (!results.length) { + message(root.dataset.tEmpty || 'No results'); + return; + } + results.forEach(function (r, i) { + var row = document.createElement('a'); + row.className = 'td-shell-search__item'; + row.id = 'td-shell-search-option-' + i; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', 'false'); + row.setAttribute('tabindex', '-1'); + row.href = r.doc.ref; + + var title = document.createElement('div'); + title.className = 'td-shell-search__item-title'; + title.appendChild(highlight(r.doc.title || r.doc.ref, q)); + row.appendChild(title); + + var ref = document.createElement('div'); + ref.className = 'td-shell-search__item-ref'; + ref.textContent = r.doc.ref; + row.appendChild(ref); + + if (r.excerpt) { + var excerpt = document.createElement('div'); + excerpt.className = 'td-shell-search__item-excerpt'; + excerpt.appendChild(highlight(r.excerpt, q)); + row.appendChild(excerpt); + } + + row.addEventListener('pointermove', function () { + if (selected !== i) select(i); + }); + list.appendChild(row); + }); + select(0); + announce( + (root.dataset.tResults || '{count} results').replace( + '{count}', + String(results.length), + ), + ); + } + + var debounce = 0; + input.addEventListener('input', function () { + window.clearTimeout(debounce); + debounce = window.setTimeout(function () { + render(input.value); + }, 80); + }); + input.addEventListener('keydown', function (e) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (results.length) select(Math.min(selected + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (results.length) select(Math.max(selected - 1, 0)); + } else if (e.key === 'Home') { + e.preventDefault(); + if (results.length) select(0); + } else if (e.key === 'End') { + e.preventDefault(); + if (results.length) select(results.length - 1); + } else if (e.key === 'Enter') { + var row = list.querySelectorAll('[role="option"]')[selected]; + if (row && row.href) window.location.href = row.href; + } + }); + + document.addEventListener('keydown', tabTrap(panel, isOpen), true); + + document.addEventListener('keydown', function (e) { + if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') { + e.preventDefault(); + if (isOpen()) { + close(); + } else { + open(); + } + } else if (e.key === 'Escape' && isOpen()) { + close(); + } + }); + openers.forEach(function (el) { + el.addEventListener('click', open); + }); + root + .querySelectorAll('[data-td-shell-search-close]') + .forEach(function (el) { + el.addEventListener('click', close); + }); + + // Show Ctrl instead of the Command badge on non-Apple platforms. + var apple = /Mac|iPhone|iPad|iPod/.test( + navigator.platform || navigator.userAgent, + ); + if (!apple) { + document + .querySelectorAll('[data-td-shell-meta-key]') + .forEach(function (el) { + el.textContent = 'Ctrl'; + }); + } + } + + initSearch(); +})(); diff --git a/assets/js/docs-shell.js b/assets/js/docs-shell.js index 76fbcef0..252ac08a 100644 --- a/assets/js/docs-shell.js +++ b/assets/js/docs-shell.js @@ -3,8 +3,8 @@ * * Modules: rootMenu (root switcher), drawer (mobile navigation), collapse * (desktop sidebar and hover overlay), resize, treeScroll, toc (SVG track, - * clip-path highlight, and moving dot), and search (command dialog with a - * CJK substring fallback for lunr). + * clip-path highlight, and moving dot). The local search/Palette controller + * lives in command-palette.js so it can be omitted independently. * * The theme keeps the `td-color-theme` localStorage key and * . The collapsed sidebar state is stored under @@ -127,18 +127,24 @@ if (!root) return; var btn = root.querySelector('[data-td-shell-root-toggle]'); var pop = root.querySelector('.td-shell-root__pop'); + var closeTimer = 0; if (!btn || !pop) return; - function close() { + function close(restoreFocus) { if (pop.hidden) return; + window.clearTimeout(closeTimer); pop.classList.remove('is-open'); btn.setAttribute('aria-expanded', 'false'); - window.setTimeout(function () { + closeTimer = window.setTimeout(function () { pop.hidden = true; }, 100); + if (restoreFocus === true) btn.focus(); document.removeEventListener('pointerdown', onOutside, true); } function open() { + window.clearTimeout(closeTimer); + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.closeOthers('root-menu', ['drawer']); pop.hidden = false; btn.setAttribute('aria-expanded', 'true'); window.requestAnimationFrame(function () { @@ -146,18 +152,22 @@ }); document.addEventListener('pointerdown', onOutside, true); } + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.register('root-menu', function (restoreFocus) { + close(restoreFocus); + }); function onOutside(e) { - if (!root.contains(e.target)) close(); + if (!root.contains(e.target)) close(false); } btn.addEventListener('click', function () { if (pop.hidden) { open(); } else { - close(); + close(false); } }); document.addEventListener('keydown', function (e) { - if (e.key === 'Escape' && !pop.hidden) close(); + if (e.key === 'Escape' && !pop.hidden) close(true); }); } @@ -172,6 +182,8 @@ ); var lastOpener = null; function open(event) { + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.closeOthers('drawer'); lastOpener = event.currentTarget; html.setAttribute('data-td-shell-drawer', 'open'); openers.forEach(function (el) { @@ -182,6 +194,8 @@ closeButton.focus(); }); } + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.register('drawer', close); function close(restoreFocus) { var wasOpen = html.hasAttribute('data-td-shell-drawer'); html.removeAttribute('data-td-shell-drawer'); @@ -869,333 +883,6 @@ }); } - /* --------------------------------------------------------------- search */ - - function initSearch() { - var root = document.getElementById('td-shell-search'); - if (!root) return; - var input = root.querySelector('.td-shell-search__input'); - var list = root.querySelector('.td-shell-search__list'); - var panel = root.querySelector('.td-shell-search__panel'); - var status = root.querySelector('[data-td-shell-search-status]'); - if (!input || !list || !panel || !status) return; - - var CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿]/; - var index = null; - var docs = null; - var docByRef = new Map(); - var loading = false; - var results = []; - var selected = 0; - var hideTimer = 0; - var maxResults = parseInt(root.dataset.maxResults, 10); - if (!Number.isFinite(maxResults) || maxResults < 1) maxResults = 10; - var openers = document.querySelectorAll('[data-td-shell-search-open]'); - var lastOpener = null; - - function isOpen() { - return root.classList.contains('is-open'); - } - - function open(event) { - lastOpener = - event && event.currentTarget - ? event.currentTarget - : document.activeElement; - window.clearTimeout(hideTimer); - root.hidden = false; - html.setAttribute('data-td-shell-lock', ''); - openers.forEach(function (el) { - el.setAttribute('aria-expanded', 'true'); - }); - input.setAttribute('aria-expanded', 'true'); - window.requestAnimationFrame(function () { - root.classList.add('is-open'); - }); - input.focus(); - input.select(); - ensureIndex(); - } - function close() { - root.classList.remove('is-open'); - html.removeAttribute('data-td-shell-lock'); - openers.forEach(function (el) { - el.setAttribute('aria-expanded', 'false'); - }); - input.setAttribute('aria-expanded', 'false'); - input.removeAttribute('aria-activedescendant'); - if (lastOpener && root.contains(document.activeElement)) - lastOpener.focus(); - hideTimer = window.setTimeout(function () { - root.hidden = true; - }, 240); - } - - function announce(text) { - status.textContent = ''; - window.requestAnimationFrame(function () { - status.textContent = text; - }); - } - - function message(text) { - list.textContent = ''; - input.removeAttribute('aria-activedescendant'); - var el = document.createElement('div'); - el.className = 'td-shell-search__empty'; - el.textContent = text; - list.appendChild(el); - announce(text); - } - - function ensureIndex() { - if (index || loading) return; - loading = true; - list.setAttribute('aria-busy', 'true'); - if (!docs) message(root.dataset.tLoading || '…'); - fetch(root.dataset.indexSrc) - .then(function (r) { - return r.json(); - }) - .then(function (data) { - docs = data; - data.forEach(function (d) { - docByRef.set(d.ref, d); - }); - index = lunr(function () { - this.ref('ref'); - this.field('title', { boost: 5 }); - this.field('categories', { boost: 3 }); - this.field('tags', { boost: 3 }); - this.field('headings', { boost: 3 }); - this.field('description', { boost: 2 }); - this.field('body'); - data.forEach(function (d) { - this.add(d); - }, this); - }); - loading = false; - list.removeAttribute('aria-busy'); - render(input.value); - }) - .catch(function () { - loading = false; - list.removeAttribute('aria-busy'); - message(root.dataset.tEmpty || 'No results'); - }); - } - - // lunr cannot tokenize CJK reliably, so scan the indexed text for substrings. - function queryCjk(q) { - var hits = []; - var needle = q.toLowerCase(); - docs.forEach(function (d) { - var titleAt = (d.title || '').toLowerCase().indexOf(needle); - var headingAt = (d.headings || '').toLowerCase().indexOf(needle); - var descAt = (d.description || '').toLowerCase().indexOf(needle); - var bodyAt = (d.body || '').toLowerCase().indexOf(needle); - var score = - (titleAt >= 0 ? 100 : 0) + - (headingAt >= 0 ? 50 : 0) + - (descAt >= 0 ? 30 : 0) + - (bodyAt >= 0 ? 10 : 0); - if (!score) return; - var excerpt = d.excerpt || ''; - if (bodyAt >= 0) { - var start = Math.max(0, bodyAt - 24); - excerpt = - (start > 0 ? '…' : '') + d.body.slice(start, bodyAt + 56) + '…'; - } else if (descAt >= 0) { - excerpt = d.description; - } - hits.push({ doc: d, score: score, excerpt: excerpt }); - }); - hits.sort(function (a, b) { - return b.score - a.score; - }); - return hits.slice(0, maxResults); - } - - // Latin queries retain exact, wildcard, and edit-distance matching. - function queryLatin(q) { - var found = index.query(function (builder) { - lunr.tokenizer(q.toLowerCase()).forEach(function (token) { - var term = token.toString(); - builder.term(term, { boost: 100 }); - builder.term(term, { - wildcard: - lunr.Query.wildcard.LEADING | lunr.Query.wildcard.TRAILING, - boost: 10, - }); - builder.term(term, { editDistance: 2 }); - }); - }); - return found - .slice(0, maxResults) - .map(function (r) { - var doc = docByRef.get(r.ref); - return doc - ? { doc: doc, excerpt: doc.excerpt || doc.description || '' } - : null; - }) - .filter(Boolean); - } - - function highlight(text, q) { - var fragment = document.createDocumentFragment(); - var at = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1; - if (at < 0) { - fragment.appendChild(document.createTextNode(text)); - return fragment; - } - fragment.appendChild(document.createTextNode(text.slice(0, at))); - var mark = document.createElement('mark'); - mark.textContent = text.slice(at, at + q.length); - fragment.appendChild(mark); - fragment.appendChild(document.createTextNode(text.slice(at + q.length))); - return fragment; - } - - function select(i) { - var options = Array.prototype.slice.call( - list.querySelectorAll('[role="option"]'), - ); - if (!options.length) { - input.removeAttribute('aria-activedescendant'); - return; - } - selected = Math.max(0, Math.min(i, options.length - 1)); - options.forEach(function (row, n) { - row.setAttribute('aria-selected', n === selected ? 'true' : 'false'); - }); - var row = options[selected]; - input.setAttribute('aria-activedescendant', row.id); - row.scrollIntoView({ block: 'nearest' }); - } - - function render(q) { - q = (q || '').trim(); - if (!docs || !index) return; - list.textContent = ''; - results = []; - selected = 0; - input.removeAttribute('aria-activedescendant'); - if (!q) { - status.textContent = ''; - return; - } - - try { - results = CJK.test(q) ? queryCjk(q) : queryLatin(q); - } catch (e) { - results = []; - } - if (!results.length) { - message(root.dataset.tEmpty || 'No results'); - return; - } - results.forEach(function (r, i) { - var row = document.createElement('a'); - row.className = 'td-shell-search__item'; - row.id = 'td-shell-search-option-' + i; - row.setAttribute('role', 'option'); - row.setAttribute('aria-selected', 'false'); - row.setAttribute('tabindex', '-1'); - row.href = r.doc.ref; - - var title = document.createElement('div'); - title.className = 'td-shell-search__item-title'; - title.appendChild(highlight(r.doc.title || r.doc.ref, q)); - row.appendChild(title); - - var ref = document.createElement('div'); - ref.className = 'td-shell-search__item-ref'; - ref.textContent = r.doc.ref; - row.appendChild(ref); - - if (r.excerpt) { - var excerpt = document.createElement('div'); - excerpt.className = 'td-shell-search__item-excerpt'; - excerpt.appendChild(highlight(r.excerpt, q)); - row.appendChild(excerpt); - } - - row.addEventListener('pointermove', function () { - if (selected !== i) select(i); - }); - list.appendChild(row); - }); - select(0); - announce( - (root.dataset.tResults || '{count} results').replace( - '{count}', - String(results.length), - ), - ); - } - - var debounce = 0; - input.addEventListener('input', function () { - window.clearTimeout(debounce); - debounce = window.setTimeout(function () { - render(input.value); - }, 80); - }); - input.addEventListener('keydown', function (e) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (results.length) select(Math.min(selected + 1, results.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (results.length) select(Math.max(selected - 1, 0)); - } else if (e.key === 'Home') { - e.preventDefault(); - if (results.length) select(0); - } else if (e.key === 'End') { - e.preventDefault(); - if (results.length) select(results.length - 1); - } else if (e.key === 'Enter') { - var row = list.querySelectorAll('[role="option"]')[selected]; - if (row && row.href) window.location.href = row.href; - } - }); - - document.addEventListener('keydown', tabTrap(panel, isOpen), true); - - document.addEventListener('keydown', function (e) { - if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') { - e.preventDefault(); - if (isOpen()) { - close(); - } else { - open(); - } - } else if (e.key === 'Escape' && isOpen()) { - close(); - } - }); - openers.forEach(function (el) { - el.addEventListener('click', open); - }); - root - .querySelectorAll('[data-td-shell-search-close]') - .forEach(function (el) { - el.addEventListener('click', close); - }); - - // Show Ctrl instead of the Command badge on non-Apple platforms. - var apple = /Mac|iPhone|iPad|iPod/.test( - navigator.platform || navigator.userAgent, - ); - if (!apple) { - document - .querySelectorAll('[data-td-shell-meta-key]') - .forEach(function (el) { - el.textContent = 'Ctrl'; - }); - } - } - /* ----------------------------------------------------------------- boot */ initRootMenu(); @@ -1211,7 +898,6 @@ initAsideRelocate(); initToc(); initPageContext(); - initSearch(); // Restore transitions after the first painted frame. window.requestAnimationFrame(function () { diff --git a/assets/js/surface-coordinator.js b/assets/js/surface-coordinator.js new file mode 100644 index 00000000..9c5a282c --- /dev/null +++ b/assets/js/surface-coordinator.js @@ -0,0 +1,55 @@ +/** + * surface-coordinator.js — coordinate mutually exclusive transient UI. + * + * A surface registers a close callback and may request that the coordinator + * close every other registered surface before it opens. The callbacks own + * their own focus-restoration policy; coordinated closes deliberately do not + * steal focus from the surface being opened. + */ +(function () { + 'use strict'; + + var surfaces = new Map(); + + function register(name, close) { + if (!name || typeof close !== 'function') return function () {}; + var callbacks = surfaces.get(name) || []; + callbacks.push(close); + surfaces.set(name, callbacks); + return function () { + var current = surfaces.get(name) || []; + current = current.filter(function (callback) { + return callback !== close; + }); + if (current.length) { + surfaces.set(name, current); + } else { + surfaces.delete(name); + } + }; + } + + function closeOthers(activeName, keepNames) { + var keep = new Set(keepNames || []); + surfaces.forEach(function (callbacks, name) { + if (name !== activeName && !keep.has(name)) + callbacks.slice().forEach(function (close) { + close(false); + }); + }); + } + + function closeAll(restoreFocus) { + surfaces.forEach(function (callbacks) { + callbacks.slice().forEach(function (close) { + close(restoreFocus === true); + }); + }); + } + + window.OinkSurfaceCoordinator = { + register: register, + closeOthers: closeOthers, + closeAll: closeAll, + }; +})(); diff --git a/docs/prd4-navigation-command-palette-contract.md b/docs/prd4-navigation-command-palette-contract.md index 1794669c..e051bc0b 100644 --- a/docs/prd4-navigation-command-palette-contract.md +++ b/docs/prd4-navigation-command-palette-contract.md @@ -131,9 +131,10 @@ and Palette controller. Print follows the same omission rule. No default telemetry request is allowed. -The current characterization intentionally exposes places where source assets -are still gated by params.offlineSearch alone. Issue -[pgsty/oink#13](https://github.com/pgsty/oink/issues/13) owns closing that gap. +The runtime-isolation implementation in +[pgsty/oink#13](https://github.com/pgsty/oink/issues/13) makes the capability +predicate authoritative for dialog markup, the local index reference, Lunr, +and the Palette controller. ## Compatibility and non-goals diff --git a/layouts/_partials/head.html b/layouts/_partials/head.html index 049b4fad..531bcbba 100644 --- a/layouts/_partials/head.html +++ b/layouts/_partials/head.html @@ -78,7 +78,7 @@ {{ if hugo.IsProduction }}{{ $jquery = $jquery | fingerprint }}{{ end -}} -{{ if .Site.Params.offlineSearch -}} +{{ if partial "shell/search-enabled.html" . -}} {{ $lunr := resources.Get "js/third_party/lunr.min.js" -}} {{ if hugo.IsProduction }}{{ $lunr = $lunr | fingerprint }}{{ end -}} +{{- end }} {{- $darkMode := partialCached "dark-mode-config.html" "dark-mode-global" -}} {{ if $darkMode.enable -}} diff --git a/scripts/check-image-zoom.py b/scripts/check-image-zoom.py index d4cd8835..ca8e66da 100644 --- a/scripts/check-image-zoom.py +++ b/scripts/check-image-zoom.py @@ -291,8 +291,9 @@ def check_template_contracts() -> list[str]: " ARG ", bundle.group(2), ) + placeholders = len(re.findall(r"%(?:v|s)", bundle.group(1))) arguments = re.findall(r"\$[A-Za-z][A-Za-z0-9]*|\.[A-Za-z][A-Za-z0-9_.]*|\bARG\b", expression) - require(bundle.group(1).count("%v") == len(arguments), "bundle key format/argument count diverged", errors) + require(placeholders == len(arguments), "bundle key format/argument count diverged", errors) require("$hasImageZoom" in bundle.group(2), "bundle key omits the Zoom variant", errors) require('resources.Get "js/image-zoom.js"' in scripts, "scripts.html does not append Zoom", errors) diff --git a/scripts/check-prd4-actions.py b/scripts/check-prd4-actions.py new file mode 100644 index 00000000..8dc9b05d --- /dev/null +++ b/scripts/check-prd4-actions.py @@ -0,0 +1,487 @@ +#!/usr/bin/env python3 +"""Build and verify PRD 4 action descriptors, registry, and command safety.""" + +from __future__ import annotations + +import importlib.util +import html as html_module +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from html.parser import HTMLParser +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT_SCRIPT = ROOT / "scripts" / "check-prd4-contract.py" +BUILTINS = [ + "copy_markdown", + "view_markdown", + "edit_page", + "create_issue", + "print", + "switch_theme", + "switch_language", + "switch_version", + "open_github", +] +ACTION_FIELDS = { + "id", + "title", + "description", + "icon", + "keywords", + "kind", + "available", + "disabledReason", + "url", + "target", + "placements", + "options", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def load_contract_module() -> Any: + spec = importlib.util.spec_from_file_location("prd4_contract", CONTRACT_SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError("cannot load PRD 4 fixture helper") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def action_config(helper: Any, subpath: bool, offline_search: bool = True) -> str: + config = helper.config_text("flat", offline_search, subpath) + config = config.replace( + " github_project_repo: https://github.com/pgsty/oink\n", + " github_project_repo: https://github.com/acme/project\n" + " github_repo: https://github.com/acme/docs\n" + " github_branch: release\n" + " github_subdir: website\n" + " version_menu: Version v1\n" + " version: v1\n" + " version_menu_pagelinks: true\n" + " versions:\n" + " - name: Version 1\n" + " version: v1\n" + " url: https://v1.example.com\n" + " - name: ---\n" + " - name: Future\n" + " version: v2\n", + ) + config = config.replace( + " feedback:\n enable: false\n", + " showLightDarkModeMenu: true\n" + " feedback:\n enable: false\n", + ) + en_commands = ( + " params:\n" + " ui:\n" + " command_palette:\n" + " commands:\n" + " - id: status\n" + " title: Service status\n" + " description: Current service health\n" + " url: https://status.example.com/\n" + " icon: fa-solid fa-signal\n" + " keywords: [uptime, incident]\n" + " - id: print_now\n" + " title: Print now\n" + " action: print\n" + " keywords: [paper]\n" + " - id: theme_now\n" + " title: Choose theme\n" + " action: switch_theme\n" + " - id: language_now\n" + " title: Choose language\n" + " action: switch_language\n" + " - id: version_now\n" + " title: Choose version\n" + " action: switch_version\n" + " - id: escaped\n" + " title: ''\n" + " url: /safe/\n" + ) + zh_commands = ( + " params:\n" + " ui:\n" + " command_palette:\n" + " commands:\n" + " - id: print_now\n" + " title: 立即打印\n" + " keywords: [纸张]\n" + " - id: status\n" + " title: 服务状态\n" + " description: 当前服务健康状态\n" + " keywords: [可用性, 事故]\n" + ) + config = config.replace(" weight: 1\n zh:\n", " weight: 1\n" + en_commands + " zh:\n") + config = config.replace(" weight: 2\nmenus:\n", " weight: 2\n" + zh_commands + "menus:\n") + return config + + +def build( + helper: Any, + workspace: Path, + name: str, + config: str, +) -> tuple[Path, str]: + site = workspace / f"site-{name}" + output = workspace / f"public-{name}" + shutil.copytree(helper.SITE_FIXTURE_PATH, site) + (site / "hugo.yaml").write_text(config, encoding="utf-8") + log = helper.run_hugo(site, output, workspace / "cache") + return output, log + + +def manifest_from(html: str) -> dict[str, Any]: + match = re.search( + r'', + html, + re.DOTALL, + ) + require(match is not None, "page omitted the action manifest") + payload = match.group(1) + require(" dict[str, dict[str, Any]]: + return {record["id"]: record for record in records} + + +def attribute(html: str, action_id: str, name: str) -> str: + match = re.search( + rf']*\bdata-oink-action="{re.escape(action_id)}")([^>]*)>', + html, + ) + require(match is not None, f"missing anchor for {action_id}") + value = re.search(rf'\b{name}="([^"]*)"', match.group(1)) + require(value is not None, f"{action_id} anchor omitted {name}") + return html_module.unescape(value.group(1)) + + +def validate_manifest( + manifest: dict[str, Any], lang: str, prefix: str +) -> None: + require(manifest.get("version") == 1, "manifest version changed") + require(manifest.get("language") == lang, f"{lang} manifest language changed") + actions = manifest.get("actions") + require(isinstance(actions, list), f"{lang} actions are not an array") + require([action.get("id") for action in actions] == BUILTINS, f"{lang} built-in order changed") + for action in actions: + require(ACTION_FIELDS <= set(action), f"{lang}/{action.get('id')} descriptor is incomplete") + require(isinstance(action["available"], bool), f"{lang}/{action['id']} availability is not boolean") + require(isinstance(action["keywords"], list), f"{lang}/{action['id']} keywords are not an array") + require(isinstance(action["options"], list), f"{lang}/{action['id']} options are not an array") + if not action["available"]: + require(action["disabledReason"], f"{lang}/{action['id']} lacks an unavailable reason") + + by_id = map_by_id(actions) + markdown = f"{prefix}{lang}/docs/guides/tutorial/index.md" + require(by_id["copy_markdown"]["url"] == markdown, f"{lang} copy URL is not subpath safe") + require(by_id["view_markdown"]["url"] == markdown, f"{lang} view URL diverges from copy") + require(by_id["copy_markdown"]["available"] is True, f"{lang} copy is unavailable") + require( + by_id["edit_page"]["url"] + == "https://github.com/acme/docs/edit/release/website/content/docs/guides/tutorial." + + ("zh.md" if lang == "zh" else "md"), + f"{lang} edit URL changed: {by_id['edit_page']['url']}", + ) + expected_title = "First+Tutorial" if lang == "en" else "%E7%AC%AC%E4%B8%80%E4%B8%AA%E6%95%99%E7%A8%8B" + require( + by_id["create_issue"]["url"] + == f"https://github.com/acme/docs/issues/new?title={expected_title}", + f"{lang} issue URL encoding changed: {by_id['create_issue']['url']}", + ) + require(by_id["open_github"]["url"] == "https://github.com/acme/project", f"{lang} project URL changed") + require(by_id["switch_theme"]["available"] is True, f"{lang} theme action unavailable") + require( + [option["id"] for option in by_id["switch_theme"]["options"]] + == ["auto", "light", "dark"], + f"{lang} theme choices changed", + ) + language_options = by_id["switch_language"]["options"] + require(len(language_options) == 2, f"{lang} language options changed") + require( + all(prefix in option["url"] for option in language_options), + f"{lang} language target lost deployment prefix", + ) + versions = by_id["switch_version"]["options"] + require([option["id"] for option in versions] == ["v1", "v2"], f"{lang} version options changed") + require(versions[0]["active"] is True and versions[0]["available"] is True, f"{lang} active version changed") + require(versions[1]["available"] is False and versions[1]["disabledReason"], f"{lang} disabled version lacks reason") + + commands = map_by_id(manifest.get("commands", [])) + require( + set(commands) + == { + "status", + "print_now", + "theme_now", + "language_now", + "version_now", + "escaped", + }, + f"{lang} command merge changed", + ) + require(commands["status"]["kind"] == "url", f"{lang} URL command kind changed") + require(commands["status"]["url"] == "https://status.example.com/", f"{lang} status URL fallback changed") + require(commands["print_now"]["action"] == "print", f"{lang} built-in command changed") + require( + commands["theme_now"]["action"] == "switch_theme" + and commands["language_now"]["action"] == "switch_language" + and commands["version_now"]["action"] == "switch_version", + f"{lang} choice command aliases changed", + ) + if lang == "en": + require(commands["status"]["title"] == "Service status", "EN title changed") + else: + require(commands["status"]["title"] == "服务状态", "ZH title did not localize by ID") + require(commands["status"]["keywords"] == ["可用性", "事故"], "ZH keywords did not localize") + require(commands["print_now"]["title"] == "立即打印", "out-of-order ZH merge failed") + + +def run_invalid_build(helper: Any, workspace: Path, name: str, command_yaml: str, expected: str) -> None: + site = workspace / f"invalid-{name}" + output = workspace / f"invalid-public-{name}" + shutil.copytree(helper.SITE_FIXTURE_PATH, site) + config = helper.config_text("flat", True, True) + addition = ( + " params:\n" + " ui:\n" + " command_palette:\n" + " commands:\n" + + command_yaml + ) + config = config.replace(" weight: 1\n zh:\n", " weight: 1\n" + addition + " zh:\n") + (site / "hugo.yaml").write_text(config, encoding="utf-8") + result = subprocess.run( + [ + "hugo", + "--source", + str(site), + "--themesDir", + str(ROOT.parent), + "--destination", + str(output), + "--cacheDir", + str(workspace / "cache-invalid"), + ], + cwd=site, + env={**os.environ, "HUGO_ENVIRONMENT": "development"}, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + check=False, + ) + require(result.returncode != 0, f"invalid {name} command unexpectedly built") + require(expected in result.stdout, f"invalid {name} build missed {expected!r}:\n{result.stdout}") + + +def main() -> int: + try: + helper = load_contract_module() + with tempfile.TemporaryDirectory(prefix="oink-prd4-actions-") as temp: + workspace = Path(temp) + for deployment, subpath, prefix in ( + ("root", False, "/"), + ("subpath", True, "/preview/"), + ): + output, log = build( + helper, + workspace, + deployment, + action_config(helper, subpath), + ) + require( + all("invalid search_boost" in line for line in log.splitlines() if "WARN" in line), + f"{deployment} action fixture emitted an unrelated warning", + ) + for lang in helper.LANGUAGES: + path = output / lang / "docs" / "guides" / "tutorial" / "index.html" + html = path.read_text(encoding="utf-8") + manifest = manifest_from(html) + validate_manifest(manifest, lang, prefix) + for action_id in ("copy_markdown", "view_markdown", "edit_page", "create_issue", "print"): + require( + html.count(f'data-oink-action="{action_id}"') == 1, + f"{deployment}/{lang} page action {action_id} is missing or duplicated", + ) + require("data-td-page-copy " not in html, "legacy copy controller remains") + require("data-td-page-print" not in html, "legacy print controller remains") + require("" not in html, "command title escaped inert JSON") + by_id = map_by_id(manifest["actions"]) + for action_id in ("view_markdown", "edit_page", "create_issue"): + require( + attribute(html, action_id, "href") == by_id[action_id]["url"], + f"{deployment}/{lang} no-JS {action_id} link diverges from descriptor", + ) + require( + attribute(html, action_id, "target") == "_blank", + f"{deployment}/{lang} {action_id} no longer opens externally", + ) + require( + "noopener" in attribute(html, action_id, "rel").split(), + f"{deployment}/{lang} {action_id} lost opener protection", + ) + + reference = ( + output / lang / "docs" / "reference" / "index.html" + ).read_text(encoding="utf-8") + reference_actions = map_by_id(manifest_from(reference)["actions"]) + require( + reference_actions["copy_markdown"]["available"] is False + and reference_actions["view_markdown"]["available"] is False, + f"{deployment}/{lang} page without Markdown advertises Markdown actions", + ) + + bundles = sorted((output / "js").glob("main-*.js")) + require(bundles, f"{deployment} emitted no main bundle") + require( + any("OinkActionRegistry" in bundle.read_text(encoding="utf-8") for bundle in bundles), + f"{deployment} omitted the registry runtime", + ) + require( + any("data-oink-action" in bundle.read_text(encoding="utf-8") for bundle in bundles), + f"{deployment} omitted the page-action controller", + ) + + print_html = (output / "en" / "_print" / "docs" / "index.html").read_text( + encoding="utf-8" + ) + print_manifest = manifest_from(print_html) + require( + map_by_id(print_manifest["actions"])["print"]["available"] is True, + f"{deployment} print output omitted executable print data", + ) + require( + print_html.count('data-oink-action="print"') >= 1, + f"{deployment} print output lost its print control", + ) + + output, _ = build( + helper, + workspace, + "search-off", + action_config(helper, True, offline_search=False), + ) + html = (output / "en" / "docs" / "guides" / "tutorial" / "index.html").read_text(encoding="utf-8") + require("oink-action-manifest" in html, "search-off page omitted action data") + require('id="td-shell-search"' not in html, "search-off page gained Palette markup") + search_off_bundles = [ + bundle.read_text(encoding="utf-8") + for bundle in sorted((output / "js").glob("main-*.js")) + ] + require( + any("OinkActionRegistry" in source for source in search_off_bundles), + "search-off page omitted the action registry runtime", + ) + require( + any("data-oink-action" in source for source in search_off_bundles), + "search-off page omitted the page-action controller", + ) + + invalid_cases = { + "unknown": ( + " - id: bad\n action: not_real\n", + 'references unsupported action "not_real"', + ), + "both": ( + " - id: bad\n action: print\n url: /bad/\n", + "must define exactly one of url or action", + ), + "callback": ( + " - id: bad\n url: /bad/\n callback: alert\n", + 'uses unsupported key "callback"', + ), + "javascript": ( + " - id: bad\n url: 'javascript:alert(1)'\n", + "uses unsafe URL", + ), + "data": ( + " - id: bad\n url: 'data:text/html,boom'\n", + "uses unsafe URL", + ), + "protocol-relative": ( + " - id: bad\n url: //evil.example/x\n", + "uses unsafe URL", + ), + "duplicate": ( + " - id: bad\n url: /one/\n" + " - id: bad\n url: /two/\n", + 'duplicate command id "bad"', + ), + "reserved": ( + " - id: print\n url: /bad/\n", + 'is reserved by a built-in action', + ), + "numeric-title": ( + " - id: bad\n title: 42\n url: /bad/\n", + 'field "title" must be a string', + ), + "map-icon": ( + " - id: bad\n icon: {class: bad}\n url: /bad/\n", + 'field "icon" must be a string', + ), + "scalar-keywords": ( + " - id: bad\n keywords: bad\n url: /bad/\n", + 'field "keywords" must be an array of strings', + ), + "mixed-keywords": ( + " - id: bad\n keywords: [good, 42]\n url: /bad/\n", + 'field "keywords" must be an array of strings', + ), + } + for name, (yaml, expected) in invalid_cases.items(): + run_invalid_build(helper, workspace, name, yaml, expected) + + for script in ( + "prd4-action-registry.test.js", + "prd4-page-actions.test.js", + "prd4-dark-mode.test.js", + ): + behavior = subprocess.run( + ["node", str(ROOT / "tests" / "js" / script)], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + require( + behavior.returncode == 0, + f"{script} failed:\n" + (behavior.stdout + behavior.stderr).strip(), + ) + + docs_shell = (ROOT / "assets" / "js" / "docs-shell.js").read_text(encoding="utf-8") + authored = (ROOT / "assets" / "js" / "authored-a11y.js").read_text(encoding="utf-8") + dark_mode = (ROOT / "assets" / "js" / "dark-mode.js").read_text(encoding="utf-8") + require("fetchMarkdown" not in docs_shell, "Markdown executor remains duplicated in docs-shell") + require("window.print()" not in authored, "print executor remains duplicated") + require( + "registerExecutor('switch_theme'" in dark_mode + and "apply(button.getAttribute('data-bs-theme-value'))" in dark_mode, + "theme controls and registry do not share the same apply executor", + ) + except (OSError, RuntimeError, json.JSONDecodeError) as exc: + print(f"PRD 4 action registry check failed: {exc}", file=sys.stderr) + return 1 + + print("PRD 4 action registry and command manifest checks passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/prd4/site/content/docs/guides/tutorial.md b/tests/fixtures/prd4/site/content/docs/guides/tutorial.md index 1398aac7..c8791283 100644 --- a/tests/fixtures/prd4/site/content/docs/guides/tutorial.md +++ b/tests/fixtures/prd4/site/content/docs/guides/tutorial.md @@ -2,9 +2,10 @@ title: First Tutorial description: A tutorial used for active-path and search checks. tags: [postgres] -outputs: [HTML, print] +outputs: [HTML, print, markdown] icon: fa-solid fa-rocket search_keywords: [postgresql, pgboost] +github_project_repo: https://github.com/page/override --- ## Install PostgreSQL diff --git a/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md b/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md index 80cf12e2..1c441ece 100644 --- a/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md +++ b/tests/fixtures/prd4/site/content/docs/guides/tutorial.zh.md @@ -2,9 +2,10 @@ title: 第一个教程 description: 用于活动路径与中文搜索检查的教程。 tags: [数据库] -outputs: [HTML, print] +outputs: [HTML, print, markdown] icon: fa-solid fa-rocket search_keywords: [数据库别名, 增强词] +github_project_repo: https://github.com/page/override --- ## 安装 PostgreSQL diff --git a/tests/js/prd4-action-registry.test.js b/tests/js/prd4-action-registry.test.js new file mode 100644 index 00000000..23e59726 --- /dev/null +++ b/tests/js/prd4-action-registry.test.js @@ -0,0 +1,255 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const registryModule = require( + path.join(__dirname, '..', '..', 'assets/js/action-registry.js'), +); + +const builtinIds = [ + 'copy_markdown', + 'view_markdown', + 'edit_page', + 'create_issue', + 'print', + 'switch_theme', + 'switch_language', + 'switch_version', + 'open_github', +]; + +function descriptor(id, values = {}) { + return { + id, + title: id, + description: id, + icon: 'fa-solid fa-bolt', + keywords: [id], + kind: 'invoke', + available: true, + disabledReason: '', + url: '', + target: 'self', + placements: { page: true, palette: true }, + options: [], + ...values, + }; +} + +function harness({ actions, commands = [], fetchImpl, clipboard = true } = {}) { + const events = { + assigned: [], opened: [], printed: 0, copied: [], fetched: [], + execCommands: [], fallbackText: [], + }; + const fakeWindow = { + location: { + href: 'https://example.org/preview/en/docs/page/', + assign(url) { + events.assigned.push(url); + }, + }, + open(url, target, features) { + events.opened.push({ url, target, features }); + }, + print() { + events.printed += 1; + }, + navigator: clipboard ? { + clipboard: { + writeText(text) { + events.copied.push(text); + return Promise.resolve(); + }, + }, + } : {}, + }; + let textarea = null; + const fakeDocument = { + getElementById() { + return null; + }, + createElement(name) { + assert.equal(name, 'textarea'); + textarea = { + value: '', style: {}, + setAttribute() {}, + select() { events.fallbackText.push(this.value); }, + remove() { textarea = null; }, + }; + return textarea; + }, + body: { + appendChild(node) { assert.equal(node, textarea); }, + }, + execCommand(command) { + events.execCommands.push(command); + return command === 'copy'; + }, + }; + const fetchApi = + fetchImpl || + ((url) => { + events.fetched.push(url); + return Promise.resolve({ ok: true, text: () => Promise.resolve('# Page') }); + }); + const registry = registryModule.create({ + window: fakeWindow, + document: fakeDocument, + fetch: fetchApi, + manifest: { version: 1, actions: actions || [], commands }, + }); + return { registry, events }; +} + +(async () => { + const actions = builtinIds.map((id) => descriptor(id)); + actions[0] = descriptor('copy_markdown', { + kind: 'copy', + url: '/preview/en/docs/page/index.md', + }); + actions[1] = descriptor('view_markdown', { + kind: 'url', + url: '/preview/en/docs/page/index.md', + target: 'blank', + }); + actions[2] = descriptor('edit_page', { + kind: 'url', + available: false, + disabledReason: 'Repository unavailable', + }); + actions[5] = descriptor('switch_theme', { + kind: 'choice', + options: [{ id: 'dark', title: 'Dark', value: 'dark', available: true }], + }); + actions[6] = descriptor('switch_language', { + kind: 'choice', + options: [{ id: 'zh', title: '中文', url: '/zh/', available: true }], + }); + actions[7] = descriptor('switch_version', { + kind: 'choice', + options: [{ id: 'v2', title: 'Version 2', url: 'https://v2.example.com/', available: true }], + }); + const commands = [ + { + id: 'status', + kind: 'url', + url: 'https://status.example.com/', + target: 'blank', + }, + { id: 'print_now', kind: 'builtin', action: 'print' }, + { id: 'theme_now', kind: 'builtin', action: 'switch_theme' }, + { id: 'language_now', kind: 'builtin', action: 'switch_language' }, + { id: 'version_now', kind: 'builtin', action: 'switch_version' }, + { id: 'unsafe', kind: 'url', url: 'javascript:alert(1)' }, + { id: 'unknown', kind: 'builtin', action: 'not_real' }, + ]; + const { registry, events } = harness({ actions, commands }); + + assert.deepEqual(registry.list().map((action) => action.id), builtinIds); + assert.deepEqual( + registry.list({ placement: 'page' }).map((action) => action.id), + builtinIds, + ); + assert.equal(registry.get('unknown'), null); + assert.deepEqual(registry.commands().map((command) => command.id), [ + 'status', + 'print_now', + 'theme_now', + 'language_now', + 'version_now', + ]); + assert.equal(events.fetched.length, 0, 'registry creation caused a request'); + + const pending = registry.preloadMarkdown('/preview/en/docs/page/index.md'); + await registry.run('copy_markdown', { source: 'page' }); + await pending; + await registry.run('copy_markdown', { source: 'palette' }); + assert.equal(events.fetched.length, 1, 'markdown was fetched more than once'); + assert.deepEqual(events.copied, ['# Page', '# Page']); + + await registry.run('view_markdown'); + assert.deepEqual(events.opened[0], { + url: 'https://example.org/preview/en/docs/page/index.md', + target: '_blank', + features: 'noopener,noreferrer', + }); + await registry.run('print'); + await registry.runCommand('print_now'); + assert.equal(events.printed, 2); + await registry.runCommand('status'); + assert.equal(events.opened[1].url, 'https://status.example.com/'); + for (const [command, action] of [ + ['theme_now', 'switch_theme'], + ['language_now', 'switch_language'], + ['version_now', 'switch_version'], + ]) { + const result = await registry.runCommand(command); + assert.equal(result.requiresChoice, true, `${command} did not request a choice`); + assert.equal(result.action.id, action); + assert.ok(result.options.length > 0); + } + + await assert.rejects(registry.run('edit_page'), (error) => { + assert.equal(error.code, 'unavailable'); + assert.equal(error.message, 'Repository unavailable'); + return true; + }); + await assert.rejects(registry.run('not_real'), { code: 'unsupported_action' }); + await assert.rejects(registry.runCommand('unsafe'), { + code: 'unsupported_command', + }); + + let theme = ''; + registry.registerExecutor('switch_theme', ({ value }) => { + theme = value.value; + }); + await registry.run('switch_theme', { value: { value: 'dark' } }); + assert.equal(theme, 'dark'); + assert.throws( + () => registry.registerExecutor('switch_theme', () => {}), + { code: 'duplicate_executor' }, + ); + assert.throws(() => registry.registerExecutor('not_real', () => {}), { + code: 'unsupported_executor', + }); + + assert.equal( + registry.safeUrl('/preview/en/docs/'), + 'https://example.org/preview/en/docs/', + ); + for (const unsafe of [ + 'javascript:alert(1)', + 'data:text/html,boom', + '//evil.example/x', + '\\evil.example\\x', + ' https://evil.example/', + ]) { + assert.equal(registry.safeUrl(unsafe), null, unsafe); + } + + let attempts = 0; + const retryHarness = harness({ + actions: [actions[0]], + fetchImpl() { + attempts += 1; + if (attempts === 1) return Promise.resolve({ ok: false }); + return Promise.resolve({ ok: true, text: () => Promise.resolve('# Retry') }); + }, + }); + await assert.rejects( + retryHarness.registry.preloadMarkdown('/preview/en/docs/page/index.md'), + ); + await retryHarness.registry.run('copy_markdown'); + assert.equal(attempts, 2, 'failed markdown promise was not invalidated'); + + const fallbackHarness = harness({ actions: [actions[0]], clipboard: false }); + await fallbackHarness.registry.run('copy_markdown'); + assert.deepEqual(fallbackHarness.events.execCommands, ['copy']); + assert.deepEqual(fallbackHarness.events.fallbackText, ['# Page']); + + console.log('PRD 4 action registry behavior checks passed'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/tests/js/prd4-dark-mode.test.js b/tests/js/prd4-dark-mode.test.js new file mode 100644 index 00000000..1ee007c8 --- /dev/null +++ b/tests/js/prd4-dark-mode.test.js @@ -0,0 +1,91 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function classes() { + const values = new Set(); + return { + toggle(name, force) { if (force) values.add(name); else values.delete(name); }, + has(name) { return values.has(name); }, + }; +} + +(() => { + const storage = new Map(); + const attributes = new Map([['data-theme-init', '']]); + const callbacks = new Map(); + const light = { + attributes: new Map([['data-bs-theme-value', 'light']]), + classList: classes(), listeners: new Map(), + getAttribute(name) { return this.attributes.get(name); }, + setAttribute(name, value) { this.attributes.set(name, value); }, + addEventListener(name, callback) { this.listeners.set(name, callback); }, + }; + let registryExecutor = null; + const fakeDocument = { + documentElement: { + setAttribute(name, value) { attributes.set(name, value); }, + getAttribute(name) { return attributes.get(name); }, + removeAttribute(name) { attributes.delete(name); }, + }, + querySelectorAll(selector) { + if (selector === '[data-bs-theme-value]') return [light]; + if (selector === '[data-td-theme-toggle]') return []; + return []; + }, + }; + const media = { + matches: false, + addEventListener(name, callback) { callbacks.set(`media:${name}`, callback); }, + }; + const fakeWindow = { + matchMedia() { return media; }, + addEventListener(name, callback) { callbacks.set(name, callback); }, + OinkActions: { + get(id) { return id === 'switch_theme' ? { id } : null; }, + registerExecutor(id, executor) { + assert.equal(id, 'switch_theme'); + registryExecutor = executor; + }, + }, + }; + const fakeStorage = { + getItem(key) { return storage.get(key) || null; }, + setItem(key, value) { storage.set(key, value); }, + }; + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'assets/js/dark-mode.js'), + 'utf8', + ); + vm.runInNewContext(source, { + window: fakeWindow, + document: fakeDocument, + localStorage: fakeStorage, + Promise, + }); + + assert.equal(attributes.get('data-bs-theme'), 'light'); + assert.equal(attributes.has('data-theme-init'), false); + assert.equal(typeof registryExecutor, 'function'); + callbacks.get('DOMContentLoaded')(); + light.listeners.get('click')(); + assert.equal(storage.get('td-color-theme'), 'light'); + assert.equal(attributes.get('data-bs-theme'), 'light'); + assert.equal(light.attributes.get('aria-pressed'), 'true'); + assert.equal(light.classList.has('is-active'), true); + + registryExecutor({ value: { value: 'dark' } }); + assert.equal(storage.get('td-color-theme'), 'dark'); + assert.equal(attributes.get('data-bs-theme'), 'dark'); + assert.equal(light.attributes.get('aria-pressed'), 'false'); + + storage.set('td-color-theme', 'auto'); + media.matches = true; + callbacks.get('media:change')(); + assert.equal(attributes.get('data-bs-theme'), 'dark'); + + console.log('PRD 4 shared theme executor checks passed'); +})(); diff --git a/tests/js/prd4-page-actions.test.js b/tests/js/prd4-page-actions.test.js new file mode 100644 index 00000000..e8ab8d9c --- /dev/null +++ b/tests/js/prd4-page-actions.test.js @@ -0,0 +1,98 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function control(id, root) { + const listeners = new Map(); + const label = { textContent: 'Copy Markdown', dataset: {} }; + const classes = new Set(); + return { + dataset: { oinkAction: id }, + listeners, + label, + classList: { + add(value) { classes.add(value); }, + remove(value) { classes.delete(value); }, + contains(value) { return classes.has(value); }, + }, + querySelector(selector) { + return selector === '[data-td-page-copy-label]' ? label : null; + }, + closest(selector) { + return selector === '[data-td-page-context]' ? root : null; + }, + addEventListener(name, handler, options) { + listeners.set(name, { handler, options }); + }, + }; +} + +(async () => { + const status = { textContent: '' }; + const root = { + dataset: { tCopied: 'Markdown copied', tCopyError: 'Copy failed' }, + querySelector(selector) { + return selector === '[data-td-page-context-status]' ? status : null; + }, + }; + const copy = control('copy_markdown', root); + const print = control('print', root); + const events = { preload: [], run: [], timers: [] }; + const actions = { + copy_markdown: { id: 'copy_markdown', available: true, url: '/page.md' }, + print: { id: 'print', available: true }, + }; + const fakeWindow = { + OinkActions: { + get(id) { return actions[id] || null; }, + preloadMarkdown(url) { events.preload.push(url); return Promise.resolve(); }, + run(id, context) { events.run.push({ id, context }); return Promise.resolve(); }, + }, + requestAnimationFrame(callback) { callback(); }, + setTimeout(callback) { events.timers.push(callback); }, + }; + const fakeDocument = { + querySelectorAll(selector) { + assert.equal(selector, '[data-oink-action]'); + return [copy, print]; + }, + }; + const source = fs.readFileSync( + path.join(__dirname, '..', '..', 'assets/js/page-actions.js'), + 'utf8', + ); + vm.runInNewContext(source, { + window: fakeWindow, + document: fakeDocument, + Promise, + }); + + copy.listeners.get('pointerenter').handler(); + copy.listeners.get('focus').handler(); + assert.deepEqual(events.preload, ['/page.md', '/page.md']); + assert.equal(copy.listeners.get('pointerenter').options.once, true); + + copy.listeners.get('click').handler(); + await Promise.resolve(); + await Promise.resolve(); + assert.equal(events.run[0].id, 'copy_markdown'); + assert.equal(events.run[0].context.source, 'page'); + assert.equal(copy.classList.contains('is-copied'), true); + assert.equal(copy.label.textContent, 'Markdown copied'); + assert.equal(status.textContent, 'Markdown copied'); + events.timers[0](); + assert.equal(copy.classList.contains('is-copied'), false); + assert.equal(copy.label.textContent, 'Copy Markdown'); + + print.listeners.get('click').handler(); + assert.equal(events.run[1].id, 'print'); + assert.equal(events.run[1].context.source, 'page'); + + console.log('PRD 4 page action DOM binding checks passed'); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); From ebfd78b226b5c1e2e7ad569e369456869b0662ab Mon Sep 17 00:00:00 2001 From: Feng Ruohang Date: Wed, 12 Aug 2026 14:46:00 +0800 Subject: [PATCH 08/11] feat: upgrade search to command palette --- .github/workflows/ci.yml | 3 + assets/js/action-registry.js | 14 + assets/js/command-palette.js | 610 +++++++++++++++------ assets/js/palette-model.js | 187 +++++++ assets/scss/td/shell/_search.scss | 41 +- i18n/ar.yaml | 11 + i18n/az.yaml | 11 + i18n/bg.yaml | 11 + i18n/bn.yaml | 11 + i18n/de.yaml | 11 + i18n/en.yaml | 11 + i18n/es.yaml | 11 + i18n/et.yaml | 11 + i18n/fa.yaml | 11 + i18n/fi.yaml | 11 + i18n/fr.yaml | 11 + i18n/he.yaml | 11 + i18n/hi.yaml | 11 + i18n/hu.yaml | 11 + i18n/it.yaml | 11 + i18n/ja.yaml | 11 + i18n/ko.yaml | 11 + i18n/nl.yaml | 11 + i18n/no.yaml | 11 + i18n/oc.yaml | 11 + i18n/pl.yaml | 11 + i18n/pt-br.yaml | 11 + i18n/ro.yaml | 11 + i18n/ru.yaml | 11 + i18n/sr-cyrl.yaml | 11 + i18n/sr-latn.yaml | 11 + i18n/sv.yaml | 11 + i18n/tr.yaml | 11 + i18n/uk.yaml | 11 + i18n/zh-cn.yaml | 11 + i18n/zh-tw.yaml | 11 + i18n/zh.yaml | 11 + layouts/_partials/actions/manifest.html | 2 + layouts/_partials/actions/quick-links.html | 29 + layouts/_partials/actions/root-order.html | 10 + layouts/_partials/scripts.html | 3 +- layouts/_partials/shell/search-dialog.html | 14 +- scripts/check-prd4-palette.py | 158 ++++++ scripts/check-prd4-runtime.py | 10 +- scripts/check-prd4-search.py | 2 +- tests/js/prd4-command-palette.test.js | 426 ++++++++++++++ tests/js/prd4-palette-model.test.js | 133 +++++ tests/js/prd4-surfaces.test.js | 21 +- 48 files changed, 1821 insertions(+), 194 deletions(-) create mode 100644 assets/js/palette-model.js create mode 100644 layouts/_partials/actions/quick-links.html create mode 100644 layouts/_partials/actions/root-order.html create mode 100644 scripts/check-prd4-palette.py create mode 100644 tests/js/prd4-command-palette.test.js create mode 100644 tests/js/prd4-palette-model.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6188a8b6..b058c753 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Check action registry and command manifest run: python3 scripts/check-prd4-actions.py + - name: Check Command Palette modes and behavior + run: python3 scripts/check-prd4-palette.py + - name: Build the example site without warnings working-directory: exampleSite run: hugo --printPathWarnings --panicOnWarning diff --git a/assets/js/action-registry.js b/assets/js/action-registry.js index 368a1bc1..fdefb412 100644 --- a/assets/js/action-registry.js +++ b/assets/js/action-registry.js @@ -102,6 +102,18 @@ return commands.slice(); } + function getQuickLinks() { + return (manifest.quickLinks || []).filter(function (link) { + return link && link.kind === 'url' && safeUrl(link.url, windowObject.location.href); + }).slice(); + } + + function getRootOrder() { + return (manifest.rootOrder || []).map(function (key) { + return String(key).trim().toLowerCase(); + }).filter(Boolean); + } + function fetchMarkdown(url) { if (markdown.has(url)) return markdown.get(url); var pending = fetchApi(url) @@ -223,6 +235,8 @@ get: get, list: list, commands: getCommands, + quickLinks: getQuickLinks, + rootOrder: getRootOrder, run: run, runCommand: runCommand, registerExecutor: registerExecutor, diff --git a/assets/js/command-palette.js b/assets/js/command-palette.js index f2131fef..f1113f20 100644 --- a/assets/js/command-palette.js +++ b/assets/js/command-palette.js @@ -1,10 +1,8 @@ /** - * command-palette.js — local search dialog and keyboard interaction. - * - * This controller is bundled only when shell/search-enabled.html returns true. - * Search metadata and commands extend this controller in later PRD 4 issues. + * Command Palette: one dialog for local pages, quick links, and safe actions. + * Bundled only when shell/search-enabled.html is true. */ -(function () { +(function (global) { 'use strict'; var html = document.documentElement; @@ -40,94 +38,86 @@ }; } - function initSearch() { - var root = document.getElementById('td-shell-search'); - if (!root) return; + function initSearch(options) { + options = options || {}; + var root = options.root || document.getElementById('td-shell-search'); + if (!root) return null; var input = root.querySelector('.td-shell-search__input'); var list = root.querySelector('.td-shell-search__list'); var panel = root.querySelector('.td-shell-search__panel'); var status = root.querySelector('[data-td-shell-search-status]'); - if (!input || !list || !panel || !status) return; + if (!input || !list || !panel || !status) return null; + + var registry = options.registry || global.OinkActions; + var model = options.model || global.OinkPaletteModel; + var searchApi = options.searchApi || global.OinkSearchEngine; + if (!registry || !model || !searchApi) return null; var engine = null; - var docs = null; var loading = false; - var results = []; + var loadFailed = false; + var indexRequest = 0; + var rows = []; var selected = 0; var hideTimer = 0; + var choiceState = null; + var composing = false; + var pendingKey = ''; + var pendingActivation = 0; + var activationSerial = 0; + var session = 0; + var delayedClose = false; + var logicalOpen = false; var maxResults = parseInt(root.dataset.maxResults, 10); if (!Number.isFinite(maxResults) || maxResults < 1) maxResults = 10; var openers = document.querySelectorAll('[data-td-shell-search-open]'); var lastOpener = null; + var labels = { + actions: root.dataset.tActions || 'Actions', + commands: root.dataset.tCommands || 'Commands', + pageActions: root.dataset.tPageActions || 'Page actions', + preferences: root.dataset.tPreferences || 'Preferences', + choose: root.dataset.tChoice || 'Choose an option', + pages: root.dataset.tPages || 'Pages', + quickLinks: root.dataset.tQuickLinks || 'Quick links', + }; function isOpen() { - return root.classList.contains('is-open'); + return logicalOpen; } - function open(event) { - var opener = - event && event.currentTarget - ? event.currentTarget - : document.activeElement; - if ( - opener && - opener.closest && - opener.closest('#td-shell-sidebar') && - html.hasAttribute('data-td-shell-drawer') - ) { - var drawerOpeners = document.querySelectorAll( - '[data-td-shell-drawer-open]', - ); - opener = - Array.prototype.find.call(drawerOpeners, function (candidate) { - return candidate.offsetParent !== null; - }) || drawerOpeners[0] || opener; - } - lastOpener = opener; - if (window.OinkSurfaceCoordinator) - window.OinkSurfaceCoordinator.closeOthers('palette'); - window.clearTimeout(hideTimer); - root.hidden = false; - html.setAttribute('data-td-shell-lock', ''); - openers.forEach(function (el) { - el.setAttribute('aria-expanded', 'true'); - }); - input.setAttribute('aria-expanded', 'true'); - window.requestAnimationFrame(function () { - root.classList.add('is-open'); - }); - input.focus(); - input.select(); - ensureIndex(); + function announce(text) { + status.textContent = ''; + global.requestAnimationFrame(function () { status.textContent = text; }); } - function close(restoreFocus) { - var shouldRestore = restoreFocus !== false; - root.classList.remove('is-open'); - html.removeAttribute('data-td-shell-lock'); - openers.forEach(function (el) { - el.setAttribute('aria-expanded', 'false'); - }); - input.setAttribute('aria-expanded', 'false'); + + function resultMessage(count) { + return (root.dataset.tResults || '{count} results').replace( + '{count}', String(count), + ); + } + + function clearRows() { + list.textContent = ''; + rows = []; + selected = 0; input.removeAttribute('aria-activedescendant'); - if (shouldRestore && lastOpener && root.contains(document.activeElement)) - lastOpener.focus(); - hideTimer = window.setTimeout(function () { - root.hidden = true; - }, 240); } - if (window.OinkSurfaceCoordinator) - window.OinkSurfaceCoordinator.register('palette', close); - function announce(text) { - status.textContent = ''; - window.requestAnimationFrame(function () { - status.textContent = text; - }); + function syncBusy() { + if (loading || pendingKey) list.setAttribute('aria-busy', 'true'); + else list.removeAttribute('aria-busy'); + } + + function clearPending(activation) { + if (activation && activation !== pendingActivation) return; + pendingKey = ''; + pendingActivation = 0; + syncBusy(); } function message(text) { - list.textContent = ''; - input.removeAttribute('aria-activedescendant'); + clearRows(); var el = document.createElement('div'); el.className = 'td-shell-search__empty'; el.textContent = text; @@ -137,43 +127,125 @@ function ensureIndex() { if (engine || loading) return; + loadFailed = false; loading = true; - list.setAttribute('aria-busy', 'true'); - if (!docs) message(root.dataset.tLoading || '…'); + var request = ++indexRequest; + syncBusy(); fetch(root.dataset.indexSrc) - .then(function (r) { - return r.json(); + .then(function (response) { + if (!response.ok) throw new Error('Search index unavailable'); + return response.json(); }) .then(function (data) { - docs = data; - engine = window.OinkSearchEngine.create(data, lunr, maxResults); + if (request !== indexRequest) return; + engine = searchApi.create(data, lunr, maxResults); loading = false; - list.removeAttribute('aria-busy'); - render(input.value); + syncBusy(); + if (isOpen()) render(input.value); }) .catch(function () { + if (request !== indexRequest) return; loading = false; - list.removeAttribute('aria-busy'); - message(root.dataset.tEmpty || 'No results'); + loadFailed = true; + syncBusy(); + if (isOpen() && normalQuery(input.value)) + render(input.value, false); }); } - function highlight(text, q) { + function open(event) { + session += 1; + var openSession = session; + logicalOpen = true; + var opener = event && event.currentTarget + ? event.currentTarget + : document.activeElement; + if ( + opener && opener.closest && + opener.closest('#td-shell-sidebar') && + html.hasAttribute('data-td-shell-drawer') + ) { + var drawerOpeners = document.querySelectorAll('[data-td-shell-drawer-open]'); + opener = Array.prototype.find.call(drawerOpeners, function (candidate) { + return candidate.offsetParent !== null; + }) || drawerOpeners[0] || opener; + } + if ( + opener && opener.closest && opener.closest('[data-mobile-menu]') + ) { + opener = document.querySelector('[data-menu-toggle]') || opener; + } + lastOpener = opener; + if (global.OinkSurfaceCoordinator) + global.OinkSurfaceCoordinator.closeOthers('palette'); + global.clearTimeout(hideTimer); + delayedClose = false; + root.hidden = false; + html.setAttribute('data-td-shell-lock', ''); + openers.forEach(function (el) { el.setAttribute('aria-expanded', 'true'); }); + input.setAttribute('aria-expanded', 'true'); + global.requestAnimationFrame(function () { + if (logicalOpen && openSession === session) root.classList.add('is-open'); + }); + choiceState = null; + clearPending(); + input.focus(); + input.select(); + render(input.value); + // Empty and command-only modes are immediately useful and must not wait + // for or trigger the local index request. + if (normalQuery(input.value)) ensureIndex(); + } + + function close(restoreFocus, preservePending) { + if (!isOpen() && delayedClose) return; + delayedClose = true; + session += 1; + logicalOpen = false; + root.classList.remove('is-open'); + html.removeAttribute('data-td-shell-lock'); + openers.forEach(function (el) { el.setAttribute('aria-expanded', 'false'); }); + input.setAttribute('aria-expanded', 'false'); + input.removeAttribute('aria-activedescendant'); + choiceState = null; + if (!preservePending) clearPending(); + if ( + restoreFocus !== false && lastOpener && + root.contains(document.activeElement) + ) lastOpener.focus(); + var reducedMotion = global.matchMedia && + global.matchMedia('(prefers-reduced-motion: reduce)').matches; + hideTimer = global.setTimeout(function () { + root.hidden = true; + delayedClose = false; + }, reducedMotion ? 0 : 240); + } + if (global.OinkSurfaceCoordinator) + global.OinkSurfaceCoordinator.register('palette', close); + + function normalQuery(value) { + var query = String(value || '').trim(); + return query && query.charAt(0) !== '>'; + } + + function highlight(text, query) { + text = String(text || ''); + query = String(query || '').trim(); var fragment = document.createDocumentFragment(); - var at = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1; + var at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1; if (at < 0) { fragment.appendChild(document.createTextNode(text)); return fragment; } fragment.appendChild(document.createTextNode(text.slice(0, at))); var mark = document.createElement('mark'); - mark.textContent = text.slice(at, at + q.length); + mark.textContent = text.slice(at, at + query.length); fragment.appendChild(mark); - fragment.appendChild(document.createTextNode(text.slice(at + q.length))); + fragment.appendChild(document.createTextNode(text.slice(at + query.length))); return fragment; } - function select(i) { + function select(index) { var options = Array.prototype.slice.call( list.querySelectorAll('[role="option"]'), ); @@ -181,7 +253,7 @@ input.removeAttribute('aria-activedescendant'); return; } - selected = Math.max(0, Math.min(i, options.length - 1)); + selected = Math.max(0, Math.min(index, options.length - 1)); options.forEach(function (row, n) { row.setAttribute('aria-selected', n === selected ? 'true' : 'false'); }); @@ -190,145 +262,319 @@ row.scrollIntoView({ block: 'nearest' }); } - function render(q) { - q = (q || '').trim(); - if (!docs || !engine) return; - list.textContent = ''; - results = []; - selected = 0; - input.removeAttribute('aria-activedescendant'); - if (!q) { - status.textContent = ''; - return; - } + function pageGroups(results) { + var order = registry.rootOrder ? registry.rootOrder() : []; + return searchApi.group(results).map(function (group, sourceOrder) { + return { + key: group.key, + label: group.label || labels.pages, + rows: group.results.map(function (result) { + return { + id: 'page:' + result.doc.ref, + sourceId: result.doc.ref, + type: 'page', + title: result.doc.title || result.doc.ref, + description: result.excerpt || result.doc.description || '', + ref: result.doc.ref, + icon: result.doc.icon || 'fa-regular fa-file-lines', + available: true, + page: result, + }; + }), + sourceOrder: sourceOrder, + }; + }).sort(function (a, b) { + var aIndex = order.indexOf(a.key); + var bIndex = order.indexOf(b.key); + aIndex = aIndex < 0 ? Number.MAX_SAFE_INTEGER : aIndex; + bIndex = bIndex < 0 ? Number.MAX_SAFE_INTEGER : bIndex; + return aIndex - bIndex || model.lexical(a.label, b.label) || + a.sourceOrder - b.sourceOrder; + }); + } - try { - results = engine.query(q); - } catch (e) { - results = []; - } - if (!results.length) { - message(root.dataset.tEmpty || 'No results'); - return; + function groupsFor(value) { + if (choiceState) + return model.choiceGroup(choiceState.action, choiceState.command, labels); + var raw = String(value || '').trim(); + if (!raw) return model.emptyGroups(registry, labels); + if (raw.charAt(0) === '>') + return model.commandGroups(registry, raw.slice(1).trim(), labels); + + var groups = []; + if (engine) { + try { groups = pageGroups(engine.query(raw)); } catch (_) { groups = []; } } - var groups = window.OinkSearchEngine.group(results); - results = []; + var actions = model.actionRows(registry, raw, false); + if (actions.length) + groups.push({ key: 'actions', label: labels.actions, rows: actions }); + return groups; + } + + function appendIcon(container, icon) { + if (!icon) return; + var el = document.createElement('i'); + String(icon).split(/\s+/).filter(Boolean).forEach(function (token) { + el.classList.add(token); + }); + el.classList.add('td-shell-search__item-icon'); + el.setAttribute('aria-hidden', 'true'); + container.appendChild(el); + } + + function renderGroups(groups, query) { + clearRows(); groups.forEach(function (group) { + if (!group.rows || !group.rows.length) return; var section = document.createElement('div'); section.className = 'td-shell-search__group'; - section.setAttribute('role', 'presentation'); + section.setAttribute('role', 'group'); var heading = document.createElement('div'); heading.className = 'td-shell-search__group-label'; - heading.setAttribute('role', 'presentation'); + heading.id = 'td-shell-search-group-' + group.key; heading.textContent = group.label; + section.setAttribute('aria-labelledby', heading.id); section.appendChild(heading); - group.results.forEach(function (r) { - var i = results.length; - results.push(r); - var row = document.createElement('a'); + group.rows.forEach(function (rowData) { + var index = rows.length; + rows.push(rowData); + var row = document.createElement('div'); row.className = 'td-shell-search__item'; - row.id = 'td-shell-search-option-' + i; + row.id = 'td-shell-search-option-' + index; row.setAttribute('role', 'option'); row.setAttribute('aria-selected', 'false'); row.setAttribute('tabindex', '-1'); - row.href = r.doc.ref; + row.dataset.paletteRow = String(index); + if (!rowData.available) { + row.classList.add('is-disabled'); + row.setAttribute('aria-disabled', 'true'); + } + if (rowData.option && rowData.option.active) + row.setAttribute('aria-current', 'true'); + appendIcon(row, rowData.icon); + var meta = document.createElement('div'); + meta.className = 'td-shell-search__item-meta'; var title = document.createElement('div'); title.className = 'td-shell-search__item-title'; - title.appendChild(highlight(r.doc.title || r.doc.ref, q)); - row.appendChild(title); - - var ref = document.createElement('div'); - ref.className = 'td-shell-search__item-ref'; - ref.textContent = r.doc.ref; - row.appendChild(ref); - - if (r.excerpt) { - var excerpt = document.createElement('div'); - excerpt.className = 'td-shell-search__item-excerpt'; - excerpt.appendChild(highlight(r.excerpt, q)); - row.appendChild(excerpt); + title.appendChild(highlight(rowData.title, query)); + meta.appendChild(title); + var detail = rowData.disabledReason || rowData.description || rowData.ref; + if (detail && detail !== rowData.title) { + var description = document.createElement('div'); + description.id = row.id + '-description'; + description.className = rowData.type === 'page' + ? 'td-shell-search__item-excerpt' + : 'td-shell-search__item-ref'; + description.appendChild(highlight(detail, query)); + meta.appendChild(description); + if (!rowData.available) + row.setAttribute('aria-describedby', description.id); } - - row.addEventListener('pointermove', function () { - if (selected !== i) select(i); + row.appendChild(meta); + row.addEventListener('pointermove', function (event) { + if ((!event.pointerType || event.pointerType === 'mouse') && selected !== index) + select(index); }); + row.addEventListener('click', function () { activate(index); }); section.appendChild(row); }); list.appendChild(section); }); - select(0); - announce( - (root.dataset.tResults || '{count} results').replace( - '{count}', - String(results.length), - ), - ); + if (rows.length) { + select(0); + announce(resultMessage(rows.length)); + } + } + + function render(value, retryIndex) { + if (retryIndex === undefined) retryIndex = true; + var raw = String(value || '').trim(); + var commandOnly = raw.charAt(0) === '>'; + var groups = groupsFor(value); + var actionCount = groups.reduce(function (total, group) { + return total + group.rows.length; + }, 0); + + if (!raw || choiceState || commandOnly || engine || (loadFailed && !retryIndex)) { + if (!actionCount) { + message(loadFailed && !retryIndex && !commandOnly + ? (root.dataset.tIndexUnavailable || root.dataset.tEmpty || 'Page index unavailable') + : commandOnly + ? (root.dataset.tNoCommands || root.dataset.tEmpty || 'No results') + : (root.dataset.tEmpty || 'No results')); + } else { + renderGroups(groups, choiceState ? '' : (commandOnly ? raw.slice(1).trim() : raw)); + if (loadFailed && !retryIndex) + announce(root.dataset.tIndexUnavailable || resultMessage(rows.length)); + } + return; + } + + // Normal search can still offer matching actions while the index loads. + if (actionCount) renderGroups(groups, raw); + else message(root.dataset.tLoading || '…'); + ensureIndex(); + } + + function runRow(row) { + if (row.type === 'page' || row.type === 'quick') { + var destination = row.ref || row.url; + destination = registry.safeUrl ? registry.safeUrl(destination) : null; + if (!destination) + return Promise.reject(new Error(root.dataset.tActionFailed || 'Unsafe link')); + if (row.target === 'blank') { + global.open(destination, '_blank', 'noopener,noreferrer'); + return Promise.resolve(); + } + global.location.assign(destination); + return Promise.resolve(); + } + if (row.type === 'choice') { + return row.command + ? registry.runCommand(row.command.id, { source: 'palette', value: row.option }) + : registry.run(row.action.id, { source: 'palette', value: row.option }); + } + if (row.type === 'command') + return registry.runCommand(row.sourceId, { source: 'palette' }); + return registry.run(row.sourceId, { source: 'palette' }); + } + + function activate(index) { + var row = rows[index]; + if (!row) return; + if (!row.available) { + announce(row.disabledReason || root.dataset.tActionFailed || 'Unavailable'); + return; + } + var targetChoice = row.type === 'action' && row.action && row.action.kind === 'choice' + ? row.action + : row.type === 'command' && row.command && row.command.action + ? registry.get(row.command.action) + : null; + if (targetChoice && targetChoice.kind === 'choice') { + choiceState = { + action: targetChoice, + command: row.type === 'command' ? row.command : null, + }; + render(input.value); + announce(root.dataset.tChoice || 'Choose an option'); + return; + } + if (pendingKey) return; + var activationSession = session; + var activation = ++activationSerial; + pendingKey = row.id; + pendingActivation = activation; + syncBusy(); + var isPrint = row.sourceId === 'print' || + (row.command && row.command.action === 'print') || + (row.action && row.action.id === 'print'); + if (isPrint) close(true, true); + runRow(row).then(function (result) { + clearPending(activation); + if (activationSession !== session && !isPrint) return; + if (result && result.requiresChoice) { + choiceState = { action: result.action, command: result.command || null }; + render(input.value); + announce(root.dataset.tChoice || 'Choose an option'); + return; + } + var completedInPlace = result && ( + result.theme || + (result.action && result.action.id === 'copy_markdown') + ); + if (completedInPlace) { + choiceState = null; + render(input.value); + announce(row.title || (result.action && result.action.title) || labels.actions); + } else if (!isPrint) { + close(true); + } + }).catch(function (error) { + clearPending(activation); + if (activationSession !== session) return; + announce( + (error && error.message) || + row.disabledReason || root.dataset.tActionFailed || 'Action failed', + ); + }); } var debounce = 0; + input.addEventListener('compositionstart', function () { composing = true; }); + input.addEventListener('compositionend', function () { + composing = false; + choiceState = null; + render(input.value); + }); input.addEventListener('input', function () { - window.clearTimeout(debounce); - debounce = window.setTimeout(function () { - render(input.value); - }, 80); + if (composing) return; + choiceState = null; + global.clearTimeout(debounce); + debounce = global.setTimeout(function () { render(input.value); }, 80); }); - input.addEventListener('keydown', function (e) { - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (results.length) select(Math.min(selected + 1, results.length - 1)); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (results.length) select(Math.max(selected - 1, 0)); - } else if (e.key === 'Home') { - e.preventDefault(); - if (results.length) select(0); - } else if (e.key === 'End') { - e.preventDefault(); - if (results.length) select(results.length - 1); - } else if (e.key === 'Enter') { - var row = list.querySelectorAll('[role="option"]')[selected]; - if (row && row.href) window.location.href = row.href; + input.addEventListener('keydown', function (event) { + if (event.isComposing || composing || event.keyCode === 229) return; + if (event.key === 'ArrowDown') { + event.preventDefault(); + if (rows.length) select(Math.min(selected + 1, rows.length - 1)); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + if (rows.length) select(Math.max(selected - 1, 0)); + } else if (event.key === 'Home' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + if (rows.length) select(0); + } else if (event.key === 'End' && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + if (rows.length) select(rows.length - 1); + } else if (event.key === 'Enter') { + event.preventDefault(); + activate(selected); } }); document.addEventListener('keydown', tabTrap(panel, isOpen), true); - - document.addEventListener('keydown', function (e) { - if ((e.metaKey || e.ctrlKey) && String(e.key).toLowerCase() === 'k') { - e.preventDefault(); - if (isOpen()) { - close(); - } else { - open(); - } - } else if (e.key === 'Escape' && isOpen()) { + document.addEventListener('keydown', function (event) { + if (event.isComposing || composing || event.keyCode === 229) return; + if ((event.metaKey || event.ctrlKey) && String(event.key).toLowerCase() === 'k') { + event.preventDefault(); + if (isOpen()) close(); + else open(); + } else if (event.key === 'Escape' && isOpen()) { + event.preventDefault(); close(); } }); - openers.forEach(function (el) { - el.addEventListener('click', open); + openers.forEach(function (el) { el.addEventListener('click', open); }); + root.querySelectorAll('[data-td-shell-search-close]').forEach(function (el) { + el.addEventListener('click', close); }); - root - .querySelectorAll('[data-td-shell-search-close]') - .forEach(function (el) { - el.addEventListener('click', close); - }); - // Show Ctrl instead of the Command badge on non-Apple platforms. var apple = /Mac|iPhone|iPad|iPod/.test( navigator.platform || navigator.userAgent, ); if (!apple) { - document - .querySelectorAll('[data-td-shell-meta-key]') - .forEach(function (el) { - el.textContent = 'Ctrl'; - }); + document.querySelectorAll('[data-td-shell-meta-key]').forEach(function (el) { + el.textContent = 'Ctrl'; + }); } + + return Object.freeze({ + activate: activate, + close: close, + ensureIndex: ensureIndex, + isOpen: isOpen, + open: open, + render: render, + rows: function () { return rows.slice(); }, + }); } - initSearch(); -})(); + global.OinkCommandPalette = { init: initSearch }; + if (typeof module === 'object' && module.exports) + module.exports = global.OinkCommandPalette; + if (!global.__OINK_PALETTE_MANUAL_INIT__) initSearch(); +})(typeof window === 'object' ? window : globalThis); diff --git a/assets/js/palette-model.js b/assets/js/palette-model.js new file mode 100644 index 00000000..7c71111e --- /dev/null +++ b/assets/js/palette-model.js @@ -0,0 +1,187 @@ +/** Pure result-model helpers shared by the Command Palette controller/tests. */ +(function (global) { + 'use strict'; + + var CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿]/; + + function lexical(a, b) { + a = String(a || ''); + b = String(b || ''); + return a < b ? -1 : a > b ? 1 : 0; + } + + function normalize(value) { + var string = String(value || '').trim().toLowerCase(); + return string.normalize ? string.normalize('NFKC').replace(/\s+/g, ' ') : string; + } + + function searchable(record) { + return [record.title, record.description] + .concat(record.keywords || []) + .filter(Boolean) + .map(normalize) + .join(' '); + } + + function score(record, query) { + var needle = normalize(query); + if (!needle) return 1; + var title = normalize(record.title); + var id = normalize(record.id); + var keywords = (record.keywords || []).map(normalize); + var haystack = searchable(record); + if (title === needle || id === needle) return 1000; + if (title.indexOf(needle) === 0 || id.indexOf(needle) === 0) return 700; + var keywordAt = keywords.findIndex(function (keyword) { + return keyword === needle || keyword.indexOf(needle) === 0; + }); + if (keywordAt >= 0) return keywords[keywordAt] === needle ? 600 : 500; + var at = haystack.indexOf(needle); + if (at >= 0) return 300 - Math.min(at, 200); + + // For Latin text, allow all whitespace-delimited tokens to appear in any + // order. CJK stays deterministic substring matching like the page engine. + if (!CJK.test(needle)) { + var tokens = needle.split(/\s+/).filter(Boolean); + if (tokens.length > 1 && tokens.every(function (token) { + return haystack.indexOf(token) >= 0; + })) return 200; + } + return 0; + } + + function rank(records, query) { + return (records || []) + .map(function (record, order) { + return { record: record, score: score(record, query), order: order }; + }) + .filter(function (item) { return item.score > 0; }) + .sort(function (a, b) { + return b.score - a.score || lexical(a.record.title, b.record.title) || + lexical(a.record.id, b.record.id) || a.order - b.order; + }) + .map(function (item) { return item.record; }); + } + + function actionRows(registry, query, commandOnly) { + var rows = []; + registry.commands().forEach(function (command) { + var target = command.action ? registry.get(command.action) : null; + var available = command.available !== false && (!target || target.available !== false); + var disabledReason = command.disabledReason || (target && target.disabledReason) || ''; + rows.push({ + id: 'command:' + command.id, + sourceId: command.id, + type: 'command', + title: command.title || command.id, + description: command.description || '', + icon: command.icon || 'fa-solid fa-terminal', + keywords: command.keywords || [], + available: available, + disabledReason: disabledReason, + command: command, + target: command.target || 'self', + }); + }); + registry.list({ placement: 'palette' }).forEach(function (action) { + rows.push({ + id: 'action:' + action.id, + sourceId: action.id, + type: 'action', + title: action.title || action.id, + description: action.description || '', + icon: action.icon || 'fa-solid fa-bolt', + keywords: action.keywords || [], + available: action.available !== false, + disabledReason: action.disabledReason || '', + action: action, + }); + }); + if (!query && !commandOnly) { + // Empty mode is intentionally useful but concise: unavailable entries + // appear only when they can explain why they cannot run. + return rows.filter(function (row) { + return row.available || row.disabledReason; + }); + } + return rank(rows, query).filter(function (row) { + return row.available || row.disabledReason; + }); + } + + function emptyGroups(registry, labels) { + var quick = (registry.quickLinks ? registry.quickLinks() : []).map(function (link) { + return { + id: 'quick:' + link.id, + sourceId: link.id, + type: 'quick', + title: link.title || link.id, + description: link.description || '', + icon: link.icon || 'fa-solid fa-link', + keywords: link.keywords || [], + available: link.available !== false, + disabledReason: link.disabledReason || '', + url: link.url, + target: link.target || 'self', + }; + }); + var actions = actionRows(registry, '', false); + var pageActionIds = new Set([ + 'copy_markdown', 'view_markdown', 'edit_page', 'create_issue', 'print', + ]); + var pageActions = actions.filter(function (row) { + return row.action && pageActionIds.has(row.action.id); + }); + var preferences = actions.filter(function (row) { + return row.action && row.action.kind === 'choice'; + }); + var commands = actions.filter(function (row) { + return pageActions.indexOf(row) < 0 && preferences.indexOf(row) < 0; + }); + return [ + { key: 'quick', label: labels.quickLinks, rows: quick }, + { key: 'page-actions', label: labels.pageActions || labels.actions, rows: pageActions }, + { key: 'preferences', label: labels.preferences || labels.actions, rows: preferences }, + { key: 'commands', label: labels.commands || labels.actions, rows: commands }, + ].filter(function (group) { return group.rows.length; }); + } + + function commandGroups(registry, query, labels) { + var rows = actionRows(registry, query, true); + return rows.length ? [{ key: 'actions', label: labels.actions, rows: rows }] : []; + } + + function choiceGroup(action, command, labels) { + var rows = (action.options || []).map(function (option) { + return { + id: 'choice:' + action.id + ':' + option.id, + sourceId: option.id, + type: 'choice', + title: option.title || option.id, + description: option.disabledReason || '', + icon: action.icon || 'fa-solid fa-list', + keywords: [], + available: option.available !== false, + disabledReason: option.disabledReason || '', + action: action, + command: command || null, + option: option, + }; + }); + return [{ key: 'choice', label: labels.choose, rows: rows }]; + } + + var api = { + CJK: CJK, + actionRows: actionRows, + choiceGroup: choiceGroup, + commandGroups: commandGroups, + emptyGroups: emptyGroups, + lexical: lexical, + normalize: normalize, + rank: rank, + score: score, + }; + global.OinkPaletteModel = api; + if (typeof module === 'object' && module.exports) module.exports = api; +})(typeof window === 'object' ? window : globalThis); diff --git a/assets/scss/td/shell/_search.scss b/assets/scss/td/shell/_search.scss index 0bd61236..8454af16 100644 --- a/assets/scss/td/shell/_search.scss +++ b/assets/scss/td/shell/_search.scss @@ -137,7 +137,9 @@ } .td-shell-search__item { - display: block; + display: flex; + align-items: center; + gap: 10px; padding: 8px 10px; border-radius: 10px; color: var(--bs-body-color); @@ -151,6 +153,16 @@ background: var(--td-shell-primary-dim); } + &.is-disabled { + color: var(--td-shell-muted-fg); + cursor: not-allowed; + + .td-shell-search__item-icon, + .td-shell-search__item-meta { + opacity: 0.68; + } + } + mark { padding: 0; background: transparent; @@ -159,6 +171,18 @@ } } +.td-shell-search__item-icon { + width: 18px; + flex: 0 0 18px; + text-align: center; + color: var(--td-shell-muted-fg); +} + +.td-shell-search__item-meta { + min-width: 0; + flex: 1 1 auto; +} + .td-shell-search__group + .td-shell-search__group { margin-top: 6px; } @@ -239,7 +263,20 @@ @media (max-width: 575.98px) { .td-shell-search__panel { - margin-top: 6vh; + width: calc(100vw - 16px); + max-height: calc(100dvh - 16px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); + margin: calc(8px + env(safe-area-inset-top)) auto 0; + border-radius: 12px; + } + + .td-shell-search__head, + .td-shell-search__item { + min-height: 44px; + } + + .td-shell-search__item-ref, + .td-shell-search__item-excerpt { + white-space: normal; } .td-shell-search__foot { diff --git a/i18n/ar.yaml b/i18n/ar.yaml index 375fce71..8147a317 100644 --- a/i18n/ar.yaml +++ b/i18n/ar.yaml @@ -76,6 +76,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/az.yaml b/i18n/az.yaml index d1b2cc0b..387b1192 100644 --- a/i18n/az.yaml +++ b/i18n/az.yaml @@ -75,6 +75,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/bg.yaml b/i18n/bg.yaml index e5cbea16..bbebc3cb 100644 --- a/i18n/bg.yaml +++ b/i18n/bg.yaml @@ -65,6 +65,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/bn.yaml b/i18n/bn.yaml index fec51828..13e7f992 100644 --- a/i18n/bn.yaml +++ b/i18n/bn.yaml @@ -79,6 +79,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/de.yaml b/i18n/de.yaml index 5d450ec0..75f96253 100644 --- a/i18n/de.yaml +++ b/i18n/de.yaml @@ -83,6 +83,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/en.yaml b/i18n/en.yaml index e6c51cfd..6924a8be 100644 --- a/i18n/en.yaml +++ b/i18n/en.yaml @@ -16,6 +16,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_empty_query: Quick links and actions +ui_palette_actions: Actions +ui_palette_page_actions: Page actions +ui_palette_preferences: Preferences +ui_palette_commands: Commands +ui_palette_quick_links: Quick links +ui_palette_no_commands: No matching commands +ui_palette_choose: Choose an option +ui_palette_action_failed: Action could not be completed +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/es.yaml b/i18n/es.yaml index 3640d78a..0db6e780 100644 --- a/i18n/es.yaml +++ b/i18n/es.yaml @@ -78,6 +78,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/et.yaml b/i18n/et.yaml index f19699cc..041c3be1 100644 --- a/i18n/et.yaml +++ b/i18n/et.yaml @@ -63,6 +63,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/fa.yaml b/i18n/fa.yaml index 2be83bb1..e26bc857 100644 --- a/i18n/fa.yaml +++ b/i18n/fa.yaml @@ -72,6 +72,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/fi.yaml b/i18n/fi.yaml index 603a0099..088be6ff 100644 --- a/i18n/fi.yaml +++ b/i18n/fi.yaml @@ -68,6 +68,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/fr.yaml b/i18n/fr.yaml index b8e162f5..f02d9b66 100644 --- a/i18n/fr.yaml +++ b/i18n/fr.yaml @@ -75,6 +75,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/he.yaml b/i18n/he.yaml index 79d4b3e4..fa128b51 100644 --- a/i18n/he.yaml +++ b/i18n/he.yaml @@ -80,6 +80,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 30c6d829..5d6722db 100644 --- a/i18n/hi.yaml +++ b/i18n/hi.yaml @@ -70,6 +70,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/hu.yaml b/i18n/hu.yaml index d570e25e..29b5693c 100644 --- a/i18n/hu.yaml +++ b/i18n/hu.yaml @@ -66,6 +66,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/it.yaml b/i18n/it.yaml index 9d0ba9e5..eca261dd 100644 --- a/i18n/it.yaml +++ b/i18n/it.yaml @@ -65,6 +65,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 1df1cb15..4eb9d409 100644 --- a/i18n/ja.yaml +++ b/i18n/ja.yaml @@ -71,6 +71,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/ko.yaml b/i18n/ko.yaml index e3d0bad4..5ff98e8b 100644 --- a/i18n/ko.yaml +++ b/i18n/ko.yaml @@ -65,6 +65,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/nl.yaml b/i18n/nl.yaml index cee749c4..c9a56d6a 100644 --- a/i18n/nl.yaml +++ b/i18n/nl.yaml @@ -70,6 +70,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/no.yaml b/i18n/no.yaml index 1a7890fe..0bef1215 100644 --- a/i18n/no.yaml +++ b/i18n/no.yaml @@ -79,6 +79,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/oc.yaml b/i18n/oc.yaml index c0c996ff..c4787178 100644 --- a/i18n/oc.yaml +++ b/i18n/oc.yaml @@ -74,6 +74,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/pl.yaml b/i18n/pl.yaml index 900de767..afabb945 100644 --- a/i18n/pl.yaml +++ b/i18n/pl.yaml @@ -65,6 +65,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/pt-br.yaml b/i18n/pt-br.yaml index 81cb305a..8326dd14 100644 --- a/i18n/pt-br.yaml +++ b/i18n/pt-br.yaml @@ -77,6 +77,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/ro.yaml b/i18n/ro.yaml index aac7fa77..52f63d1c 100644 --- a/i18n/ro.yaml +++ b/i18n/ro.yaml @@ -77,6 +77,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 2dbfa773..a36c28b4 100644 --- a/i18n/ru.yaml +++ b/i18n/ru.yaml @@ -77,6 +77,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/sr-cyrl.yaml b/i18n/sr-cyrl.yaml index c5ee5734..9a2dce97 100644 --- a/i18n/sr-cyrl.yaml +++ b/i18n/sr-cyrl.yaml @@ -77,6 +77,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/sr-latn.yaml b/i18n/sr-latn.yaml index b9ae93d1..af3f7d6c 100644 --- a/i18n/sr-latn.yaml +++ b/i18n/sr-latn.yaml @@ -77,6 +77,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/sv.yaml b/i18n/sv.yaml index 394ecbcc..8826becc 100644 --- a/i18n/sv.yaml +++ b/i18n/sv.yaml @@ -70,6 +70,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/tr.yaml b/i18n/tr.yaml index d556685b..478f6e56 100644 --- a/i18n/tr.yaml +++ b/i18n/tr.yaml @@ -76,6 +76,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/uk.yaml b/i18n/uk.yaml index 67ca6bc0..08e5362a 100644 --- a/i18n/uk.yaml +++ b/i18n/uk.yaml @@ -75,6 +75,17 @@ ui_search_results: '{count} results found' ui_search_nav: Navigate ui_search_open: Open ui_search_close: Close +ui_palette_pages: Pages +ui_palette_index_unavailable: Page index unavailable; actions still work +ui_palette_action_failed: Action could not be completed +ui_palette_choose: Choose an option +ui_palette_no_commands: No matching commands +ui_palette_quick_links: Quick links +ui_palette_actions: Actions +ui_palette_commands: Commands +ui_palette_preferences: Preferences +ui_palette_page_actions: Page actions +ui_palette_empty_query: Quick links and actions ui_sidebar_nav: Section navigation ui_main_nav: Main navigation ui_home: Home diff --git a/i18n/zh-cn.yaml b/i18n/zh-cn.yaml index b3a08461..932d16cc 100644 --- a/i18n/zh-cn.yaml +++ b/i18n/zh-cn.yaml @@ -16,6 +16,17 @@ ui_search_results: '找到 {count} 条结果' ui_search_nav: 选择 ui_search_open: 打开 ui_search_close: 关闭 +ui_palette_empty_query: 快速链接与操作 +ui_palette_actions: 操作 +ui_palette_page_actions: 页面操作 +ui_palette_preferences: 偏好设置 +ui_palette_commands: 命令 +ui_palette_quick_links: 快速链接 +ui_palette_no_commands: 未找到匹配命令 +ui_palette_choose: 请选择一项 +ui_palette_action_failed: 无法完成此操作 +ui_palette_pages: 页面 +ui_palette_index_unavailable: 页面索引暂不可用,操作仍可使用 ui_sidebar_nav: 章节导航 ui_main_nav: 主导航 ui_home: 首页 diff --git a/i18n/zh-tw.yaml b/i18n/zh-tw.yaml index 6b5b7005..79f62d0b 100644 --- a/i18n/zh-tw.yaml +++ b/i18n/zh-tw.yaml @@ -15,6 +15,17 @@ ui_search_loading: 正在載入搜尋索引… ui_search_nav: 選擇 ui_search_open: 開啟 ui_search_close: 關閉 +ui_palette_empty_query: 快速連結與操作 +ui_palette_actions: 操作 +ui_palette_page_actions: 頁面操作 +ui_palette_preferences: 偏好設定 +ui_palette_commands: 命令 +ui_palette_quick_links: 快速連結 +ui_palette_no_commands: 找不到符合的命令 +ui_palette_choose: 請選擇一項 +ui_palette_action_failed: 無法完成此操作 +ui_palette_pages: 頁面 +ui_palette_index_unavailable: 頁面索引暫時無法使用,操作仍可使用 ui_sidebar_nav: 章節導覽 ui_main_nav: 主導覽 ui_home: 首頁 diff --git a/i18n/zh.yaml b/i18n/zh.yaml index bd3ee676..b89b3a21 100644 --- a/i18n/zh.yaml +++ b/i18n/zh.yaml @@ -17,6 +17,17 @@ ui_search_results: '找到 {count} 条结果' ui_search_nav: 选择 ui_search_open: 打开 ui_search_close: 关闭 +ui_palette_empty_query: 快速链接与操作 +ui_palette_actions: 操作 +ui_palette_page_actions: 页面操作 +ui_palette_preferences: 偏好设置 +ui_palette_commands: 命令 +ui_palette_quick_links: 快速链接 +ui_palette_no_commands: 未找到匹配命令 +ui_palette_choose: 请选择一项 +ui_palette_action_failed: 无法完成此操作 +ui_palette_pages: 页面 +ui_palette_index_unavailable: 页面索引暂不可用,操作仍可使用 ui_sidebar_nav: 章节导航 ui_main_nav: 主导航 ui_home: 首页 diff --git a/layouts/_partials/actions/manifest.html b/layouts/_partials/actions/manifest.html index 2786f381..487a7941 100644 --- a/layouts/_partials/actions/manifest.html +++ b/layouts/_partials/actions/manifest.html @@ -4,4 +4,6 @@ "language" .Site.Language.Lang "actions" $context.actions "commands" (partial "actions/site-commands.html" .) + "quickLinks" (partial "actions/quick-links.html" .) + "rootOrder" (partial "actions/root-order.html" .) ) -}} diff --git a/layouts/_partials/actions/quick-links.html b/layouts/_partials/actions/quick-links.html new file mode 100644 index 00000000..3de9ccdf --- /dev/null +++ b/layouts/_partials/actions/quick-links.html @@ -0,0 +1,29 @@ +{{- /* Resolve the same configured top-level Hugo Menu entries used by the + shell utility rail into inert Palette rows. */ -}} +{{- $p := . -}} +{{- $config := partial "shell/config.html" $p -}} +{{- $baseURL := urls.Parse $p.Site.BaseURL -}} +{{- $links := slice -}} +{{- range $p.Site.Menus.main -}} + {{- $key := .Identifier -}} + {{- with .Page }}{{ if not $key }}{{ $key = .Section }}{{ end }}{{ end -}} + {{- if in $config.quickLinks $key -}} + {{- $href := .URL -}} + {{- $description := .Params.description | default "" | plainify -}} + {{- $target := urls.Parse .URL -}} + {{- $external := and $target.Host (ne $baseURL.Host $target.Host) -}} + {{- if not $external }}{{ $href = .URL | relLangURL }}{{ end -}} + {{- with .Page -}} + {{- $href = .RelPermalink -}} + {{- $description = .Description | default $description | plainify -}} + {{- end -}} + {{- $links = $links | append (dict + "id" (printf "quick_%s" ($key | default .Name | urlize)) + "title" (.Name | plainify) "description" $description + "icon" (partial "menu-icon.html" .) "keywords" (slice (.Name | plainify) $key) + "kind" "url" "url" $href "target" (cond $external "blank" "self") + "available" true "disabledReason" "" + ) -}} + {{- end -}} +{{- end -}} +{{- return $links -}} diff --git a/layouts/_partials/actions/root-order.html b/layouts/_partials/actions/root-order.html new file mode 100644 index 00000000..af3e6eef --- /dev/null +++ b/layouts/_partials/actions/root-order.html @@ -0,0 +1,10 @@ +{{- /* Stable page-result group order follows the top-level Hugo main menu. */ -}} +{{- $order := slice -}} +{{- range .Site.Menus.main -}} + {{- $key := .Identifier -}} + {{- with .Page }}{{ if not $key }}{{ $key = .Section }}{{ end }}{{ end -}} + {{- if not $key }}{{ $key = .Name | urlize }}{{ end -}} + {{- $key = lower (strings.TrimSpace $key) -}} + {{- if and $key (not (in $order $key)) }}{{ $order = $order | append $key }}{{ end -}} +{{- end -}} +{{- return $order -}} diff --git a/layouts/_partials/scripts.html b/layouts/_partials/scripts.html index 5c508bd4..a3760346 100644 --- a/layouts/_partials/scripts.html +++ b/layouts/_partials/scripts.html @@ -98,6 +98,7 @@ {{ $jsSidebarNav := resources.Get "js/sidebar-nav.js" -}} {{ $jsActions := resources.Get "js/action-registry.js" -}} {{ $jsPageActions := resources.Get "js/page-actions.js" -}} +{{ $jsPaletteModel := resources.Get "js/palette-model.js" -}} {{ $jsPalette := resources.Get "js/command-palette.js" -}} {{ $jsSearchEngine := resources.Get "js/search-engine.js" -}} {{ $jsSearch := resources.Get "js/search.js" | resources.ExecuteAsTemplate "js/search.js" .Site.Home -}} @@ -141,7 +142,7 @@ {{ else if $hasGoogleSearch -}} {{ $jsArray = $jsArray | append $jsSearch -}} {{ end -}} -{{ if $localSearch }}{{ $jsArray = $jsArray | append $jsSearchEngine $jsPalette }}{{ end -}} +{{ if $localSearch }}{{ $jsArray = $jsArray | append $jsSearchEngine $jsPaletteModel $jsPalette }}{{ end -}} {{ if .Page.Store.Get "hasmermaid" -}} {{ partial "scripts/mermaid.html" . -}} diff --git a/layouts/_partials/shell/search-dialog.html b/layouts/_partials/shell/search-dialog.html index 8a9c8b91..788a8713 100644 --- a/layouts/_partials/shell/search-dialog.html +++ b/layouts/_partials/shell/search-dialog.html @@ -5,7 +5,17 @@ data-max-results="{{ .Site.Params.offlineSearchMaxResults | default 10 }}" data-t-empty="{{ T "ui_search_empty" }}" data-t-loading="{{ T "ui_search_loading" }}" - data-t-results="{{ T "ui_search_results" }}"> + data-t-results="{{ T "ui_search_results" }}" + data-t-actions="{{ T "ui_palette_actions" }}" + data-t-page-actions="{{ T "ui_palette_page_actions" }}" + data-t-preferences="{{ T "ui_palette_preferences" }}" + data-t-commands="{{ T "ui_palette_commands" }}" + data-t-quick-links="{{ T "ui_palette_quick_links" }}" + data-t-no-commands="{{ T "ui_palette_no_commands" }}" + data-t-choice="{{ T "ui_palette_choose" }}" + data-t-action-failed="{{ T "ui_palette_action_failed" }}" + data-t-index-unavailable="{{ T "ui_palette_index_unavailable" }}" + data-t-pages="{{ T "ui_palette_pages" }}">