395 lines
15 KiB
JavaScript
395 lines
15 KiB
JavaScript
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);
|