update Markdown skills
This commit is contained in:
406
src/lib/skills/skillFields.js
Normal file
406
src/lib/skills/skillFields.js
Normal file
@@ -0,0 +1,406 @@
|
||||
import { hasFrontmatter, normalizeDefinition, parseFrontmatter, parseSkill } from './registry';
|
||||
|
||||
/**
|
||||
* 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: '', source: 'candidates.activity', periods: [],
|
||||
};
|
||||
|
||||
/** The Owliver editor's fields, before a definition is loaded into them. */
|
||||
export const EMPTY_OWLIVER_FIELDS = {
|
||||
id: '', name: '', description: '', pages: [],
|
||||
triggers: [], suggestions: [], capabilities: [], source: '', periods: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 || '',
|
||||
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');
|
||||
const first = skill.owliver.capabilities[0];
|
||||
const response = first ? skill.owliver.responses[first] : 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: skill.owliver.capabilities,
|
||||
source: response?.source || '',
|
||||
periods: response?.periods || [],
|
||||
};
|
||||
} 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;
|
||||
}
|
||||
|
||||
/** The index of `key` at `indent` within `[from, to)`, or -1. */
|
||||
function findKey(lines, key, indent, from, to) {
|
||||
const pattern = new RegExp(`^${' '.repeat(indent)}${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*:`);
|
||||
for (let i = from; i < to; i += 1) {
|
||||
if (pattern.test(lines[i])) 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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user