import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; function fail(message) { throw new Error(message); } function blockBody(source, marker) { const start = source.indexOf(marker); if (start === -1) { fail(`Missing ${marker}`); } const open = source.indexOf("{", start); let depth = 0; for (let index = open; index < source.length; index++) { if (source[index] === "{") { depth++; } else if (source[index] === "}") { depth--; if (depth === 0) { return source.slice(open + 1, index); } } } fail(`Unclosed ${marker}`); } function objectKeys(body) { return [...body.matchAll(/^\t\t(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s/gm)].map((match) => match[1] || match[2] || match[3]); } function stringArray(source, name) { const match = source.match(new RegExp(`(?:const|static) ${name} = \\[([^\\]]+)\\]`)); if (!match) { fail(`Missing ${name}`); } return JSON.parse(`[${match[1]}]`); } function detailAliases(source) { const body = blockBody(source, "const DETAIL_ALIASES = {"); const aliases = {}; for (const line of body.split("\n")) { const match = line.match(/^\t\t(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+)):\s*"([^"]+)"/); if (match) { aliases[match[1] || match[2] || match[3]] = match[4]; } } return aliases; } function readmeMetadata(readme, tag) { const attributes = {}; const tableStart = readme.indexOf("| Attribute |"); if (tableStart !== -1) { let started = false; for (const line of readme.slice(tableStart).split("\n")) { if (!line.startsWith("|")) { if (started) { break; } continue; } const cells = line.split("|").map((cell) => cell.trim()); const name = cells[1]?.replace(/`/g, ""); if (!name || name === "Attribute" || /^-+$/.test(cells[1])) { continue; } started = true; attributes[name] = { required: cells[2] === "Yes", description: cells[3] || "", values: cells[4] || "", }; } } const example = {}; const exampleMatch = readme.match(new RegExp(`<${tag}\\s+([^>]+)>`)); if (exampleMatch) { for (const match of exampleMatch[1].matchAll(/([a-z-]+)="([^"]*)"/g)) { example[match[1]] = match[2]; } } return { attributes, example }; } function tokens(values) { return [...values.matchAll(/`([^`]+)`/g)].map((match) => match[1]); } function defaultToken(values) { return values.match(/`([^`]+)`\s*\(default\)/i)?.[1] || null; } function humanize(name) { return name .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); } function fieldLabel(ui, name) { return ui.labels?.[name] || humanize(name); } function selectOptions(ui, values) { const defaultValue = defaultToken(values); const options = []; if (!defaultValue) { options.push({ label: "Auto-detect", value: "" }); } for (const token of tokens(values)) { options.push({ label: ui.selectLabels?.[token] || humanize(token), value: token === defaultValue ? "" : token, }); } return options.filter((option, index) => options.findIndex((candidate) => candidate.value === option.value) === index); } function buildField(ui, attribute, meta, example, required, details, aliases) { const values = meta?.values || ""; const valueTokens = tokens(values); const isDetails = attribute === "details"; const isSelect = !isDetails && valueTokens.length >= 2; const field = { name: attribute, label: fieldLabel(ui, attribute), type: isDetails ? "details" : isSelect ? "select" : "text", help: meta?.description || "", required: attribute === required, }; if (isDetails) { field.customDefault = details.defaults.join(","); field.available = details.available; field.aliases = aliases; } else if (isSelect) { field.options = selectOptions(ui, values); } else { const placeholder = example[attribute] || valueTokens[0] || ui.placeholders?.[attribute] || ""; if (placeholder) { field.placeholder = placeholder; } } if (!field.help) { delete field.help; } return field; } export async function loadComponentMetadata({ require, ui, components }) { const output = []; for (const name of components) { const packagePath = require.resolve(`${name}/package.json`); const packageDir = dirname(packagePath); const pkg = JSON.parse(await readFile(packagePath, "utf8")); if (pkg.version === "0.0.0") { fail(`${name}@0.0.0 is a placeholder on npm. Use the local package until it is published.`); } const file = pkg.main || `${name}.js`; const entry = join(packageDir, file); if (!existsSync(entry)) { fail(`Missing entrypoint for ${name}: ${entry}`); } const source = await readFile(entry, "utf8"); const readmePath = join(packageDir, "README.md"); const readme = existsSync(readmePath) ? await readFile(readmePath, "utf8") : ""; const tag = source.match(/customElements\.define\(\s*["']([^"']+)["']/)?.[1]; if (!tag) { fail(`Could not find the custom element tag in ${name}`); } const observed = stringArray(source, "observedAttributes"); const details = { available: objectKeys(blockBody(source, "const detailRenderers = {")), defaults: stringArray(source, "DEFAULT_DETAILS"), }; const aliases = detailAliases(source); const metadata = readmeMetadata(readme, tag); if (!Object.keys(metadata.attributes).length) { console.warn(`Warning: no attribute metadata found in ${name}/README.md`); } const required = Object.keys(metadata.attributes).find((attribute) => metadata.attributes[attribute].required) || observed[0]; const presentation = ui.components?.[name] || { title: `${humanize(name.replace(/-embed$/, ""))} Card`, description: pkg.description || "", icon: "editor-code", emdashIcon: "code", keywords: [], }; if (!details.available.length || !details.defaults.length) { fail(`Could not read details metadata from ${name}`); } const attributes = Object.fromEntries(observed.map((attribute) => [attribute, { type: "string", default: "" }])); const fields = observed.map((attribute) => buildField(ui, attribute, metadata.attributes[attribute], metadata.example, required, details, aliases)); const license = ["LICENSE", "LICENSE.md", "LICENSE.txt"].map((file) => join(packageDir, file)).find((file) => existsSync(file)); output.push({ package: name, tag, file, entry, packageDir, license, version: pkg.version, title: presentation.title, description: presentation.description, icon: presentation.icon || "editor-code", emdashIcon: presentation.emdashIcon || "code", keywords: presentation.keywords || [], required, attributes, fields, details, aliases, }); } return output; }