Compare commits

...

6 Commits

Author SHA1 Message Date
Wendelin
75e55d9757 Remove customElements entry from package.json 2026-02-09 13:23:44 +01:00
Wendelin
58f035b028 Add API documentation generation and display components 2026-02-09 13:23:27 +01:00
Wendelin
bf9fec5a81 update dependency 2026-02-04 15:42:45 +01:00
Wendelin
040585f693 Add cli to package.json 2026-02-04 15:36:59 +01:00
Wendelin
a6b031358c Code more like cli 2026-02-04 15:36:59 +01:00
Wendelin
2b0a6d7964 Add first auto doc test 2026-02-04 15:36:59 +01:00
8 changed files with 2057 additions and 24 deletions

View File

@@ -5,6 +5,7 @@ import yaml from "js-yaml";
import { marked } from "marked";
import path from "path";
import paths from "../paths.cjs";
import { generateComponentApiMarkdown } from "./gallery/api-docs.js";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
@@ -39,11 +40,19 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
const demoFile = path.resolve(pageDir, `${pageId}.ts`);
const descriptionFile = path.resolve(pageDir, `${pageId}.markdown`);
const componentFile = path.resolve(
"src/components",
`${path.basename(pageId)}.ts`
);
const hasDemo = fs.existsSync(demoFile);
let hasDescription = fs.existsSync(descriptionFile);
let hasApiDocs = false;
let metadata = {};
let descriptionContent = "";
let apiDocsContent = "";
if (hasDescription) {
let descriptionContent = fs.readFileSync(descriptionFile, "utf-8");
descriptionContent = fs.readFileSync(descriptionFile, "utf-8");
if (descriptionContent.startsWith("---")) {
const metadataEnd = descriptionContent.indexOf("---", 3);
@@ -52,22 +61,45 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
.substring(metadataEnd + 3)
.trim();
}
}
// If description is just metadata
if (descriptionContent === "") {
hasDescription = false;
} else {
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
fs.writeFileSync(
path.resolve(galleryBuild, `${pageId}-description.ts`),
`
import {html} from "lit";
export default html\`${descriptionContent}\`
`
if (fs.existsSync(componentFile)) {
// eslint-disable-next-line no-await-in-loop
const apiDocsMarkdown = await generateComponentApiMarkdown(componentFile);
if (apiDocsMarkdown) {
hasApiDocs = true;
apiDocsContent = marked(`## API docs\n\n${apiDocsMarkdown}`).replace(
/`/g,
"\\`"
);
}
}
if (descriptionContent === "") {
hasDescription = false;
} else {
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
fs.writeFileSync(
path.resolve(galleryBuild, `${pageId}-description.ts`),
`
import {html} from "lit";
export default html\`${descriptionContent}\`
`
);
}
if (hasApiDocs) {
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
fs.writeFileSync(
path.resolve(galleryBuild, `${pageId}-api-docs.ts`),
`
import {html} from "lit";
export default html\`${apiDocsContent}\`
`
);
}
content += ` "${pageId}": {
metadata: ${JSON.stringify(metadata)},
${
@@ -75,6 +107,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
? `description: () => import("./${pageId}-description").then(m => m.default),`
: ""
}
${hasApiDocs ? `apiDocs: () => import("./${pageId}-api-docs").then(m => m.default),` : ""}
${hasDemo ? `demo: () => import("../src/pages/${pageId}")` : ""}
},\n`;

View File

@@ -0,0 +1,292 @@
import { cli as analyzeCustomElements } from "@custom-elements-manifest/analyzer/cli.js";
import path from "path";
const toCamelCase = (value) =>
value.replace(/-([a-z])/g, (_match, char) => char.toUpperCase());
const mdCode = (value) => {
if (value === undefined || value === null || value === "") {
return "";
}
return `\`${String(value).replace(/`/g, "\\`")}\``;
};
const mdText = (value) =>
String(value || "")
.replace(/\|/g, "\\|")
.replace(/\r?\n+/g, "<br>")
.trim();
const markdownTable = (headers, rows) => {
if (!rows.length) {
return "";
}
const header = `| ${headers.join(" | ")} |`;
const separator = `| ${headers.map(() => "---").join(" | ")} |`;
const body = rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
return `${header}\n${separator}\n${body}`;
};
const mergeAttributesIntoFields = (manifest) => {
if (!manifest?.modules) {
return manifest;
}
for (const mod of manifest.modules) {
if (!mod.declarations) {
continue;
}
for (const declaration of mod.declarations) {
if (!declaration.attributes?.length) {
continue;
}
declaration.members = declaration.members || [];
const memberNames = new Set(
declaration.members.map((member) => member.name)
);
const membersByName = new Map(
declaration.members.map((member) => [member.name, member])
);
declaration.attributes = declaration.attributes.map((attribute) => {
if (attribute.fieldName) {
return attribute;
}
const camelName = toCamelCase(attribute.name);
let inferredFieldName;
if (memberNames.has(camelName)) {
inferredFieldName = camelName;
} else if (memberNames.has(attribute.name)) {
inferredFieldName = attribute.name;
}
return inferredFieldName
? { ...attribute, fieldName: inferredFieldName }
: attribute;
});
for (const attribute of declaration.attributes) {
if (!attribute.fieldName) {
continue;
}
const existingMember = membersByName.get(attribute.fieldName);
if (existingMember) {
if (!existingMember.attribute) {
existingMember.attribute = attribute.name;
}
continue;
}
const newMember = {
kind: "field",
name: attribute.fieldName,
privacy: "public",
type: attribute.type,
default: attribute.default,
description: attribute.description,
attribute: attribute.name,
};
declaration.members.push(newMember);
membersByName.set(attribute.fieldName, newMember);
memberNames.add(attribute.fieldName);
}
}
}
return manifest;
};
const formatType = (type) => {
if (!type) {
return "";
}
if (typeof type === "string") {
return type;
}
if (type.text) {
return type.text;
}
return "";
};
const getWebAwesomeSuperclassDocsUrl = (superclass) => {
const packageName = superclass?.package || "";
if (!packageName.startsWith("@home-assistant/webawesome")) {
return undefined;
}
const match = packageName.match(/components\/([^/]+)/);
if (match?.[1]) {
return `https://webawesome.com/docs/components/${match[1]}`;
}
return "https://webawesome.com/docs/components/";
};
const renderComponentApiMarkdown = (manifest) => {
if (!manifest?.modules?.length) {
return "";
}
const sections = [];
for (const mod of manifest.modules) {
for (const declaration of mod.declarations || []) {
if (declaration.kind !== "class") {
continue;
}
const classHeading = declaration.tagName
? `### ${mdCode(declaration.tagName)}`
: `### ${mdCode(declaration.name)}`;
sections.push(classHeading);
if (declaration.description) {
sections.push("#### Description");
sections.push(mdText(declaration.description));
}
const properties = (declaration.members || [])
.filter(
(member) => member.kind === "field" && member.privacy !== "private"
)
.map((member) => [
mdCode(member.name),
mdText(member.attribute || ""),
mdCode(formatType(member.type) || ""),
mdCode(member.default || ""),
mdText(member.description || ""),
]);
const propertiesTable = markdownTable(
["Name", "Attribute", "Type", "Default", "Description"],
properties
);
if (propertiesTable) {
sections.push("#### Properties");
sections.push(propertiesTable);
}
const events = (declaration.events || []).map((event) => [
mdCode(event.name),
mdCode(formatType(event.type) || ""),
mdText(event.description || ""),
]);
const eventsTable = markdownTable(
["Name", "Type", "Description"],
events
);
if (eventsTable) {
sections.push("#### Events");
sections.push(eventsTable);
}
const cssProperties = (declaration.cssProperties || []).map(
(property) => [
mdCode(property.name),
mdCode(property.default || ""),
mdText(property.description || ""),
]
);
const cssPropertiesTable = markdownTable(
["Name", "Default", "Description"],
cssProperties
);
if (cssPropertiesTable) {
sections.push("#### CSS custom properties");
sections.push(
"[How to use CSS custom properties](https://developer.mozilla.org/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties)"
);
sections.push(cssPropertiesTable);
}
const cssParts = (declaration.cssParts || []).map((part) => [
mdCode(part.name),
mdText(part.description || ""),
]);
const cssPartsTable = markdownTable(["Name", "Description"], cssParts);
if (cssPartsTable) {
sections.push("#### CSS shadow parts");
sections.push(
"[How to style shadow parts with ::part()](https://developer.mozilla.org/docs/Web/CSS/::part)"
);
sections.push(cssPartsTable);
}
const slots = (declaration.slots || []).map((slot) => [
mdCode(slot.name || "(default)"),
slot.name ? "no" : "yes",
mdText(slot.description || ""),
]);
const slotsTable = markdownTable(
["Name", "Default", "Description"],
slots
);
if (slotsTable) {
sections.push("#### Slots");
sections.push(slotsTable);
}
sections.push("#### Class");
sections.push(
markdownTable(
["Name", "Tag name"],
[[mdCode(declaration.name), mdCode(declaration.tagName || "")]]
)
);
if (declaration.superclass?.name) {
const docsUrl = getWebAwesomeSuperclassDocsUrl(declaration.superclass);
const notes = docsUrl ? `[Web Awesome docs](${docsUrl})` : "";
sections.push("#### Superclass");
sections.push(
markdownTable(
["Name", "Package", "Docs"],
[
[
mdCode(declaration.superclass.name),
mdText(declaration.superclass.package || ""),
notes,
],
]
)
);
}
}
}
return sections.filter(Boolean).join("\n\n").trim();
};
export const generateComponentApiMarkdown = async (componentFile) => {
const manifest = await analyzeCustomElements({
argv: [
"analyze",
"--litelement",
"--globs",
path.relative(process.cwd(), componentFile),
"--quiet",
],
cwd: process.cwd(),
noWrite: true,
});
mergeAttributesIntoFields(manifest);
return renderComponentApiMarkdown(manifest);
};

