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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
.DS_Store
__pycache__/
*.py[cod]
.hugo_build.lock
public/
resources/
.idea/
.codex/
.cursor/
.claude/
tmp/
tmp/
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
253 changes: 253 additions & 0 deletions assets/js/action-registry.js
Original file line number Diff line number Diff line change
@@ -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);
7 changes: 0 additions & 7 deletions assets/js/authored-a11y.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
})();
Loading