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 });