Packages
noora
0.85.0
0.86.0
0.85.0
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
scripts/generate-web-components.js
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const check = process.argv.includes("--check");
const generatedNotice =
"<!-- Generated by scripts/generate-web-components.js. Do not edit directly. -->";
function fail(message) {
throw new Error(`Invalid web component contract: ${message}`);
}
function validateContract(contract, filename) {
for (const key of [
"name",
"tagName",
"elementClassName",
"className",
"modulePath",
"description",
"usage",
]) {
if (typeof contract[key] !== "string" || contract[key].length === 0) {
fail(`${filename} must define a non-empty ${key}`);
}
}
if (!contract.tagName.includes("-")) {
fail(`${filename} tagName must contain a hyphen`);
}
for (const key of [
"attributes",
"readonlyProperties",
"slots",
"cssParts",
"examples",
]) {
if (!Array.isArray(contract[key])) {
fail(`${filename} must define ${key} as an array`);
}
}
if (
contract.notes !== undefined &&
(!Array.isArray(contract.notes) ||
contract.notes.some((note) => typeof note !== "string"))
) {
fail(`${filename} notes must be an array of strings`);
}
if (
contract.stylingExample !== undefined &&
typeof contract.stylingExample !== "string"
) {
fail(`${filename} stylingExample must be a string`);
}
const attributeNames = new Set();
const propertyNames = new Set();
for (const attribute of contract.attributes) {
if (!attribute.name || !attribute.property || !attribute.description) {
fail(
`${filename} attributes must define name, property, and description`,
);
}
if (!["boolean", "string"].includes(attribute.type)) {
fail(
`${filename} attribute ${attribute.name} has unsupported type ${attribute.type}`,
);
}
if (
attribute.phoenixName !== undefined &&
typeof attribute.phoenixName !== "string"
) {
fail(`${filename} attribute ${attribute.name} has invalid phoenixName`);
}
if (attributeNames.has(attribute.name)) {
fail(`${filename} defines attribute ${attribute.name} more than once`);
}
if (propertyNames.has(attribute.property)) {
fail(`${filename} defines property ${attribute.property} more than once`);
}
if (
attribute.values &&
(!Array.isArray(attribute.values) ||
attribute.values.length === 0 ||
attribute.values.some((value) => typeof value !== "string"))
) {
fail(`${filename} attribute ${attribute.name} has invalid values`);
}
if (
Object.hasOwn(attribute, "default") &&
attribute.values &&
!attribute.values.includes(attribute.default)
) {
fail(
`${filename} attribute ${attribute.name} default is not an allowed value`,
);
}
attributeNames.add(attribute.name);
propertyNames.add(attribute.property);
}
const exampleIds = new Set();
for (const example of contract.examples) {
if (
!example.id ||
!example.title ||
!example.description ||
!example.markup
) {
fail(
`${filename} examples must define id, title, description, and markup`,
);
}
if (exampleIds.has(example.id)) {
fail(`${filename} defines example ${example.id} more than once`);
}
exampleIds.add(example.id);
}
}
function typescriptType(attribute) {
if (attribute.values) {
return attribute.values.map((value) => JSON.stringify(value)).join(" | ");
}
return attribute.type;
}
function typescriptPropertyType(attribute) {
const type = typescriptType(attribute);
return Object.hasOwn(attribute, "default") || attribute.type === "boolean"
? type
: `${type} | undefined`;
}
function markdownCode(value) {
return `\`${String(value).replaceAll("`", "\\`").replaceAll("|", "\\|")}\``;
}
function markdownCell(value) {
return String(value).replaceAll("|", "\\|").replaceAll("\n", " ");
}
function defaultText(attribute) {
if (!Object.hasOwn(attribute, "default")) return "None";
return markdownCode(JSON.stringify(attribute.default));
}
function allowedValuesText(attribute) {
if (attribute.type === "boolean") return markdownCode("boolean");
if (!attribute.values) return markdownCode("string");
return attribute.values.map(markdownCode).join(", ");
}
function renderTypes(contracts) {
const blocks = contracts.map((contract) => {
const slotDocs = contract.slots
.map(
(slot) =>
` * @slot${slot.name ? ` ${slot.name}` : ""} - ${slot.description}`,
)
.join("\n");
const partDocs = contract.cssParts
.map((part) => ` * @csspart ${part.name} - ${part.description}`)
.join("\n");
const publicAttributes = contract.attributes.filter(
(attribute) => attribute.publicProperty !== false,
);
const fields = [
...publicAttributes.map(
(attribute) =>
` /** ${attribute.description} */\n ${attribute.property}: ${typescriptPropertyType(attribute)};`,
),
...contract.readonlyProperties.map(
(property) =>
` /** ${property.description} */\n readonly ${property.name}: ${property.type};`,
),
].join("\n");
return `/**
* ${contract.description}
*
* @element ${contract.tagName}
${slotDocs}
${partDocs}
*/
export class ${contract.elementClassName} extends HTMLElement {
${fields}
}
export function register${contract.elementClassName}(): void;`;
});
const tagNames = contracts
.map(
(contract) => ` "${contract.tagName}": ${contract.elementClassName};`,
)
.join("\n");
return `// Generated by scripts/generate-web-components.js. Do not edit directly.
${blocks.join("\n\n")}
declare global {
interface HTMLElementTagNameMap {
${tagNames}
}
}
`;
}
function manifestAttribute(attribute) {
const result = {
name: attribute.name,
description: attribute.description,
type: { text: typescriptType(attribute) },
};
if (attribute.publicProperty !== false) {
result.fieldName = attribute.property;
}
if (Object.hasOwn(attribute, "default")) {
result.default = JSON.stringify(attribute.default);
}
return result;
}
function manifestMember(attribute) {
const result = {
kind: "field",
name: attribute.property,
description: attribute.description,
type: { text: typescriptPropertyType(attribute) },
attribute: attribute.name,
};
if (Object.hasOwn(attribute, "default")) {
result.default = JSON.stringify(attribute.default);
}
return result;
}
function renderManifest(contracts) {
const contractsByModule = new Map();
for (const contract of contracts) {
const moduleContracts = contractsByModule.get(contract.modulePath) ?? [];
moduleContracts.push(contract);
contractsByModule.set(contract.modulePath, moduleContracts);
}
const modules = Array.from(
contractsByModule,
([modulePath, moduleContracts]) => ({
kind: "javascript-module",
path: modulePath,
declarations: moduleContracts.map((contract) => {
const publicAttributes = contract.attributes.filter(
(attribute) => attribute.publicProperty !== false,
);
return {
kind: "class",
description: contract.description,
name: contract.elementClassName,
tagName: contract.tagName,
customElement: true,
superclass: { name: "LitElement", package: "lit" },
members: [
...publicAttributes.map(manifestMember),
...contract.readonlyProperties.map((property) => ({
kind: "field",
name: property.name,
description: property.description,
type: { text: property.type },
readonly: true,
})),
],
attributes: contract.attributes.map(manifestAttribute),
slots: contract.slots.map((slot) => ({
...(slot.name ? { name: slot.name } : {}),
description: slot.description,
})),
cssParts: contract.cssParts.map((part) => ({
name: part.name,
description: part.description,
})),
};
}),
exports: moduleContracts.flatMap((contract) => [
{
kind: "js",
name: contract.elementClassName,
declaration: {
name: contract.elementClassName,
module: modulePath,
},
},
{
kind: "custom-element-definition",
name: contract.tagName,
declaration: {
name: contract.elementClassName,
module: modulePath,
},
},
]),
}),
);
return `${JSON.stringify(
{
schemaVersion: "1.0.0",
readme: "./README.md",
modules,
},
null,
2,
)}\n`;
}
function renderComponentDocumentation(contract) {
const attributes = contract.attributes
.map(
(attribute) =>
`| ${markdownCode(attribute.name)} | ${allowedValuesText(attribute)} | ${defaultText(attribute)} | ${markdownCell(attribute.description)} |`,
)
.join("\n");
const properties = contract.readonlyProperties
.map(
(property) =>
`| ${markdownCode(property.name)} | ${markdownCode(property.type)} | ${markdownCell(property.description)} |`,
)
.join("\n");
const slots = contract.slots
.map(
(slot) =>
`| ${markdownCode(slot.name || "default")} | ${markdownCell(slot.description)} |`,
)
.join("\n");
const parts = contract.cssParts
.map(
(part) =>
`| ${markdownCode(part.name)} | ${markdownCell(part.description)} |`,
)
.join("\n");
const examples = contract.examples
.map(
(example) => `## ${example.title}
${example.description}
\`\`\`html
${example.markup}
\`\`\``,
)
.join("\n\n");
const propertiesSection =
contract.readonlyProperties.length === 0
? ""
: `
## Read-only properties
| Property | Type | Description |
| --- | --- | --- |
${properties}
`;
const slotsSection =
contract.slots.length === 0
? ""
: `
## Slots
| Slot | Description |
| --- | --- |
${slots}
`;
const partsSection =
contract.cssParts.length === 0
? ""
: `
## Styling parts
| Part | Description |
| --- | --- |
${parts}
`;
const stylingExample = contract.stylingExample
? `
Use the standard \`::part()\` selector when an application needs a targeted override:
\`\`\`css
${contract.stylingExample}
\`\`\`
`
: "";
const notes = (contract.notes ?? []).join("\n\n");
return `${generatedNotice}
# ${contract.name}
${contract.description}
\`\`\`html
${contract.usage}
\`\`\`
## Attributes
| Attribute | Type or allowed values | Default | Description |
| --- | --- | --- | --- |
${attributes}
${propertiesSection}${slotsSection}${partsSection}${stylingExample}
${notes}
${examples}
`;
}
function renderIndexDocumentation(contracts) {
const components = contracts
.map(
(contract) =>
`- [${contract.name}](./components/${contract.name.toLowerCase()}.md): ${contract.description}`,
)
.join("\n");
return `${generatedNotice}
# Noora web components
Install the public package from the npm package registry:
\`\`\`sh
npm install @tuist/noora
\`\`\`
Import the design tokens and the component registration bundle once in the browser entry point:
\`\`\`javascript
import "@tuist/noora/tokens.css";
import "@tuist/noora/web-components";
\`\`\`
The registration bundle defines every published Noora custom element. The package also includes \`custom-elements.json\` for tools that support the Custom Elements Manifest format and TypeScript declarations for the public element properties.
Component contracts are consumed during the build. Applications do not fetch the contract files at runtime.
## Components
${components}
`;
}
async function loadContracts() {
const componentsDirectory = path.join(root, "components");
const filenames = (await readdir(componentsDirectory))
.filter((filename) => filename.endsWith(".json"))
.sort();
return Promise.all(
filenames.map(async (filename) => {
const contract = JSON.parse(
await readFile(path.join(componentsDirectory, filename), "utf8"),
);
validateContract(contract, filename);
return contract;
}),
);
}
async function emit(relativePath, content) {
const absolutePath = path.join(root, relativePath);
if (check) {
let existing;
try {
existing = await readFile(absolutePath, "utf8");
} catch {
process.stderr.write(`${relativePath} is missing\n`);
process.exitCode = 1;
return;
}
if (existing !== content) {
process.stderr.write(`${relativePath} is stale\n`);
process.exitCode = 1;
}
return;
}
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, content);
}
const contracts = await loadContracts();
await emit("types/web-components.d.ts", renderTypes(contracts));
await emit("custom-elements.json", renderManifest(contracts));
await emit("docs/web-components.md", renderIndexDocumentation(contracts));
for (const contract of contracts) {
await emit(
`docs/components/${contract.name.toLowerCase()}.md`,
renderComponentDocumentation(contract),
);
}