Files
doormilxpress_astryx/src/lib/skills/uiConfig.js
2026-08-19 17:36:27 +05:30

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);