update Markdown skills

This commit is contained in:
2026-08-19 17:36:27 +05:30
parent aa2fb41871
commit d3f7f439f6
30 changed files with 2341 additions and 221 deletions

View File

@@ -1,8 +1,20 @@
import {
SUPPORTED_DATA_SOURCES, SUPPORTED_PERIODS, SUPPORTED_SECTION_TYPES,
SUPPORTED_SKILL_PAGES, canonicalPage, dataSourceFor, isSourceWritable, surfaceFor,
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.
*
@@ -53,9 +65,7 @@ export function normalizeSection(raw, {
/* 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);
const id = slugify(raw.id) || slugify(raw.title) || slugify(fallbackId);
if (!id) {
errors.push(`${where}: a section needs an \`id\`.`);
return null;
@@ -70,7 +80,20 @@ export function normalizeSection(raw, {
}
seen.add(id);
const type = String(raw.type || '').trim();
/**
* 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;
@@ -89,10 +112,16 @@ export function normalizeSection(raw, {
if (wantPlacement) {
const surface = surfaceFor(page);
const declaredPlacement = String(raw.position || raw.placement || '').trim();
placement = declaredPlacement || surface.placements[0];
if (!surface.placements.includes(placement)) {
/* `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: ${placement} on ${page}. Supported placements: ${surface.placements.join(', ')}.`
`Unsupported placement: ${declaredPlacement} on ${page}. `
+ `Supported placements: ${surface.placements.join(', ')}.`
);
return null;
}
@@ -102,7 +131,7 @@ export function normalizeSection(raw, {
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();
const source = declaredSource;
if (!source) {
errors.push(`${at || `${where}.${id}`}: a section needs \`data.source\`.`);
return null;
@@ -190,6 +219,22 @@ export function normalizeSection(raw, {
* 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',
@@ -223,12 +268,68 @@ export function normalizeSkillUi(rawUi, { declaredPages = [], skillId = '' } = {
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.'] };
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. */