Files
orca/config/scripts/generate-plugin-authoring-skill-references.mjs
T
Neil 8503ecda71 plugins: check runs the worker and scenarios; RelativeTime (#37047)
- orca plugin check mounts the plugin's worker (main) on the same mock host
  as each panel, so plugin.request reaches its orca.panels.handle handlers
  as in Orca; a worker that fails to activate or deactivate is an issue
  named after its file. In orca/testing, a worker mounted on a host answers
  that host's panel requests unless `requests` stubs the name.
- Check scenarios: <entry>.check.ts (e.g. src/panel.check.ts) exports
  defineCheck({ name: { ...mockHost options, persisted, theme, events } });
  check renders the panel once per scenario and labels each result.
- RelativeTime (catalog 3, since 1.4.247) and { type: 'relative', at } Table
  cells: Orca ticks them (each second under a minute, then less often), so
  plugins never poll. Inline inside Text; test kit reads them as drawn.
- Typings, docs, skill guide, generated references; gallery shows it.
2026-11-01 06:56:19 -08:00

434 lines
18 KiB
JavaScript

// Generates the orca-plugin-authoring skill references from the plugin source of truth.
// `--write` rewrites them; the default (`--check`) fails when one is stale.
import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { readTypingsModule } from './plugin-authoring-typings-outline.mjs'
import { loadRepoTypeScriptModules, REPO_ROOT } from './plugin-schema-module-loader.mjs'
const GUIDE = 'orca-plugin-authoring'
const REFERENCE_DIR = ['skill-guides', GUIDE, 'references']
const GENERATED_NOTE =
'<!-- Generated by config/scripts/generate-plugin-authoring-skill-references.mjs. Do not edit. -->'
const TYPINGS = {
// The `orca` module is split by size; `orca plugin types` joins these into one file.
components: [
'resources/plugins/declarative/orca.d.ts',
'resources/plugins/declarative/orca-components.d.ts',
'resources/plugins/declarative/orca-components-layout.d.ts',
'resources/plugins/declarative/orca-components-data.d.ts'
],
// Merges into module 'orca': shell, files, on, the worker bridge and plugin data.
services: 'resources/plugins/types/orca-services.d.ts',
frame: 'resources/plugins/types/orca-frame.d.ts',
worker: 'resources/plugins/types/orca-worker.d.ts',
testing: 'resources/plugins/types/orca-testing.d.ts'
}
const SURFACES = ['worker', 'panel', 'editor']
const LONG_ENUM = 8
function loadSources() {
return loadRepoTypeScriptModules({
catalog: 'src/shared/plugins/declarative/plugin-declarative-catalog.ts',
limits: 'src/shared/plugins/declarative/plugin-declarative-limits.ts',
hostApi: 'src/shared/plugins/workers/plugin-host-api.ts',
hostMajor: 'src/shared/plugins/workers/plugin-host-api-major.ts',
hostDoc: 'src/shared/plugins/workers/plugin-host-api-reference-doc.ts',
typeText: 'src/shared/plugins/workers/plugin-schema-type-text.ts',
events: 'src/shared/plugins/events/plugin-events.ts',
eventContribution: 'src/shared/plugins/contributions/plugin-event-contribution.ts',
capabilities: 'src/shared/plugins/capabilities/plugin-capabilities.ts',
schema: 'src/shared/plugins/manifest/plugin-json-schema.ts',
fieldDocs: 'src/shared/plugins/manifest/plugin-schema-field-docs.ts',
cli: 'src/cli/specs/plugin.ts',
cliView: 'src/cli/specs/plugin-view.ts'
})
}
async function readTypings(repoRoot) {
const read = (file) => readFile(path.join(repoRoot, ...file.split('/')), 'utf8')
const components = (await Promise.all(TYPINGS.components.map(read))).join('\n')
return {
components: readTypingsModule(components, TYPINGS.components[0], 'orca'),
services: readTypingsModule(await read(TYPINGS.services), TYPINGS.services, 'orca'),
frame: readTypingsModule(await read(TYPINGS.frame), TYPINGS.frame, 'orca/frame'),
worker: readTypingsModule(await read(TYPINGS.worker), TYPINGS.worker, 'orca/worker'),
testing: readTypingsModule(await read(TYPINGS.testing), TYPINGS.testing, 'orca/testing')
}
}
const code = (text) => `\`${text}\``
const withSummary = (line, text) => (text ? `${line}: ${text}` : line)
const bullet = (member) => `- ${withSummary(code(member.text), member.summary)}`
function document(title, intro, sections) {
const lines = [`# ${title}`, '', GENERATED_NOTE, '', ...intro, ...sections.flat()]
return `${lines.join('\n').trimEnd()}\n`
}
function childrenLabel(policy, catalog) {
const labels = {
none: 'none',
text: 'text',
inline: `text, ${[...catalog.PLUGIN_DECLARATIVE_INLINE_COMPONENTS].join(', ')}`,
items: 'ListItem only',
tabs: 'Tab only',
any: 'any'
}
if (!labels[policy]) {
throw new Error(`No reference label for component child policy ${policy}`)
}
return labels[policy]
}
function renderComponents({ catalog, limits }, { components, services }) {
const vocabulary = ['Gap', 'Align', 'Tone', 'Size', 'Variant', 'Press'].map(
(name) => `- ${code(name)}: ${code(components.typeText(name))}`
)
const icons = components.unionLiterals('IconName')
const entries = catalog.PLUGIN_DECLARATIVE_COMPONENT_NAMES.map((name) => {
const propsType = components.componentProps(name)
if (!propsType) {
throw new Error(`orca.d.ts does not export component ${name}`)
}
const props = components.literalMembers(propsType).map((member) => member.text)
const children = childrenLabel(catalog.PLUGIN_DECLARATIVE_CATALOG[name].children, catalog)
return `- ${code(name)} (children: ${children}): ${props.length > 0 ? code(props.join('; ')) : 'no props'}`
})
const runtime = [
...[...components.functions, ...services.functions].map((fn) => ({
...fn,
text: fn.text.replace(/^function /u, '')
})),
...['host', 'api', 'env', 'ui'].flatMap((name) => components.value(name)),
...['shell', 'files', 'plugin', 'storage', 'settings', 'secrets'].flatMap((name) =>
services.value(name)
)
].map(bullet)
const typeLine = (module, name) =>
`- ${withSummary(code(module.typeDeclaration(name)), module.typeSummary(name))}`
const types = [
...['PersistScope', 'AtomOptions', 'PanelContext', 'PanelSetup', 'HostError'].map((name) =>
typeLine(components, name)
),
...[
'PluginRequestOptions',
'PluginBridgeApi',
'DataChange',
'StorageApi',
'SettingsApi',
'SecretsApi'
].map((name) => typeLine(services, name))
]
const limit = limits.PLUGIN_DECLARATIVE_LIMITS
return document(
'Components panel API',
[
'Everything a components panel (`"ui": "components"`) imports comes from `orca`. Components are JSX tags only; props are semantic (no `className`, `style` or lowercase DOM tags). Event props are functions. An unknown prop or enum value is dropped with a warning; an unknown component draws a placeholder.',
''
],
[
['## Prop vocabulary', '', ...vocabulary],
[`- ${code('IconName')}: ${icons.join(', ')}. Any other name draws a fallback glyph.`, ''],
['## Components', '', ...entries, ''],
['## Runtime', '', ...runtime, ''],
[
'## Panel ↔ worker contract',
'',
"Declare requests and worker posts once with declaration merging, in a file both the panel and the worker include: `declare module 'orca' { interface PluginRequests { 'todos.list': { input: { done?: boolean }; output: Todo[] } } interface PluginPanelEvents { 'todos.changed': { count: number } } }`. Declared names are typed end to end (`plugin.request`, `plugin.on`, the worker's `orca.panels.handle` and `orca.panels.post`, and `orca/testing`'s `requests` and `panel.post`); any other name carries JSON.",
''
],
['## Types', '', ...types, ''],
[
'## Limits',
'',
`A render is refused, keeping the last good one, past ${limit.maxNodes} nodes, depth ${limit.maxDepth}, ${limit.maxTreeBytes / 1024} KiB of data, ${limit.maxTextLength / 1024} KiB per text, ${limit.maxLabelLength / 1024} KiB per label prop, ${limit.maxPropsPerNode} props per node or ${limit.maxSelectOptions} Select options.`
]
]
)
}
function renderTesting({ testing }) {
const members = (name) =>
testing.literalMembers(name, { flatten: false }).map((member) => bullet(member))
return document(
'Test kit (`orca/testing`)',
[
'`ORCA plugin test` runs `*.test.ts(x)` files in Node with `orca` and `orca/testing` resolved to what this Orca ships, and plugin sources (panels and the worker `main`) loaded as Orca loads them. Every act resolves once the panel or worker has settled: `await` it and never add sleeps.',
''
],
[
['## Functions', '', ...testing.functions.map(bullet), ''],
['## `TestPanel` (from `mount`)', '', ...members('TestPanel'), ''],
['## `TestWorker` (from `mountWorker`)', '', ...members('TestWorker'), ''],
['## `MockHost` (from `mockHost`)', '', ...members('MockHost'), ''],
[
'## Options',
'',
...['MountOptions', 'MountWorkerOptions', 'MockHostOptions', 'CheckScenario'].map(
(name) => `- ${code(testing.typeDeclaration(name))}`
)
]
]
)
}
function hostVersion({ hostMajor }, minor) {
return `${hostMajor.PLUGIN_HOST_API_MAJOR}.${minor}`
}
function signature({ typeText }, spec) {
const { REFERENCE_TYPE_TEXT, zodTypeText } = typeText
const omittable = spec.params.safeParse(undefined).success
return `(params${omittable ? '?' : ''}: ${zodTypeText(spec.params, 'input', REFERENCE_TYPE_TEXT)}) => ${zodTypeText(spec.result, 'output', REFERENCE_TYPE_TEXT)}`
}
function renderHostApi(sources, { frame, worker }) {
const { hostApi, hostDoc, capabilities } = sources
const names = hostApi.PLUGIN_HOST_METHOD_NAMES
const methodsFor = (surface) => hostApi.pluginHostMethodsFor(surface).map(code).join(', ')
const methods = names.map((name) => {
const spec = hostApi.PLUGIN_HOST_API[name]
const facts = [
spec.capability ? `capability ${code(spec.capability)}` : 'no capability',
spec.surfaces.join(', '),
`since ${hostVersion(sources, spec.since)}`,
...(spec.deprecatedSince === undefined
? []
: [`deprecated ${hostVersion(sources, spec.deprecatedSince)}`]),
...(spec.userActivation ? ['needs a user gesture'] : [])
]
return `- ${code(name)} (${facts.join('; ')}): ${spec.summary}\n ${code(signature(sources, spec))}`
})
const surfaceNotes = {
worker: 'Worker (`trusted-worker`, `main`): `orca` passed to `activate(orca)`',
panel:
'Panel: global `orca` in an HTML panel; `host` imported from `orca` in a components panel',
editor: 'Custom editor: global `orca` in the editor frame'
}
return document(
'Host API by surface',
[
`Host API ${hostVersion(sources, hostApi.PLUGIN_HOST_API_MINOR)} (\`"pluginApi": 1\`). Call \`host.call(method, params, { signal })\`; a surface not listed for a method gets \`not_permitted\`. Params are strict: a field this Orca does not know is refused with \`invalid_params\`, so check \`host.has(method)\` or \`api.minor\` before using a method newer than your \`engines.orca\` floor. Each call also needs the manifest capability and the user's approval.`,
''
],
[
[
'## Methods per surface',
'',
...SURFACES.map((s) => `- ${surfaceNotes[s]}: ${methodsFor(s)}`),
''
],
["## Worker API (`import type { OrcaWorkerApi } from 'orca/worker'`)", ''],
[...worker.literalMembers('OrcaWorkerApi').map(bullet), ''],
['## Frame API (global `orca`, also `window.orca`, in HTML panels and custom editors)', ''],
frame.literalMembers('OrcaFrameApi').map(bullet),
[
...frame
.literalMembers('EditorDocumentApi')
.map((m) => bullet({ ...m, text: `document.${m.text}` })),
''
],
['## Methods', '', ...methods, ''],
[
'## Capabilities',
'',
...capabilities.PLUGIN_CAPABILITY_KINDS.map(
(kind) =>
`- ${code(kind)} (since ${capabilities.PLUGIN_CAPABILITY_SINCE[kind]}): ${capabilities.PLUGIN_CAPABILITY_DESCRIPTIONS[kind]}`
),
''
],
[
'## Errors',
'',
'A failed call rejects with an `Error` carrying `code`, `message`, `retryable` and, for `rate_limited`, `retryAfterMs`. Treat an unknown code as `action_failed`.',
'',
...Object.entries(hostDoc.PLUGIN_HOST_ERROR_CODE_MEANINGS).map(
([errorCode, meaning]) => `- ${code(errorCode)}: ${meaning}`
)
]
]
)
}
function renderEvents({ events, eventContribution, typeText }, { services, frame }) {
const { REFERENCE_TYPE_TEXT, zodTypeText } = typeText
const workerEvents = eventContribution.PLUGIN_EVENT_NAMES.map(
(name) =>
`- ${code(name)} (capability ${code(eventContribution.PLUGIN_EVENT_CAPABILITY[name])}; since ${eventContribution.PLUGIN_EVENT_SINCE[name]}): ${events.PLUGIN_EVENT_SUMMARIES[name]}\n ${code(zodTypeText(events.PLUGIN_EVENT_PAYLOAD_SCHEMAS[name], 'output', REFERENCE_TYPE_TEXT))}`
)
return document(
'Events',
[],
[
[
'## Worker events',
'',
'List each in `contributes.events` (`{ "on": "<event>" }`), with `main`, the `trusted-worker` profile and the capability shown; then `orca.events.on(event, handler)` in `activate(orca)` returns `{ dispose() }`. Payloads:',
'',
...workerEvents,
''
],
[
'## Frame events (`orca.on(event, handler)` in HTML panels and custom editors)',
'',
...frame.literalMembers('FrameEvents', { flatten: false }).map(bullet),
''
],
[
'## Components panel events (`on(event, handler)` from `orca`)',
'',
...services.literalMembers('OrcaEvents', { flatten: false }).map(bullet),
'- Every worker event above, with the same payload and capability; a panel needs no `contributes.events` entry, it subscribes while it is open. HTML panels get them on `orca.on` too.'
]
]
)
}
function typeLabel(schema) {
if (schema.$ref) {
return schema.$ref.replace('#/$defs/', '')
}
if (schema.const !== undefined) {
return JSON.stringify(schema.const)
}
if (schema.enum) {
return schema.enum.length > LONG_ENUM
? `one of ${schema.enum.join(', ')}`
: schema.enum.map((value) => JSON.stringify(value)).join(' | ')
}
if (schema.type === 'array' && schema.items && !Array.isArray(schema.items)) {
return `${typeLabel(schema.items)}[]`
}
if (schema.anyOf) {
return [...new Set(schema.anyOf.map(typeLabel))].join(' | ')
}
return Array.isArray(schema.type) ? schema.type.join(' | ') : (schema.type ?? 'any')
}
function fieldLines(schema, pathRules, prefix = '') {
const required = new Set(schema.required ?? [])
return Object.entries(schema.properties ?? {}).flatMap(([key, field]) => {
const since = field['x-orca-since'] ? `, since ${field['x-orca-since']}` : ''
const facts = `${typeLabel(field)}${required.has(key) ? ', required' : ''}${since}`
const description = (field.description ?? '').replace(` ${pathRules}`, '')
const line = `- ${code(`${prefix}${key}`)} (${facts}): ${description}`
return field.type === 'object' && field.properties
? [line, ...fieldLines(field, pathRules, `${prefix}${key}.`)]
: [line]
})
}
function renderManifest({ schema, fieldDocs }) {
const manifest = schema.pluginManifestJsonSchema()
const pathRules = fieldDocs.PLUGIN_PATH_RUNTIME_RULES
const lines = (fields) => fieldLines(fields, pathRules)
const reserved = (field) => (field.description ?? '').startsWith('Reserved for ')
const top = {
...manifest,
properties: Object.fromEntries(
Object.entries(manifest.properties).filter(([key]) => key !== 'contributes')
)
}
const contributes = manifest.properties.contributes
const open = Object.entries(contributes.properties).filter(([, field]) => !reserved(field))
const closed = Object.keys(contributes.properties).filter((key) =>
reserved(contributes.properties[key])
)
return document(
'orca-plugin.json fields',
[
`\`Since\` is the first Orca release that reads a field: raise \`engines.orca\` to the newest one you use. Orca refuses unknown keys at every level. File paths are ${pathRules.replace(/^Relative/u, 'relative')}`,
''
],
[
['## Top level', '', ...lines(top), ''],
[
'## Contribution points (`contributes`)',
'',
...lines({ properties: Object.fromEntries(open) }),
''
],
[`Reserved (must stay empty arrays): ${closed.map(code).join(', ')}.`, ''],
...Object.entries(manifest.$defs ?? {}).map(([name, fragment]) => [
`## ${name}`,
'',
fragment.description,
'',
...lines(fragment),
''
])
]
)
}
function renderCli({ cli, cliView }) {
const specs = [
...cli.PLUGIN_AUTHORING_COMMAND_SPECS,
...cli.PLUGIN_RUNTIME_COMMAND_SPECS,
...cliView.PLUGIN_VIEW_COMMAND_SPECS
]
const exitCodes = specs
.flatMap((spec) => spec.notes ?? [])
.find((note) => note.startsWith('Exit codes'))
return document(
'Plugin CLI commands',
[
`\`ORCA\` is the Orca executable you resolved. Every command takes \`--json\`; branch on \`error.code\`. ${exitCodes ?? ''} Run \`ORCA <command> --help\` for a command's notes.`,
''
],
[specs.map((spec) => `- ${code(spec.usage.replace(/^orca /u, 'ORCA '))}: ${spec.summary}`)]
)
}
/** Every reference this generator owns, as `{ path, content }`. */
export async function buildPluginAuthoringReferences(repoRoot = REPO_ROOT) {
const sources = loadSources()
const typings = await readTypings(repoRoot)
const files = {
'cli.md': renderCli(sources),
'components.md': renderComponents(sources, typings),
'events.md': renderEvents(sources, typings),
'host-api.md': renderHostApi(sources, typings),
'manifest.md': renderManifest(sources),
'testing.md': renderTesting(typings)
}
return Object.entries(files).map(([name, content]) => ({
path: path.join(repoRoot, ...REFERENCE_DIR, name),
content
}))
}
export async function findStalePluginAuthoringReferences(artifacts, repoRoot = REPO_ROOT) {
const stale = []
for (const artifact of artifacts) {
const current = await readFile(artifact.path, 'utf8').catch(() => null)
if (current?.replace(/\r\n/g, '\n') !== artifact.content) {
stale.push(path.relative(repoRoot, artifact.path).split(path.sep).join('/'))
}
}
return stale
}
async function main() {
const artifacts = await buildPluginAuthoringReferences()
if (process.argv.includes('--write')) {
await Promise.all(artifacts.map((artifact) => writeFile(artifact.path, artifact.content)))
return
}
const stale = await findStalePluginAuthoringReferences(artifacts)
if (stale.length > 0) {
throw new Error(
`Plugin authoring skill references are stale:\n${stale.join('\n')}\nRun pnpm generate:plugin-authoring-skill-references.`
)
}
}
if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error)
process.exitCode = 1
})
}