mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 02:17:31 +00:00
- 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.
434 lines
18 KiB
JavaScript
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
|
|
})
|
|
}
|