186
custom-elements.json Normal file
View File

@@ -0,0 +1,186 @@
{
"schemaVersion": "1.0.0",
"readme": "",
"modules": [
{
"kind": "javascript-module",
"path": "src/components/ha-alert.ts",
"declarations": [
{
"kind": "class",
"description": "A custom alert component for displaying messages with various alert types.",
"name": "HaAlert",
"cssProperties": [
{
"description": "The color used for \"info\" alerts.",
"name": "--info-color"
},
{
"description": "The color used for \"warning\" alerts.",
"name": "--warning-color"
},
{
"description": "The color used for \"error\" alerts.",
"name": "--error-color"
},
{
"description": "The color used for \"success\" alerts.",
"name": "--success-color"
},
{
"description": "The primary text color used in the alert.",
"name": "--primary-text-color"
}
],
"cssParts": [
{
"description": "The container for the alert.",
"name": "issue-type"
},
{
"description": "The container for the alert icon.",
"name": "icon"
},
{
"description": "The container for the alert content.",
"name": "content"
},
{
"description": "The container for the alert actions.",
"name": "action"
},
{
"description": "The container for the alert title.",
"name": "title"
}
],
"slots": [
{
"description": "The main content of the alert.",
"name": ""
},
{
"description": "Slot for providing a custom icon for the alert.",
"name": "icon"
},
{
"description": "Slot for providing custom actions or buttons for the alert.",
"name": "action"
}
],
"members": [
{
"kind": "field",
"name": "title",
"type": {
"text": "string"
},
"privacy": "public",
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"attribute": "title"
},
{
"kind": "field",
"name": "alertType",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"privacy": "public",
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"attribute": "alert-type"
},
{
"kind": "field",
"name": "dismissable",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"attribute": "dismissable"
},
{
"kind": "field",
"name": "narrow",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"attribute": "narrow"
},
{
"kind": "method",
"name": "_dismissClicked",
"privacy": "private"
}
],
"events": [
{
"description": "Fired when the dismiss button is clicked.",
"name": "alert-dismissed-clicked"
}
],
"attributes": [
{
"name": "title",
"type": {
"text": "string"
},
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"fieldName": "title"
},
{
"name": "alert-type",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"fieldName": "alertType"
},
{
"name": "dismissable",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"fieldName": "dismissable"
},
{
"name": "narrow",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"fieldName": "narrow"
}
],
"superclass": {
"name": "LitElement",
"package": "lit"
},
"tagName": "ha-alert",
"customElement": true
}
],
"exports": [
{
"kind": "custom-element-definition",
"name": "ha-alert",
"declaration": {
"name": "HaAlert",
"module": "src/components/ha-alert.ts"
}
}
]
}
]
}

