import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry'; import { SUPPORTED_OWLIVER_CAPABILITIES, sourceSupportsOption } from './surfaces'; /** * A definition, read into editor fields — and edited fields, written back. * * Two things used to be missing here, and between them they are the whole of * "uploading a `.md` does nothing" and "the form fields do not work": * * - **Nothing read a file into the fields.** All three upload buttons set the * Markdown and stopped, so an author who uploaded a complete definition was * looking at a form full of empty boxes beside it. The two editors each had * a reader that did exactly this job — `metaFromSource` and * `draftFromSource` — and neither was reachable from the upload path. They * live here now, so one file is read the same way wherever it arrives. * - **Nothing wrote the fields back.** Both editors regenerated the whole * definition from a template while the Markdown was untouched, and stopped * the moment it was touched — which is every edit of an existing skill, * because `touched` starts true there. Typing a new name updated React * state that nothing saved. `patchFrontmatter` is the missing half: a field * edits the key it owns, in place, and the body is never rewritten. * * Everything here reads the *registered* form of a definition — what * `parseSkill` made of it — rather than the raw text. The form therefore shows * what the product will actually do, including the placement an author left out * and the vocabulary that filled it in. */ /* ── Reading a definition into fields ───────────────────────────────────── */ /** * A definition, or nothing. * * `parseSkill` is deliberately forgiving: a file with no frontmatter at all * still parses, taking its id from the path it was given. That is right for the * registry, and wrong here — a text file dropped on the upload button would * otherwise fill the ID field with `custom` and the name with `Untitled skill`, * which reads as a definition the product understood. A form is filled from a * definition or it is left alone. */ function readSkill(source) { /* `hasFrontmatter`, not a second regex of this module's own. The two used to be written out separately and drifted: a file the registry was willing to read could be one this rejected, and rejecting it here empties a form the preview beside it has just filled in. One detector, one answer. */ if (!hasFrontmatter(source)) return null; return parseSkill(source, { custom: true }); } /** * A definition, cleaned up on the way in. * * Re-exported under the name the editors use, so an uploaded file is stored in * the form every reader already agrees on rather than carrying a byte-order * mark into `patchFrontmatter`, which would not recognise the fence and would * write a second one above it. */ export const normalizeUpload = normalizeDefinition; /** Whether a definition can be read into the fields at all. */ export const isReadableDefinition = (source) => hasFrontmatter(source); /** The Board (UI) editor's fields, before a definition is loaded into them. */ export const EMPTY_BOARD_FIELDS = { id: '', name: '', description: '', pages: [], type: 'flow', placement: '', title: '', source: 'candidates.activity', periods: [], }; /** * The Owliver editor's fields, before a definition is loaded into them. * * `responses` is keyed by capability, because that is how the format is keyed * and how the runtime reads it. It used to be one `source` and one `periods` * shared by every selected capability, and that single field is the whole of * the bug this shape exists to remove: * * - A capability selected before a source was picked composed **no** * `responses:` block at all. `normalizeSkillOwliver` then dropped every * capability for want of a source, `owliver.capabilities` normalized to * `[]`, and `owliverSkillsForContext` excludes a skill with none — so the * definition registered, showed as active on its page, and contributed no * suggestion chip. * - Two capabilities could not read different sources, and one global source * that suited the first often could not be drawn as the second: `list` * against `candidates.activity` is refused by `normalizeSection`, so * picking it silently cost the author that capability. * * A form that cannot express what the format can is not a shortcut to it. */ export const EMPTY_OWLIVER_FIELDS = { id: '', name: '', description: '', pages: [], triggers: [], suggestions: [], capabilities: [], responses: {}, }; /** * A Board skill, read back into the fields that compose one. * * The section comes from the registry's reading rather than from the Markdown * text, so a definition that omitted `placement:` shows the placement it will * actually render at. */ export function boardFieldsFromSource(source) { try { const skill = readSkill(source); if (!skill) throw new Error('no frontmatter'); /* The first section, in declaration order — what the single-valued fields can show. `pages` still carries every page the definition reaches, and `uiIsEditableFromFields` is what stops those fields writing over the ones this record cannot represent. */ const section = Object.values(skill.ui || {}).flatMap((p) => p.sections || [])[0]; return { id: skill.id, name: skill.name, description: skill.description, pages: skill.pages, type: section?.type || EMPTY_BOARD_FIELDS.type, placement: section?.placement || '', /* The heading the card draws. Composed from the skill's name when it is left out, but it is a field of its own in the format and a definition that set it must survive a round trip through the form. */ title: section?.title || '', source: section?.source || EMPTY_BOARD_FIELDS.source, periods: section?.periods || [], }; } catch { return EMPTY_BOARD_FIELDS; } } /** * An Owliver skill, read back into the fields that compose one. * * Reads the resolved capabilities, which includes the ones whose source was * inherited from a `ui:` block rather than written out — so the form shows what * Owliver can be asked for, not what the file happened to spell. */ export function owliverFieldsFromSource(source) { try { const skill = readSkill(source); if (!skill) throw new Error('no frontmatter'); /** * Every capability the definition *declares*, not only the ones that * resolved. * * `skill.owliver.capabilities` is filtered to those that found a reading, * which is right for the runtime and wrong for a form: a stored definition * declaring `summary` and `list` where `list` lost its source would open * with `list` simply absent, and the next save would delete a capability * its author never removed. The one that cannot answer is exactly the one * they opened the editor to fix, so it is shown — checked, with no source, * and refused by the same validation as before. * * Read from the frontmatter the definition actually carries, and narrowed * to the closed vocabulary, so a typo is still not a capability. */ const declared = (() => { let data; try { ({ data } = parseFrontmatter(String(source))); } catch { return []; } const block = data?.owliver; if (!block || typeof block !== 'object' || Array.isArray(block)) return []; const listed = Array.isArray(block.capabilities) ? block.capabilities : []; const keyed = block.responses && typeof block.responses === 'object' && !Array.isArray(block.responses) ? Object.keys(block.responses) : []; return [...listed, ...keyed] .map((capability) => String(capability).trim()) .filter((capability) => SUPPORTED_OWLIVER_CAPABILITIES.includes(capability)); })(); /* Declaration order first, then anything that resolved without being listed — a definition whose capability came from its `ui:` section. */ const capabilities = [...new Set([...declared, ...skill.owliver.capabilities])]; /** * Every capability's own reading, not the first one's. * * Reading one and showing it against all of them is how a two-capability * definition lost the second's source the first time any field was edited: * the form wrote back what it had read, and it had read half the block. */ const responses = Object.fromEntries( capabilities.map((capability) => { const response = skill.owliver.responses[capability]; return [capability, { source: response?.source || '', periods: response?.periods || [], limit: response?.limit ?? null, }]; }) ); return { id: skill.id, name: skill.name, description: skill.description, pages: skill.pages, /** * Only the triggers the definition actually claimed. * * `skill.triggers` falls back to the skill's own name when none are * declared, which is right for matching and wrong to put in a form: the * field would fill with an inherited phrase, and the next edit would * write it into the file as a declared one. That flips * `declaredTriggers`, which the matcher weighs differently — and it would * pin the *old* name's phrase the moment the skill is renamed. */ triggers: skill.declaredTriggers ? skill.triggers : [], suggestions: skill.owliver.suggestions.map((s) => s.label), capabilities, responses, }; } catch { return EMPTY_OWLIVER_FIELDS; } } /** * Which management surfaces this definition belongs to, for the upload handoff. * * A file dropped into the Board editor that declares only an `owliver:` block * is being edited in the wrong half of the product. Knowing that lets the * editor offer the other one rather than showing a form none of whose fields * apply. */ export function facetsFromSource(source) { try { return readSkill(source)?.facets || []; } catch { return []; } } /** * Which of the `ui:` shapes a definition uses. * * `shorthand` is one section applying to every declared page — the form the * editors compose, and the only one whose four fields can represent the whole * block. `list` and `per-page` can each hold several sections across several * pages, so the fields would have to throw away everything but the first to * write them back. Telling them apart is what lets the editor show a multi-page * definition without being able to flatten it. */ export function uiShape(source) { let data; try { ({ data } = parseFrontmatter(String(source))); } catch { return 'none'; } const ui = data?.ui; if (!ui || typeof ui !== 'object') return 'none'; if (Array.isArray(ui)) return ui.length ? 'list' : 'none'; const SECTION_KEYS = ['type', 'source', 'data', 'placement', 'position', 'periods', 'title']; return SECTION_KEYS.some((key) => key in ui) ? 'shorthand' : 'per-page'; } /** Whether the section fields can write this definition's `ui:` block back. */ export const uiIsEditableFromFields = (source) => uiShape(source) !== 'list' && uiShape(source) !== 'per-page'; /** * Whether this definition's pages come from its `ui:` block rather than a * `pages:` key of its own. * * When they do, the Pages field is a read-out and must not be written back. * Writing it would add a `pages:` list that agrees with the block today and * silently governs it tomorrow: `normalizeSkillUi` refuses an entry naming a * page that `pages:` does not list, so the next entry the author adds to the * block would be rejected by a key they never wrote. A derived value is shown, * not owned. */ export function pagesAreDerived(source) { let data; try { ({ data } = parseFrontmatter(String(source))); } catch { return false; } if (Array.isArray(data?.pages) && data.pages.length) return false; return uiShape(source) !== 'none'; } /* ── Writing fields back into the frontmatter ───────────────────────────── */ /** * A scalar, written so the parser reads back what was meant. * * Quoted whenever the plain form would be read as something else — a value * containing `:` or `#`, one with edge whitespace, or one that looks like a * number, a boolean or null but is a string. */ function writeScalar(value) { if (value === null || value === undefined) return 'null'; if (typeof value === 'boolean' || typeof value === 'number') return String(value); const text = String(value); const ambiguous = text === '' || /[:#]/.test(text) || text !== text.trim() || /^(true|false|null|~)$/.test(text) || /^-?\d+$/.test(text) || /^-?\d*\.\d+$/.test(text) || /^['"-]/.test(text); return ambiguous ? `'${text.replace(/'/g, "''")}'` : text; } /** * A value, as the block lines that follow its key. * * Returns `null` for a scalar, which is written on the key's own line instead. * Only the subset in `yaml.js` is emitted — block maps and block sequences, * nested to any depth — because that is the only subset the parser reads back. */ function writeBlock(value, indent) { const pad = ' '.repeat(indent); if (Array.isArray(value)) { return value.map((item) => { if (item && typeof item === 'object' && !Array.isArray(item)) { const entries = Object.entries(item).filter(([, v]) => v !== undefined); if (!entries.length) return `${pad}- {}`; return entries .map(([k, v], i) => { const prefix = i === 0 ? `${pad}- ` : `${pad} `; const nested = writeBlock(v, indent + 4); return nested === null ? `${prefix}${k}: ${writeScalar(v)}` : `${prefix}${k}:\n${nested}`; }) .join('\n'); } return `${pad}- ${writeScalar(item)}`; }).join('\n'); } if (value && typeof value === 'object') { return Object.entries(value) .filter(([, v]) => v !== undefined) .map(([k, v]) => { const nested = writeBlock(v, indent + 2); return nested === null ? `${pad}${k}: ${writeScalar(v)}` : `${pad}${k}:\n${nested}`; }) .join('\n'); } return null; } /** The lines belonging to the key at `start`: everything indented under it. */ function blockEnd(lines, start, indent) { let end = start + 1; while (end < lines.length) { const line = lines[end]; if (line.trim() === '' || /^\s*#/.test(line)) { end += 1; continue; } if (line.match(/^\s*/)[0].replace(/\t/g, ' ').length <= indent) break; end += 1; } /* Trailing blanks and comments belong to whatever comes next, not to this key — a comment written above the following key must not be swallowed by the block above it. */ while (end > start + 1 && lines[end - 1].trim() === '') end -= 1; return end; } /** * How wide a line's indentation is. * * The same measurement the parser makes — `yaml.js` reads a leading run of * whitespace and counts a tab as two — and it has to be, because a key the * parser can see and the * writer cannot is a key the writer will decide is missing and add a second * copy of. * * That is not hypothetical: a definition stored on this account indents with * U+00A0. JavaScript's `\s` matches it, so the parser read the file correctly * and every screen showed the right values; the writer compared against literal * spaces, found no `title:` inside `ui:`, and appended a whole second `ui:` * block on the first edit. The Go port agrees with the parser here too — see * `jsIsSpace` in `internal/definition/jsvalue.go`, which lists `0x00A0` — so the * writer was the only thing in the chain using a narrower idea of a space. */ const indentWidth = (line) => (line.match(/^\s*/)?.[0] || '').replace(/\t/g, ' ').length; /** The index of `key` at `indent` within `[from, to)`, or -1. */ function findKey(lines, key, indent, from, to) { const pattern = new RegExp(`^${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`); for (let i = from; i < to; i += 1) { const line = lines[i]; if (line === undefined || indentWidth(line) !== indent) continue; /* Measured, then matched on what is left — so the comparison is about how deep the key sits, never about which characters were used to put it there. Identical for ASCII input, which is every definition this repository ships. */ if (pattern.test(line.replace(/^\s*/, ''))) return i; } return -1; } /** * One dotted path, set in a frontmatter line array. * * Scalars replace the value on their own line; anything else replaces the block * beneath it. A key that is not there is appended to the end of its parent, so * a definition that never declared `status:` gains one rather than being * refused. Every line the path does not touch — including comments and key * order — is left exactly as written, which is why this is a patch and not a * re-serialisation. */ function setPath(lines, path, value) { const parts = path.split('.'); let from = 0; let to = lines.length; let indent = 0; for (let depth = 0; depth < parts.length - 1; depth += 1) { const at = findKey(lines, parts[depth], indent, from, to); /* A parent that does not exist is created empty, then descended into. */ if (at === -1) { lines.splice(to, 0, `${' '.repeat(indent)}${parts[depth]}:`); from = to + 1; to = from; indent += 2; continue; } const end = blockEnd(lines, at, indent); from = at + 1; to = end; indent += 2; } const key = parts[parts.length - 1]; const at = findKey(lines, key, indent, from, to); const block = writeBlock(value, indent + 2); const replacement = block === null ? [`${' '.repeat(indent)}${key}: ${writeScalar(value)}`] : [`${' '.repeat(indent)}${key}:`, ...block.split('\n')]; if (at === -1) { lines.splice(to, 0, ...replacement); return replacement.length; } const end = blockEnd(lines, at, indent); lines.splice(at, end - at, ...replacement); return replacement.length - (end - at); } /** * A definition with some frontmatter keys changed, and nothing else touched. * * `patch` is keyed by dotted path — `name`, `pages`, `ui.source`, * `owliver.capabilities` — and a value of `undefined` leaves that path alone. * `null` writes `null`; to remove a key, pass `REMOVE`. * * The body below the closing `---` is never read and never rewritten. That is * the property that makes this safe to run on every keystroke: an author's * prose, their comments and the order they wrote their keys in all survive a * change to a field they did not write. */ export const REMOVE = Symbol('remove'); export function patchFrontmatter(source, patch = {}) { /* Normalized first, for the same reason the parser is: a fence this does not recognise is a fence it writes a second copy of. */ const raw = normalizeDefinition(source); const match = /^---[ \t]*\n([\s\S]*?)\n---[ \t]*(?=\n|$)/.exec(raw); /* No frontmatter to patch: give the definition one rather than silently dropping the edit. */ if (!match) { const lines = []; for (const [path, value] of Object.entries(patch)) { if (value === undefined || value === REMOVE) continue; setPath(lines, path, value); } return `---\n${lines.join('\n')}\n---\n\n${raw.trim()}\n`; } const lines = match[1].split('\n'); for (const [path, value] of Object.entries(patch)) { if (value === undefined) continue; if (value === REMOVE) { const parts = path.split('.'); const indent = (parts.length - 1) * 2; /* Only a top-level or one-deep key is removable, which is every key the editors own. */ let from = 0; let to = lines.length; if (parts.length > 1) { const parent = findKey(lines, parts[0], 0, 0, lines.length); if (parent === -1) continue; from = parent + 1; to = blockEnd(lines, parent, 0); } const at = findKey(lines, parts[parts.length - 1], indent, from, to); if (at !== -1) lines.splice(at, blockEnd(lines, at, indent) - at); continue; } setPath(lines, path, value); } return `---\n${lines.join('\n')}\n---${raw.slice(match[0].length)}`; } /* ── Writing fields back as one canonical patch ─────────────────────────── */ /** * The frontmatter a set of Board fields means. * * Both composing paths go through this: a fresh draft applies it to an empty * definition, an existing one applies it to the file already open. That is what * makes "typed into the form" and "pasted as Markdown" the same artefact — the * fields have one writer, so there is no second field set that only one path * knows how to express. * * `existing` is the definition being edited, and only two questions are asked of * it: whether its `pages:` are derived from its `ui:` entries, and whether its * `ui:` block is one this form can represent at all. Both are refusals to * overwrite what the fields cannot hold, never a second shape. */ export function boardPatch(fields, { existing = '' } = {}) { const derived = existing ? pagesAreDerived(existing) : false; const editable = existing ? uiIsEditableFromFields(existing) : true; const section = editable ? { 'ui.type': fields.type || undefined, 'ui.placement': fields.placement || undefined, 'ui.title': fields.title || undefined, 'ui.source': fields.source || undefined, /* Periods only where the reading has any. A source that counts nothing over time keeps no period list, so switching to one cannot leave the previous source's windows behind as a block that validates and does nothing. */ 'ui.periods': fields.periods?.length && sourceSupportsOption(fields.source, 'periods') ? fields.periods : REMOVE, } : {}; return { id: fields.id || undefined, name: fields.name || undefined, description: fields.description || undefined, pages: fields.pages?.length && !derived ? fields.pages : undefined, ...section, }; } /** * One capability's response, as the format writes it. * * Returns null when the capability has no source. That is deliberate and is the * point of the whole change: an unconfigured capability is left out of * `responses:` so `normalizeSkillOwliver` reports it by name and * `validateSkillSource` refuses the save. The alternative — inventing a source * to make the block well-formed — is how a definition gets saved reading data * its author never chose. */ function responseFor(fields, capability) { const response = fields.responses?.[capability]; const source = response?.source || ''; if (!source) return null; const entry = { source }; if (response.periods?.length && sourceSupportsOption(source, 'periods')) { entry.periods = response.periods; } if (response.limit && sourceSupportsOption(source, 'limit')) { entry.limit = Number(response.limit); } return entry; } /** * The frontmatter a set of Owliver fields means. * * Every selected capability that has a source is written with its *own* * `source:`, and its own periods or limit where the reading takes them. Nothing * is inherited from a `ui:` block here: this editor owns definitions that have * no `ui:` block at all, and a response that relies on inheriting one silently * loses its reading the day the section is edited. */ export function owliverPatch(fields, { existing = '' } = {}) { const derived = existing ? pagesAreDerived(existing) : false; const responses = {}; for (const capability of fields.capabilities || []) { const response = responseFor(fields, capability); if (response) responses[capability] = response; } return { id: fields.id || undefined, name: fields.name || undefined, description: fields.description || undefined, pages: fields.pages?.length && !derived ? fields.pages : undefined, triggers: fields.triggers?.length ? fields.triggers : REMOVE, 'owliver.enabled': true, 'owliver.suggestions': fields.suggestions?.length ? fields.suggestions : REMOVE, 'owliver.capabilities': fields.capabilities?.length ? fields.capabilities : REMOVE, /* An empty mapping is not a mapping the parser will take — `owliver.responses` must be a mapping of capability names — so nothing configured removes the key rather than writing a header with nothing under it. */ 'owliver.responses': Object.keys(responses).length ? responses : REMOVE, }; } /** * Which selected capabilities are not configured, by name. * * The editor shows these against the capability rather than only as a refusal * on save, so "Summary needs a source" is read where the source is chosen. The * *refusal* is still `validateSkillSource`'s, on the same definition the * registry reads — this only says the same thing earlier. */ export const unconfiguredCapabilities = (fields) => (fields.capabilities || []).filter((capability) => !responseFor(fields, capability));