@hazem3500/palette 0.9.106 → 0.9.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -256,7 +256,8 @@ export default defineConfig({
256
256
  plugins: [
257
257
  paletteComponents({
258
258
  enabled: process.env.PREVIEW_BUILD === "1",
259
- // Every component export of these packages that has editable props.
259
+ // Every component export of these packages that has editable props. "@acme/ui/*" lists
260
+ // every subpath in the package's exports map (@acme/ui/button, @acme/ui/badge, …).
260
261
  packages: ["@acme/ui"],
261
262
  // App components, by module and export, relative to the Vite root.
262
263
  components: ["src/cards/ProjectCard.tsx#ProjectCard", "src/nav/TopBar.tsx"],
@@ -286,7 +287,7 @@ export default defineConfig({
286
287
  - **Finding the instance.** Picking an element reads React's fiber links (`return`, `child`, `memoizedState`) to find the nearest instrumented instance, like the fiber adapter, and never writes them. In Viewport, the framed app's own instances answer.
287
288
  - **Cost.** The preview build carries a 2 KB gzip runtime plus each component's prop list. The test fixture measured 2.3 KB for 13 components, and on an Apple M4 Max about 0.6 µs per instance to mount and 0.3 µs per update.
288
289
  - **Limits.**
289
- - Code that compares element types, such as `child.type === Tab` inside the package, sees the wrapper instead of the original. Exclude such components.
290
+ - Code that compares element types, such as `child.type === Tab`, would see the wrapper instead of the original. The plugin scans each package's shipped JavaScript for such comparisons and leaves those components uninstrumented, with a warning naming the file. A minified package renames its locals, so the scan can't see them there: exclude such components yourself.
290
291
  - A component with several root elements is selected by its first one.
291
292
  - Components used only inside the package itself, not imported by the app, aren't instrumented.
292
293
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hazem3500/palette",
3
- "version": "0.9.106",
3
+ "version": "0.9.107",
4
4
  "description": "Point, area, and threaded comments for website branches.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/vite/index.d.mts CHANGED
@@ -4,7 +4,11 @@ export interface PaletteComponentsOptions {
4
4
  * `process.env.PREVIEW === "1"`. Off, the plugin has no hooks and the build is unchanged.
5
5
  */
6
6
  enabled?: boolean;
7
- /** Design-system packages (or package subpaths): every eligible component export is instrumented. */
7
+ /**
8
+ * Design-system packages or package subpaths: every eligible component export is instrumented.
9
+ * "@scope/ds/*" expands to every subpath in the package's exports map. Components the package
10
+ * compares by element type (`child.type === Item`) are left alone, with a warning.
11
+ */
8
12
  packages?: string[];
9
13
  /**
10
14
  * App components to instrument, relative to the Vite root: "src/cards/Card.tsx" for every
package/vite/index.mjs CHANGED
@@ -9,6 +9,7 @@
9
9
  // from @hazem3500/palette/react-instrument. Node-only.
10
10
  import { resolve } from "node:path";
11
11
  import { collectComponents, defaultExclude } from "./metadata.mjs";
12
+ import { expandPackages, packageDirectory, packageName, typeComparedNames } from "./packages.mjs";
12
13
 
13
14
  const PREFIX = "\0palette-components:";
14
15
  const RUNTIME = "@hazem3500/palette/react-instrument";
@@ -17,7 +18,10 @@ const RUNTIME = "@hazem3500/palette/react-instrument";
17
18
  export function paletteComponents(options = {}) {
18
19
  const name = "palette-components";
19
20
  if (options.enabled !== true) return { name };
20
- const packages = [...new Set(options.packages ?? [])];
21
+ const listed = [...new Set(options.packages ?? [])];
22
+ /** Specifiers after expanding `@scope/ds/*` globs. */
23
+ let packages = listed.filter((entry) => !entry.endsWith("/*"));
24
+ let expanded;
21
25
  const modules = (options.components ?? []).map((entry) => {
22
26
  const [file, exported] = String(entry).split("#");
23
27
  return { file, exports: exported ? exported.split(",").map((item) => item.trim()).filter(Boolean) : undefined };
@@ -49,8 +53,32 @@ export function paletteComponents(options = {}) {
49
53
  return lines.join("\n") + "\n";
50
54
  }
51
55
 
56
+ const expand = () => (expanded ??= expandPackages(listed, root).then((result) => {
57
+ packages = result.specifiers;
58
+ return result;
59
+ }));
60
+
52
61
  async function prepare(context) {
62
+ const { warnings } = await expand();
63
+ for (const warning of warnings) context.warn(warning);
53
64
  const { entries, skipped } = await collectComponents({ root, packages, modules, tsconfig: options.tsconfig, exclude: options.exclude ?? defaultExclude });
65
+ // A component the package compares by element type (`child.type === Footer`) would stop
66
+ // matching once the app renders the wrapper: leave it as it is, and say where.
67
+ const compared = new Map();
68
+ for (const entry of entries) {
69
+ if (entry.kind !== "package" || !entry.components.length) continue;
70
+ const name = packageName(entry.specifier);
71
+ if (!compared.has(name)) {
72
+ const dir = packageDirectory(name, root);
73
+ compared.set(name, dir ? await typeComparedNames(dir) : new Map());
74
+ }
75
+ const names = compared.get(name);
76
+ entry.components = entry.components.filter((component) => {
77
+ const file = names.get(component.name) ?? names.get(component.export);
78
+ if (file) skipped.push({ from: entry.specifier, name: component.export, reason: `compared by element type in ${file}` });
79
+ return !file;
80
+ });
81
+ }
54
82
  let count = 0;
55
83
  for (const [index, entry] of entries.entries()) {
56
84
  if (!entry.components.length) continue;
@@ -67,8 +95,13 @@ export function paletteComponents(options = {}) {
67
95
  return {
68
96
  name,
69
97
  enforce: "pre",
70
- config: () => ({ optimizeDeps: { include: [RUNTIME, ...packages] } }),
98
+ async config(config) {
99
+ root = resolve(config.root ?? process.cwd());
100
+ const { specifiers } = await expand();
101
+ return { optimizeDeps: { include: [RUNTIME, ...specifiers] } };
102
+ },
71
103
  configResolved(config) {
104
+ if (resolve(config.root) !== root) expanded = undefined;
72
105
  root = config.root;
73
106
  },
74
107
  buildStart() {
@@ -0,0 +1,77 @@
1
+ // Package lookups for @hazem3500/palette/vite: subpath globs, and components a package compares
2
+ // by element type. Node-only.
3
+ import { existsSync } from "node:fs";
4
+ import { readFile, readdir, stat } from "node:fs/promises";
5
+ import { dirname, join, relative, sep } from "node:path";
6
+
7
+ /** "@scope/ds/button" → "@scope/ds"; "ds/x" → "ds". */
8
+ export const packageName = (specifier) => specifier.split("/").slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
9
+
10
+ /** The directory of an installed package, looked up from the app root like Node does. */
11
+ export function packageDirectory(name, root) {
12
+ for (let dir = root; ; dir = dirname(dir)) {
13
+ const candidate = join(dir, "node_modules", ...name.split("/"));
14
+ if (existsSync(join(candidate, "package.json"))) return candidate;
15
+ if (dirname(dir) === dir) return undefined;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * `@scope/ds/*` becomes every subpath the package's `exports` map lists (`@scope/ds/button`, …),
21
+ * with the root entry when there is one. Pattern exports (`./*`) can't be listed and are reported.
22
+ */
23
+ export async function expandPackages(list, root) {
24
+ const specifiers = [], warnings = [];
25
+ for (const entry of list) {
26
+ if (!entry.endsWith("/*")) {
27
+ specifiers.push(entry);
28
+ continue;
29
+ }
30
+ const name = entry.slice(0, -2), dir = packageDirectory(name, root);
31
+ if (!dir) {
32
+ warnings.push(`${entry}: package not found`);
33
+ continue;
34
+ }
35
+ const exports = JSON.parse(await readFile(join(dir, "package.json"), "utf8")).exports;
36
+ const keys = exports && typeof exports === "object" && Object.keys(exports).some((key) => key.startsWith(".")) ? Object.keys(exports) : ["."];
37
+ for (const key of keys) {
38
+ if (key.includes("*")) warnings.push(`${entry}: "${key}" is a pattern export; list its subpaths explicitly`);
39
+ else if (key === "./package.json" || /\.(css|json)$/.test(key)) continue;
40
+ else specifiers.push(key === "." ? name : `${name}${key.slice(1)}`);
41
+ }
42
+ }
43
+ return { specifiers: [...new Set(specifiers)], warnings };
44
+ }
45
+
46
+ const SCRIPT = /\.(m|c)?js$/;
47
+ const LIMIT = 4_000_000;
48
+ // `x.type === Footer`, `Footer !== child.type`, `type === Footer` after destructuring.
49
+ const TYPE_LEFT = /\.type\s*[!=]==?\s*([A-Za-z_$][\w$]*)/g;
50
+ const TYPE_RIGHT = /([A-Za-z_$][\w$]*)\s*[!=]==?\s*[\w$\])?.]*\.type\b/g;
51
+
52
+ /**
53
+ * Names a package compares against an element's type in its own runtime code, with the first file
54
+ * that does. Instrumenting such a component would make the comparison fail for the app's elements.
55
+ * Minified packages rename their locals, so this finds what the shipped code still spells out.
56
+ */
57
+ export async function typeComparedNames(dir) {
58
+ const found = new Map();
59
+ let read = 0;
60
+ async function walk(path) {
61
+ for (const entry of await readdir(path, { withFileTypes: true })) {
62
+ if (entry.name === "node_modules" || entry.name.startsWith(".")) continue;
63
+ const file = join(path, entry.name);
64
+ if (entry.isDirectory()) await walk(file);
65
+ else if (SCRIPT.test(entry.name) && !entry.name.endsWith(".map")) {
66
+ const size = (await stat(file)).size;
67
+ if ((read += size) > LIMIT) return;
68
+ const code = await readFile(file, "utf8");
69
+ for (const pattern of [TYPE_LEFT, TYPE_RIGHT])
70
+ for (const match of code.matchAll(pattern))
71
+ if (/^[A-Z]/.test(match[1]) && !found.has(match[1])) found.set(match[1], relative(dir, file).split(sep).join("/"));
72
+ }
73
+ }
74
+ }
75
+ await walk(dir);
76
+ return found;
77
+ }