View File

@@ -0,0 +1,51 @@
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { HaMarkdown } from "../../../src/components/ha-markdown";
import { PAGES } from "../../build/import-pages";
@customElement("page-api-docs")
class PageApiDocs extends HaMarkdown {
@property() public page!: string;
render() {
if (!PAGES[this.page].apiDocs) {
return nothing;
}
return html`${until(
PAGES[this.page]
.apiDocs()
.then((content) => html`<div class="root">${content}</div>`),
""
)}`;
}
static styles = [
HaMarkdown.styles,
css`
:host {
display: block;
}
.root {
max-width: 800px;
margin: 16px auto;
}
.root > *:first-child {
margin-top: 0;
}
.root > *:last-child {
margin-bottom: 0;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"page-api-docs": PageApiDocs;
}
}

View File

@@ -2,7 +2,7 @@ import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, query, state } from "lit/decorators";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
@@ -10,6 +10,7 @@ import "../../src/components/ha-icon-button";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
import "./components/page-api-docs";
import "./components/page-description";
const GITHUB_DEMO_URL =
@@ -95,6 +96,9 @@ class HaGallery extends LitElement {
`
: ""}
${dynamicElement(`demo-${this._page.replace("/", "-")}`)}
${PAGES[this._page].apiDocs
? html`<page-api-docs .page=${this._page}></page-api-docs>`
: nothing}
</div>
<div class="page-footer">
<div class="header">Help us to improve our documentation</div>

View File

@@ -20,7 +20,8 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"analyze": "cem analyze --litelement --globs \"src/components/ha-alert.ts\" --dev"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -150,6 +151,7 @@
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.0",
"@bundle-stats/plugin-webpack-filter": "4.21.9",
"@custom-elements-manifest/analyzer": "0.11.0",
"@lokalise/node-api": "15.6.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",

View File

@@ -25,6 +25,36 @@ declare global {
}
}
/**
* A custom alert component for displaying messages with various alert types.
*
* @element ha-alert
*
* @property {string} title - The title of the alert. Defaults to an empty string.
* @property {"info" | "warning" | "error" | "success"} alertType - The type of alert to display.
* Defaults to "info". Determines the styling and icon used.
* @property {boolean} dismissable - Whether the alert can be dismissed. Defaults to `false`.
* If `true`, a dismiss button is displayed.
* @property {boolean} narrow - Whether the alert should use a narrow layout. Defaults to `false`.
*
* @slot - The main content of the alert.
* @slot icon - Slot for providing a custom icon for the alert.
* @slot action - Slot for providing custom actions or buttons for the alert.
*
* @fires alert-dismissed-clicked - Fired when the dismiss button is clicked.
*
* @csspart issue-type - The container for the alert.
* @csspart icon - The container for the alert icon.
* @csspart content - The container for the alert content.
* @csspart action - The container for the alert actions.
* @csspart title - The container for the alert title.
*
* @cssprop --info-color - The color used for "info" alerts.
* @cssprop --warning-color - The color used for "warning" alerts.
* @cssprop --error-color - The color used for "error" alerts.
* @cssprop --success-color - The color used for "success" alerts.
* @cssprop --primary-text-color - The primary text color used in the alert.
*/
@customElement("ha-alert")
class HaAlert extends LitElement {
// eslint-disable-next-line lit/no-native-attributes
@@ -35,7 +65,7 @@ class HaAlert extends LitElement {
| "warning"
| "error"
| "success" = "info";
@property({ type: Boolean }) public dismissable = false;
@property({ type: Boolean }) public narrow = false;

1451
yarn.lock

File diff suppressed because it is too large Load Diff