/** * A skill's declared section, as a node in the page tree. * * This is the join between the two halves of the UI system. A Board skill's * `ui:` block is still parsed by `uiConfig.normalizeSection`, still validated * against the closed vocabulary, and still drawn by the same nine components — * nothing about definitions changes. What this adds is *identity*: the section * becomes an addressable node, so it can be hidden, moved and reordered by the * same operations that move a built-in. * * **Nothing here writes back to Markdown.** A node carries `origin: 'skill'`, * and a change to it is stored as an operation in the person's own layout * patch. The definition on disk, and the definition in the account's custom * skills, are read-only from here — which is what keeps a layout preference * from silently editing something another user also sees. */ import { dataSourceFor, sourceSupportsOption } from '@/lib/skills/surfaces'; import { makeNode } from './node'; /** Ids are `skill--
`, so provenance is legible in the DOM. */ export const skillNodeId = (skillId, sectionId) => [ 'skill', slug(skillId), slug(sectionId), ].filter(Boolean).join('-'); const slug = (value) => String(value || '') .toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); /** * One normalized section, adapted. * * Only options the source actually declares are carried across. The skill * format checks that a period is a real period; this checks that the reading * takes periods at all — a stricter rule, and one a definition written against * the looser one could fail. Dropping the option is right where refusing the * node would not be: a Board card must never disappear because of a parameter * that was doing nothing anyway. */ export function skillSectionNode(skill, section) { const params = {}; if (section.periods?.length && sourceSupportsOption(section.source, 'periods')) { params.periods = [...section.periods]; } if (section.limit && sourceSupportsOption(section.source, 'limit')) { params.limit = section.limit; } return makeNode({ id: skillNodeId(skill.id, section.id), type: section.type, origin: 'skill', data: { source: section.source, params }, props: { /* The same fallback the surface uses, so a section with no title of its own is still named by the skill that contributed it. */ title: section.title || skill.name, ...(section.description ? { description: section.description } : {}), /* Attribution is what lets a reader tell an extension from a built-in panel, and which skill to switch off. */ attribution: skill.name, ...(section.editable ? { editable: true } : {}), }, }); } /** * Every section a page's definitions contribute, grouped by placement. * * Grouped because that is how the composition consumes them: each * `skill-surface` node names one placement and takes the sections that declared * it. A placement no slot offers simply has nowhere to render, which is the * same outcome as today. */ export function skillNodesByPlacement(sections = []) { const out = {}; for (const { skill, section } of sections) { if (!dataSourceFor(section.source)) continue; const key = section.placement || ''; if (!out[key]) out[key] = []; out[key].push(skillSectionNode(skill, section)); } return out; }