diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac3d4226..a4720314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,27 @@ 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: Check PRD 4 runtime isolation + run: python3 scripts/check-prd4-runtime.py + + - name: Check sidebar icon density policies + run: python3 scripts/check-sidebar-icons.py + + - name: Check search metadata and ranking + run: python3 scripts/check-prd4-search.py + + - 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: Check PRD 4 bilingual migration and starter guidance + run: python3 scripts/check-prd4-docs.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/CHANGELOG.md b/CHANGELOG.md index 5ebeb5d9..e70ce14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to OINK are documented here. The project follows ### Added +- Add one-level Hugo Menu dropdowns on desktop and matching mobile accordions, + preserving independent parent navigation, keyboard operation, active paths, + external-link safety, and flat-menu compatibility. +- Add `all`, `groups`, and `none` sidebar icon-density policies. The absent + compatibility default remains `all`; the starter example opts into + `groups`. +- Add local-search keywords, positive boost multipliers, canonical exclusion, + root/section/type grouping metadata, deterministic breadcrumbs and icons, + language-separated size budgets, and identical boost behavior in Lunr and + CJK substring ranking. +- Upgrade the existing local-search dialog to a Command Palette with empty, + text, and `>` command modes, plus quick links, grouped page results, + context-aware actions, localized safe site commands, choice actions, and a + shared page-action registry. +- Add an unreleased bilingual PRD 4 migration reference and machine-checked + root/subpath starter fixtures. Published availability remains gated on the + owning changes being merged and included in a tagged release. - Add validated `badge`, `icon`, `kbd`, `fields`, and `filetree` content primitives with semantic HTML, responsive presentation, and dedicated print and Markdown fallbacks. diff --git a/README.md b/README.md index 060f2d54..e5463d3f 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,14 @@ Sites can locally host their own faces and override the documented Theme implementation contracts: [Content primitives](docs/content-primitives.md) · [Enhanced code blocks](docs/enhanced-code-blocks.md) · -[Typography tokens](docs/typography-tokens.md) +[Typography tokens](docs/typography-tokens.md) · +[PRD 4 contract](docs/prd4-navigation-command-palette-contract.md) + +The PRD 4 migration reference is currently an **unreleased development +draft**. It must not be treated as available in the latest tag until the +release notes name a containing version: +[English](docs/prd4-migration-guide.md) · +[简体中文](docs/prd4-migration-guide.zh.md). ## Localization status diff --git a/assets/js/action-registry.js b/assets/js/action-registry.js new file mode 100644 index 00000000..fdefb412 --- /dev/null +++ b/assets/js/action-registry.js @@ -0,0 +1,253 @@ +/** + * action-registry.js — shared execution for page actions and the Palette. + * + * Page-specific URLs and localized labels arrive as inert JSON. The only + * executable operations are the built-ins in this file or explicitly + * registered theme executors; site configuration can never add callbacks. + */ +(function (global) { + 'use strict'; + + var BUILTINS = new Set([ + 'copy_markdown', + 'view_markdown', + 'edit_page', + 'create_issue', + 'print', + 'switch_theme', + 'switch_language', + 'switch_version', + 'open_github', + ]); + var SAFE_SCHEMES = new Set(['http:', 'https:']); + + function readManifest(documentObject) { + var node = documentObject.getElementById('oink-action-manifest'); + if (!node) return { version: 1, actions: [], commands: [] }; + try { + var parsed = JSON.parse(node.textContent || '{}'); + return parsed && parsed.version === 1 + ? parsed + : { version: 1, actions: [], commands: [] }; + } catch (_) { + return { version: 1, actions: [], commands: [] }; + } + } + + function safeUrl(value, base) { + if (!value || /^\s|\s$/.test(value) || /^[/\\]{2}/.test(value) || /\\/.test(value)) + return null; + try { + var parsed = new URL(value, base); + if (!SAFE_SCHEMES.has(parsed.protocol)) return null; + return parsed.href; + } catch (_) { + return null; + } + } + + function create(options) { + options = options || {}; + var windowObject = options.window || global; + var documentObject = options.document || windowObject.document; + var manifest = options.manifest || readManifest(documentObject); + var fetchApi = + options.fetch || + (windowObject.fetch + ? windowObject.fetch.bind(windowObject) + : function () { + return Promise.reject(new Error('Fetch unavailable')); + }); + var actions = new Map(); + var commands = []; + var executors = new Map(); + var markdown = new Map(); + + (manifest.actions || []).forEach(function (action) { + if (BUILTINS.has(action.id) && !actions.has(action.id)) + actions.set(action.id, Object.freeze(action)); + }); + (manifest.commands || []).forEach(function (command) { + if (!command || !command.id) return; + if (command.kind === 'builtin') { + if (!BUILTINS.has(command.action)) return; + } else if (command.kind === 'url') { + if (!safeUrl(command.url, windowObject.location.href)) return; + } else { + return; + } + commands.push(Object.freeze(command)); + }); + + function failure(code, action, message) { + var error = new Error(message || code); + error.code = code; + error.action = action || null; + return error; + } + + function get(id) { + return actions.get(id) || null; + } + + function list(query) { + query = query || {}; + var placement = query.placement; + return Array.from(actions.values()).filter(function (action) { + return !placement || (action.placements && action.placements[placement]); + }); + } + + function getCommands() { + 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) + .then(function (response) { + if (!response.ok) throw failure('markdown_fetch_failed', null); + return response.text(); + }) + .catch(function (error) { + markdown.delete(url); + throw error; + }); + markdown.set(url, pending); + return pending; + } + + function fallbackCopy(text) { + return new Promise(function (resolve, reject) { + var textarea = documentObject.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + documentObject.body.appendChild(textarea); + textarea.select(); + try { + if (documentObject.execCommand('copy')) resolve(); + else reject(failure('clipboard_failed', null)); + } catch (error) { + reject(error); + } + textarea.remove(); + }); + } + + function writeClipboard(text) { + var clipboard = windowObject.navigator && windowObject.navigator.clipboard; + return clipboard && clipboard.writeText + ? clipboard.writeText(text) + : fallbackCopy(text); + } + + function openUrl(action, value) { + var target = value && value.url ? value.url : action.url; + var url = safeUrl(target, windowObject.location.href); + if (!url) return Promise.reject(failure('unsafe_url', action)); + if (action.target === 'blank' || (value && value.target === 'blank')) { + windowObject.open(url, '_blank', 'noopener,noreferrer'); + } else { + windowObject.location.assign(url); + } + return Promise.resolve({ action: action, url: url }); + } + + function run(id, context) { + var action = get(id); + context = context || {}; + if (!action) return Promise.reject(failure('unsupported_action', null, id)); + if (!action.available) + return Promise.reject( + failure('unavailable', action, action.disabledReason || 'Unavailable'), + ); + var custom = executors.get(id); + if (custom) return Promise.resolve().then(function () { return custom(context, action); }); + if (id === 'copy_markdown') { + if (!action.url) return Promise.reject(failure('unavailable', action)); + return fetchMarkdown(action.url) + .then(writeClipboard) + .then(function () { return { action: action }; }); + } + if (id === 'print') { + windowObject.print(); + return Promise.resolve({ action: action }); + } + if (action.kind === 'url') return openUrl(action, context.value); + if (action.kind === 'choice' && context.value && context.value.url) + return openUrl(action, context.value); + return Promise.reject(failure('missing_executor', action)); + } + + function runCommand(id, context) { + var command = commands.find(function (candidate) { return candidate.id === id; }); + if (!command) return Promise.reject(failure('unsupported_command', null, id)); + if (command.kind === 'builtin') { + var action = get(command.action); + if ( + action && + action.kind === 'choice' && + !(context && context.value) + ) { + if (!action.available) + return Promise.reject( + failure('unavailable', action, action.disabledReason || 'Unavailable'), + ); + return Promise.resolve({ + action: action, + command: command, + options: (action.options || []).slice(), + requiresChoice: true, + }); + } + return run(command.action, context); + } + return openUrl(command, context && context.value); + } + + function registerExecutor(id, executor) { + if (!BUILTINS.has(id) || typeof executor !== 'function') + throw failure('unsupported_executor', get(id), id); + if (executors.has(id)) throw failure('duplicate_executor', get(id), id); + executors.set(id, executor); + } + + function invalidateMarkdown(url) { + if (url) markdown.delete(url); + else markdown.clear(); + } + + return Object.freeze({ + get: get, + list: list, + commands: getCommands, + quickLinks: getQuickLinks, + rootOrder: getRootOrder, + run: run, + runCommand: runCommand, + registerExecutor: registerExecutor, + preloadMarkdown: fetchMarkdown, + invalidateMarkdown: invalidateMarkdown, + safeUrl: function (value) { return safeUrl(value, windowObject.location.href); }, + }); + } + + var api = { BUILTINS: BUILTINS, create: create, safeUrl: safeUrl }; + global.OinkActionRegistry = api; + if (global.document) global.OinkActions = create(); + if (typeof module === 'object' && module.exports) module.exports = api; +})(typeof window === 'object' ? window : globalThis); diff --git a/assets/js/authored-a11y.js b/assets/js/authored-a11y.js index 1fc625b7..c537387a 100644 --- a/assets/js/authored-a11y.js +++ b/assets/js/authored-a11y.js @@ -38,11 +38,4 @@ icon.setAttribute('aria-hidden', 'true'); }); - // Print controls also appear in the standalone print output, which does not - // load docs-shell.js. Keep the behavior in this global, CSP-safe bundle. - document.querySelectorAll('[data-td-page-print]').forEach(function (button) { - button.addEventListener('click', function () { - window.print(); - }); - }); })(); 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..f1113f20 --- /dev/null +++ b/assets/js/command-palette.js @@ -0,0 +1,580 @@ +/** + * Command Palette: one dialog for local pages, quick links, and safe actions. + * Bundled only when shell/search-enabled.html is true. + */ +(function (global) { + '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(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 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 loading = false; + 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 logicalOpen; + } + + function announce(text) { + status.textContent = ''; + global.requestAnimationFrame(function () { status.textContent = text; }); + } + + function resultMessage(count) { + return (root.dataset.tResults || '{count} results').replace( + '{count}', String(count), + ); + } + + function clearRows() { + list.textContent = ''; + rows = []; + selected = 0; + input.removeAttribute('aria-activedescendant'); + } + + 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) { + clearRows(); + var el = document.createElement('div'); + el.className = 'td-shell-search__empty'; + el.textContent = text; + list.appendChild(el); + announce(text); + } + + function ensureIndex() { + if (engine || loading) return; + loadFailed = false; + loading = true; + var request = ++indexRequest; + syncBusy(); + fetch(root.dataset.indexSrc) + .then(function (response) { + if (!response.ok) throw new Error('Search index unavailable'); + return response.json(); + }) + .then(function (data) { + if (request !== indexRequest) return; + engine = searchApi.create(data, lunr, maxResults); + loading = false; + syncBusy(); + if (isOpen()) render(input.value); + }) + .catch(function () { + if (request !== indexRequest) return; + loading = false; + loadFailed = true; + syncBusy(); + if (isOpen() && normalQuery(input.value)) + render(input.value, false); + }); + } + + 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 = 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 + query.length); + fragment.appendChild(mark); + fragment.appendChild(document.createTextNode(text.slice(at + query.length))); + return fragment; + } + + function select(index) { + var options = Array.prototype.slice.call( + list.querySelectorAll('[role="option"]'), + ); + if (!options.length) { + input.removeAttribute('aria-activedescendant'); + return; + } + selected = Math.max(0, Math.min(index, 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 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; + }); + } + + 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 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', 'group'); + + var heading = document.createElement('div'); + heading.className = 'td-shell-search__group-label'; + heading.id = 'td-shell-search-group-' + group.key; + heading.textContent = group.label; + section.setAttribute('aria-labelledby', heading.id); + section.appendChild(heading); + + 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-' + index; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', 'false'); + row.setAttribute('tabindex', '-1'); + 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(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.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); + }); + 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 () { + if (composing) return; + choiceState = null; + global.clearTimeout(debounce); + debounce = global.setTimeout(function () { render(input.value); }, 80); + }); + 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 (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); }); + root.querySelectorAll('[data-td-shell-search-close]').forEach(function (el) { + el.addEventListener('click', close); + }); + + 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'; + }); + } + + return Object.freeze({ + activate: activate, + close: close, + ensureIndex: ensureIndex, + isOpen: isOpen, + open: open, + render: render, + rows: function () { return rows.slice(); }, + }); + } + + 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/dark-mode.js b/assets/js/dark-mode.js index 14cfef7a..5f9ff39a 100644 --- a/assets/js/dark-mode.js +++ b/assets/js/dark-mode.js @@ -98,6 +98,18 @@ syncDirectThemeButtons() } + if (window.OinkActions && window.OinkActions.get('switch_theme')) { + window.OinkActions.registerExecutor('switch_theme', context => { + const value = context && context.value + const theme = typeof value === 'string' ? value : value && value.value + if (!['auto', 'light', 'dark'].includes(theme)) { + return Promise.reject(new Error('Unsupported theme')) + } + apply(theme) + return { theme } + }) + } + window.addEventListener('DOMContentLoaded', () => { syncDirectThemeButtons() diff --git a/assets/js/docs-shell.js b/assets/js/docs-shell.js index 76fbcef0..2d931a59 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'); @@ -742,18 +756,15 @@ /* ---------------------------------------------------------- pageContext */ - // LLM and Markdown actions in the TOC rail's action list. + // LLM actions in the TOC rail's action list. Page actions use OinkActions. function initPageContext() { document .querySelectorAll('[data-td-page-context]') .forEach(function (root) { - var status = root.querySelector('[data-td-page-context-status]'); var openInLinks = root.querySelectorAll('[data-td-page-open-in]'); var openInPrompt = root.dataset.tdPageOpenInPrompt || 'Read from %s so I can ask questions about it.'; - var copyButtons = root.querySelectorAll('[data-td-page-copy]'); - var cached = new Map(); // Match Nextra's current behavior: use the browser URL at activation // time so the deployed host, query string, and current hash survive. @@ -773,427 +784,7 @@ syncOpenInLink(link); }); }); - - function announce(text) { - if (!status) return; - status.textContent = ''; - window.requestAnimationFrame(function () { - status.textContent = text; - }); - } - - function fallbackCopy(text) { - return new Promise(function (resolve, reject) { - var textarea = document.createElement('textarea'); - textarea.value = text; - textarea.setAttribute('readonly', ''); - textarea.style.position = 'fixed'; - textarea.style.opacity = '0'; - document.body.appendChild(textarea); - textarea.select(); - try { - if (document.execCommand('copy')) { - resolve(); - } else { - reject(new Error('copy failed')); - } - } catch (error) { - reject(error); - } - textarea.remove(); - }); - } - - function writeClipboard(text) { - if (navigator.clipboard && navigator.clipboard.writeText) { - return navigator.clipboard.writeText(text); - } - return fallbackCopy(text); - } - - function showCopied(button) { - var label = button.querySelector('[data-td-page-copy-label]'); - button.classList.add('is-copied'); - if (label && !label.dataset.original) - label.dataset.original = label.textContent; - if (label) - label.textContent = root.dataset.tCopied || label.textContent; - announce(root.dataset.tCopied || 'Copied'); - window.setTimeout(function () { - button.classList.remove('is-copied'); - if (label && label.dataset.original) - label.textContent = label.dataset.original; - }, 1400); - } - - function fetchMarkdown(url) { - if (cached.has(url)) return Promise.resolve(cached.get(url)); - return fetch(url) - .then(function (response) { - if (!response.ok) throw new Error('Markdown request failed'); - return response.text(); - }) - .then(function (text) { - cached.set(url, text); - return text; - }); - } - - copyButtons.forEach(function (button) { - var url = button.dataset.url; - if (!url) return; - // Warm the cache on intent rather than on load: most readers never - // press this, and an unconditional fetch is a request per page view. - ['pointerenter', 'focus'].forEach(function (event) { - button.addEventListener( - event, - function () { - fetchMarkdown(url).catch(function () { - /* retry on activation */ - }); - }, - { once: true }, - ); - }); - button.addEventListener('click', function () { - fetchMarkdown(url) - .then(writeClipboard) - .then(function () { - showCopied(button); - }) - .catch(function () { - announce(root.dataset.tCopyError || 'Copy failed'); - }); - }); - }); - }); - } - - /* --------------------------------------------------------------- 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 */ @@ -1211,7 +802,6 @@ initAsideRelocate(); initToc(); initPageContext(); - initSearch(); // Restore transitions after the first painted frame. window.requestAnimationFrame(function () { diff --git a/assets/js/navbar-menu.js b/assets/js/navbar-menu.js new file mode 100644 index 00000000..cbb72ee3 --- /dev/null +++ b/assets/js/navbar-menu.js @@ -0,0 +1,141 @@ +/** + * navbar-menu.js — one-level desktop disclosure and mobile accordion. + * + * Parent labels remain ordinary links. Only the adjacent button owns the + * disclosure state, so navigation and expansion never compete for one click. + */ +(function () { + 'use strict'; + + function setDisclosure(toggle, panel, owner, open) { + panel.hidden = !open; + owner.classList.toggle('is-open', open); + toggle.setAttribute('aria-expanded', open ? 'true' : 'false'); + var label = open + ? toggle.dataset.labelCollapse + : toggle.dataset.labelExpand; + if (label) toggle.setAttribute('aria-label', label); + } + + function panelItems(panel) { + return Array.prototype.filter.call( + panel.querySelectorAll('a[href], button:not([disabled])'), + function (item) { + return !item.hasAttribute('hidden'); + }, + ); + } + + function initDesktopMenus() { + document.querySelectorAll('[data-td-navbar-menu]').forEach(function (menu, index) { + var toggle = menu.querySelector('[data-td-navbar-toggle]'); + var panel = menu.querySelector('[data-td-navbar-panel]'); + var surfaceName = 'navbar-menu-' + index; + if (!toggle || !panel) return; + + function isOpen() { + return !panel.hidden; + } + function close(restoreFocus) { + if (!isOpen()) return; + setDisclosure(toggle, panel, menu, false); + if (restoreFocus === true) toggle.focus(); + } + function open(focusFirst) { + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.closeOthers(surfaceName); + setDisclosure(toggle, panel, menu, true); + if (focusFirst) + window.requestAnimationFrame(function () { + var items = panelItems(panel); + if (items.length) items[0].focus(); + }); + } + + if (window.OinkSurfaceCoordinator) + window.OinkSurfaceCoordinator.register(surfaceName, close); + + toggle.addEventListener('click', function () { + if (isOpen()) close(false); + else open(false); + }); + toggle.addEventListener('keydown', function (event) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + open(true); + } else if (event.key === 'Escape' && isOpen()) { + event.preventDefault(); + close(true); + } + }); + panel.addEventListener('keydown', function (event) { + var items = panelItems(panel); + var current = items.indexOf(document.activeElement); + var next = current; + if (event.key === 'Escape') { + event.preventDefault(); + close(true); + return; + } else if (event.key === 'ArrowDown') { + next = current < 0 ? 0 : Math.min(current + 1, items.length - 1); + } else if (event.key === 'ArrowUp') { + next = current < 0 ? items.length - 1 : Math.max(current - 1, 0); + } else if (event.key === 'Home') { + next = 0; + } else if (event.key === 'End') { + next = items.length - 1; + } else { + return; + } + if (items.length) { + event.preventDefault(); + items[next].focus(); + } + }); + document.addEventListener( + 'pointerdown', + function (event) { + if (isOpen() && !menu.contains(event.target)) close(false); + }, + true, + ); + menu.addEventListener('focusout', function (event) { + if (isOpen() && !menu.contains(event.relatedTarget)) close(false); + }); + }); + } + + function initMobileAccordions() { + var root = document.querySelector('[data-mobile-menu]'); + if (!root) return; + var sections = Array.prototype.slice.call( + root.querySelectorAll('[data-td-navbar-accordion]'), + ); + var singleOpen = root.dataset.accordionSingleOpen === 'true'; + + function setOpen(section, open) { + var toggle = section.querySelector('[data-td-navbar-accordion-toggle]'); + var panel = section.querySelector('[data-td-navbar-accordion-panel]'); + if (!toggle || !panel) return; + if (open && singleOpen) + sections.forEach(function (other) { + if (other !== section) setOpen(other, false); + }); + setDisclosure(toggle, panel, section, open); + } + + sections.forEach(function (section) { + var toggle = section.querySelector('[data-td-navbar-accordion-toggle]'); + var panel = section.querySelector('[data-td-navbar-accordion-panel]'); + var parent = section.querySelector('.mobile-menu-parent-link'); + if (!toggle || !panel) return; + toggle.addEventListener('click', function () { + setOpen(section, panel.hidden); + }); + if (parent && parent.classList.contains('active')) setOpen(section, true); + }); + } + + initDesktopMenus(); + initMobileAccordions(); +})(); diff --git a/assets/js/page-actions.js b/assets/js/page-actions.js new file mode 100644 index 00000000..a26449fa --- /dev/null +++ b/assets/js/page-actions.js @@ -0,0 +1,50 @@ +/** Bind the progressive-enhancement page rail to the shared action registry. */ +(function () { + 'use strict'; + if (!window.OinkActions) return; + + function announce(root, text) { + var status = root && root.querySelector('[data-td-page-context-status]'); + if (!status) return; + status.textContent = ''; + window.requestAnimationFrame(function () { status.textContent = text; }); + } + + function showCopied(button, root) { + var label = button.querySelector('[data-td-page-copy-label]'); + button.classList.add('is-copied'); + if (label && !label.dataset.original) label.dataset.original = label.textContent; + if (label) label.textContent = root.dataset.tCopied || label.textContent; + announce(root, root.dataset.tCopied || 'Copied'); + window.setTimeout(function () { + button.classList.remove('is-copied'); + if (label && label.dataset.original) label.textContent = label.dataset.original; + }, 1400); + } + + document.querySelectorAll('[data-oink-action]').forEach(function (control) { + var id = control.dataset.oinkAction; + var action = window.OinkActions.get(id); + if (!action || !action.available) return; + var root = control.closest('[data-td-page-context]'); + + if (id === 'copy_markdown') { + ['pointerenter', 'focus'].forEach(function (eventName) { + control.addEventListener(eventName, function () { + window.OinkActions.preloadMarkdown(action.url).catch(function () {}); + }, { once: true }); + }); + control.addEventListener('click', function () { + window.OinkActions.run(id, { source: 'page' }) + .then(function () { showCopied(control, root); }) + .catch(function () { + announce(root, (root && root.dataset.tCopyError) || 'Copy failed'); + }); + }); + } else if (id === 'print') { + control.addEventListener('click', function () { + window.OinkActions.run(id, { source: 'page' }); + }); + } + }); +})(); 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/js/search-engine.js b/assets/js/search-engine.js new file mode 100644 index 00000000..fcca7bd6 --- /dev/null +++ b/assets/js/search-engine.js @@ -0,0 +1,169 @@ +/** + * search-engine.js — deterministic page search for the Command Palette. + * + * The index builder is injected so this module can be unit-tested without a + * DOM. Both Latin/Lunr and CJK substring paths apply keywords and the same + * final per-document boost multiplier. + */ +(function () { + 'use strict'; + + var CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿]/; + + function number(value, fallback) { + var parsed = Number(value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } + + function lexical(a, b) { + a = String(a || ''); + b = String(b || ''); + return a < b ? -1 : a > b ? 1 : 0; + } + + function compare(a, b) { + return ( + b.score - a.score || + lexical(a.doc.title, b.doc.title) || + lexical(a.doc.ref, b.doc.ref) + ); + } + + function group(results) { + var groups = []; + var groupByKey = new Map(); + (results || []).forEach(function (result) { + var rawKey = result.doc.root || result.doc.type || 'pages'; + var key = String(rawKey).trim().toLowerCase() || 'pages'; + var current = groupByKey.get(key); + if (!current) { + current = { + key: key, + label: + (result.doc.breadcrumb && result.doc.breadcrumb[0]) || + result.doc.root || + result.doc.type || + 'Pages', + results: [], + }; + groupByKey.set(key, current); + groups.push(current); + } + current.results.push(result); + }); + return groups; + } + + function create(documents, lunrApi, maxResults) { + var docs = documents || []; + var limit = number(maxResults, 10); + var docByRef = new Map(); + docs.forEach(function (doc) { + docByRef.set(doc.ref, doc); + }); + + var index = lunrApi(function () { + this.ref('ref'); + this.field('title', { boost: 5 }); + this.field('categories', { boost: 3 }); + this.field('tags', { boost: 3 }); + this.field('keywords', { boost: 4 }); + this.field('headings', { boost: 3 }); + this.field('description', { boost: 2 }); + this.field('body'); + docs.forEach(function (doc) { + this.add(doc); + }, this); + }); + + function queryCjk(query) { + var needle = query.toLowerCase(); + var hits = []; + docs.forEach(function (doc) { + var titleAt = (doc.title || '').toLowerCase().indexOf(needle); + var keywordText = Array.isArray(doc.keywords) + ? doc.keywords.join(' ') + : doc.keywords || ''; + var keywordAt = keywordText.toLowerCase().indexOf(needle); + var headingAt = (doc.headings || '').toLowerCase().indexOf(needle); + var descAt = (doc.description || '').toLowerCase().indexOf(needle); + var bodyAt = (doc.body || '').toLowerCase().indexOf(needle); + var textScore = + (titleAt >= 0 ? 100 : 0) + + (keywordAt >= 0 ? 80 : 0) + + (headingAt >= 0 ? 50 : 0) + + (descAt >= 0 ? 30 : 0) + + (bodyAt >= 0 ? 10 : 0); + if (!textScore) return; + var excerpt = doc.excerpt || ''; + if (bodyAt >= 0) { + var start = Math.max(0, bodyAt - 24); + excerpt = + (start > 0 ? '…' : '') + + doc.body.slice(start, bodyAt + 56) + + '…'; + } else if (descAt >= 0) { + excerpt = doc.description; + } else if (keywordAt >= 0) { + excerpt = keywordText; + } + hits.push({ + doc: doc, + excerpt: excerpt, + score: textScore * number(doc.boost, 1), + textScore: textScore, + }); + }); + return hits.sort(compare).slice(0, limit); + } + + function queryLatin(query) { + var found = index.query(function (builder) { + lunrApi.tokenizer(query.toLowerCase()).forEach(function (token) { + var term = token.toString(); + builder.term(term, { boost: 100 }); + builder.term(term, { + wildcard: + lunrApi.Query.wildcard.LEADING | + lunrApi.Query.wildcard.TRAILING, + boost: 10, + }); + builder.term(term, { editDistance: 2 }); + }); + }); + return found + .map(function (result) { + var doc = docByRef.get(result.ref); + return doc + ? { + doc: doc, + excerpt: doc.excerpt || doc.description || '', + score: result.score * number(doc.boost, 1), + textScore: result.score, + } + : null; + }) + .filter(Boolean) + .sort(compare) + .slice(0, limit); + } + + return { + query: function (query) { + return CJK.test(query) ? queryCjk(query) : queryLatin(query); + }, + queryCjk: queryCjk, + queryLatin: queryLatin, + }; + } + + window.OinkSearchEngine = { + CJK: CJK, + compare: compare, + group: group, + lexical: lexical, + create: create, + }; + if (typeof module === 'object' && module.exports) + module.exports = window.OinkSearchEngine; +})(); 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/assets/json/offline-search-index.json b/assets/json/offline-search-index.json index aaf77cec..265429bb 100644 --- a/assets/json/offline-search-index.json +++ b/assets/json/offline-search-index.json @@ -5,19 +5,20 @@ {{- $.Scratch.Add "offline-search-index" slice -}} {{- $pages := where .Site.Pages "Kind" "in" (slice "page" "section") -}} {{- range $pages -}} -{{- if or .Params.exclude_search .Params.excludeSearch }}{{ continue }}{{ end -}} +{{- if or .Params.search_exclude .Params.exclude_search .Params.excludeSearch }}{{ continue }}{{ end -}} {{- if not .Content }}{{ continue }}{{ end -}} {{- /* We have to apply `htmlUnescape` again after `truncate` because `truncate` applies `html.EscapeString` if the argument is not HTML. */ -}} {{- /* Individual taxonomies can be added in the next line by add '"taxonomy-name" (.Params.taxonomy-name | default "")' to the dict (as seen for categories and tags). */ -}} {{- $summary := (.Description | default (.Summary | plainify)) | htmlUnescape | strings.TrimSpace -}} {{- $headings := replace .TableOfContents "" " " | plainify | htmlUnescape | strings.TrimSpace -}} -{{- $doc := dict +{{- $metadata := partial "search/metadata.html" . -}} +{{- $doc := merge $metadata (dict "ref" .RelPermalink "title" .Title "categories" (.Params.categories | default "") "tags" (.Params.tags | default "") "excerpt" (($summary | default .Plain) | htmlUnescape | truncate (.Site.Params.offlineSearchSummaryLength | default 70) | htmlUnescape) --}} +) -}} {{- if eq $scope "heading" -}} {{- $doc = merge $doc (dict "headings" $headings) -}} {{- else if eq $scope "summary" -}} diff --git a/assets/scss/td/_site-navbar.scss b/assets/scss/td/_site-navbar.scss index 23e4396a..e6d9e707 100644 --- a/assets/scss/td/_site-navbar.scss +++ b/assets/scss/td/_site-navbar.scss @@ -94,6 +94,129 @@ } } + .nav-menu { + position: relative; + } + + .nav-menu__parent { + display: flex; + align-items: center; + } + + .nav-menu__parent-link { + padding-inline-end: 4px; + border-start-end-radius: 3px; + border-end-end-radius: 3px; + } + + .nav-menu__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 32px; + padding: 0; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--bs-tertiary-color); + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--td-brand-elev); + color: var(--bs-body-color); + } + + .td-shell-icon { + width: 14px; + height: 14px; + transition: transform 140ms ease; + } + } + + .nav-menu.is-open .nav-menu__toggle .td-shell-icon { + transform: rotate(180deg); + } + + .nav-menu__panel { + position: absolute; + top: calc(100% + 8px); + inset-inline-start: 0; + z-index: 110; + min-width: 260px; + max-width: unquote('min(360px, calc(100vw - 32px))'); + padding: 7px; + border: 1px solid var(--bs-border-color); + border-radius: 10px; + background: var(--bs-body-bg); + box-shadow: var(--td-brand-shadow-md); + + &[hidden] { + display: none; + } + } + + .nav-menu__item-link, + .nav-menu__group-title { + display: grid; + grid-template-columns: 20px minmax(0, 1fr) auto; + gap: 1px 9px; + align-items: center; + padding: 8px 9px; + border-radius: 7px; + color: var(--bs-body-color); + font-size: 0.875rem; + font-weight: 500; + + &:hover, + &:focus-visible { + background: var(--td-brand-elev); + color: var(--bs-link-color); + text-decoration: none; + } + + &.active { + color: var(--bs-link-color); + } + + > i { + grid-row: 1 / span 2; + width: 20px; + color: var(--bs-tertiary-color); + text-align: center; + } + } + + .nav-menu__group-title { + margin-block-start: 3px; + font-weight: 650; + } + + .td-navbar-entry__description { + grid-column: 2 / -1; + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 400; + line-height: 1.35; + } + + .td-navbar-entry__external { + display: inline-flex; + color: var(--bs-tertiary-color); + + .td-shell-icon { + width: 13px; + height: 13px; + } + } + + .td-navbar-group__items { + margin-inline-start: 12px; + padding-inline-start: 6px; + border-inline-start: 1px solid var(--bs-border-color); + } + .nav-util { display: inline-flex; align-items: center; @@ -269,6 +392,84 @@ } } + .mobile-menu-section { + border-bottom: 1px solid var(--bs-border-color); + } + + .mobile-menu-parent { + display: grid; + grid-template-columns: minmax(0, 1fr) 42px; + align-items: center; + } + + .mobile-menu-disclosure { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--bs-tertiary-color); + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--td-brand-elev); + color: var(--bs-body-color); + } + + .td-shell-icon { + width: 16px; + height: 16px; + transition: transform 140ms ease; + } + } + + .mobile-menu-section.is-open .mobile-menu-disclosure .td-shell-icon { + transform: rotate(180deg); + } + + .mobile-menu-children { + padding-block: 0 7px; + padding-inline-start: 24px; + + &[hidden] { + display: none; + } + + .mobile-menu-link { + padding-block: 9px; + font-size: 0.9rem; + } + + .td-navbar-entry__description { + display: block; + margin-block-start: 2px; + } + + .td-navbar-group__items { + margin-inline-start: 15px; + } + } + + .td-navbar-entry__external { + display: inline-flex; + margin-inline-start: auto; + color: var(--bs-tertiary-color); + + .td-shell-icon { + width: 14px; + height: 14px; + } + } + + .mobile-menu-group-title { + font-weight: 650; + } + // Rows that pair an action with its options: language, color theme. .mobile-menu-row { display: flex; @@ -334,7 +535,8 @@ // Separators between top-level entries, but not inside a paired row. > .mobile-menu-link, - > .mobile-menu-row { + > .mobile-menu-row, + > .mobile-menu-section { border-bottom: 1px solid var(--bs-border-color); } @@ -343,6 +545,13 @@ } } +@media (prefers-reduced-motion: reduce) { + .landing-header .nav-menu__toggle .td-shell-icon, + .mobile-menu .mobile-menu-disclosure .td-shell-icon { + transition: none; + } +} + .td-default { main > section:first-of-type { @include media-breakpoint-up(md) { diff --git a/assets/scss/td/shell/_search.scss b/assets/scss/td/shell/_search.scss index e84841f9..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,32 @@ } } +.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; +} + +.td-shell-search__group-label { + padding: 6px 10px 4px; + color: var(--td-shell-muted-fg); + font-size: 11px; + font-weight: 600; + line-height: 16px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + .td-shell-search__item-title { font-size: 14px; font-weight: 500; @@ -225,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/docs/prd4-migration-guide.md b/docs/prd4-migration-guide.md new file mode 100644 index 00000000..a4d796d5 --- /dev/null +++ b/docs/prd4-migration-guide.md @@ -0,0 +1,471 @@ +# OINK PRD 4 migration and configuration reference + +Status: **unreleased development documentation** + +This reference describes the implementation tracked by +[pgsty/oink#11](https://github.com/pgsty/oink/issues/11). It is not a statement +that the latest OINK tag contains these features. A consuming site may advertise +them only after the owning theme changes are merged, a release tag includes +them, the site pins that tag, and the hosted checks in this document pass. + +The normative design decisions remain in the +[machine-checked contract](prd4-navigation-command-palette-contract.md). + +## Release status {#release-status} + +PRD 4 is implemented on a development branch and is not yet released. Keep +configuration changes on a matching consumer branch until all release gates +are complete. Do not copy an example into a production site pinned to an older +tag and assume the older theme will understand it. + +The release sequence is: + +1. merge the owning theme changes; +2. pass the minimum/current Hugo CI matrix; +3. publish a tagged theme release naming PRD 4; +4. update and pin the consumer to that tag; +5. pass consumer CI and hosted smoke tests; +6. only then document the feature as generally available. + +## Authority boundaries {#authority-boundaries} + +PRD 4 adds behavior, not another information architecture. + +| Concern | Authoritative source | +| --- | --- | +| Global navigation | Hugo `menus.main` | +| Sidebar | Hugo content tree and existing docs navigation | +| Product or content-domain switch | Existing root switcher | +| Page discovery | Per-language local search index | +| Page and Palette actions | Shared internal action registry | + +Do not add `docs.json`, `navigation.yaml`, or a second menu tree. Quick links +and Palette root order are projections of the same Hugo Menu. + +## Migration path {#migration-path} + +Flat menus need no migration. To adopt PRD 4 deliberately: + +1. keep the site on a branch and pin the containing OINK release when it exists; +2. add one child level to `menus.main` with Hugo `parent` identifiers; +3. select a sidebar icon policy explicitly for new sites; +4. add search metadata to representative pages and cascades; +5. add only safe URL or built-in-ID commands; +6. build both root and subpath variants, then run keyboard and screen-reader + checks before deployment. + +### Upgrade gate {#upgrade-gate} + +OINK supports Hugo Extended 0.160.1 and the current CI version. A release must +pass both before a consumer updates. Pin the theme version in `go.mod`; do not +use `@latest` as a production release policy. + +### Root and subpath builds {#root-and-subpath-builds} + +Use `pageRef` for site-owned menu entries. Hugo then resolves the active +language and `baseURL` correctly: + +```sh +hugo --baseURL https://docs.example.com/ +hugo --baseURL https://example.com/preview/ +``` + +In the second build, site-owned internal navbar, root-switcher, search-index, +page-action, command, language, and version URLs must remain under `/preview/`. +External destinations—including edit, issue, GitHub, root/version, and +configured-command targets—remain unchanged. Do not hard-code domain-root +paths in a consumer override. + +## Nested navigation {#nested-navigation} + +One child level is interactive. The parent label remains an ordinary link; its +adjacent button only opens or closes the dropdown or accordion. + +```yaml +menus: + main: + - identifier: docs + name: Docs + pageRef: /docs + weight: 10 + - identifier: guides + parent: docs + name: Guides + pageRef: /docs/tutorial + weight: 10 + params: + icon: fa-solid fa-route + description: Task-oriented tutorials + - identifier: reference + parent: docs + name: Reference + pageRef: /docs/reference + weight: 20 + params: + icon: fa-solid fa-book + description: Configuration and API reference +``` + +An item without children keeps the flat link path. A parent is active when it +or a descendant is current, but only the exact page receives +`aria-current="page"`. + +### Navigation interaction {#navigation-interaction} + +Desktop disclosure works with click, Enter, Space, and ArrowDown. ArrowDown +opens and focuses the first item. Escape closes and restores button focus. +Focus leaving the menu or a pointer press outside closes it. No behavior +requires hover. + +Mobile uses the same split link/button model. Multiple accordions may remain +open by default. To opt into a single open section: + +```yaml +params: + ui: + navbar_accordion_single_open: true +``` + +Language, version, theme, and search remain in the utility area rather than +becoming children of the content menu. + +### Deep menu degradation {#deep-menu-degradation} + +Entries below the supported child level emit a Hugo build warning and render +as linked, static group headings with ordinary descendants. They never create +a third-level flyout. Treat the warning as a prompt to move deep information +architecture into the content sidebar, not as permission to suppress it. + +### External navigation {#external-navigation} + +Cross-host entries receive an external-link affordance and open with +`rel="noopener noreferrer"`. Internal links remain language- and +subpath-aware. The target decision is derived by the theme; site configuration +does not inject arbitrary link behavior. + +## Sidebar icon policy {#sidebar-icon-policy} + +Set `params.ui.sidebar_icon_policy` to one of: + +| Value | Result | +| --- | --- | +| `all` | Every eligible sidebar entry shows its resolved icon. | +| `groups` | Roots and nodes with children show icons; ordinary leaves do not. | +| `none` | Sidebar item icons are omitted. | + +The pre-1.0 compatibility default for an absent setting is `all`. New starter +sites explicitly choose `groups`. Invalid values warn and fall back to `all`. +This separates a starter recommendation from a compatibility change. + +## Search metadata and index {#search-metadata-and-index} + +Local search stays offline-capable and language-separated. PRD 4 extends the +existing index; it does not replace it with a remote service. + +### Canonical search fields {#canonical-search-fields} + +```yaml +--- +title: PostgreSQL configuration +search_keywords: [postgres, postgresql, pg] +search_boost: 1.5 +search_exclude: false +--- +``` + +- `search_keywords` accepts one string or an array and participates in Latin + and CJK substring matching. +- `search_boost` must be a finite positive number and defaults to `1.0`. + Invalid, zero, negative, infinite, and non-numeric values warn and use + `1.0`. +- `search_exclude` is the canonical exclusion flag. + +`exclude_search` and `excludeSearch` are deprecated for new content but remain +accepted compatibility aliases throughout the 0.x release line. Their earliest +possible removal is the future 1.0 major release, with a changelog entry and +migration notice. Exclusion uses +**any-true-wins** precedence: if any canonical or alias flag is true, the page +is excluded. `search_exclude: false` cannot override a true legacy alias. + +### Cascade inheritance {#cascade-inheritance} + +Use Hugo cascade for a product or section default, then override individual +pages when needed: + +```yaml +--- +title: Documentation +cascade: + search_boost: 1.25 +--- +``` + +The index resolves `search_boost` after cascade inheritance. A page-level value +wins through Hugo's normal front matter rules. + +### Index schema and fallbacks {#index-schema-and-fallbacks} + +Every record keeps `ref`, `title`, `categories`, `tags`, and `excerpt`, and adds +the following deterministic metadata: + +| Field | Value and fallback | +| --- | --- | +| `root` | Lower-case `FirstSection`; `home` when no section exists. | +| `section` | Lower-case `CurrentSection`; falls back to `root`. | +| `type` | Lower-case Hugo page type; falls back to `root`. | +| `keywords` | Normalized `search_keywords` array; empty when absent. | +| `boost` | Valid inherited multiplier; otherwise `1.0`. | +| `breadcrumb` | Localized title path from root through the page. | +| `icon` | Page, current section, root, then stable type/root fallback. | + +`params.offlineSearchIndex` controls optional text fields: + +| Scope | Additional fields | +| --- | --- | +| `title` | No additional text fields. | +| `heading` | `headings` | +| `summary` | `headings`, `description` | +| `content` | `headings`, `description`, `body` | + +`summary` is a good starter choice. `content` gives the broadest recall but +usually produces the largest download. Each language emits an independent +index and never falls back to another language's records. + +### Ranking behavior {#ranking-behavior} + +The final page score is `text match score × search_boost`. Keywords and the +multiplier apply to both Lunr and the deterministic CJK substring path. Stable +title/reference tie-breaks prevent locale- or browser-dependent ordering. +`params.offlineSearchMaxResults` limits page hits; matching Actions are grouped +separately and do not consume the page allowance. + +### Index size budget {#index-size-budget} + +The regression fixture enforces, per language, at most 2 MiB uncompressed and +512 KiB gzip. A consumer with more content must measure its own generated +indexes and choose `title`, `heading`, `summary`, or `content` deliberately. +The fixture budget is a release guard, not a promise that arbitrary site +content can never exceed it. + +## Command Palette and actions {#command-palette-and-actions} + +The existing Cmd/Ctrl-K local-search dialog becomes the Command Palette. There +is still one dialog and one local index. + +### Palette modes {#palette-modes} + +| Input | Results | Index request | +| --- | --- | --- | +| Empty | Quick links, page actions, preferences, configured commands | None | +| Normal text | Pages grouped by root, plus matching Actions | Lazy, same origin | +| Leading `>` | Built-in and configured commands only | None | + +`@docs` and `@blog` scopes are not part of the first version. An index failure +does not remove commands, and a stale request cannot overwrite a newer Palette +session. + +### Built-in action IDs {#built-in-action-ids} + +| ID | Kind | Typical availability | +| --- | --- | --- | +| `copy_markdown` | Invoke | Current page has a Markdown output. | +| `view_markdown` | URL | Current page has a Markdown output. | +| `edit_page` | URL | Repository/edit URL can be resolved. | +| `create_issue` | URL | Repository is configured. | +| `print` | Invoke | Interactive HTML output. | +| `switch_theme` | Choice | Theme switching is enabled. | +| `switch_language` | Choice | More than one language target exists. | +| `switch_version` | Choice | Version entries exist. | +| `open_github` | URL | Project repository is configured. | + +The corresponding PRD 4 page and Palette actions share descriptors and URL +resolution. Copy Markdown shares one pending/success cache, Print calls the +shared print executor, and theme controls call the same theme application +function. PRD 4 URL actions remain real anchors whose href matches the shared +descriptor, preserving no-JavaScript navigation. Historical rail-only actions +outside the PRD 4 built-in set remain separate compatibility features. + +### Custom commands and localization {#custom-commands-and-localization} + +Define default-language records under the language's parameters. Translate by +the stable `id`, not array position: + +```yaml +languages: + en: + params: + ui: + command_palette: + commands: + - id: status + title: Service status + description: View uptime and incidents + url: https://status.example.com/ + icon: fa-solid fa-signal + keywords: [uptime, incident] + - id: print_page + title: Print this page + action: print + keywords: [paper, pdf] + zh: + params: + ui: + command_palette: + commands: + - id: print_page + title: 打印此页 + keywords: [纸张, PDF] + - id: status + title: 服务状态 + keywords: [可用性, 故障] +``` + +The current language overrides the default record by ID; omitted fields fall +back to the default language. A default record—or a locale-only new ID—must +define exactly one `url` or one allowed built-in `action` ID. An override for +an existing ID may omit both and inherit the default; after merging, every +effective command still has exactly one execution kind. + +### Command security boundary {#command-security-boundary} + +Configuration is inert data. It cannot provide callbacks, event handlers, +function names, JavaScript source, or an executor. Unknown/reserved IDs, +duplicate IDs, unsupported keys, malformed string fields, `url` plus `action`, +and unknown built-in actions fail the Hugo build. + +URLs may be site-relative or explicit `http:`/`https:` URLs. The build rejects +`javascript:`, `data:`, `vbscript:`, `file:`, protocol-relative URLs, +backslashes, and control characters; the runtime validates the URL again. +External URLs use `noopener noreferrer`. Rendering uses escaped template text +or DOM `textContent`; it never evaluates manifest data. + +## Keyboard and screen readers {#keyboard-and-screen-readers} + +### Navbar interaction table {#navbar-interaction-table} + +| Context | Key or action | Result | +| --- | --- | --- | +| Parent link | Enter | Navigate to the parent page. | +| Disclosure button | Enter or Space | Toggle the panel. | +| Desktop disclosure | ArrowDown | Open and focus the first actionable item. | +| Open desktop panel | ArrowUp/Down, Home/End | Move among actionable items. | +| Open desktop panel | Escape | Close and restore disclosure focus. | +| Open desktop panel | Tab or outside press | Leave normally and close the panel. | +| Mobile parent link | Activate | Navigate without toggling the accordion. | +| Mobile disclosure | Activate | Toggle without navigating. | + +Disclosure buttons expose `aria-expanded` and `aria-controls`. Panels use the +disclosure pattern, not an ARIA application menu, so child links retain native +link semantics. + +### Palette interaction table {#palette-interaction-table} + +| Key or action | Result | +| --- | --- | +| Cmd/Ctrl-K | Open or close the Palette. | +| ArrowUp/ArrowDown | Move the active listbox option. | +| Cmd/Ctrl-Home or Cmd/Ctrl-End | Move to the first or last option. | +| Enter | Activate the current option once. | +| Escape | Close and restore a visible invoking control. | +| Tab/Shift-Tab | Stay within the modal dialog. | +| IME composition keys | Edit the composition; do not navigate or execute. | + +DOM focus stays in the editable combobox while `aria-activedescendant` points +to the active listbox option. Result sections are labelled groups. Disabled +choices with a reason remain discoverable and cannot execute. A polite live +region announces counts, errors, and action outcomes without re-announcing +every arrow movement. Reduced-motion users do not wait for the close +transition. On mobile, focus returns to the visible drawer/menu opener rather +than a control hidden inside the closed surface. + +## Runtime and privacy guarantees {#runtime-and-privacy-guarantees} + +The Palette capability is enabled only when all conditions hold: + +1. `params.offlineSearch` is true; +2. the page is home or uses a shell surface; +3. the current output is not `print`. + +When disabled, the page omits Palette dialog markup, its local-index reference, +Lunr, the Palette result model, and the Palette controller. Print output omits +the same runtime. The action manifest and shared page-action registry may still +exist because progressive page actions are independent of search. + +Empty and `>` modes do not fetch the index. Normal text fetches only the active +language's same-origin generated JSON. OINK sends no default telemetry, query +upload, analytics event, or remote-search request. A consumer's explicitly +configured analytics, comments, hosted search, or external command URL is a +separate site policy and must be disclosed by that site. + +## Compatibility window {#compatibility-window} + +- Existing flat `menus.main` HTML and behavior remain supported without + configuration changes. +- `params.ui.sidebar_icon_policy` remains `all` when absent throughout 0.x; + starters choose `groups` explicitly. +- `exclude_search` and `excludeSearch` remain search-exclusion aliases + throughout 0.x and can be removed no earlier than 1.0. +- Cmd/Ctrl-K stays the Palette shortcut and ordinary text remains page search. +- No migration adds a second navigation authority or default network service. + +Any alias removal or default change requires a future major-release migration +entry and updated characterization fixtures. + +## Starter versus theme defaults {#starter-versus-theme-defaults} + +The minimal `exampleSite/hugo.yaml` intentionally demonstrates a nested Hugo +Menu, `sidebar_icon_policy: groups`, summary-sized local search, and safe URL +and built-in commands. These are starter choices. The theme itself continues +to preserve the compatibility defaults for a consumer that configures none of +them. + +## Verification and release evidence {#verification-and-release-evidence} + +### Local verification gates {#local-verification-gates} + +Run the focused theme checks under every supported Hugo version: + +```sh +python3 scripts/check-prd4-contract.py +python3 scripts/check-prd4-runtime.py +python3 scripts/check-sidebar-icons.py +python3 scripts/check-prd4-search.py +python3 scripts/check-prd4-actions.py +python3 scripts/check-prd4-palette.py +python3 scripts/check-prd4-docs.py +``` + +These cover root/subpath output, EN/ZH, flat/nested/deep menus, search on/off, +home/docs/blog/plain/print surfaces, CJK ranking, registry security, Palette +state, and documentation parity. The consumer site must separately run its +Hugo, link, translation, Playwright, and axe suites. Passing one layer is not +evidence that a later delivery layer passed. + +### Evidence ledger {#evidence-ledger} + +| Gate | Current evidence | Release state | +| --- | --- | --- | +| Contract/runtime/navigation/search/actions/Palette | Focused results in [theme #18](https://github.com/pgsty/oink/issues/18#issuecomment-5263306916) and owning issues | Local pass; merge CI pending | +| Consumer root/subpath, EN/ZH, browser and axe | [Consumer #3 evidence](https://github.com/pgsty/oink.pgsty.com/issues/3#issuecomment-5263727126) | Local pass; consumer CI pending | +| Minimum/current Hugo matrix | [Theme CI workflow](../.github/workflows/ci.yml) | Must pass on the merged commit | +| Tagged theme artifact | Release page and checksum for the containing tag | Pending | +| Consumer version pin and deploy | Consumer commit and deployment run | Pending | +| Hosted root/subpath smoke test | Public URLs, observed asset paths, keyboard and telemetry trace | Pending | + +Local visual inspection, unit tests, integration tests, CI, package/tag +publication, consumer deployment, and hosted availability are distinct gates. + +### Release checklist {#release-checklist} + +- [ ] Owning theme changes are reviewed and merged. +- [ ] Hugo 0.160.1 and current-version CI are green on that merge. +- [ ] Changelog and tag name the PRD 4 feature set. +- [ ] Consumer pins the containing tag rather than a local replacement. +- [ ] Consumer non-browser, browser, and axe suites pass in CI. +- [ ] Root and subpath deployments load language-local indexes and internal + URLs from the correct prefix. +- [ ] Print and search-disabled pages omit Palette runtime. +- [ ] A network trace confirms no default query or telemetry request. +- [ ] Hosted keyboard and screen-reader smoke tests pass. +- [ ] Only after every preceding gate is complete may published docs remove + the unreleased warning. diff --git a/docs/prd4-migration-guide.zh.md b/docs/prd4-migration-guide.zh.md new file mode 100644 index 00000000..e65bf4ff --- /dev/null +++ b/docs/prd4-migration-guide.zh.md @@ -0,0 +1,437 @@ +# OINK PRD 4 迁移与配置参考 + +状态:**未发布的开发文档** + +本文描述 [pgsty/oink#11](https://github.com/pgsty/oink/issues/11) 跟踪的实现, +不表示 OINK 最新标签已经包含这些功能。只有当主题改动合并、某个发布标签明确 +包含它们、消费站固定到该标签,并且本文的线上验收门禁全部通过后,消费站才 +可以宣称这些功能可用。 + +规范性的设计决策仍以 +[机器可检查契约](prd4-navigation-command-palette-contract.md)为准。 + +## 发布状态 {#release-status} + +PRD 4 已在开发分支实现,但尚未发布。所有配置变更应保留在匹配的消费站分支, +直到发布门禁全部完成。不要把示例复制到仍固定旧标签的生产站点,并假定旧主题 +能够理解这些配置。 + +发布顺序如下: + +1. 合并主题侧功能改动; +2. 通过最低与当前 Hugo 版本的 CI 矩阵; +3. 发布明确包含 PRD 4 的主题标签; +4. 消费站更新并固定到该标签; +5. 通过消费站 CI 与线上冒烟测试; +6. 完成以上步骤后,才可将功能标记为正式可用。 + +## 权威数据边界 {#authority-boundaries} + +PRD 4 增加交互能力,但不增加第二套信息架构。 + +| 关注点 | 权威来源 | +| --- | --- | +| 全局导航 | Hugo `menus.main` | +| 侧栏 | Hugo 内容树与现有文档导航 | +| 产品或内容域切换 | 现有 root switcher | +| 页面发现 | 按语言隔离的本地搜索索引 | +| 页面与 Palette 动作 | 共享的内部 action registry | + +不要增加 `docs.json`、`navigation.yaml` 或第二棵菜单树。Quick links 和 +Palette 的 root 顺序都来自同一个 Hugo Menu 投影。 + +## 迁移路径 {#migration-path} + +平铺菜单无需迁移。若要主动采用 PRD 4: + +1. 先在分支上修改,并在包含该功能的 OINK 版本发布后固定该版本; +2. 使用 Hugo `parent` identifier 给 `menus.main` 增加一层 child; +3. 新站点显式选择侧栏图标策略; +4. 给代表性页面和 cascade 增加搜索元数据; +5. 只增加安全 URL 或内置 action ID 命令; +6. 同时构建 root 与 subpath 版本,并在部署前完成键盘和屏幕阅读器检查。 + +### 升级门禁 {#upgrade-gate} + +OINK 支持 Hugo Extended 0.160.1 与 CI 当前版本。消费站升级前,发布版本必须 +同时通过这两个版本。生产环境应在 `go.mod` 固定主题版本,不要把 `@latest` +当作发布策略。 + +### Root 与 Subpath 构建 {#root-and-subpath-builds} + +站内菜单项使用 `pageRef`,Hugo 才能正确解析当前语言与 `baseURL`: + +```sh +hugo --baseURL https://docs.example.com/ +hugo --baseURL https://example.com/preview/ +``` + +第二个构建中,站点拥有的内部 navbar、root switcher、搜索索引、页面动作、 +命令、语言与版本 URL 都必须位于 `/preview/` 下。外部 edit、issue、GitHub、 +root/version 与自定义命令目标保持不变。不要在消费站 override 中硬编码域名 +根路径。 + +## 嵌套导航 {#nested-navigation} + +只有一层 child 具备交互行为。Parent label 仍然是普通链接,相邻按钮只负责 +打开或关闭 dropdown/accordion。 + +```yaml +menus: + main: + - identifier: docs + name: 文档 + pageRef: /docs + weight: 10 + - identifier: guides + parent: docs + name: 指南 + pageRef: /docs/tutorial + weight: 10 + params: + icon: fa-solid fa-route + description: 面向任务的教程 + - identifier: reference + parent: docs + name: 参考 + pageRef: /docs/reference + weight: 20 + params: + icon: fa-solid fa-book + description: 配置与 API 参考 +``` + +没有 children 的项继续走原有平铺链接路径。当 parent 自身或后代为当前页时, +parent 显示 active;只有精确匹配的页面才获得 `aria-current="page"`。 + +### 导航交互 {#navigation-interaction} + +桌面 disclosure 支持点击、Enter、Space 和 ArrowDown。ArrowDown 会打开面板并 +聚焦第一项。Escape 关闭面板并将焦点还给 disclosure button。焦点离开菜单或 +在外部按下指针时,面板关闭。任何行为都不依赖 hover。 + +移动端使用同样的 link/button 分离模型。默认允许多个 accordion 同时打开。 +若需要单开模式: + +```yaml +params: + ui: + navbar_accordion_single_open: true +``` + +语言、版本、主题和搜索仍位于 utility 区域,不会变成内容菜单的 children。 + +### 深层菜单降级 {#deep-menu-degradation} + +超过支持 child 层级的条目会产生 Hugo build warning,并渲染为带链接的静态 +group heading 与普通后代,不会生成第三级 flyout。这个 warning 提醒站点把 +深层信息架构放回内容侧栏,不应被当作可以静默忽略的提示。 + +### 外部导航 {#external-navigation} + +跨 host 条目带有外链视觉提示,并使用 `rel="noopener noreferrer"` 打开。 +内部链接仍能感知语言和 subpath。Target 由主题解析,站点配置不能注入任意 +链接执行行为。 + +## 侧栏图标策略 {#sidebar-icon-policy} + +`params.ui.sidebar_icon_policy` 接受: + +| 值 | 行为 | +| --- | --- | +| `all` | 每个符合条件的侧栏条目显示解析后的图标。 | +| `groups` | Root 和有 children 的节点显示图标;普通 leaf 不显示。 | +| `none` | 不输出侧栏条目图标。 | + +在 1.0 之前,未配置时的兼容默认值仍为 `all`;新 starter site 显式选择 +`groups`。非法值会 warning 并回退到 `all`。这样能把 starter 建议与兼容性 +默认值变更分开。 + +## 搜索元数据与索引 {#search-metadata-and-index} + +本地搜索继续支持离线工作,并按语言隔离。PRD 4 扩展现有索引,不会用远程 +服务替换它。 + +### 规范搜索字段 {#canonical-search-fields} + +```yaml +--- +title: PostgreSQL 配置 +search_keywords: [postgres, postgresql, pg] +search_boost: 1.5 +search_exclude: false +--- +``` + +- `search_keywords` 接受单个字符串或数组,同时参与 Latin 和 CJK substring + 匹配。 +- `search_boost` 必须是有限正数,默认 `1.0`。非法、零、负数、无穷大或非数字 + 值会 warning 并使用 `1.0`。 +- `search_exclude` 是规范排除字段。 + +新内容不应再使用 `exclude_search` 与 `excludeSearch`;这两个字段已经弃用,但在 +整个 0.x 发布线内继续作为兼容别名。最早只能在未来 1.0 major release 删除, +并且必须提供 changelog 与迁移提示。 +排除规则采用 **任意 true 即排除**:规范字段或任一别名为 true 时,页面即被 +排除;`search_exclude: false` 不能覆盖为 true 的旧别名。 + +### Cascade 继承 {#cascade-inheritance} + +使用 Hugo cascade 设置产品或 section 默认值,必要时再由页面覆盖: + +```yaml +--- +title: 文档 +cascade: + search_boost: 1.25 +--- +``` + +索引在 Hugo cascade 继承完成后解析 `search_boost`。页面值按照 Hugo 标准 +front matter 规则覆盖继承值。 + +### 索引 Schema 与回退 {#index-schema-and-fallbacks} + +每条记录保留 `ref`、`title`、`categories`、`tags`、`excerpt`,并增加以下 +确定性元数据: + +| 字段 | 值与回退 | +| --- | --- | +| `root` | 小写 `FirstSection`;没有 section 时为 `home`。 | +| `section` | 小写 `CurrentSection`;缺失时回退到 `root`。 | +| `type` | 小写 Hugo page type;缺失时回退到 `root`。 | +| `keywords` | 规范化的 `search_keywords` 数组;缺失时为空数组。 | +| `boost` | 有效的继承 multiplier;否则为 `1.0`。 | +| `breadcrumb` | 从 root 到当前页的本地化标题路径。 | +| `icon` | 依次使用页面、当前 section、root、稳定 type/root 回退。 | + +`params.offlineSearchIndex` 控制可选文本字段: + +| Scope | 增加字段 | +| --- | --- | +| `title` | 不增加文本字段。 | +| `heading` | `headings` | +| `summary` | `headings`、`description` | +| `content` | `headings`、`description`、`body` | + +`summary` 适合作为 starter 默认值。`content` 召回最广,但下载通常最大。每种 +语言生成独立索引,绝不会回退到另一种语言的记录。 + +### 排序行为 {#ranking-behavior} + +页面最终分数为 `text match score × search_boost`。Keywords 与 multiplier +同时应用于 Lunr 和确定性的 CJK substring 路径。稳定的 title/ref tie-break +避免排序随 locale 或浏览器变化。`params.offlineSearchMaxResults` 只限制页面 +命中;匹配的 Actions 独立分组,不占用页面配额。 + +### 索引大小预算 {#index-size-budget} + +回归 fixture 对每种语言设置 2 MiB 未压缩、512 KiB gzip 上限。内容更多的 +消费站必须测量自己的生成索引,并主动选择 `title`、`heading`、`summary` 或 +`content`。Fixture budget 是发布门禁,不保证任意站点内容都不会超过它。 + +## Command Palette 与动作 {#command-palette-and-actions} + +现有 Cmd/Ctrl-K 本地搜索 dialog 升级为 Command Palette,仍然只有一个 dialog +和一份本地索引。 + +### Palette 模式 {#palette-modes} + +| 输入 | 结果 | 索引请求 | +| --- | --- | --- | +| 空查询 | Quick links、页面动作、偏好设置、自定义命令 | 无 | +| 普通文本 | 按 root 分组的页面与匹配 Actions | 延迟加载、同源 | +| 前缀 `>` | 仅内置与自定义命令 | 无 | + +第一版不支持 `@docs` 与 `@blog` scope。索引失败不会移除命令,旧异步请求也 +不能覆盖新的 Palette session。 + +### 内置 Action ID {#built-in-action-ids} + +| ID | 类型 | 通常可用条件 | +| --- | --- | --- | +| `copy_markdown` | Invoke | 当前页存在 Markdown output。 | +| `view_markdown` | URL | 当前页存在 Markdown output。 | +| `edit_page` | URL | 能解析仓库/edit URL。 | +| `create_issue` | URL | 已配置仓库。 | +| `print` | Invoke | 交互式 HTML output。 | +| `switch_theme` | Choice | 已启用主题切换。 | +| `switch_language` | Choice | 存在多于一个语言目标。 | +| `switch_version` | Choice | 存在版本条目。 | +| `open_github` | URL | 已配置项目仓库。 | + +对应的 PRD 4 页面与 Palette actions 共享 descriptor 和 URL 解析。Copy +Markdown 共享 pending/success cache;Print 调用共享 print executor;主题控件 +调用同一个 theme apply 函数。PRD 4 URL actions 保留 href 与共享 descriptor +一致的真实 anchor,确保无 JavaScript 时仍可导航。PRD 4 内置集合之外的历史 +rail-only actions 继续作为独立兼容功能。 + +### 自定义命令与本地化 {#custom-commands-and-localization} + +在默认语言参数下定义完整记录,并用稳定 `id` 而不是数组位置进行翻译: + +```yaml +languages: + en: + params: + ui: + command_palette: + commands: + - id: status + title: Service status + description: View uptime and incidents + url: https://status.example.com/ + icon: fa-solid fa-signal + keywords: [uptime, incident] + - id: print_page + title: Print this page + action: print + keywords: [paper, pdf] + zh: + params: + ui: + command_palette: + commands: + - id: print_page + title: 打印此页 + keywords: [纸张, PDF] + - id: status + title: 服务状态 + keywords: [可用性, 故障] +``` + +当前语言按 ID 覆盖默认记录,缺失字段回退到默认语言。默认记录或仅在某 locale +新增的 ID 必须且只能定义一个 `url` 或一个允许的内置 `action` ID;已有 ID 的 +本地化 override 可以同时省略两者并继承默认值。合并后的每条有效命令仍然必须 +只有一种执行类型。 + +### 命令安全边界 {#command-security-boundary} + +配置是 inert data,不能提供 callback、event handler、function name、JavaScript +源码或 executor。未知或保留 ID、重复 ID、不支持的 key、字段类型错误、同时 +提供 `url` 与 `action`、未知内置 action 都会让 Hugo build 失败。 + +URL 只允许站内相对地址或显式 `http:`/`https:`。构建会拒绝 `javascript:`、 +`data:`、`vbscript:`、`file:`、protocol-relative URL、反斜杠与控制字符; +runtime 还会再次校验。外部 URL 使用 `noopener noreferrer`。渲染只使用模板 +转义文本或 DOM `textContent`,绝不会执行 manifest 数据。 + +## 键盘与屏幕阅读器 {#keyboard-and-screen-readers} + +### Navbar 交互表 {#navbar-interaction-table} + +| 上下文 | 按键或动作 | 结果 | +| --- | --- | --- | +| Parent link | Enter | 导航到 parent 页面。 | +| Disclosure button | Enter 或 Space | 切换面板。 | +| 桌面 disclosure | ArrowDown | 打开并聚焦第一项。 | +| 已打开桌面面板 | ArrowUp/Down、Home/End | 在可操作项之间移动。 | +| 已打开桌面面板 | Escape | 关闭并恢复 disclosure 焦点。 | +| 已打开桌面面板 | Tab 或外部按下 | 正常离开并关闭面板。 | +| 移动端 parent link | 激活 | 导航但不切换 accordion。 | +| 移动端 disclosure | 激活 | 切换但不导航。 | + +Disclosure button 暴露 `aria-expanded` 与 `aria-controls`。面板使用 disclosure +pattern,而不是 ARIA application menu,因此 child links 保留原生链接语义。 + +### Palette 交互表 {#palette-interaction-table} + +| 按键或动作 | 结果 | +| --- | --- | +| Cmd/Ctrl-K | 打开或关闭 Palette。 | +| ArrowUp/ArrowDown | 移动 active listbox option。 | +| Cmd/Ctrl-Home 或 Cmd/Ctrl-End | 移到第一项或最后一项。 | +| Enter | 只执行一次当前 option。 | +| Escape | 关闭并把焦点还给可见的调用控件。 | +| Tab/Shift-Tab | 焦点保持在 modal dialog 内。 | +| IME composition 按键 | 继续编辑输入法组合,不导航也不执行。 | + +DOM focus 保持在可编辑 combobox 中,`aria-activedescendant` 指向 active listbox +option。结果 section 是带标签的 group。有原因的 disabled choice 可被发现但不 +执行。Polite live region 宣布结果数、错误与动作结果,不会在每次箭头移动时重复 +播报。Reduced-motion 用户无需等待关闭动画。移动端焦点会回到可见的 drawer/ +menu opener,而不是已经隐藏在关闭 surface 内的控件。 + +## Runtime 与隐私保证 {#runtime-and-privacy-guarantees} + +只有同时满足以下条件时才启用 Palette capability: + +1. `params.offlineSearch` 为 true; +2. 页面是 home 或使用 shell surface; +3. 当前 output 不是 `print`。 + +禁用时,页面不会输出 Palette dialog markup、本地索引引用、Lunr、Palette result +model 与 Palette controller。Print output 同样省略这些 runtime。Action manifest +与共享页面动作 registry 可能仍存在,因为 progressive page actions 独立于搜索。 + +空查询与 `>` 模式不获取索引;普通文本只获取当前语言、同源生成的 JSON。 +OINK 默认不发送 telemetry、query upload、analytics event 或 remote-search +request。消费站显式配置的 analytics、评论、托管搜索或外部命令 URL 属于另一项 +站点策略,必须由消费站自行披露。 + +## 兼容窗口 {#compatibility-window} + +- 现有平铺 `menus.main` 无需改配置,HTML 与行为继续受到兼容测试保护。 +- 整个 0.x 期间,未配置 `params.ui.sidebar_icon_policy` 时仍为 `all`;starter + 显式选择 `groups`。 +- `exclude_search` 与 `excludeSearch` 在整个 0.x 期间保留,最早只能在 1.0 + 删除。 +- Cmd/Ctrl-K 继续作为 Palette 快捷键,普通文本继续搜索页面。 +- 迁移不会增加第二套导航权威源或默认网络服务。 + +任何别名删除或默认值变更都需要未来 major release 的迁移条目与更新后的 +characterization fixtures。 + +## Starter 与主题默认值 {#starter-versus-theme-defaults} + +最小 `exampleSite/hugo.yaml` 有意演示嵌套 Hugo Menu、 +`sidebar_icon_policy: groups`、summary 大小的本地搜索,以及安全 URL 与内置 +命令。这些是 starter 选择;对于没有配置它们的消费站,主题继续维持兼容默认值。 + +## 验证与发布证据 {#verification-and-release-evidence} + +### 本地验证门禁 {#local-verification-gates} + +在所有支持的 Hugo 版本下运行主题专项检查: + +```sh +python3 scripts/check-prd4-contract.py +python3 scripts/check-prd4-runtime.py +python3 scripts/check-sidebar-icons.py +python3 scripts/check-prd4-search.py +python3 scripts/check-prd4-actions.py +python3 scripts/check-prd4-palette.py +python3 scripts/check-prd4-docs.py +``` + +这些检查覆盖 root/subpath、EN/ZH、flat/nested/deep menu、search on/off、 +home/docs/blog/plain/print surface、CJK 排序、registry 安全、Palette 状态与文档 +等价性。消费站仍必须单独运行 Hugo、链接、翻译、Playwright 与 axe 测试。某一层 +通过不能证明后续交付层也已通过。 + +### 证据台账 {#evidence-ledger} + +| 门禁 | 当前证据 | 发布状态 | +| --- | --- | --- | +| 契约/runtime/导航/搜索/actions/Palette | [主题 #18](https://github.com/pgsty/oink/issues/18#issuecomment-5263306916) 与各 owning issue 的专项结果 | 本地通过;merge CI 待完成 | +| 消费站 root/subpath、EN/ZH、浏览器与 axe | [消费站 #3 证据](https://github.com/pgsty/oink.pgsty.com/issues/3#issuecomment-5263727126) | 本地通过;消费站 CI 待完成 | +| 最低/当前 Hugo 矩阵 | [主题 CI workflow](../.github/workflows/ci.yml) | 必须在合并 commit 上通过 | +| 带标签主题产物 | 包含该功能的 release 页面与 checksum | 待完成 | +| 消费站版本固定与部署 | 消费站 commit 与 deployment run | 待完成 | +| 线上 root/subpath 冒烟 | 公网 URL、实际资源路径、键盘与 telemetry trace | 待完成 | + +本地视觉检查、单元测试、集成测试、CI、package/tag 发布、消费站部署与线上可用性 +是不同门禁。 + +### 发布检查清单 {#release-checklist} + +- [ ] 主题 owning changes 已 review 并合并。 +- [ ] Hugo 0.160.1 与当前版本 CI 在合并 commit 上为绿色。 +- [ ] Changelog 与 tag 明确列出 PRD 4 功能集。 +- [ ] 消费站固定包含该功能的 tag,而不是本地 replacement。 +- [ ] 消费站非浏览器、浏览器与 axe 套件在 CI 中通过。 +- [ ] Root 与 subpath 部署从正确前缀加载语言本地索引与内部 URL。 +- [ ] Print 与禁用搜索页面不加载 Palette runtime。 +- [ ] Network trace 确认没有默认 query 或 telemetry request。 +- [ ] 线上键盘与屏幕阅读器冒烟通过。 +- [ ] 只有前述门禁全部完成后,发布文档才能删除“未发布”警告。 diff --git a/docs/prd4-navigation-command-palette-contract.md b/docs/prd4-navigation-command-palette-contract.md new file mode 100644 index 00000000..a0a22161 --- /dev/null +++ b/docs/prd4-navigation-command-palette-contract.md @@ -0,0 +1,181 @@ +# PRD 4 navigation and Command Palette contract + +Status: implemented on the development branch; not yet released + +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 +release-facing migration reference is available in +[English](prd4-migration-guide.md) and +[Simplified Chinese](prd4-migration-guide.zh.md). + +The contract deliberately separates public decisions from rendered +observations. scripts/check-prd4-contract.py records the implementation across +the complete fixture matrix. A change to an observation must update the +relevant assertion and explain whether it is a compatible change. + +## 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 before 1.0. Version 1.0 is the earliest release +that may reconsider this default; it does not imply that 1.0 will change it. +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 are deprecated for new content and cannot be +removed before 1.0. Version 1.0 is the earliest release that may remove them, +with a major-release migration notice. 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: + +- root is the lower-case first-section key; +- section is the lower-case current-section key, falling back to root; +- type is the lower-case Hugo page type, falling back to root; +- breadcrumb is the localized LinkTitle/Title path from root through the page; +- icon resolves from page, current section, root, then a stable type/root icon; +- search_keywords accepts a scalar or array and is always emitted as an array; +- search_boost resolves after Hugo cascade inheritance; a non-numeric, + non-finite, zero, or negative value warns and emits the default 1.0. + +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. + +Corresponding PRD 4 page and Palette actions share internal descriptors and URL +resolution. Copy Markdown and Print also share their registry executors; +theme controls call the same theme application function. Historical rail-only +compatibility actions outside the PRD 4 built-in set remain out of scope. +Availability, title, description, icon, keywords, execution kind, URL, and +disabled reason are data rather than duplicated behavior. + +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 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 + +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. Nested fixtures verify the +split parent link/disclosure behavior on desktop and mobile. The deep fixture +verifies the warning and static-group degradation contract without creating a +third-level flyout. diff --git a/exampleSite/hugo.yaml b/exampleSite/hugo.yaml index 7f5ef122..5b7708cb 100644 --- a/exampleSite/hugo.yaml +++ b/exampleSite/hugo.yaml @@ -9,8 +9,13 @@ outputs: params: description: A complete example of OINK's composable landing page. - offlineSearch: false + offlineSearch: true + offlineSearchIndex: summary + offlineSearchSummaryLength: 80 + offlineSearchMaxResults: 10 ui: + quick_links: [docs, blog] + sidebar_icon_policy: groups image_zoom: enable: true feedback: @@ -20,3 +25,57 @@ languages: en: label: English weight: 1 + params: + ui: + command_palette: + commands: + - id: example_status + title: Example service status + description: Open the example status page + url: https://status.example.org/ + icon: fa-solid fa-signal + keywords: [uptime, incident] + - id: print_example + title: Print this page + action: print + icon: fa-solid fa-print + keywords: [paper, pdf] + - id: example_docs + title: Open content primitives + description: Navigate within the example site + url: /docs/content-primitives/ + icon: fa-solid fa-puzzle-piece + keywords: [component, authoring] + +menus: + main: + - identifier: docs + name: Documentation + pageRef: /docs + weight: 10 + - identifier: primitives + parent: docs + name: Content primitives + pageRef: /docs/content-primitives + weight: 10 + params: + icon: fa-solid fa-puzzle-piece + description: Semantic authoring components + - identifier: typography + parent: docs + name: Typography + pageRef: /docs/typography + weight: 20 + params: + icon: fa-solid fa-font + description: Theme typography roles + - identifier: blog + name: Blog + pageRef: /blog + weight: 20 + - identifier: source + name: Source + url: https://github.com/pgsty/oink + weight: 30 + params: + icon: fa-brands fa-github diff --git a/i18n/ar.yaml b/i18n/ar.yaml index ae62c92a..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/az.yaml b/i18n/az.yaml index 4a45a2f6..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/bg.yaml b/i18n/bg.yaml index 25befcd7..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/bn.yaml b/i18n/bn.yaml index e35a5ebb..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/de.yaml b/i18n/de.yaml index de9bad8b..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 @@ -139,6 +150,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/en.yaml b/i18n/en.yaml index 11b89614..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 @@ -56,6 +67,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default ui_image_zoom_dialog: Image preview ui_image_zoom_open: Open image preview diff --git a/i18n/es.yaml b/i18n/es.yaml index 89352144..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 @@ -134,6 +145,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/et.yaml b/i18n/et.yaml index 25b89e4b..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 @@ -139,6 +150,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/fa.yaml b/i18n/fa.yaml index f213ac30..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/fi.yaml b/i18n/fi.yaml index 0a87511f..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 @@ -134,6 +145,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/fr.yaml b/i18n/fr.yaml index 25f9b5b2..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 @@ -134,6 +145,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/he.yaml b/i18n/he.yaml index 44e17c1e..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/hi.yaml b/i18n/hi.yaml index 65b99ca2..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/hu.yaml b/i18n/hu.yaml index 5a54d6e4..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/it.yaml b/i18n/it.yaml index 9b04095a..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/ja.yaml b/i18n/ja.yaml index 0bdfef5b..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 @@ -134,6 +145,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/ko.yaml b/i18n/ko.yaml index 25d4f5bf..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/nl.yaml b/i18n/nl.yaml index f8f34af7..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/no.yaml b/i18n/no.yaml index 5755dade..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/oc.yaml b/i18n/oc.yaml index 8f3fbbe4..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 @@ -134,6 +145,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/pl.yaml b/i18n/pl.yaml index 6c066023..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/pt-br.yaml b/i18n/pt-br.yaml index 39270fbe..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 @@ -133,6 +144,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/ro.yaml b/i18n/ro.yaml index eafec362..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 @@ -133,6 +144,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/ru.yaml b/i18n/ru.yaml index 4b63679a..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 @@ -133,6 +144,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/sr-cyrl.yaml b/i18n/sr-cyrl.yaml index 31b95748..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 @@ -133,6 +144,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/sr-latn.yaml b/i18n/sr-latn.yaml index 29e4b300..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 @@ -133,6 +144,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/sv.yaml b/i18n/sv.yaml index 65a3149e..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/tr.yaml b/i18n/tr.yaml index d9b26932..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 @@ -136,6 +147,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/uk.yaml b/i18n/uk.yaml index ba71dbf5..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 @@ -135,6 +146,7 @@ ui_code_collapse: Collapse code ui_code_group_label: Code examples ui_kbd_with: with ui_field_required: required +ui_action_unavailable: unavailable ui_field_default: default # Explicit English fallbacks for untranslated OINK UI strings. diff --git a/i18n/zh-cn.yaml b/i18n/zh-cn.yaml index 678cc115..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: 首页 @@ -56,6 +67,7 @@ ui_code_collapse: 收起代码 ui_code_group_label: 代码示例 ui_kbd_with: 加 ui_field_required: 必填 +ui_action_unavailable: 暂不可用 ui_field_default: 默认值 ui_image_zoom_dialog: 图片预览 ui_image_zoom_open: 打开图片预览 diff --git a/i18n/zh-tw.yaml b/i18n/zh-tw.yaml index 3b3c709a..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: 首頁 @@ -125,6 +136,7 @@ ui_code_collapse: 收合程式碼 ui_code_group_label: 程式碼範例 ui_kbd_with: 加 ui_field_required: 必填 +ui_action_unavailable: 暂不可用 ui_field_default: 預設值 ui_image_zoom_dialog: 圖片預覽 ui_image_zoom_open: 開啟圖片預覽 diff --git a/i18n/zh.yaml b/i18n/zh.yaml index 7fbcfc57..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: 首页 @@ -57,6 +68,7 @@ ui_code_collapse: 收起代码 ui_code_group_label: 代码示例 ui_kbd_with: 加 ui_field_required: 必填 +ui_action_unavailable: 暂不可用 ui_field_default: 默认值 ui_image_zoom_dialog: 图片预览 ui_image_zoom_open: 打开图片预览 diff --git a/layouts/_partials/actions/context.html b/layouts/_partials/actions/context.html new file mode 100644 index 00000000..d76c868f --- /dev/null +++ b/layouts/_partials/actions/context.html @@ -0,0 +1,104 @@ +{{- /* Build page-local descriptors for the stable PRD 4 action IDs. Keep URL + construction in Hugo so the page rail and Command Palette share it. */ -}} +{{- $p := . -}} +{{- $pagePlacement := true -}} +{{- with $p.Site.Params.ui.page_context_menu -}} + {{- if reflect.IsMap . -}} + {{- if isset . "enable" }}{{ $pagePlacement = .enable }}{{ end -}} + {{- else -}} + {{- $pagePlacement = . -}} + {{- end -}} +{{- end -}} +{{- if isset $p.Params "context_menu" }}{{ $pagePlacement = $p.Params.context_menu }}{{ end -}} + +{{- $urls := partial "actions/page-urls.html" $p -}} +{{- $markdownURL := $urls.markdown -}} +{{- $editURL := $urls.edit -}} +{{- $issueURL := $urls.createIssue -}} +{{- $unavailable := T "ui_action_unavailable" -}} + +{{- $darkMode := partialCached "dark-mode-config.html" "dark-mode-global" -}} +{{- $languageOptions := slice -}} +{{- range partial "language-targets.html" $p -}} + {{- $languageOptions = $languageOptions | append (dict + "id" .code "title" .label "url" .url "active" .active + "available" true "disabledReason" "" + ) -}} +{{- end -}} +{{- $versionOptions := slice -}} +{{- $pagePath := "" -}} +{{- if $p.Site.Params.version_menu_pagelinks }}{{ $pagePath = $p.RelPermalink }}{{ end -}} +{{- $baseURL := strings.TrimSuffix "/" $p.Site.BaseURL -}} +{{- range $p.Site.Params.versions -}} + {{- if ne .name "---" -}} + {{- $url := strings.TrimSuffix "/" (.url | default "") -}} + {{- $pagelinks := ne .pagelinks false -}} + {{- $target := $url -}} + {{- if and $target $pagelinks }}{{ $target = printf "%s%s" $target $pagePath }}{{ end -}} + {{- $versionOptions = $versionOptions | append (dict + "id" (.version | default (.name | urlize)) + "title" (.name | default .version | plainify) + "url" $target + "active" (or (eq .version $p.Site.Params.version) (eq $baseURL $url)) + "available" (ne $url "") + "disabledReason" (cond (ne $url "") "" (printf "URL %s" (T "ui_field_required"))) + ) -}} + {{- end -}} +{{- end -}} +{{- $shellConfig := partial "shell/config.html" $p -}} +{{- $projectRepo := $shellConfig.projectRepo | default "" -}} +{{- $pageOnly := dict "page" $pagePlacement "palette" true -}} +{{- $paletteOnly := dict "page" false "palette" true -}} +{{- $copyTitle := T "ui_copy_markdown" -}} +{{- $viewTitle := T "post_view_markdown" -}} +{{- $editTitle := T "post_edit_this" -}} +{{- $issueTitle := T "post_create_issue" -}} +{{- $themeTitle := T "ui_theme_toggle" -}} +{{- $languageTitle := T "ui_language_switch" -}} +{{- $versionTitle := $p.Site.Params.version_menu | default "Version" -}} +{{- $githubTitle := "GitHub" -}} +{{- $actions := slice + (dict "id" "copy_markdown" "title" $copyTitle "description" $copyTitle + "icon" "fa-regular fa-copy" "keywords" (slice $copyTitle "markdown" "copy") + "kind" "copy" "available" (ne $markdownURL "") "disabledReason" (cond (ne $markdownURL "") "" (printf "%s: %s" $copyTitle $unavailable)) "url" $markdownURL + "target" "self" "placements" $pageOnly "options" (slice)) + (dict "id" "view_markdown" "title" $viewTitle "description" $viewTitle + "icon" "fa-brands fa-markdown" "keywords" (slice $viewTitle "markdown" "source") + "kind" "url" "available" (ne $markdownURL "") "disabledReason" (cond (ne $markdownURL "") "" (printf "%s: %s" $viewTitle $unavailable)) "url" $markdownURL + "target" "blank" "placements" $pageOnly "options" (slice)) + (dict "id" "edit_page" "title" $editTitle "description" $editTitle + "icon" "fa-solid fa-pencil" "keywords" (slice $editTitle "edit" "github") + "kind" "url" "available" (ne $editURL "") "disabledReason" (cond (ne $editURL "") "" (printf "%s: %s" $editTitle $unavailable)) "url" $editURL + "target" "blank" "placements" $pageOnly "options" (slice)) + (dict "id" "create_issue" "title" $issueTitle "description" $issueTitle + "icon" "fa-regular fa-circle-question" "keywords" (slice $issueTitle "issue" "github") + "kind" "url" "available" (ne $issueURL "") "disabledReason" (cond (ne $issueURL "") "" (printf "%s: %s" $issueTitle $unavailable)) "url" $issueURL + "target" "blank" "placements" $pageOnly "options" (slice)) + (dict "id" "print" "title" (T "ui_print_page") "description" (T "ui_print_page") + "icon" "fa-solid fa-print" "keywords" (slice (T "ui_print_page") "print") + "kind" "invoke" "available" true "disabledReason" "" "url" "" + "target" "self" "placements" $pageOnly "options" (slice)) + (dict "id" "switch_theme" "title" $themeTitle "description" $themeTitle + "icon" "fa-solid fa-circle-half-stroke" "keywords" (slice $themeTitle (T "ui_theme_light") (T "ui_theme_dark")) + "kind" "choice" "available" $darkMode.enable "disabledReason" (cond $darkMode.enable "" (printf "%s: %s" $themeTitle $unavailable)) "url" "" + "target" "self" "placements" $paletteOnly "options" (slice + (dict "id" "auto" "title" (T "ui_theme_auto") "value" "auto" "available" true "disabledReason" "") + (dict "id" "light" "title" (T "ui_theme_light") "value" "light" "available" true "disabledReason" "") + (dict "id" "dark" "title" (T "ui_theme_dark") "value" "dark" "available" true "disabledReason" ""))) + (dict "id" "switch_language" "title" $languageTitle "description" $languageTitle + "icon" "fa-solid fa-language" "keywords" (slice $languageTitle "language") + "kind" "choice" "available" (gt (len $languageOptions) 1) "disabledReason" (cond (gt (len $languageOptions) 1) "" (printf "%s: %s" $languageTitle $unavailable)) "url" "" + "target" "self" "placements" $paletteOnly "options" $languageOptions) + (dict "id" "switch_version" "title" $versionTitle + "description" $versionTitle "icon" "fa-solid fa-code-branch" + "keywords" (slice $versionTitle "version") "kind" "choice" + "available" (gt (len $versionOptions) 0) "disabledReason" (cond (gt (len $versionOptions) 0) "" (printf "%s: %s" $versionTitle $unavailable)) "url" "" + "target" "self" "placements" $paletteOnly "options" $versionOptions) + (dict "id" "open_github" "title" $githubTitle "description" $githubTitle + "icon" "fa-brands fa-github" "keywords" (slice "GitHub" "repository" "source") + "kind" "url" "available" (ne $projectRepo "") "disabledReason" (cond (ne $projectRepo "") "" (printf "%s: %s" $githubTitle $unavailable)) "url" $projectRepo + "target" "blank" "placements" $paletteOnly "options" (slice)) +-}} +{{- $byID := dict -}} +{{- range $actions }}{{ $byID = merge $byID (dict .id .) }}{{ end -}} +{{- return (dict "actions" $actions "byId" $byID "pagePlacement" $pagePlacement "urls" $urls) -}} diff --git a/layouts/_partials/actions/manifest.html b/layouts/_partials/actions/manifest.html new file mode 100644 index 00000000..487a7941 --- /dev/null +++ b/layouts/_partials/actions/manifest.html @@ -0,0 +1,9 @@ +{{- $context := partial "actions/context.html" . -}} +{{- return (dict + "version" 1 + "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/page-urls.html b/layouts/_partials/actions/page-urls.html new file mode 100644 index 00000000..f6939d8f --- /dev/null +++ b/layouts/_partials/actions/page-urls.html @@ -0,0 +1,44 @@ +{{- /* Resolve page-action destinations once for descriptors and HTML. */ -}} +{{- $p := . -}} +{{- $markdownURL := "" -}} +{{- with $p.AlternativeOutputFormats.Get "markdown" }}{{ $markdownURL = .RelPermalink }}{{ end -}} +{{- $printURL := "" -}} +{{- with $p.CurrentSection -}} + {{- with .AlternativeOutputFormats.Get "print" }}{{ $printURL = .RelPermalink }}{{ end -}} +{{- end -}} +{{- $editURL := "" -}} +{{- $childURL := "" -}} +{{- $issueURL := "" -}} +{{- $projectIssueURL := "" -}} +{{- $repoPath := "" -}} +{{- $repo := $p.Param "github_repo" -}} +{{- $legacyURL := $p.Param "github_url" -}} +{{- $projectRepo := $p.Param "github_project_repo" | default $repo | default "" -}} +{{- if and $p.File $legacyURL -}} + {{- warnf "Warning: use of `github_url` is deprecated. For details, see https://oink.pgsty.com/docs/content/repository-links/#github_url-optional" -}} + {{- $editURL = $legacyURL -}} +{{- else if and $p.File $repo -}} + {{- $sourcePath := strings.TrimPrefix (add hugo.WorkingDir "/") $p.File.Filename -}} + {{- $subdir := $p.Param "github_subdir" | default "" -}} + {{- $branch := $p.Param "github_branch" | default "main" -}} + {{- $pathBase := $p.Param "path_base_for_github_subdir" -}} + {{- $pathRename := "" -}} + {{- if reflect.IsMap $pathBase -}} + {{- $pathRename = $pathBase.to -}} + {{- $pathBase = $pathBase.from -}} + {{- end -}} + {{- with $pathBase }}{{ $sourcePath = replaceRE . $pathRename $sourcePath }}{{ end -}} + {{- $repoPath = printf "%s/%s/%s" $branch $subdir $sourcePath | replaceRE "//+" "/" -}} + {{- $editURL = printf "%s/edit/%s" $repo $repoPath -}} + {{- $issueURL = printf "%s/issues/new?%s" $repo (querify "title" $p.Title) -}} + {{- $stub := resources.Get "stubs/new-page-template.md" -}} + {{- $childQS := querify "value" $stub.Content "filename" "change-me.md" -}} + {{- $childURL = printf "%s/new/%s?%s" $repo (path.Dir $repoPath) $childQS -}} + {{- with $p.Param "github_project_repo" }}{{ $projectIssueURL = printf "%s/issues/new" . }}{{ end -}} +{{- end -}} +{{- return (dict + "markdown" $markdownURL "printSection" $printURL + "edit" $editURL "createChild" $childURL "createIssue" $issueURL + "createProjectIssue" $projectIssueURL "projectRepo" $projectRepo + "repoPath" $repoPath +) -}} 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/actions/site-commands.html b/layouts/_partials/actions/site-commands.html new file mode 100644 index 00000000..1d3f1e1b --- /dev/null +++ b/layouts/_partials/actions/site-commands.html @@ -0,0 +1,91 @@ +{{- /* Compile localized site commands into inert data. Configuration can + reference a built-in action or a safe URL, never executable code. */ -}} +{{- $allowedActions := slice "copy_markdown" "view_markdown" "edit_page" "create_issue" "print" "switch_theme" "switch_language" "switch_version" "open_github" -}} +{{- $allowedKeys := slice "id" "title" "description" "icon" "keywords" "url" "action" -}} +{{- $defaultSite := site -}} +{{- range hugo.Sites -}} + {{- if .IsDefault }}{{ $defaultSite = . }}{{ end -}} +{{- end -}} +{{- $defaultCommands := slice -}} +{{- with $defaultSite.Params.ui.command_palette }}{{ with .commands }}{{ $defaultCommands = . }}{{ end }}{{ end -}} +{{- $currentCommands := slice -}} +{{- $languageParams := site.Language.Params | default dict -}} +{{- with $languageParams.ui }}{{ with .command_palette }}{{ with .commands }}{{ $currentCommands = . }}{{ end }}{{ end }}{{ end -}} +{{- if not $currentCommands }}{{ with site.Params.ui.command_palette }}{{ with .commands }}{{ $currentCommands = . }}{{ end }}{{ end }}{{ end -}} +{{- if and $defaultCommands (not (reflect.IsSlice $defaultCommands)) }}{{ errorf "params.ui.command_palette.commands must be an array" }}{{ end -}} +{{- if and $currentCommands (not (reflect.IsSlice $currentCommands)) }}{{ errorf "params.ui.command_palette.commands must be an array" }}{{ end -}} +{{- $records := dict -}} +{{- $order := slice -}} +{{- range $defaultCommands -}} + {{- $validated := partial "actions/validate-command.html" (dict "record" . "scope" "command") -}} + {{- $id := .id | default "" | printf "%v" -}} + {{- if not (findRE `^[a-z][a-z0-9_-]*$` $id) }}{{ errorf "invalid command id %q" $id }}{{ end -}} + {{- if isset $records $id }}{{ errorf "duplicate command id %q" $id }}{{ end -}} + {{- $records = merge $records (dict $id .) -}} + {{- $order = $order | append $id -}} +{{- end -}} +{{- if ne site.Language.Lang $defaultSite.Language.Lang -}} + {{- $localizedSeen := dict -}} + {{- range $currentCommands -}} + {{- $validated := partial "actions/validate-command.html" (dict "record" . "scope" "localized command") -}} + {{- $id := .id | default "" | printf "%v" -}} + {{- if not (findRE `^[a-z][a-z0-9_-]*$` $id) }}{{ errorf "invalid command id %q" $id }}{{ end -}} + {{- if isset $localizedSeen $id }}{{ errorf "duplicate localized command id %q" $id }}{{ end -}} + {{- $localizedSeen = merge $localizedSeen (dict $id true) -}} + {{- $base := index $records $id | default dict -}} + {{- if not (isset $records $id) }}{{ $order = $order | append $id }}{{ end -}} + {{- $merged := merge $base . -}} + {{- range $key, $value := . -}} + {{- $scratch := collections.NewScratch -}} + {{- range $baseKey, $baseValue := $merged }}{{ $scratch.Set $baseKey $baseValue }}{{ end -}} + {{- $scratch.Set $key $value -}} + {{- $merged = $scratch.Values -}} + {{- end -}} + {{- $recordScratch := collections.NewScratch -}} + {{- range $existingID, $existingRecord := $records }}{{ $recordScratch.Set $existingID $existingRecord }}{{ end -}} + {{- $recordScratch.Set $id $merged -}} + {{- $records = $recordScratch.Values -}} + {{- end -}} +{{- end -}} +{{- $commands := slice -}} +{{- range $order -}} + {{- $id := . -}} + {{- $record := index $records $id -}} + {{- range $key, $_ := $record -}} + {{- if not (in $allowedKeys $key) }}{{ errorf "command %q uses unsupported key %q" $id $key }}{{ end -}} + {{- end -}} + {{- if in $allowedActions $id }}{{ errorf "command id %q is reserved by a built-in action" $id }}{{ end -}} + {{- $rawURL := $record.url | default "" | printf "%v" -}} + {{- $url := strings.TrimSpace $rawURL -}} + {{- $action := $record.action | default "" | printf "%v" | strings.TrimSpace -}} + {{- if eq (ne $url "") (ne $action "") }}{{ errorf "command %q must define exactly one of url or action" $id }}{{ end -}} + {{- if and $action (not (in $allowedActions $action)) }}{{ errorf "command %q references unsupported action %q" $id $action }}{{ end -}} + {{- if $url -}} + {{- $parsed := urls.Parse $url -}} + {{- $scheme := lower $parsed.Scheme -}} + {{- if or (ne $rawURL $url) (findRE `[\x00-\x20\x7f]` $url) (strings.HasPrefix $url "//") (findRE `\\` $url) (and $scheme (not (in (slice "http" "https") $scheme))) -}} + {{- errorf "command %q uses unsafe URL %q" $id $url -}} + {{- end -}} + {{- if not (or $parsed.Scheme $parsed.Host) -}} + {{- if strings.HasPrefix $url "#" -}} + {{- /* A fragment remains relative to the current page. */ -}} + {{- else -}} + {{- $url = strings.TrimPrefix "/" $url | relLangURL -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- $keywords := $record.keywords | default (slice) -}} + {{- $normalizedKeywords := slice -}} + {{- range $keywords }}{{ $normalizedKeywords = $normalizedKeywords | append (printf "%v" .) }}{{ end -}} + {{- $target := "self" -}} + {{- if $url }}{{ with urls.Parse $url }}{{ if or .Scheme .Host }}{{ $target = "blank" }}{{ end }}{{ end }}{{ end -}} + {{- $commands = $commands | append (dict + "id" $id "title" ($record.title | default $id | printf "%v") + "description" ($record.description | default "" | printf "%v") + "icon" ($record.icon | default "fa-solid fa-link" | printf "%v") + "keywords" $normalizedKeywords "kind" (cond (ne $url "") "url" "builtin") + "url" $url "action" $action "target" $target "available" true + "disabledReason" "" "placements" (dict "page" false "palette" true) + ) -}} +{{- end -}} +{{- return $commands -}} diff --git a/layouts/_partials/actions/validate-command.html b/layouts/_partials/actions/validate-command.html new file mode 100644 index 00000000..86efefe3 --- /dev/null +++ b/layouts/_partials/actions/validate-command.html @@ -0,0 +1,27 @@ +{{- /* Reject malformed configuration before normalization can stringify it + into apparently valid command data. Localized records may remain partial. */ -}} +{{- $record := .record -}} +{{- $scope := .scope | default "command" -}} +{{- if not (reflect.IsMap $record) -}} + {{- errorf "%s must be a map" $scope -}} +{{- end -}} +{{- range slice "id" "title" "description" "icon" "url" "action" -}} + {{- if isset $record . -}} + {{- $value := index $record . -}} + {{- if ne (printf "%T" $value) "string" -}} + {{- errorf "%s field %q must be a string" $scope . -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- if isset $record "keywords" -}} + {{- $keywords := index $record "keywords" -}} + {{- if not (reflect.IsSlice $keywords) -}} + {{- errorf "%s field %q must be an array of strings" $scope "keywords" -}} + {{- end -}} + {{- range $keywords -}} + {{- if ne (printf "%T" .) "string" -}} + {{- errorf "%s field %q must be an array of strings" $scope "keywords" -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- return true -}} 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 -}} @@ -200,8 +207,8 @@ {{ $jsArray = $jsArray | append $jsScrollSpyPatch -}} {{ end -}} -{{ $bundleKey := printf "%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v" - $shell $localSearch .Site.Params.offlineSearch $hasMarkmap +{{ $bundleKey := printf "%v-%v-%v-%s-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v-%v" + $shell $localSearch $hasGoogleSearch .Site.Language.Lang $hasMarkmap $hasPlantuml .Site.Params.drawio.enable $hasAsciinema $hasEcharts $hasInfographic $hasDocCarousel $hasGiscus $hasFeedback $hasCodeRuntime $hasTabRuntime $hasImageZoom (not (.Param "ui.scrollSpy.disable")) | md5 -}} diff --git a/layouts/_partials/search-input.html b/layouts/_partials/search-input.html index 7d71115f..c0bfe978 100644 --- a/layouts/_partials/search-input.html +++ b/layouts/_partials/search-input.html @@ -27,33 +27,13 @@ {{- end -}} -{{ if .Site.Params.offlineSearch -}} +{{ if partial "shell/search-enabled.html" . -}} {{ .Scratch.Add "oink-search" 1 -}} - -{{ $offlineSearchLink := partialCached "offline-search-index.html" . .Site.Language.Lang -}} - - - + {{- end -}} {{ if gt (.Scratch.Get "oink-search") 1 -}} diff --git a/layouts/_partials/search/boost.html b/layouts/_partials/search/boost.html new file mode 100644 index 00000000..8a4ced51 --- /dev/null +++ b/layouts/_partials/search/boost.html @@ -0,0 +1,21 @@ +{{- /* Resolve search_boost after Hugo cascade inheritance. Only numeric, + finite positive multipliers are accepted. */ -}} +{{- $page := . -}} +{{- $boost := 1.0 -}} +{{- if isset $page.Params "search_boost" -}} + {{- $value := index $page.Params "search_boost" -}} + {{- $raw := printf "%v" $value -}} + {{- $numericTypes := slice + "int" "int8" "int16" "int32" "int64" + "uint" "uint8" "uint16" "uint32" "uint64" + "float32" "float64" + -}} + {{- if and (in $numericTypes (printf "%T" $value)) + (gt (float $value) 0) + (not (in (slice "+Inf" "Inf" "-Inf" "NaN") $raw)) -}} + {{- $boost = float $value -}} + {{- else -}} + {{- warnf "%s: invalid search_boost %q; using 1.0" ($page.Path | default $page.RelPermalink) $raw -}} + {{- end -}} +{{- end -}} +{{- return $boost -}} diff --git a/layouts/_partials/search/metadata.html b/layouts/_partials/search/metadata.html new file mode 100644 index 00000000..00756ad8 --- /dev/null +++ b/layouts/_partials/search/metadata.html @@ -0,0 +1,53 @@ +{{- /* Deterministic local-search metadata derived from Hugo's page tree. */ -}} +{{- $page := . -}} +{{- $rootPage := $page.FirstSection -}} +{{- if not $rootPage }}{{ $rootPage = $page -}}{{ end -}} +{{- $sectionPage := $page.CurrentSection -}} +{{- if not $sectionPage }}{{ $sectionPage = $rootPage }}{{ end -}} +{{- $root := $rootPage.Section -}} +{{- if not $root -}} + {{- with $rootPage.File }}{{ $root = .ContentBaseName }}{{ end -}} +{{- end -}} +{{- $root = $root | default "home" | lower -}} +{{- $section := "" -}} +{{- with $sectionPage.File -}} + {{- if ne .ContentBaseName "_index" }}{{ $section = .ContentBaseName }}{{ end -}} +{{- end -}} +{{- $section = $section | default $sectionPage.Section | default $root | lower -}} +{{- $type := $page.Type | default $root | lower -}} +{{- $breadcrumb := slice -}} +{{- $trail := slice -}} +{{- range $page.Ancestors.Reverse -}} + {{- if and (not .IsHome) (or (eq . $rootPage) (.IsDescendant $rootPage)) -}} + {{- $trail = $trail | append . -}} + {{- end -}} +{{- end -}} +{{- range $trail }}{{ $breadcrumb = $breadcrumb | append (.LinkTitle | default .Title) }}{{ end -}} +{{- if and (not $page.IsHome) (not (in $trail $page)) -}} + {{- $breadcrumb = $breadcrumb | append ($page.LinkTitle | default $page.Title) -}} +{{- end -}} +{{- $icon := $page.Params.icon | default $sectionPage.Params.icon | default $rootPage.Params.icon -}} +{{- if not $icon -}} + {{- $icons := dict + "docs" "fa-solid fa-book" + "blog" "fa-solid fa-blog" + "swagger" "fa-solid fa-code" + -}} + {{- $icon = index $icons $type | default (index $icons $root) | default "fa-solid fa-file-lines" -}} +{{- end -}} +{{- $keywords := $page.Params.search_keywords | default (slice) -}} +{{- if not (reflect.IsSlice $keywords) }}{{ $keywords = slice $keywords }}{{ end -}} +{{- $normalizedKeywords := slice -}} +{{- range $keywords -}} + {{- $keyword := strings.TrimSpace (printf "%v" .) -}} + {{- if $keyword }}{{ $normalizedKeywords = $normalizedKeywords | append $keyword }}{{ end -}} +{{- end -}} +{{- return (dict + "root" $root + "section" $section + "type" $type + "keywords" $normalizedKeywords + "boost" (partial "search/boost.html" $page) + "breadcrumb" $breadcrumb + "icon" $icon +) -}} diff --git a/layouts/_partials/shell/docs-sidebar-tree.html b/layouts/_partials/shell/docs-sidebar-tree.html index e3c2827c..ba0531e5 100644 --- a/layouts/_partials/shell/docs-sidebar-tree.html +++ b/layouts/_partials/shell/docs-sidebar-tree.html @@ -4,6 +4,7 @@ {{ $context := .context -}} {{ $sidebarRootID := .sidebarRootID -}} {{ $cacheSidebar := .cacheSidebar -}} +{{ $iconPolicy := .iconPolicy -}} {{ with $context -}} {{ $shouldDelayActive := $cacheSidebar -}} @@ -21,6 +22,7 @@