import { SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES, SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, placementFor, surfaceFor, } from './surfaces'; /** * A name, as an id. * * Exported because two places need the same rule and were carrying their own: * a section falling back to its title, and a definition falling back to its * name when it declares no `id:`. Two slug functions that agree today is a * definition whose id changes the day they stop agreeing. */ export const slugify = (value) => String(value || '') .toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); /** * The `ui:` block of a skill definition, checked and normalized. * * A definition declares what it wants; this decides whether the product can * honour it, and turns a loose YAML shape into one predictable record. Two * things follow from doing it here rather than in the renderer: * * - **Nothing unknown reaches a component.** Every page, placement, type, * data source and period is checked against the closed vocabulary in * `surfaces.js`. An unrecognised value is an error with a message naming * it, not a silently dropped key and not a rendered blank. * - **Only listed keys survive.** The normalized section carries exactly the * fields the renderers read. Anything else an author writes is ignored * rather than passed through, so no property can arrive at React that this * module did not put there. */ /** * A section as the renderers receive it. Nothing else is carried. * * Used by both consumers of a definition. The page passes a `page`, so the * section is checked against that surface's placements; Owliver passes * `placement: false`, because a chat reply has no placement to sit at — the * rest of the checks, and the record that comes out, are identical. That is * deliberate: it is what makes a flow drawn in the panel the same section as * the flow drawn on the page rather than a parallel shape that resembles it. * * `types` narrows what `type:` may be. The page offers the components it can * mount; Owliver offers those plus the shapes that are only answers. */ export function normalizeSection(raw, { page, errors, seen, fallbackId = '', where: label = null, placement: wantPlacement = true, types = SUPPORTED_SECTION_TYPES, shapeFor = (type) => type, }) { const where = label || `ui.${page}`; /* Where an error points. A page section is addressed by the id it was given; a capability response is already addressed by the capability it answers, so appending a generated section id there would name something the author never wrote. */ const at = label ? where : null; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { errors.push(`${where}: each section must be a mapping of options.`); return null; } /* An id is how a section is keyed and de-duplicated, not something an author should have to invent for a definition that declares exactly one. Falls back to the title, then to the skill's own id. */ const id = slugify(raw.id) || slugify(raw.title) || slugify(fallbackId); if (!id) { errors.push(`${where}: a section needs an \`id\`.`); return null; } if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) { errors.push(`${at || `${where}.${id}`}: \`id\` must be lower-case letters, numbers and dashes.`); return null; } if (seen.has(id)) { errors.push(`${where}: two sections share the id \`${id}\`.`); return null; } seen.add(id); /** * The shape this section is drawn with. * * Inferred from the source when it is not written, the same way a placement * left out is filled in from the surface. A source declares the shapes it can * fill, in the order they suit it, so the first is the reading its author * would have chosen — and a definition naming a page, a placement and a * source has already said everything that needs saying. Refusing it for the * one field it can derive would be the format asking for ceremony. */ const declaredSource = String(raw.data?.source ?? raw.source ?? '').trim(); const type = String(raw.type || '').trim() || dataSourceFor(declaredSource)?.shapes?.find((shape) => types.includes(shape)) || ''; if (!type) { errors.push(`${at || `${where}.${id}`}: a section needs a \`type\`.`); return null; } if (!types.includes(type)) { errors.push( `Unsupported skill component: ${type}. Supported types: ${types.join(', ')}.` ); return null; } /* `position:` is what the format calls it; `placement:` is accepted because it is the word the rest of the system uses. A section that is an answer rather than a panel has nowhere to be placed, and says so with `null`. */ let placement = null; if (wantPlacement) { const surface = surfaceFor(page); const declaredPlacement = String(raw.position || raw.placement || '').trim(); /* `placementFor` reads the surface's own list and the alias table, so `grid-card` and `after-position-card` are one placement written two ways and neither resolves to something the surface does not offer. */ placement = declaredPlacement ? placementFor(page, declaredPlacement) : surface.placements[0]; if (!placement) { errors.push( `Unsupported placement: ${declaredPlacement} on ${page}. ` + `Supported placements: ${surface.placements.join(', ')}.` ); return null; } } /* `data.source:` and a flat `source:` mean the same thing. The nested form groups options when a section grows; the flat form is what a one-section definition actually reads like, and refusing it would be the format being precious about punctuation. */ const source = declaredSource; if (!source) { errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`); return null; } if (!SUPPORTED_DATA_SOURCES.includes(source)) { errors.push( `Unsupported data source: ${source}. Supported sources: ${SUPPORTED_DATA_SOURCES.join(', ')}.` ); return null; } /* A source knows which shapes it can fill. Asking for a timeline of something that has no dates is an authoring mistake worth naming now rather than rendering as an empty panel later. */ const definition = dataSourceFor(source); /* `shapeFor` is how a type that is not a drawn component is exempted: a summary is prose, so there is no shape for a source to be incompatible with. Every drawn type maps to itself. */ const shape = shapeFor(type); if (shape && definition.shapes && !definition.shapes.includes(shape)) { errors.push( `${at || `${where}.${id}`}: \`${source}\` cannot be shown as \`${type}\`. It supports: ${definition.shapes.join(', ')}.` ); return null; } const periods = Array.isArray(raw.periods) ? raw.periods.map((p) => String(p).trim()) : []; const unknownPeriod = periods.find((p) => !SUPPORTED_PERIODS.includes(p)); if (unknownPeriod) { errors.push( `Unsupported period: ${unknownPeriod}. Supported periods: ${SUPPORTED_PERIODS.join(', ')}.` ); return null; } const limit = Number(raw.limit); /** * Whether this section offers controls rather than a read-out. * * Two independent things must agree before anything is editable, and this is * the first: the definition asking for it, and the source declaring that it * can be written at all. The second is the page publishing a handler for that * source at render time. A definition asking to edit a reading the product * does not expose for writing is an authoring mistake worth naming here, * rather than a control that silently does nothing. */ const editable = raw.editable === true || raw.editable === 'true'; if (editable && !isSourceWritable(source)) { errors.push( `${at || `${where}.${id}`}: \`${source}\` cannot be edited. It is a reading, not a setting.` ); return null; } return { id, title: String(raw.title || '').trim() || null, description: String(raw.description || '').trim() || null, type, placement, source, /* Declared intent only. A section stays read-only wherever no page offers to accept the write — see `SkillSurface` and `SkillSectionBlock`. */ editable, /* Context the page must supply for this source to resolve. */ context: definition.context, periods, limit: Number.isFinite(limit) && limit > 0 ? Math.min(50, Math.round(limit)) : null, }; } /** * The keys that mean "this object *is* a section". * * How the two shapes are told apart. A definition may write its UI either way: * * ui: ui: * type: flow positions: * placement: … sections: * source: … - type: flow * * The first is one section applying to every page the skill declares; the * second addresses pages by name and can differ per page. Both are legitimate, * and the difference is structural — an object carrying `type` or `source` is a * section, an object whose keys are page names is a page map. Nothing is * decided by a skill id, and neither shape is privileged. * * There is a third, and it is the one people actually write: * * ui: * - page: Positions * placement: grid-card * source: position.activity * - page: Analytics * placement: panel * source: hires.performance * * A list of sections, each naming its own page. It reads the way the thing * reads — "this skill puts this here, and that there" — and it was the one * shape the parser refused, with a single error that took the whole block down * and left the definition declaring no pages at all. Several entries may name * the same page; they become several sections on it, in the order written. */ const SECTION_KEYS = new Set([ 'id', 'type', 'title', 'description', 'placement', 'position', 'data', 'source', 'periods', 'limit', 'editable', ]); const looksLikeSection = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).some((key) => SECTION_KEYS.has(key)); /** The sections a page entry declares, in either the list or single-section form. */ function sectionsOf(config) { if (Array.isArray(config?.sections)) return config.sections; if (Array.isArray(config)) return config; if (looksLikeSection(config)) return [config]; return null; } /** * The whole `ui:` block, normalized per page. * * Returns `{ ui, errors }`. `ui` holds only what validated, so a definition with * one bad section still registers its good ones — and the author still sees why * the other was refused. */ export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {}) { const errors = []; const ui = {}; if (rawUi == null) return { ui, errors }; if (typeof rawUi !== 'object') { return { ui, errors: ['`ui` must be a section, a list of sections, or a mapping of page names to sections.'], }; } const pages = declaredPages.map(canonicalPage).filter(Boolean); /** * A list, where every entry names the page it belongs to. * * Grouped rather than keyed, so two entries naming one page are two sections * on it rather than the second quietly replacing the first — which is what a * page map would have done. `seen` is shared across the whole list because * section ids are unique per definition, not per page. */ if (Array.isArray(rawUi)) { const seen = new Set(); rawUi.forEach((entry, index) => { const where = `ui[${index}]`; if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { errors.push(`${where}: each entry must be a mapping of options.`); return; } const rawPage = entry.page ?? entry.pages ?? entry.surface; if (rawPage == null || Array.isArray(rawPage)) { errors.push(`${where}: an entry needs a \`page\`.`); return; } const page = canonicalPage(rawPage); if (!page) { errors.push( `Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.` ); return; } /* Declared `pages:` still governs reach when it is written: a definition may not render onto a surface it did not say it applied to. When it is not written, the list is the declaration — see `parseSkill`. */ if (pages.length && !pages.includes(page)) { errors.push(`\`${where}\` names \`${rawPage}\`, which is not listed under \`pages\`.`); return; } const section = normalizeSection(entry, { page, errors, seen, fallbackId: `${skillId}-${index + 1}`, where, }); if (!section) return; if (!ui[page]) ui[page] = { sections: [] }; ui[page].sections.push(section); }); return { ui, errors }; } /* Shorthand: one section, applied to every page the skill declares. The keys inside it are section options and are never read as page names — which is exactly what this branch exists to prevent. */ if (looksLikeSection(rawUi)) { if (!pages.length) { return { ui, errors: ['`ui` is configured but the skill declares no `pages`.'] }; } for (const page of pages) { const section = normalizeSection(rawUi, { page, errors, seen: new Set(), fallbackId: skillId, }); if (section) ui[page] = { sections: [section] }; } return { ui, errors }; } /* Otherwise every key is a page name. */ for (const [rawPage, config] of Object.entries(rawUi)) { const page = canonicalPage(rawPage); if (!page) { errors.push( `Unsupported page: ${rawPage}. Supported pages: ${SUPPORTED_SKILL_PAGES.join(', ')}.` ); continue; } /* A page cannot be extended unless the skill also declares it. Otherwise a definition could render onto a surface it never said it applied to. */ if (pages.length && !pages.includes(page)) { errors.push(`\`ui.${rawPage}\` is configured but \`${rawPage}\` is not listed under \`pages\`.`); continue; } const rawSections = sectionsOf(config); if (!rawSections) { errors.push(`ui.${rawPage}: expected a \`sections\` list, or a single section.`); continue; } const seen = new Set(); const sections = rawSections .map((section) => normalizeSection(section, { page: rawPage, errors, seen, fallbackId: skillId, })) .filter(Boolean); if (sections.length) ui[page] = { sections }; } return { ui, errors }; } /** Every section this skill contributes to a page, in declaration order. */ export const sectionsForPage = (skill, page) => { const key = canonicalPage(page); return key ? skill?.ui?.[key]?.sections || [] : []; }; /** How many UI sections a definition registers, across every page. */ export const countSections = (skill) => Object.values(skill?.ui || {}).reduce((n, page) => n + (page.sections?.length || 0), 0);