poveste 0.16.1 → 0.17.0

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
@@ -47,7 +47,7 @@ Run `npx poveste --help` for the full list of options.
47
47
 
48
48
  ## Requirements
49
49
 
50
- Node `>=22.22.2` and Vite `^8.0.0`. See
50
+ Node `>=24.15.0` and Vite `^8.0.0`. See
51
51
  [supported versions](https://poveste.dev/guide/getting-started#supported-versions) for
52
52
  what proves each range.
53
53
 
@@ -1,2 +1,9 @@
1
- export declare function serializeError(error: unknown, seen?: WeakSet<Error>): unknown;
1
+ /** What this module needs of a thrown value to be able to describe it. */
2
+ interface ErrorLike {
3
+ name: string;
4
+ message: string;
5
+ stack?: string | undefined;
6
+ }
7
+ export declare function serializeError(error: unknown, seen?: WeakSet<ErrorLike>): unknown;
2
8
  export declare function deserializeError(value: unknown): unknown;
9
+ export {};
@@ -7,6 +7,27 @@ const MARK = 'pvt:error';
7
7
  function isSerialized(value) {
8
8
  return typeof value === 'object' && value !== null && value.kind === MARK;
9
9
  }
10
+ /*
11
+ * Not `instanceof Error`, and not `Error.isError` either. A `DOMException` from
12
+ * jsdom fails both — the first because `runScripts: 'dangerously'` makes jsdom a
13
+ * separate realm, the second because jsdom's is a webidl2js object rather than a
14
+ * genuine error — while carrying a name, a message and a stack. It is what a
15
+ * story's own DOM call throws, and a clone of one retains nothing, so the host
16
+ * received `{}` (#1097).
17
+ *
18
+ * A `stack` is required and not merely read: `{ name, message }` is a shape a
19
+ * story may legitimately throw, and it should arrive as the data it is.
20
+ */
21
+ function errorLike(value) {
22
+ if (Error.isError(value)) {
23
+ return true;
24
+ }
25
+ if (typeof value !== 'object' || value === null) {
26
+ return false;
27
+ }
28
+ const { name, message, stack } = value;
29
+ return typeof name === 'string' && typeof message === 'string' && typeof stack === 'string';
30
+ }
10
31
  // A property the clone rejects takes the whole message down with it, which is worse
11
32
  // than dropping the property.
12
33
  function cloneable(value) {
@@ -25,7 +46,7 @@ function cloneable(value) {
25
46
  * the error it was called to describe and replace it with its own.
26
47
  */
27
48
  export function serializeError(error, seen = new WeakSet()) {
28
- if (!(error instanceof Error)) {
49
+ if (!errorLike(error)) {
29
50
  return cloneable(error) ? error : String(error);
30
51
  }
31
52
  if (seen.has(error)) {
@@ -1,5 +1,4 @@
1
1
  import { cpus } from 'node:os';
2
- import { escapeRegExp } from '@poveste/shared';
3
2
  import path, { relative } from 'pathe';
4
3
  import pc from 'picocolors';
5
4
  import { TEMP_PATH } from '../alias.js';
@@ -25,7 +24,7 @@ export function useCollectStories(options, ctx) {
25
24
  // @TODO temporary fix for https://github.com/histoire-dev/histoire/issues/409
26
25
  /vite\w*\/dist\/client\/(client|env).mjs/,
27
26
  ...ctx.config.viteNodeInlineDeps ?? [],
28
- new RegExp(escapeRegExp(path.resolve(TEMP_PATH, 'plugins'))),
27
+ new RegExp(RegExp.escape(path.resolve(TEMP_PATH, 'plugins'))),
29
28
  ],
30
29
  transformMode: ctx.config.viteNodeTransformMode,
31
30
  });
@@ -23,7 +23,10 @@ function createVirtualConsole() {
23
23
  if (type === JSDOM_ERROR_CSS_PARSING)
24
24
  return;
25
25
  if (type === JSDOM_ERROR_UNHANDLED_EXCEPTION) {
26
- globalThis.console.error((err.cause instanceof Error ? err.cause.stack : undefined) ?? err.message);
26
+ // `Error.isError` and not `instanceof`: the cause was constructed inside
27
+ // jsdom's realm, where `instanceof Error` is false and the stack — the
28
+ // only part that says which line threw — is discarded (#1093).
29
+ globalThis.console.error((Error.isError(err.cause) ? err.cause.stack : undefined) ?? err.message);
27
30
  }
28
31
  else {
29
32
  globalThis.console.error(err.message);
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Resolves `specifier` as Node would from `from`, to a real path, or `undefined`
3
+ * when nothing answers.
4
+ */
5
+ export type Resolver = (specifier: string, from: string) => string | undefined;
6
+ export declare const nodeResolver: Resolver;
7
+ export interface VendoredAlias {
8
+ /** The bare package the alias points at, `vue`. */
9
+ name: string;
10
+ /** The range it is pinned to, `^3.5.26`, or `''` when the spec carries none. */
11
+ range: string;
12
+ }
13
+ /**
14
+ * What each vendored dependency aliases, by the name it is installed under:
15
+ * `{ 'poveste-vue': { name: 'vue', range: '^3.5.26' } }` from
16
+ * `"poveste-vue": "npm:vue@^3.5.26"`.
17
+ *
18
+ * Derived from the manifest rather than listed, because the set is whatever
19
+ * `@poveste/vendors` declares and a list here would keep working while silently
20
+ * covering one name fewer. The range comes back with the name because the caller
21
+ * needs both and this is the only place the spec is parsed.
22
+ */
23
+ export declare function vendoredAliases(dependencies: Record<string, string> | undefined): Record<string, VendoredAlias>;
24
+ /**
25
+ * Whether `version` satisfies a caret range, for the caret ranges `@poveste/vendors`
26
+ * actually declares and **nothing else**.
27
+ *
28
+ * A range this cannot read with certainty is refused rather than approximated. That
29
+ * is the point: `poveste` has no `semver` dependency, a patch release is the wrong
30
+ * place to add one, and a hand-rolled `satisfies` that silently mis-handles a form it
31
+ * was never given is the failure this repo keeps producing. So `^X.Y.Z` with a major
32
+ * of at least 1 is answered, and `^0.x`, a prerelease, a comparator set or anything
33
+ * else returns false and leaves the chrome on the copy it can certainly run.
34
+ */
35
+ export declare function satisfiesCaret(version: string | undefined, range: string | undefined): boolean;
36
+ /**
37
+ * Points the chrome's Vue at the project's, when they are two copies.
38
+ *
39
+ * `@poveste/vendors` depends on `poveste-vue`, an npm alias of `vue`. An alias is
40
+ * a **distinct package name**, so a package manager installs a second physical copy
41
+ * whatever the project already has — the project's Vue satisfying the aliased range
42
+ * does not collapse them, and `resolve.dedupe` cannot, because it matches on name.
43
+ *
44
+ * Two copies is two reactivity systems. The chrome's components render with the
45
+ * vendored one while `RouterView` — reached through `poveste-vue-router`, whose own
46
+ * `vue` import is bare — reacts with the project's, so `RouterView`'s first render
47
+ * is never invalidated and a Vue consumer's `poveste dev` showed a sidebar, a
48
+ * breadcrumb and no story at all. Nothing warns: neither copy is wrong, they simply
49
+ * cannot see each other (#1060).
50
+ *
51
+ * Only Vue. The reactivity identity is what breaks, and `@poveste/plugin-vue`
52
+ * declaring `vue` as a peer is what licenses pointing the chrome at the project's
53
+ * copy — no peer range licenses the same for pinia, vue-router, VueUse or Iconify,
54
+ * and making the chrome's pinned copies authoritative for a project's own story code
55
+ * is a larger change than this defect asks for.
56
+ *
57
+ * Empty when the project has no Vue of its own, when the two already resolve to one
58
+ * file, or when the project's Vue does not satisfy the range vendors pins — in each
59
+ * case the vendored copy is what the chrome should keep. Refusing on anything it
60
+ * cannot read with certainty is deliberate: the failure of this function is a chrome
61
+ * that keeps a Vue it can certainly run, and the failure of guessing is a chrome
62
+ * handed one it cannot.
63
+ *
64
+ * The range is checked rather than assumed from the peer floor. A project only holds
65
+ * a Vue of its own because a framework plugin's peer range put it there — except that
66
+ * `--legacy-peer-deps` is exactly how a project ends up holding one the peer range
67
+ * would have refused (#1062), which leaves a reader on, say, Vue 3.2 with a chrome
68
+ * collapsed onto a Vue that cannot run it. Such a project is already broken without
69
+ * this fix, but "already broken" is not a reason to hand it a different break.
70
+ */
71
+ export declare function collapseVendoredVue(options: {
72
+ root: string;
73
+ resolve?: Resolver;
74
+ }): Record<string, string>;
@@ -0,0 +1,157 @@
1
+ import { readFileSync, realpathSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { join } from 'pathe';
4
+ export const nodeResolver = (specifier, from) => {
5
+ try {
6
+ // `createRequire` resolves relative to the *dirname* of what it is given, so a
7
+ // directory has to be named as a file inside it or every lookup starts one
8
+ // level too high.
9
+ return realpathSync(createRequire(join(from, 'noop.js')).resolve(specifier));
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ };
15
+ /**
16
+ * What each vendored dependency aliases, by the name it is installed under:
17
+ * `{ 'poveste-vue': { name: 'vue', range: '^3.5.26' } }` from
18
+ * `"poveste-vue": "npm:vue@^3.5.26"`.
19
+ *
20
+ * Derived from the manifest rather than listed, because the set is whatever
21
+ * `@poveste/vendors` declares and a list here would keep working while silently
22
+ * covering one name fewer. The range comes back with the name because the caller
23
+ * needs both and this is the only place the spec is parsed.
24
+ */
25
+ export function vendoredAliases(dependencies) {
26
+ const aliases = {};
27
+ for (const [installedAs, spec] of Object.entries(dependencies ?? {})) {
28
+ if (!spec.startsWith('npm:')) {
29
+ continue;
30
+ }
31
+ // Split on the last `@` rather than matching a name pattern: a scoped package
32
+ // opens with one, so the separator is positional and a regex that allows both
33
+ // is the kind the linter rejects for backtracking.
34
+ const target = spec.slice('npm:'.length);
35
+ const at = target.lastIndexOf('@');
36
+ aliases[installedAs] = at > 0
37
+ ? { name: target.slice(0, at), range: target.slice(at + 1) }
38
+ : { name: target, range: '' };
39
+ }
40
+ return aliases;
41
+ }
42
+ /**
43
+ * Whether `version` satisfies a caret range, for the caret ranges `@poveste/vendors`
44
+ * actually declares and **nothing else**.
45
+ *
46
+ * A range this cannot read with certainty is refused rather than approximated. That
47
+ * is the point: `poveste` has no `semver` dependency, a patch release is the wrong
48
+ * place to add one, and a hand-rolled `satisfies` that silently mis-handles a form it
49
+ * was never given is the failure this repo keeps producing. So `^X.Y.Z` with a major
50
+ * of at least 1 is answered, and `^0.x`, a prerelease, a comparator set or anything
51
+ * else returns false and leaves the chrome on the copy it can certainly run.
52
+ */
53
+ export function satisfiesCaret(version, range) {
54
+ const wanted = /^\^(\d+)\.(\d+)\.(\d+)$/.exec(range ?? '');
55
+ const have = /^(\d+)\.(\d+)\.(\d+)$/.exec(version ?? '');
56
+ if (!wanted || !have) {
57
+ return false;
58
+ }
59
+ const [wantedMajor, wantedMinor, wantedPatch] = wanted.slice(1).map(Number);
60
+ const [major, minor, patch] = have.slice(1).map(Number);
61
+ // `^0.x` narrows to the minor, and no vendored range uses it. Refuse rather than
62
+ // implement a rule nothing here exercises.
63
+ if (wantedMajor === 0 || wantedMajor === undefined || major === undefined) {
64
+ return false;
65
+ }
66
+ if (major !== wantedMajor) {
67
+ return false;
68
+ }
69
+ if (minor !== wantedMinor) {
70
+ return minor > wantedMinor;
71
+ }
72
+ return patch >= wantedPatch;
73
+ }
74
+ /**
75
+ * Points the chrome's Vue at the project's, when they are two copies.
76
+ *
77
+ * `@poveste/vendors` depends on `poveste-vue`, an npm alias of `vue`. An alias is
78
+ * a **distinct package name**, so a package manager installs a second physical copy
79
+ * whatever the project already has — the project's Vue satisfying the aliased range
80
+ * does not collapse them, and `resolve.dedupe` cannot, because it matches on name.
81
+ *
82
+ * Two copies is two reactivity systems. The chrome's components render with the
83
+ * vendored one while `RouterView` — reached through `poveste-vue-router`, whose own
84
+ * `vue` import is bare — reacts with the project's, so `RouterView`'s first render
85
+ * is never invalidated and a Vue consumer's `poveste dev` showed a sidebar, a
86
+ * breadcrumb and no story at all. Nothing warns: neither copy is wrong, they simply
87
+ * cannot see each other (#1060).
88
+ *
89
+ * Only Vue. The reactivity identity is what breaks, and `@poveste/plugin-vue`
90
+ * declaring `vue` as a peer is what licenses pointing the chrome at the project's
91
+ * copy — no peer range licenses the same for pinia, vue-router, VueUse or Iconify,
92
+ * and making the chrome's pinned copies authoritative for a project's own story code
93
+ * is a larger change than this defect asks for.
94
+ *
95
+ * Empty when the project has no Vue of its own, when the two already resolve to one
96
+ * file, or when the project's Vue does not satisfy the range vendors pins — in each
97
+ * case the vendored copy is what the chrome should keep. Refusing on anything it
98
+ * cannot read with certainty is deliberate: the failure of this function is a chrome
99
+ * that keeps a Vue it can certainly run, and the failure of guessing is a chrome
100
+ * handed one it cannot.
101
+ *
102
+ * The range is checked rather than assumed from the peer floor. A project only holds
103
+ * a Vue of its own because a framework plugin's peer range put it there — except that
104
+ * `--legacy-peer-deps` is exactly how a project ends up holding one the peer range
105
+ * would have refused (#1062), which leaves a reader on, say, Vue 3.2 with a chrome
106
+ * collapsed onto a Vue that cannot run it. Such a project is already broken without
107
+ * this fix, but "already broken" is not a reason to hand it a different break.
108
+ */
109
+ export function collapseVendoredVue(options) {
110
+ const { root } = options;
111
+ const resolve = options.resolve ?? nodeResolver;
112
+ // From the vendors directory, not from the project: that is where the import
113
+ // happens, and an isolated `node_modules` layout makes `poveste-vue` invisible
114
+ // from the project root while the chrome resolves it perfectly well.
115
+ // Through an exported subpath: vendors' `exports` map does not list
116
+ // `./package.json`, so resolving that name directly fails.
117
+ const vendorsEntry = resolve('@poveste/vendors/vue', root);
118
+ const vendorsDir = vendorsEntry ? join(vendorsEntry, '..') : undefined;
119
+ if (!vendorsDir) {
120
+ return {};
121
+ }
122
+ const vueAlias = Object.entries(vendoredAliases(readJson(join(vendorsDir, 'package.json'))?.dependencies))
123
+ .find(([, alias]) => alias.name === 'vue');
124
+ if (!vueAlias) {
125
+ return {};
126
+ }
127
+ const [installedAs, { range }] = vueAlias;
128
+ const vendored = resolve(installedAs, vendorsDir);
129
+ const project = resolve('vue', root);
130
+ if (!vendored || !project || vendored === project) {
131
+ return {};
132
+ }
133
+ if (!satisfiesCaret(versionOf('vue/package.json', resolve, root), range)) {
134
+ return {};
135
+ }
136
+ // To the bare name, not to `project`. A resolved path is whatever the `require`
137
+ // condition answers — for Vue that is a CJS shim, and handing the chrome that
138
+ // replaced a split reactivity system with a browser app that does not boot at all.
139
+ // The name lets Vite resolve its own entry and condition, and `resolve.dedupe`
140
+ // above then genuinely covers the result.
141
+ return { [installedAs]: 'vue' };
142
+ }
143
+ function versionOf(specifier, resolve, root) {
144
+ const manifest = resolve(specifier, root);
145
+ if (!manifest) {
146
+ return undefined;
147
+ }
148
+ return readJson(manifest)?.version;
149
+ }
150
+ function readJson(file) {
151
+ try {
152
+ return JSON.parse(readFileSync(file, 'utf8'));
153
+ }
154
+ catch {
155
+ return undefined;
156
+ }
157
+ }
package/dist/node/vite.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { createRequire } from 'node:module';
3
- import { escapeRegExp } from '@poveste/shared';
4
3
  import { lookup as lookupMime } from 'mrmime';
5
4
  import { dirname, join, relative } from 'pathe';
6
5
  import { loadConfigFromFile as loadViteConfigFromFile, mergeConfig as mergeViteConfig, searchForWorkspaceRoot, } from 'vite';
@@ -11,6 +10,7 @@ import { notifyStoryChange } from './stories.js';
11
10
  import { chromeCssScopePlugin, globalStylesPlugin, userCssScopePlugin, } from './style-isolation/index.js';
12
11
  import { applyHeadTransform } from './util/head.js';
13
12
  import { viteCommand, viteMode } from './util/vite-mode.js';
13
+ import { collapseVendoredVue } from './vendors.js';
14
14
  import { createVirtualFilesPlugin } from './virtual/vite-plugin.js';
15
15
  const require = createRequire(import.meta.url);
16
16
  export async function mergePovesteViteConfig(viteConfig, ctx) {
@@ -153,6 +153,14 @@ export async function getViteConfigWithPlugins(isServer, ctx) {
153
153
  ],
154
154
  alias: {
155
155
  'poveste-style': join(APP_PATH, process.env['POVESTE_DEV'] ? 'app/style/main.pcss' : 'style.css'),
156
+ // `dedupe` above cannot reach this one: the chrome's Vue is installed
157
+ // under a different name, and dedupe matches on the name (#1060).
158
+ //
159
+ // Not under `POVESTE_DEV`. That path already aliases bare `vue` to
160
+ // `@poveste/vendors/vue`, so sending `poveste-vue` back to `vue` would
161
+ // point the vendored entry at itself — the empty re-export its own
162
+ // comment warns about.
163
+ ...(isServer || process.env['POVESTE_DEV'] ? {} : collapseVendoredVue({ root: ctx.root })),
156
164
  },
157
165
  ...(isServer
158
166
  ? {
@@ -338,7 +346,7 @@ export async function getViteConfigWithPlugins(isServer, ctx) {
338
346
  if (/\.(?:vue|js)(?:$|\?)/.test(id)) {
339
347
  const original = code;
340
348
  for (const [flag, value] of Object.entries(flags)) {
341
- code = code.replace(new RegExp(escapeRegExp(flag), 'g'), value);
349
+ code = code.replace(new RegExp(RegExp.escape(flag), 'g'), value);
342
350
  }
343
351
  if (original !== code)
344
352
  return code;
@@ -398,11 +406,11 @@ export async function getViteConfigWithPlugins(isServer, ctx) {
398
406
  ['vue', 'vue'],
399
407
  ].reduce((acc, [name, entry]) => {
400
408
  acc.push({
401
- find: new RegExp(`^${escapeRegExp(name)}$`),
409
+ find: new RegExp(`^${RegExp.escape(name)}$`),
402
410
  replacement: `@poveste/vendors/${entry}`,
403
411
  });
404
412
  acc.push({
405
- find: new RegExp(`^${escapeRegExp(name)}/`),
413
+ find: new RegExp(`^${RegExp.escape(name)}/`),
406
414
  replacement: `@poveste/vendors/${entry}/`,
407
415
  });
408
416
  return acc;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poveste",
3
3
  "type": "module",
4
- "version": "0.16.1",
4
+ "version": "0.17.0",
5
5
  "description": "Interactive component playgrounds for Vue, Nuxt, Svelte, SvelteKit and Quasar — a drop-in fork of histoire, with Quasar support histoire does not ship.",
6
6
  "author": {
7
7
  "name": "Sorin Gitlan",
@@ -67,17 +67,17 @@
67
67
  "plugin.js"
68
68
  ],
69
69
  "engines": {
70
- "node": ">=22.22.2"
70
+ "node": ">=24.15.0"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "vite": "^8.0.0"
74
74
  },
75
75
  "dependencies": {
76
76
  "@11ty/gray-matter": "^3.0.0",
77
- "@poveste/app": "^0.16.1",
78
- "@poveste/controls": "^0.16.1",
79
- "@poveste/shared": "^0.16.1",
80
- "@poveste/vendors": "^0.16.1",
77
+ "@poveste/app": "^0.17.0",
78
+ "@poveste/controls": "^0.17.0",
79
+ "@poveste/shared": "^0.17.0",
80
+ "@poveste/vendors": "^0.17.0",
81
81
  "change-case": "^5.4.4",
82
82
  "chokidar": "^5.0.0",
83
83
  "defu": "^6.1.4",