Files
doormilxpress_astryx/src/lib/skills/uiConfig.js
2026-08-14 17:33:16 +05:30

294 lines
11 KiB
JavaScript

import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
} from './surfaces';
/**
* 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 slug = (value) => String(value || '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
const id = slug(raw.id) || slug(raw.title) || slug(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);
const type = String(raw.type || '').trim();
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();
placement = declaredPlacement || surface.placements[0];
if (!surface.placements.includes(placement)) {
errors.push(
`Unsupported placement: ${placement} 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 = String(raw.data?.source ?? raw.source ?? '').trim();
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.
*/
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' || Array.isArray(rawUi)) {
return { ui, errors: ['`ui` must be a section, or a mapping of page names to sections.'] };
}
const pages = declaredPages.map(canonicalPage).filter(Boolean);
/* 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);