diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 217803a7da..051236bfdd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -883,6 +883,9 @@ jobs: name: perspective-server-wasm64-dist path: rust/perspective-server/dist/wasm + - name: Consumer checks + run: pnpm run test:consumers + - name: Run Tests id: run_tests run: pnpm run test -- --fetch-snapshots @@ -945,9 +948,6 @@ jobs: name: perspective-server-wasm64-dist path: rust/perspective-server/dist/wasm - - name: Consumer checks - run: pnpm run test:consumers - - name: Run Tests id: run_tests run: pnpm run test -- --fetch-snapshots diff --git a/docs/md/how_to/javascript/viewer.md b/docs/md/how_to/javascript/viewer.md index 19e25d6df8..07d8f9b1aa 100644 --- a/docs/md/how_to/javascript/viewer.md +++ b/docs/md/how_to/javascript/viewer.md @@ -47,18 +47,30 @@ the table after a page reload, and restore() will fail. ## Attributes -`` can be configured via HTML attributes or JavaScript -properties. When set as attributes, the viewer will apply the configuration on -initialization: +`` reads one HTML attribute, `theme`, which selects the +initial theme by the `--psp-theme-name` a loaded stylesheet declares. Every +other setting is applied through the JavaScript API, typically `restore()`, +and the viewer reflects the active theme back onto the same attribute when the +user picks one: ```html - - + ``` +```javascript +await viewer.restore({ columns: ["Sales", "Profit"], group_by: ["Region"] }); +``` + +## Editor and tooling support + +`@perspective-dev/viewer` ships a +[Custom Elements Manifest](https://github.com/webcomponents/custom-elements-manifest) +at `dist/custom-elements.json`, advertised by the `customElements` field of its +`package.json`. It enumerates the element's methods, static methods, events, +the `theme` attribute, slots, the `tab` shadow part and every theme CSS custom +property, so editors, Storybook and documentation generators that read the +manifest can offer completion and reference tables without any further setup. + ## UI Features The viewer provides an interactive side panel with: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 657af5c4de..5d263dfc4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -185,6 +185,7 @@ overrides: '@perspective-dev/client@5.3.0>@perspective-dev/server': 5.3.0 '@perspective-dev/client@5.4.0>@perspective-dev/server': 5.4.0 '@perspective-dev/client@5.5.0>@perspective-dev/server': 5.5.0 + apache-arrow>@types/node: '>=22' importers: @@ -2368,9 +2369,6 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@20.19.23': - resolution: {integrity: sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==} - '@types/node@24.9.1': resolution: {integrity: sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==} @@ -5004,9 +5002,6 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} - undici-types@6.21.0: - resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -6971,10 +6966,6 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@20.19.23': - dependencies: - undici-types: 6.21.0 - '@types/node@24.9.1': dependencies: undici-types: 7.16.0 @@ -7278,7 +7269,7 @@ snapshots: '@swc/helpers': 0.5.17 '@types/command-line-args': 5.2.3 '@types/command-line-usage': 5.0.4 - '@types/node': 20.19.23 + '@types/node': 24.9.1 command-line-args: 5.2.1 command-line-usage: 7.0.3 flatbuffers: 24.12.23 @@ -9910,8 +9901,6 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} - undici-types@7.16.0: {} union@0.5.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 19f4764f04..5f7086b497 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,6 +33,7 @@ overrides: "@perspective-dev/client@5.3.0>@perspective-dev/server": "5.3.0" "@perspective-dev/client@5.4.0>@perspective-dev/server": "5.4.0" "@perspective-dev/client@5.5.0>@perspective-dev/server": "5.5.0" + "apache-arrow>@types/node": "catalog:" allowBuilds: "@fortawesome/fontawesome-free": true diff --git a/rust/perspective-viewer/build.mjs b/rust/perspective-viewer/build.mjs index a0f5fca749..c7b0e64529 100644 --- a/rust/perspective-viewer/build.mjs +++ b/rust/perspective-viewer/build.mjs @@ -23,6 +23,7 @@ import { } from "lightningcss"; import { compress } from "pro_self_extracting_wasm"; import { + buildCustomElementsManifest, buildDocsCorpus, get_host, inlineUrlVisitor, @@ -63,6 +64,11 @@ export async function build_all() { `docs corpus: ${docs_stats.chunks} chunks / ${docs_stats.bytes} bytes from ${docs_stats.files} files`, ); + const cem_stats = await buildCustomElementsManifest(); + console.log( + `custom-elements.json: ${cem_stats.members} members / ${cem_stats.events} events / ${cem_stats.cssProperties} css properties / ${cem_stats.exports} exports`, + ); + // JavaScript const BUILD = [ // No WASM assets inlined or linked. diff --git a/rust/perspective-viewer/package.json b/rust/perspective-viewer/package.json index 9075bd1461..809184ba9d 100644 --- a/rust/perspective-viewer/package.json +++ b/rust/perspective-viewer/package.json @@ -58,6 +58,7 @@ "dist/**/*" ], "types": "dist/esm/perspective-viewer.d.ts", + "customElements": "dist/custom-elements.json", "scripts": { "build": "node ./build.mjs", "clean": "node ./clean.mjs", diff --git a/rust/perspective-viewer/src/ts/extensions.ts b/rust/perspective-viewer/src/ts/extensions.ts index 63dfaa091b..1aaccf9c8a 100644 --- a/rust/perspective-viewer/src/ts/extensions.ts +++ b/rust/perspective-viewer/src/ts/extensions.ts @@ -38,6 +38,26 @@ export type PerspectiveSelectEventDetail = { view_window: ViewWindow; }; +export type PerspectiveLayoutUpdateEventDetail = { + panels: string[]; +}; + +export type PerspectiveActivePanelUpdateEventDetail = { + panel: string | null; +}; + +export type PerspectiveColumnStyleChangeEventDetail = Record< + string, + Record +> & { + panel?: string; +}; + +export type PerspectiveToggleColumnSettingsEventDetail = { + open: boolean; + column_name: string | null; +}; + export type PerspectiveConfigUpdateEventDetail = { /** * Serialize the panel config that triggered this event, ON DEMAND. The @@ -223,63 +243,124 @@ export interface PerspectiveViewerElementExt { get_worker_url(): URL; + /** Fires when a plugin's grid or chart is clicked, with the clicked row and a `config` whose filters reproduce it. */ addEventListener( name: "perspective-click", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires when a plugin's selection changes, with the configs whose filters to insert and remove. */ addEventListener( name: "perspective-select", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires on a master panel's selection, before it is applied to the global filter set. */ addEventListener( name: "perspective-global-filter", cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires whenever the global filter set changes, with the new `Filter[]`. */ addEventListener( name: "perspective-global-filter-update", cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires after the settings sidebar opens or closes, with the new open state. */ addEventListener( name: "perspective-toggle-settings", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires before the settings sidebar opens or closes, with the pending open state. */ addEventListener( name: "perspective-toggle-settings-before", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires when the column settings sidebar opens or closes, with the column it shows. */ + addEventListener( + name: "perspective-toggle-column-settings", + cb: ( + e: CustomEvent, + ) => void, options?: { signal: AbortSignal }, ): void; + /** Fires after a panel's view config changes, with a deferred `getConfig()` accessor valid only during dispatch. */ addEventListener( name: "perspective-config-update", cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires when a panel's active plugin changes, with the new plugin element as its detail. */ + addEventListener( + name: "perspective-plugin-update", + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires when a panel's per-column style config changes, keyed by column name. */ + addEventListener( + name: "perspective-column-style-change", + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires when a panel is added to or removed from the layout, with the placed panel ids in order. */ + addEventListener( + name: "perspective-layout-update", + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires when the active panel changes, with its id or `null` at zero panels. */ + addEventListener( + name: "perspective-active-panel-update", + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires with the original `pointerdown` as its detail when the status bar is pressed. */ addEventListener( name: "perspective-statusbar-pointerdown", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires when a panel's status indicator is clicked. */ + addEventListener( + name: "perspective-status-indicator-click", + cb: (e: CustomEvent) => void, + options?: { signal: AbortSignal }, + ): void; + + /** Fires when the agent invokes a tool, with a JSON string of `{name, args}` as its detail. */ + addEventListener( + name: "perspective-agent-tool", + cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires after a panel's `Table` is deleted and the panel unloaded. */ addEventListener( name: "perspective-table-delete", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; + /** Fires before a panel's `Table` is deleted, while its `View` is still live. */ addEventListener( name: "perspective-table-delete-before", - cb: (e: CustomEvent) => void, + cb: (e: CustomEvent) => void, options?: { signal: AbortSignal }, ): void; @@ -295,11 +376,24 @@ export interface PerspectiveViewerElementExt { name: "perspective-toggle-settings-before", cb: any, ): void; + removeEventListener( + name: "perspective-toggle-column-settings", + cb: any, + ): void; removeEventListener(name: "perspective-config-update", cb: any): void; + removeEventListener(name: "perspective-plugin-update", cb: any): void; + removeEventListener(name: "perspective-column-style-change", cb: any): void; + removeEventListener(name: "perspective-layout-update", cb: any): void; + removeEventListener(name: "perspective-active-panel-update", cb: any): void; removeEventListener( name: "perspective-statusbar-pointerdown", cb: any, ): void; + removeEventListener( + name: "perspective-status-indicator-click", + cb: any, + ): void; + removeEventListener(name: "perspective-agent-tool", cb: any): void; removeEventListener(name: "perspective-table-delete", cb: any): void; removeEventListener(name: "perspective-table-delete-before", cb: any): void; } diff --git a/rust/perspective-viewer/test/js/viewer_api/custom_elements_manifest.spec.ts b/rust/perspective-viewer/test/js/viewer_api/custom_elements_manifest.spec.ts new file mode 100644 index 0000000000..1a790e22a0 --- /dev/null +++ b/rust/perspective-viewer/test/js/viewer_api/custom_elements_manifest.spec.ts @@ -0,0 +1,202 @@ +// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +// ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ +// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ +// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ +// ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ +// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ +// ┃ Copyright (c) 2017, the Perspective Authors. ┃ +// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ +// ┃ This file is part of the Perspective library, distributed under the terms ┃ +// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ +// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as url from "node:url"; +import { test, expect } from "../helpers.ts"; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const ROOT = path.resolve(__dirname, "../../../../.."); + +const VIEWER = path.join(ROOT, "rust/perspective-viewer"); + +const MANIFEST = JSON.parse( + fs.readFileSync(path.join(VIEWER, "dist/custom-elements.json"), "utf8"), +); + +const MODULE = MANIFEST.modules[0]; + +const ELEMENTS = MODULE.declarations.filter((x: any) => x.customElement); + +function readRust(dir: string): string { + const out: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + out.push(readRust(full)); + } else if (entry.name.endsWith(".rs")) { + out.push(fs.readFileSync(full, "utf8")); + } + } + + return out.join("\n"); +} + +const RUST = readRust(path.join(VIEWER, "src/rust")); + +test.beforeEach(async ({ page }) => { + await page.goto("/rust/perspective-viewer/test/html/superstore.html"); + await page.evaluate(async () => { + while (!window["__TEST_PERSPECTIVE_READY__"]) { + await new Promise((x) => setTimeout(x, 10)); + } + }); +}); + +test.describe("Custom elements manifest", () => { + test("declares the current schema and a resolvable export list", () => { + expect(MANIFEST.schemaVersion).toBe("2.1.0"); + expect(MANIFEST.modules).toHaveLength(1); + expect(MODULE.path).toBe("dist/esm/perspective-viewer.js"); + const names = new Set(MODULE.declarations.map((x: any) => x.name)); + expect(names.size).toBe(MODULE.declarations.length); + for (const entry of MODULE.exports) { + expect(entry.declaration.module).toBe(MODULE.path); + expect(names.has(entry.declaration.name)).toBe(true); + } + + const tags = MODULE.exports + .filter((x: any) => x.kind === "custom-element-definition") + .map((x: any) => x.name); + + expect(tags.sort()).toEqual( + ["perspective-viewer", "perspective-viewer-plugin"].sort(), + ); + }); + + test("package.json advertises the manifest", () => { + const pkg = JSON.parse( + fs.readFileSync(path.join(VIEWER, "package.json"), "utf8"), + ); + + expect(pkg.customElements).toBe("dist/custom-elements.json"); + }); + + test("tag names match the Rust element registrations", () => { + const rust = readRust(path.join(VIEWER, "src/rust/custom_elements")); + const registered = [ + ...rust.matchAll(/CUSTOM_ELEMENT_NAME: &'static str = "([^"]+)"/g), + ].map((x) => x[1]); + + expect(ELEMENTS.map((x: any) => x.tagName).sort()).toEqual( + registered.sort(), + ); + }); + + test("instance and static members match the registered classes", async ({ + page, + }) => { + for (const element of ELEMENTS) { + const runtime = await page.evaluate((tag) => { + const cls = customElements.get(tag) as any; + const hidden = new Set([ + "constructor", + "free", + "connectedCallback", + ]); + const visible = (name: string) => + !hidden.has(name) && !name.startsWith("_"); + + return { + instance: Object.getOwnPropertyNames(cls.prototype) + .filter(visible) + .sort(), + statics: Object.getOwnPropertyNames(cls) + .filter((x) => typeof cls[x] === "function") + .filter(visible) + .sort(), + }; + }, element.tagName); + + const members = element.members ?? []; + expect( + members + .filter((x: any) => !x.static) + .map((x: any) => x.name) + .sort(), + ).toEqual(runtime.instance); + + expect( + members + .filter((x: any) => x.static) + .map((x: any) => x.name) + .sort(), + ).toEqual(runtime.statics); + } + }); + + test("events cover every typed overload and every Rust dispatch", () => { + const viewer = ELEMENTS.find( + (x: any) => x.tagName === "perspective-viewer", + ); + + const manifest = new Set(viewer.events.map((x: any) => x.name)); + const extensions = fs.readFileSync( + path.join(VIEWER, "src/ts/extensions.ts"), + "utf8", + ); + + const typed = new Set( + [...extensions.matchAll(/name: "(perspective-[a-z-]+)"/g)].map( + (x) => x[1], + ), + ); + + expect([...manifest].sort()).toEqual([...typed].sort()); + const dispatched = new Set([ + ...[ + ...RUST.matchAll( + /dispatch(?:_panel)?_event\([^");]*"([a-z-]+)"/g, + ), + ].map((x) => `perspective-${x[1]}`), + ...[ + ...RUST.matchAll( + /CustomEvent::new(?:_with_event_init_dict)?\(\s*"(perspective-[a-z-]+)"/g, + ), + ].map((x) => x[1]), + ]); + + for (const name of dispatched) { + expect(manifest.has(name), name).toBe(true); + } + }); + + test("attributes are read by the Rust element", () => { + for (const element of ELEMENTS) { + for (const attr of element.attributes ?? []) { + expect(RUST.includes(`get_attribute("${attr.name}")`)).toBe( + true, + ); + } + } + }); + + test("css properties come from the theme stylesheets", () => { + const viewer = ELEMENTS.find( + (x: any) => x.tagName === "perspective-viewer", + ); + + const names = viewer.cssProperties.map((x: any) => x.name); + expect(names).toEqual([...names].sort()); + expect(new Set(names).size).toBe(names.length); + expect(names).toContain("--psp-theme-name"); + expect(names).toContain("--psp-icon--close--mask-image"); + expect(names).toContain("--psp-label--add-expression-button--content"); + expect(names.some((x: string) => x.startsWith("--theme-"))).toBe(false); + expect( + viewer.cssProperties.find((x: any) => x.name === "--psp-theme-name") + .default, + ).toBe('"Pro Light"'); + }); +}); diff --git a/rust/perspective-viewer/tools.mjs b/rust/perspective-viewer/tools.mjs index 62b1ace079..377b41ca31 100644 --- a/rust/perspective-viewer/tools.mjs +++ b/rust/perspective-viewer/tools.mjs @@ -500,3 +500,878 @@ export function inlineUrlVisitor(fromFile) { }, ]); } + +const CEM_SCHEMA_VERSION = "2.1.0"; + +const CEM_MODULE_PATH = "dist/esm/perspective-viewer.js"; + +const CEM_ENTRY = "./src/ts/perspective-viewer.ts"; + +const CEM_WASM_DTS = "./dist/wasm/perspective-viewer.d.ts"; + +const CEM_THEMES_DIR = "./src/themes"; + +const CEM_HIDDEN_MEMBERS = new Set([ + "constructor", + "free", + "connectedCallback", +]); + +const CEM_PRIVATE_CSS_PREFIXES = ["--theme-", "--pp-"]; + +const CEM_OPEN_CSS_FAMILIES = ` +## Open CSS custom property families + +Beyond the enumerated \`cssProperties\`, \`\` reads these +families by prefix, so any name under them is honored: + +- \`--psp-user--color-\`, \`--psp-user--gradient-\`, + \`--psp-user--palette-\`: user-defined swatches offered by the color, + gradient and palette pickers. +- \`--psp-label----content\`: the UI string for any settings-schema key, + where \`\` is the key lowercased with non-alphanumerics folded to \`-\`. +- \`--psp-plugin-name----content\`: the display name of a registered + plugin.`; + +const CEM_ELEMENTS = [ + { + tagName: "perspective-viewer", + className: "PerspectiveViewerElement", + statics: { + file: "./src/ts/extensions.ts", + name: "PerspectiveViewerElementExt", + }, + attributes: [ + { + name: "theme", + type: { text: "string" }, + description: + "The active theme, as a `--psp-theme-name` value declared by a loaded stylesheet; read when the first panel is created and reflected when the user picks a theme.", + }, + ], + slots: [ + { + name: "", + description: + "The plugin element of a single-panel viewer, adopted from the light DOM.", + }, + { + name: "modal", + description: + "Top-layer overlays such as the expression editor and column settings.", + }, + { + name: "", + description: + "One named slot per panel; the viewer assigns `slot=` to each plugin element it adopts, so authors never set this by hand.", + }, + ], + cssParts: [ + { + name: "tab", + description: + "A panel's tab in the `` titlebar.", + }, + ], + cssProperties: true, + }, + { + tagName: "perspective-viewer-plugin", + className: "PerspectiveDebugPluginElement", + }, +]; + +const CEM_CSS_FAMILIES = [ + [/^--psp-theme-name$/, "Theme name"], + [/--mask-image$/, "Icon (mask image)"], + [/^--psp-icon-/, "Icon"], + [/^--psp-plugin-name--/, "Plugin display name (intl string)"], + [/^--psp-label--/, "UI label (intl string)"], + [/^--psp-datagrid--/, "Datagrid plugin"], + [/^--psp-charts--/, "Charts plugin"], + [/^--psp-openlayers--/, "Map plugin (OpenLayers)"], + [/^--psp-status/, "Status bar"], + [/^--psp-(expression|code-editor)--/, "Expression editor"], + [/^--psp-plugin/, "Plugin area"], + [/./, "Viewer chrome"], +]; + +const CEM_CSS_SYNTAX = [ + [/^--psp-theme-name$/, ""], + [/--mask-image$/, ""], + [/--content$/, ""], + [/--(background-)?color$/, ""], + [/--font-family$/, ""], + [/--font-size$/, ""], +]; + +function cemCssDeclarations(file) { + const text = fs.readFileSync(file, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + + const out = []; + let depth = 0; + let start = 0; + let selector = ""; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === "{") { + if (depth === 0) { + selector = text.slice(start, i).trim(); + start = i + 1; + } + + depth += 1; + } else if (ch === "}") { + depth -= 1; + if (depth === 0) { + if (/perspective-viewer(?![\w-])/.test(selector)) { + out.push(...cemSplitDeclarations(text.slice(start, i))); + } + + start = i + 1; + } + } else if (ch === ";" && depth === 0) { + start = i + 1; + } + } + + return out; +} + +function cemSplitDeclarations(body) { + const out = []; + let depth = 0; + let quote = null; + let start = 0; + const flush = (end) => { + const decl = body.slice(start, end); + const colon = decl.indexOf(":"); + const name = decl.slice(0, colon).trim(); + if (colon > 0 && name.startsWith("--")) { + out.push({ + name, + value: decl + .slice(colon + 1) + .replace(/\s+/g, " ") + .trim(), + }); + } + + start = end + 1; + }; + + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (quote !== null) { + if (ch === "\\") { + i += 1; + } else if (ch === quote) { + quote = null; + } + } else if (ch === '"' || ch === "'") { + quote = ch; + } else if (ch === "(") { + depth += 1; + } else if (ch === ")") { + depth -= 1; + } else if (ch === ";" && depth === 0) { + flush(i); + } + } + + flush(body.length); + return out; +} + +function cemCssProperties() { + const files = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith(".css")) { + files.push(full); + } + } + }; + + walk(CEM_THEMES_DIR); + files.sort(); + const defaults = new Map(); + const names = new Set(); + for (const file of files) { + const base = path.basename(file); + for (const { name, value } of cemCssDeclarations(file)) { + if (CEM_PRIVATE_CSS_PREFIXES.some((x) => name.startsWith(x))) { + continue; + } + + names.add(name); + const rank = + base === "pro.css" ? 0 : base === "defaults.css" ? 1 : 2; + const prev = defaults.get(name); + if (rank < 2 && (prev === undefined || rank < prev.rank)) { + defaults.set(name, { rank, value }); + } + } + } + + return [...names].sort().map((name) => { + const entry = { name }; + const syntax = CEM_CSS_SYNTAX.find(([re]) => re.test(name)); + if (syntax) { + entry.syntax = syntax[1]; + } + + const fallback = defaults.get(name); + if (fallback) { + entry.default = fallback.value; + } + + entry.description = CEM_CSS_FAMILIES.find(([re]) => re.test(name))[1]; + return entry; + }); +} + +function cemDoc(node) { + const docs = node.jsDoc ?? []; + if (docs.length === 0) { + return { description: "", params: {}, returns: "" }; + } + + const raw = docs[docs.length - 1] + .getText() + .replace(/^\/\*\*/, "") + .replace(/\*\/$/, "") + .split("\n") + .map((x) => x.replace(/^\s*\* ?/, "")); + + const body = []; + const params = {}; + const returns = []; + let current = body; + for (const line of raw) { + const tag = line.match(/^@(\w+)\s*(.*)$/); + if (!tag) { + current.push(line); + continue; + } + + const [, name, rest] = tag; + if (name === "param") { + const [, param, text] = rest.match(/^(\w+)\s*-?\s*([\s\S]*)$/) ?? [ + null, + rest, + "", + ]; + + params[param] = [text]; + current = params[param]; + } else if (name === "example") { + current = body; + body.push(""); + if (rest) { + body.push(rest); + } + } else if (name === "returns" || name === "return") { + returns.push(rest); + current = returns; + } else { + current = []; + } + } + + const join = (lines) => + lines + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); + return { + description: join(body), + params: Object.fromEntries( + Object.entries(params).map(([k, v]) => [k, join(v)]), + ), + returns: join(returns), + }; +} + +function cemKnownTypes() { + const known = new Map(); + const stems = (dir) => + fs.existsSync(dir) + ? fs + .readdirSync(dir) + .filter((x) => x.endsWith(".d.ts")) + .map((x) => x.slice(0, -".d.ts".length)) + : []; + + for (const name of stems("../perspective-js/dist/ts-rs")) { + known.set(name, "@perspective-dev/client"); + } + + const public_types = "../perspective-js/src/ts/public_types.ts"; + if (fs.existsSync(public_types)) { + const block = fs + .readFileSync(public_types, "utf8") + .match(/export type \{([^}]*)\}/); + + for (const name of (block?.[1] ?? "").split(",")) { + if (name.trim()) { + known.set(name.trim(), "@perspective-dev/client"); + } + } + } + + for (const name of stems("./dist/ts-rs")) { + known.set(name, "@perspective-dev/viewer"); + } + + return known; +} + +function cemType(text, known) { + const normalized = text.replace(/\s+/g, " ").trim(); + const references = []; + for (const match of normalized.matchAll(/\b[A-Z][A-Za-z0-9_]*\b/g)) { + const pkg = known.get(match[0]); + if (pkg) { + references.push({ + name: match[0], + package: pkg, + start: match.index, + end: match.index + match[0].length, + }); + } + } + + return references.length > 0 + ? { text: normalized, references } + : { text: normalized }; +} + +function cemParameters(ts, checker, known, params, doc) { + return params.map((p) => { + const name = p.name.getText(); + const entry = { name }; + if (p.dotDotDotToken) { + entry.rest = true; + } + + if (p.questionToken || p.initializer) { + entry.optional = true; + } + + entry.type = cemType( + p.type + ? p.type.getText() + : checker.typeToString(checker.getTypeAtLocation(p)), + known, + ); + + if (doc.params[name]) { + entry.description = doc.params[name]; + } + + return entry; + }); +} + +function cemReturn(ts, checker, known, node) { + if (node.type) { + return { type: cemType(node.type.getText(), known) }; + } + + const signature = checker.getSignatureFromDeclaration(node); + return signature + ? { + type: cemType( + checker.typeToString(signature.getReturnType()), + known, + ), + } + : undefined; +} + +function cemIsPublic(ts, member) { + const name = member.name?.getText(); + if (!name || CEM_HIDDEN_MEMBERS.has(name) || name.startsWith("_")) { + return false; + } + + if ( + ts.isPrivateIdentifier(member.name) || + ts.isComputedPropertyName(member.name) + ) { + return false; + } + + const flags = ts.getCombinedModifierFlags(member); + return !(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); +} + +function cemMembers( + ts, + checker, + known, + members, + { force_static = false } = {}, +) { + const out = []; + const accessors = new Map(); + for (const member of members) { + if (!cemIsPublic(ts, member)) { + continue; + } + + const name = member.name.getText(); + const doc = cemDoc(member); + const is_static = + force_static || + !!(ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Static); + + const base = { name, privacy: "public" }; + if (is_static) { + base.static = true; + } + + if (doc.description) { + base.description = doc.description; + } + + if (ts.isMethodDeclaration(member) || ts.isMethodSignature(member)) { + const entry = { kind: "method", ...base }; + const parameters = cemParameters( + ts, + checker, + known, + member.parameters, + doc, + ); + + if (parameters.length > 0) { + entry.parameters = parameters; + } + + const ret = cemReturn(ts, checker, known, member); + if (ret) { + if (doc.returns) { + ret.description = doc.returns; + } + + entry.return = ret; + } + + out.push(entry); + } else if (ts.isGetAccessor(member) || ts.isSetAccessor(member)) { + const existing = accessors.get(name); + if (existing) { + delete existing.readonly; + continue; + } + + const type_node = ts.isGetAccessor(member) + ? member.type + : member.parameters[0]?.type; + + const entry = { + kind: "field", + ...base, + type: cemType( + type_node + ? type_node.getText() + : checker.typeToString( + checker.getTypeAtLocation(member), + ), + known, + ), + readonly: true, + }; + + accessors.set(name, entry); + out.push(entry); + } else if ( + ts.isPropertyDeclaration(member) || + ts.isPropertySignature(member) + ) { + const entry = { + kind: "field", + ...base, + type: cemType( + member.type + ? member.type.getText() + : checker.typeToString( + checker.getTypeAtLocation(member), + ), + known, + ), + }; + + if ( + ts.getCombinedModifierFlags(member) & ts.ModifierFlags.Readonly + ) { + entry.readonly = true; + } + + out.push(entry); + } + } + + return out; +} + +function cemFindDeclaration(ts, source, name) { + for (const stmt of source.statements) { + if ( + (ts.isFunctionDeclaration(stmt) || ts.isClassDeclaration(stmt)) && + stmt.name?.text === name + ) { + return stmt; + } + + if (ts.isInterfaceDeclaration(stmt) && stmt.name.text === name) { + return stmt; + } + + if (ts.isVariableStatement(stmt)) { + for (const decl of stmt.declarationList.declarations) { + if (ts.isIdentifier(decl.name) && decl.name.text === name) { + return { statement: stmt, declaration: decl }; + } + } + } + } + + return null; +} + +function cemClass(ts, checker, known, cls, name) { + const doc = cemDoc(cls); + const entry = { kind: "class", name }; + if (doc.description) { + entry.description = doc.description; + } + + for (const clause of cls.heritageClauses ?? []) { + if (clause.token === ts.SyntaxKind.ExtendsKeyword) { + entry.superclass = { name: clause.types[0].expression.getText() }; + } + } + + const members = cemMembers(ts, checker, known, cls.members); + if (members.length > 0) { + entry.members = members; + } + + return entry; +} + +function cemFunction(ts, checker, known, fn, name) { + const doc = cemDoc(fn); + const entry = { kind: "function", name }; + if (doc.description) { + entry.description = doc.description; + } + + const parameters = cemParameters(ts, checker, known, fn.parameters, doc); + if (parameters.length > 0) { + entry.parameters = parameters; + } + + const ret = cemReturn(ts, checker, known, fn); + if (ret) { + if (doc.returns) { + ret.description = doc.returns; + } + + entry.return = ret; + } + + return entry; +} + +function cemVariable(ts, checker, known, { statement, declaration }, name) { + const doc = cemDoc(statement); + const entry = { + kind: "variable", + name, + type: cemType( + declaration.type + ? declaration.type.getText() + : checker.typeToString( + checker.getTypeAtLocation(declaration), + undefined, + ts.TypeFormatFlags.NoTruncation, + ), + known, + ), + }; + + if (doc.description) { + entry.description = doc.description; + } + + return entry; +} + +function cemEvents(ts, checker, known, iface) { + const events = []; + const seen = new Set(); + for (const member of iface.members) { + if ( + !ts.isMethodSignature(member) || + member.name.getText() !== "addEventListener" + ) { + continue; + } + + const [name_param, cb_param] = member.parameters; + const literal = name_param?.type; + if (!literal || !ts.isLiteralTypeNode(literal)) { + continue; + } + + const name = literal.literal.text; + if (seen.has(name)) { + continue; + } + + seen.add(name); + const entry = { name }; + const cb = cb_param?.type; + if (cb && ts.isFunctionTypeNode(cb) && cb.parameters[0]?.type) { + entry.type = cemType(cb.parameters[0].type.getText(), known); + } + + const doc = cemDoc(member); + if (doc.description) { + entry.description = doc.description; + } + + events.push(entry); + } + + return events; +} + +function cemValueExports(ts, entry) { + const exports = []; + for (const stmt of entry.statements) { + if (ts.isExportDeclaration(stmt)) { + if (stmt.isTypeOnly || !stmt.exportClause) { + continue; + } + + if (!ts.isNamedExports(stmt.exportClause)) { + continue; + } + + for (const spec of stmt.exportClause.elements) { + if (spec.isTypeOnly) { + continue; + } + + exports.push({ + name: spec.name.text, + local: (spec.propertyName ?? spec.name).text, + from: stmt.moduleSpecifier?.text ?? null, + }); + } + } else if (ts.isExportAssignment(stmt) && !stmt.isExportEquals) { + exports.push({ + name: "default", + local: "default", + from: null, + node: stmt, + }); + } + } + + return exports; +} + +/// Build `dist/custom-elements.json`, a Custom Elements Manifest for the +/// elements this package registers plus its value exports. +export async function buildCustomElementsManifest( + out_file = "dist/custom-elements.json", +) { + const ts = (await import("typescript")).default; + const config = ts.parseJsonConfigFileContent( + ts.readConfigFile("tsconfig.json", ts.sys.readFile).config, + ts.sys, + ".", + ); + + const program = ts.createProgram( + [ + CEM_ENTRY, + CEM_WASM_DTS, + ...CEM_ELEMENTS.map((x) => x.statics?.file).filter(Boolean), + ], + { ...config.options, noEmit: true }, + ); + + const checker = program.getTypeChecker(); + const known = cemKnownTypes(); + const source = (file) => { + const sf = program.getSourceFile(path.resolve(file)); + if (!sf) { + throw new Error( + `custom-elements.json: ${file} is not in the program`, + ); + } + + return sf; + }; + + const declarations = []; + const exports = []; + const wasm = source(CEM_WASM_DTS); + for (const element of CEM_ELEMENTS) { + const cls = cemFindDeclaration(ts, wasm, element.className); + if (!cls) { + throw new Error( + `custom-elements.json: ${element.className} not found in ${CEM_WASM_DTS}`, + ); + } + + const entry = cemClass(ts, checker, known, cls, element.className); + entry.tagName = element.tagName; + entry.customElement = true; + entry.superclass = { name: "HTMLElement" }; + if (element.statics) { + const iface = cemFindDeclaration( + ts, + source(element.statics.file), + element.statics.name, + ); + + const statics = cemMembers(ts, checker, known, iface.members, { + force_static: true, + }).filter( + (x) => + !["addEventListener", "removeEventListener"].includes( + x.name, + ), + ); + + entry.members = [...(entry.members ?? []), ...statics]; + const events = cemEvents(ts, checker, known, iface); + if (events.length > 0) { + entry.events = events; + } + } + + if (element.attributes) { + entry.attributes = element.attributes; + } + + if (element.slots) { + entry.slots = element.slots; + } + + if (element.cssParts) { + entry.cssParts = element.cssParts; + } + + if (element.cssProperties) { + entry.cssProperties = cemCssProperties(); + entry.description = + `${entry.description ?? ""}\n${CEM_OPEN_CSS_FAMILIES}`.trim(); + } + + declarations.push(entry); + exports.push({ + kind: "custom-element-definition", + name: element.tagName, + declaration: { name: element.className, module: CEM_MODULE_PATH }, + }); + } + + const entry_source = source(CEM_ENTRY); + for (const value of cemValueExports(ts, entry_source)) { + let decl; + if (value.node) { + const expr = value.node.expression; + const fields = ts.isObjectLiteralExpression(expr) + ? expr.properties + .map((x) => x.name?.getText()) + .filter(Boolean) + .map((x) => `${x}: typeof ${x}`) + : []; + + decl = { + kind: "variable", + name: "default", + type: cemType( + fields.length > 0 + ? `{ ${fields.join("; ")} }` + : expr.getText(), + known, + ), + }; + } else { + const file = value.from + ? path.resolve( + path.dirname(path.resolve(CEM_ENTRY)), + value.from, + ) + : path.resolve(CEM_ENTRY); + + const found = cemFindDeclaration(ts, source(file), value.local); + if (!found) { + throw new Error( + `custom-elements.json: export ${value.name} has no declaration in ${file}`, + ); + } + + if (ts.isClassDeclaration(found)) { + decl = cemClass(ts, checker, known, found, value.local); + } else if (ts.isFunctionDeclaration(found)) { + decl = cemFunction(ts, checker, known, found, value.local); + } else if (ts.isInterfaceDeclaration(found)) { + continue; + } else { + decl = cemVariable(ts, checker, known, found, value.local); + } + } + + if (!declarations.some((x) => x.name === decl.name)) { + declarations.push(decl); + } + + exports.push({ + kind: "js", + name: value.name, + declaration: { name: decl.name, module: CEM_MODULE_PATH }, + }); + } + + const manifest = { + schemaVersion: CEM_SCHEMA_VERSION, + readme: "README.md", + modules: [ + { + kind: "javascript-module", + path: CEM_MODULE_PATH, + summary: + "The `` and `` custom elements.", + declarations, + exports, + }, + ], + }; + + fs.mkdirSync(path.dirname(out_file), { recursive: true }); + fs.writeFileSync(out_file, JSON.stringify(manifest, null, 4) + "\n"); + const elements = declarations.filter((x) => x.customElement); + return { + members: elements.reduce((n, x) => n + (x.members?.length ?? 0), 0), + events: elements.reduce((n, x) => n + (x.events?.length ?? 0), 0), + cssProperties: elements.reduce( + (n, x) => n + (x.cssProperties?.length ?? 0), + 0, + ), + exports: exports.length, + }; +} diff --git a/tools/scripts/test_consumers.mjs b/tools/scripts/test_consumers.mjs index 849e980f49..8148ee94b0 100644 --- a/tools/scripts/test_consumers.mjs +++ b/tools/scripts/test_consumers.mjs @@ -26,6 +26,12 @@ const PUBLINT = "publint@0.3.25"; const ATTW = "@arethetypeswrong/cli@0.18.5"; +const AJV = "ajv-cli@5.0.0"; + +const CEM_SCHEMA_PACKAGE = "custom-elements-manifest@2.1.0"; + +const CEM_MANIFEST = "rust/perspective-viewer/dist/custom-elements.json"; + const PACKAGES = [ { dir: "rust/perspective-server" }, { dir: "rust/perspective-js" }, @@ -110,6 +116,74 @@ function check_public_types(failures) { } } +function check_custom_elements_manifest(tmp, failures) { + const pkg = JSON.parse( + fs.readFileSync( + path.join(ROOT, "rust/perspective-viewer/package.json"), + "utf8", + ), + ); + + if (pkg.customElements !== "dist/custom-elements.json") { + failures.push("viewer package.json#customElements is not the manifest"); + } + + const manifest = path.join(ROOT, CEM_MANIFEST); + if (!fs.existsSync(manifest)) { + failures.push(`${CEM_MANIFEST} is missing`); + return; + } + + const schema_dir = path.join(tmp, "cem-schema"); + fs.mkdirSync(schema_dir, { recursive: true }); + const packed = run( + "npm", + ["pack", CEM_SCHEMA_PACKAGE, "--pack-destination", schema_dir], + { cwd: schema_dir, capture: true }, + ); + + if (packed.status !== 0) { + failures.push(`npm pack ${CEM_SCHEMA_PACKAGE}`); + return; + } + + const tarball = path.join( + schema_dir, + packed.stdout.trim().split("\n").pop(), + ); + if (run("tar", ["-xzf", tarball, "-C", schema_dir]).status !== 0) { + failures.push(`extract ${CEM_SCHEMA_PACKAGE}`); + return; + } + + const schema = path.join(schema_dir, "package/schema.json"); + const validate = run("pnpm", [ + "dlx", + AJV, + "validate", + "--spec=draft7", + "--all-errors", + "-s", + schema, + "-d", + manifest, + ]); + + if (validate.status !== 0) { + failures.push(`ajv ${CEM_MANIFEST}`); + } +} + +function check_packed_manifest(tarballs, failures) { + const viewer = tarballs.find((x) => path.basename(x).includes("viewer-")); + const listing = run("tar", ["-tzf", viewer], { capture: true }); + if (!listing.stdout.includes("package/dist/custom-elements.json")) { + failures.push( + "viewer tarball does not contain dist/custom-elements.json", + ); + } +} + function lint_packages(failures) { for (const pkg of PACKAGES) { const cwd = path.join(ROOT, pkg.dir); @@ -254,8 +328,11 @@ const failures = []; const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "psp-consumers-")); try { check_public_types(failures); + check_custom_elements_manifest(tmp, failures); lint_packages(failures); - typecheck_consumer(tmp, pack_packages(tmp), failures); + const tarballs = pack_packages(tmp); + check_packed_manifest(tarballs, failures); + typecheck_consumer(tmp, tarballs, failures); build_examples(failures); } finally { fs.rmSync(tmp, { recursive: true, force: true });