poveste 0.5.2 → 0.6.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.
Files changed (62) hide show
  1. package/dist/node/bin.js +2 -1
  2. package/dist/node/build.d.ts.map +1 -1
  3. package/dist/node/build.js +31 -0
  4. package/dist/node/builtin-plugins/pinceau-tokens.d.ts.map +1 -1
  5. package/dist/node/builtin-plugins/pinceau-tokens.js +4 -3
  6. package/dist/node/commands/dev.d.ts +1 -1
  7. package/dist/node/commands/dev.d.ts.map +1 -1
  8. package/dist/node/commands/dev.js +3 -1
  9. package/dist/node/commands/port.d.ts +18 -0
  10. package/dist/node/commands/port.d.ts.map +1 -0
  11. package/dist/node/commands/port.js +35 -0
  12. package/dist/node/commands/preview.d.ts +1 -1
  13. package/dist/node/commands/preview.d.ts.map +1 -1
  14. package/dist/node/commands/preview.js +3 -1
  15. package/dist/node/config.d.ts.map +1 -1
  16. package/dist/node/config.js +9 -3
  17. package/dist/node/index.d.ts +1 -0
  18. package/dist/node/index.d.ts.map +1 -1
  19. package/dist/node/index.js +3 -0
  20. package/dist/node/markdown.d.ts.map +1 -1
  21. package/dist/node/markdown.js +2 -10
  22. package/dist/node/preview.d.ts.map +1 -1
  23. package/dist/node/preview.js +6 -1
  24. package/dist/node/server.d.ts.map +1 -1
  25. package/dist/node/server.js +22 -1
  26. package/dist/node/stories.d.ts.map +1 -1
  27. package/dist/node/stories.js +2 -10
  28. package/dist/node/util/watch-ignore.d.ts +3 -0
  29. package/dist/node/util/watch-ignore.d.ts.map +1 -0
  30. package/dist/node/util/watch-ignore.js +12 -0
  31. package/dist/node/virtual/resolved-generated-global-setup.d.ts.map +1 -1
  32. package/dist/node/virtual/resolved-generated-global-setup.js +13 -5
  33. package/dist/node/virtual/util.d.ts +11 -0
  34. package/dist/node/virtual/util.d.ts.map +1 -1
  35. package/dist/node/virtual/util.js +13 -0
  36. package/dist/node/vite.d.ts.map +1 -1
  37. package/dist/node/vite.js +12 -2
  38. package/package.json +5 -5
  39. package/src/node/__tests__/config.spec.ts +25 -0
  40. package/src/node/__tests__/port.spec.ts +65 -0
  41. package/src/node/__tests__/virtual-setup.spec.ts +13 -3
  42. package/src/node/__tests__/watch-ignore.spec.ts +33 -0
  43. package/src/node/bin.ts +2 -1
  44. package/src/node/build.ts +34 -0
  45. package/src/node/builtin-plugins/pinceau-tokens.ts +5 -3
  46. package/src/node/commands/dev.ts +5 -2
  47. package/src/node/commands/port.ts +41 -0
  48. package/src/node/commands/preview.ts +5 -2
  49. package/src/node/config.ts +10 -3
  50. package/src/node/index.ts +3 -0
  51. package/src/node/markdown.ts +2 -11
  52. package/src/node/preview.ts +6 -1
  53. package/src/node/server.ts +24 -1
  54. package/src/node/stories.ts +2 -11
  55. package/src/node/util/watch-ignore.ts +13 -0
  56. package/src/node/virtual/resolved-generated-global-setup.ts +13 -5
  57. package/src/node/virtual/util.ts +14 -0
  58. package/src/node/vite.ts +12 -1
  59. package/dist/node/builtin-plugins/tailwind-tokens.d.ts +0 -11
  60. package/dist/node/builtin-plugins/tailwind-tokens.d.ts.map +0 -1
  61. package/dist/node/builtin-plugins/tailwind-tokens.js +0 -659
  62. package/src/node/builtin-plugins/tailwind-tokens.ts +0 -692
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { getDefaultConfig, mergeConfig } from '../config.js'
3
+
4
+ // `mergeConfig(user, defaults)`: defu-style, the first argument wins.
5
+ describe('mergeConfig', () => {
6
+ it('adds user storyIgnored patterns to the defaults instead of replacing them', () => {
7
+ const merged = mergeConfig({ storyIgnored: ['**/fixtures/**'] }, getDefaultConfig())
8
+
9
+ expect(merged.storyIgnored).toEqual(expect.arrayContaining(['**/node_modules/**', '**/dist/**', '**/fixtures/**']))
10
+ })
11
+
12
+ it('does not duplicate a default a user spelled out again', () => {
13
+ const merged = mergeConfig({ storyIgnored: ['**/node_modules/**', '**/fixtures/**'] }, getDefaultConfig())
14
+
15
+ expect(merged.storyIgnored.filter(p => p === '**/node_modules/**')).toHaveLength(1)
16
+ })
17
+
18
+ // Narrowing the match is a real use — `src/**/*.story.vue` to skip binary
19
+ // folders — so the replace rule is the right one there.
20
+ it('still lets a user narrow storyMatch', () => {
21
+ const merged = mergeConfig({ storyMatch: ['src/**/*.story.vue'] }, getDefaultConfig())
22
+
23
+ expect(merged.storyMatch).toEqual(['src/**/*.story.vue'])
24
+ })
25
+ })
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { hmrPortFor, resolvePort } from '../commands/port.js'
3
+
4
+ describe('resolvePort', () => {
5
+ it('returns undefined when the flag was not passed', () => {
6
+ expect(resolvePort(undefined, 'preview')).toBeUndefined()
7
+ })
8
+
9
+ it('parses the number sade hands over as a string', () => {
10
+ expect(resolvePort('4569', 'preview')).toBe(4569)
11
+ })
12
+
13
+ it('passes a number through', () => {
14
+ expect(resolvePort(6006, 'dev')).toBe(6006)
15
+ })
16
+
17
+ // sade declares `--port <port>` but does not enforce the value, so a bare
18
+ // `--port` arrives as `true`. `Number(true)` is 1 — a valid port that binds
19
+ // to a privileged one — so this has to be rejected by type.
20
+ it.each([
21
+ ['a valueless flag', true],
22
+ ['a non-numeric value', 'abc'],
23
+ ['an empty value', ''],
24
+ ['a port above the range', '99999'],
25
+ ['a fractional port', '80.5'],
26
+ ])('rejects %s', (_name, value) => {
27
+ expect(() => resolvePort(value, 'preview')).toThrow(/--port needs a number/)
28
+ })
29
+
30
+ it('names the command in the message, so the hint is runnable', () => {
31
+ expect(() => resolvePort(true, 'dev')).toThrow(/poveste dev --port 6006/)
32
+ })
33
+ })
34
+
35
+ // `@nuxt/vite-builder` otherwise pins every server's HMR socket to the constant
36
+ // 24678, so two dev servers on different `--port`s collide and the second
37
+ // silently loses HMR (#221). The socket has to be as unique as the book port.
38
+ describe('hmrPortFor', () => {
39
+ it('derives a port distinct from the dev port', () => {
40
+ expect(hmrPortFor(6006)).not.toBe(6006)
41
+ })
42
+
43
+ it('gives distinct dev ports distinct sockets', () => {
44
+ expect(hmrPortFor(4567)).not.toBe(hmrPortFor(4568))
45
+ })
46
+
47
+ it('never lands on the framework default that collides', () => {
48
+ for (const p of [4567, 4568, 6006, 8080]) {
49
+ expect(hmrPortFor(p)).not.toBe(24678)
50
+ }
51
+ })
52
+
53
+ it('stays within the valid port range, even for a high dev port', () => {
54
+ for (const p of [1024, 6006, 45535, 45536, 65535]) {
55
+ const hmr = hmrPortFor(p)
56
+ expect(hmr).toBeGreaterThanOrEqual(0)
57
+ expect(hmr).toBeLessThanOrEqual(65535)
58
+ expect(hmr).not.toBe(p)
59
+ }
60
+ })
61
+
62
+ it('falls back to the default dev port when none was given', () => {
63
+ expect(hmrPortFor(undefined)).toBe(hmrPortFor(6006))
64
+ })
65
+ })
@@ -29,13 +29,23 @@ describe('virtual setup modules', () => {
29
29
  }
30
30
  })
31
31
 
32
- it('should export a real hook per support plugin when there is setup code', () => {
32
+ it('exports each plugin\'s canonical hook as a real function when there is setup code', () => {
33
33
  const code = resolvedGeneratedGlobalSetup(contextFactory({ setupCode: ['export function setupVue3() {}'] }))
34
34
 
35
- for (const fnName of SETUP_FN_NAMES) {
36
- expect(code).toContain(`export async function ${fnName} (payload)`)
35
+ for (const canonical of ['setupVanilla', 'setupVue3', 'setupSvelte4']) {
36
+ expect(code).toContain(`export async function ${canonical} (payload)`)
37
37
  }
38
38
  })
39
+
40
+ // setupSvelte5 is an alias of setupSvelte4. Emitting both as functions made
41
+ // getSetupHook see two interchangeable hooks in poveste's own generated
42
+ // module and warn once per story (#231).
43
+ it('declares alias hooks undefined rather than as functions', () => {
44
+ const code = resolvedGeneratedGlobalSetup(contextFactory({ setupCode: ['export function setupVue3() {}'] }))
45
+
46
+ expect(code).toContain('export const setupSvelte5 = undefined')
47
+ expect(code).not.toContain('export async function setupSvelte5 (payload)')
48
+ })
39
49
  })
40
50
 
41
51
  describe('noop', () => {
@@ -0,0 +1,33 @@
1
+ import type { Stats } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createWatchIgnore } from '../util/watch-ignore.js'
4
+
5
+ const file = { isFile: () => true } as Stats
6
+ const dir = { isFile: () => false } as Stats
7
+
8
+ describe('createWatchIgnore', () => {
9
+ const root = '/repo/.claude/worktrees/feature/examples/vue3'
10
+ const ignored = createWatchIgnore(['**/node_modules/**', '**/dist/**'], ['**/*.story.md'])
11
+
12
+ it('ignores storyIgnored paths when the root sits under a dot directory', () => {
13
+ expect(ignored(`${root}/node_modules/foo/index.js`, file)).toBe(true)
14
+ expect(ignored(`${root}/node_modules/.pnpm/foo/node_modules/bar`, dir)).toBe(true)
15
+ expect(ignored(`${root}/dist/index.html`, file)).toBe(true)
16
+ })
17
+
18
+ it('keeps included files when the root sits under a dot directory', () => {
19
+ expect(ignored(`${root}/src/Button.story.md`, file)).toBe(false)
20
+ })
21
+
22
+ it('traverses directories and skips other files', () => {
23
+ expect(ignored(`${root}/src`, dir)).toBe(false)
24
+ expect(ignored(`${root}/src/Button.vue`, file)).toBe(true)
25
+ expect(ignored(`${root}/src/Button.vue`)).toBe(false)
26
+ })
27
+
28
+ it('matches patterns resolved to absolute paths', () => {
29
+ const resolved = createWatchIgnore([`${root}/**/node_modules/**`], [`${root}/**/*.story.vue`])
30
+ expect(resolved(`${root}/node_modules/.pnpm/foo/node_modules/bar/index.js`, file)).toBe(true)
31
+ expect(resolved(`${root}/src/Button.story.vue`, file)).toBe(false)
32
+ })
33
+ })
package/src/node/bin.ts CHANGED
@@ -7,8 +7,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
7
7
 
8
8
  const { version } = JSON.parse(fs.readFileSync(resolve(__dirname, '../../package.json'), 'utf8'))
9
9
 
10
- process.env.NODE_ENV = 'development'
10
+ process.env.NODE_ENV = process.argv.includes('build') || process.argv.includes('preview') ? 'production' : 'development'
11
11
  process.env.HISTOIRE = 'true'
12
+ process.env.POVESTE = 'true'
12
13
 
13
14
  const program = sade('poveste')
14
15
  program.version(version)
package/src/node/build.ts CHANGED
@@ -39,6 +39,24 @@ const PRELOAD_MODULES = [
39
39
  'vendor',
40
40
  ]
41
41
 
42
+ // Kept out of the shared `vendor` chunk because only the app UI imports them,
43
+ // while `vendor` is imported by the sandbox too.
44
+ //
45
+ // Shiki is the source panel's syntax highlighter: 386 grammars and 60 themes,
46
+ // and it was the bulk of an 11.5 MB `vendor`. The sandbox entry pulls seven Vue
47
+ // exports from that chunk and nothing else, but a grid renders one sandbox
48
+ // iframe per cell and each iframe is its own realm — so every cell parsed and
49
+ // executed all 11.5 MB again, with the HTTP cache saving only the download.
50
+ // That was 34% of a scroll profile spent in top-level vendor evaluation (#103).
51
+ //
52
+ // They go to a chunk of their own rather than being left to Rollup: leaving
53
+ // placement automatic emitted one chunk per grammar — 402 assets — and tripled
54
+ // the blocking time it was meant to cut.
55
+ const APP_ONLY_VENDORS = [
56
+ /[\\/]node_modules[\\/]shiki[\\/]/,
57
+ /[\\/]node_modules[\\/]@shikijs[\\/]/,
58
+ ]
59
+
42
60
  const PREFETCHED_MODULES = [
43
61
  'StoryView',
44
62
  'reactivity',
@@ -155,7 +173,23 @@ export async function build(ctx: Context) {
155
173
  // Force chunk strategy
156
174
  config.build.rollupOptions.output = {
157
175
  manualChunks(id) {
176
+ // Vite's runtime helpers (`\0vite/preload-helper.js` and friends) are
177
+ // virtual modules, so the node_modules routing below never sees them
178
+ // and their placement is left to the bundler — which parked
179
+ // `__vitePreload` inside the 10 MB `highlighter` chunk. Every chunk
180
+ // importing the helper then statically imported the whole highlighter,
181
+ // and the sandbox was back to evaluating it once per grid cell (#197),
182
+ // undoing exactly what `APP_ONLY_VENDORS` is for. Pin them somewhere
183
+ // harmless instead of leaving the choice to chunking heuristics.
184
+ if (id.replace(/^\0/, '').startsWith('vite/')) {
185
+ return 'app-runtime'
186
+ }
187
+
158
188
  if (!id.includes('@poveste/app') && id.includes('node_modules')) {
189
+ if (APP_ONLY_VENDORS.some(test => test.test(id))) {
190
+ return 'highlighter'
191
+ }
192
+
159
193
  for (const test of ctx.config.build?.excludeFromVendorsChunk ?? []) {
160
194
  if ((
161
195
  typeof test === 'string' && id.includes(test)
@@ -52,7 +52,9 @@ export function pinceauTokens(_options: PinceauTokensOptions = {}): Plugin {
52
52
  }
53
53
  },
54
54
 
55
- onDev(api, onCleanup) {
55
+ async onDev(api, onCleanup) {
56
+ await generate(api)
57
+
56
58
  const watcher = api.watcher.watch(themePath)
57
59
  .on('change', () => generate(api))
58
60
  .on('add', () => generate(api))
@@ -71,8 +73,8 @@ export function pinceauTokens(_options: PinceauTokensOptions = {}): Plugin {
71
73
  function storyTemplate(pinceauConfig: any) {
72
74
  pinceauConfig = pinceauConfig?.theme || pinceauConfig?.default?.theme || {}
73
75
 
74
- return `import 'poveste-style'
75
- import './style.css'
76
+ // No `import 'poveste-style'` — see the tailwind plugin's template (#219).
77
+ return `import './style.css'
76
78
  import { createApp, h, markRaw, ref } from ${getInjectedImport('@poveste/vendors/vue')}
77
79
  import {
78
80
  HstColorShades,
@@ -4,15 +4,18 @@ import pc from 'picocolors'
4
4
  import { resolveConfigFile } from '../config.js'
5
5
  import { createContext } from '../context.js'
6
6
  import { createServer } from '../server.js'
7
+ import { resolvePort } from './port.js'
7
8
 
8
9
  export interface DevOptions {
9
- port: number
10
+ port?: number | string
10
11
  open?: boolean
11
12
  host?: string | boolean
12
13
  config?: string
13
14
  }
14
15
 
15
16
  export async function devCommand(options: DevOptions) {
17
+ const port = resolvePort(options.port, 'dev')
18
+
16
19
  let stop: () => Promise<void>
17
20
 
18
21
  async function start() {
@@ -21,7 +24,7 @@ export async function devCommand(options: DevOptions) {
21
24
  mode: 'dev',
22
25
  })
23
26
  const { server, viteConfigFile, close } = await createServer(ctx, {
24
- port: options.port,
27
+ port,
25
28
  open: options.open,
26
29
  host: options.host,
27
30
  })
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Reads a `--port` value off the parsed CLI options.
3
+ *
4
+ * sade declares the flag as taking a value but does not enforce one, so
5
+ * `poveste preview --port` arrives as the boolean `true` and reaches node's
6
+ * listen validation, which rejects it with `ERR_INVALID_ARG_VALUE` and names
7
+ * neither the flag nor the mistake. A non-numeric value fails the same way.
8
+ */
9
+ export function resolvePort(value: unknown, command: string): number | undefined {
10
+ if (value === undefined || value === null) {
11
+ return undefined
12
+ }
13
+
14
+ // Checked by type, not by coercion: a valueless flag arrives as `true`, and
15
+ // `Number(true)` is 1, a valid port that would bind to a privileged one.
16
+ const port = typeof value === 'number'
17
+ ? value
18
+ : typeof value === 'string' && value.trim() !== ''
19
+ ? Number(value)
20
+ : Number.NaN
21
+
22
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
23
+ throw new TypeError(
24
+ `--port needs a number between 0 and 65535, got ${JSON.stringify(value)}. Try \`poveste ${command} --port 6006\`.`,
25
+ )
26
+ }
27
+
28
+ return port
29
+ }
30
+
31
+ /**
32
+ * The HMR socket port for a book served on `devPort`. Derived from the dev port
33
+ * so each concurrent dev server's socket is as distinct as its book port (#175),
34
+ * offset clear of the usual dev-port range and wrapped so it stays a valid port
35
+ * for an unusually high `--port`. `@nuxt/vite-builder` otherwise pins every
36
+ * server to the framework default (24678), a constant that collides (#221).
37
+ */
38
+ export function hmrPortFor(devPort: number | undefined): number {
39
+ const base = devPort ?? 6006
40
+ return base < 45536 ? base + 20000 : base - 20000
41
+ }
@@ -1,13 +1,16 @@
1
1
  import { createContext } from '../context.js'
2
2
  import { startPreview } from '../preview.js'
3
+ import { resolvePort } from './port.js'
3
4
 
4
5
  export interface PreviewOptions {
5
- port?: number
6
+ port?: number | string
6
7
  host?: string | boolean
7
8
  open?: boolean
8
9
  }
9
10
 
10
11
  export async function previewCommand(options: PreviewOptions) {
12
+ const port = resolvePort(options.port, 'preview')
13
+
11
14
  const ctx = await createContext({
12
15
  mode: 'build',
13
16
  })
@@ -19,7 +22,7 @@ export async function previewCommand(options: PreviewOptions) {
19
22
  }
20
23
 
21
24
  const { printUrls } = await startPreview({
22
- port: options.port,
25
+ port,
23
26
  host: options.host,
24
27
  open: options.open,
25
28
  }, ctx)
@@ -15,7 +15,6 @@ import {
15
15
  mergeConfig as mergeViteConfig,
16
16
  resolveConfig as resolveViteConfig,
17
17
  } from 'vite'
18
- import { tailwindTokens } from './builtin-plugins/tailwind-tokens.js'
19
18
  import { vanillaSupport } from './builtin-plugins/vanilla-support/plugin.js'
20
19
  import { defaultColors } from './colors.js'
21
20
  import { findUp } from './util/find-up.js'
@@ -26,7 +25,6 @@ export function getDefaultConfig(): PovesteConfig {
26
25
  return {
27
26
  plugins: [
28
27
  vanillaSupport(),
29
- tailwindTokens(),
30
28
  ],
31
29
  outDir: '.poveste/dist',
32
30
  storyMatch: [
@@ -122,7 +120,6 @@ export function getDefaultConfig(): PovesteConfig {
122
120
  contrastColor: '#eee',
123
121
  },
124
122
  ],
125
- sandboxDarkClass: 'dark',
126
123
  routerMode: 'history',
127
124
  build: {
128
125
  excludeFromVendorsChunk: [],
@@ -251,6 +248,16 @@ export const mergeConfig = createDefu((obj: any, key, value) => {
251
248
  return true
252
249
  }
253
250
 
251
+ // An ignore list adds to the defaults rather than replacing them. Under the
252
+ // replace rule below, `storyIgnored: ['**/fixtures/**']` silently dropped
253
+ // `**/node_modules/**`, and the watchers then crawled the pnpm store until
254
+ // the build died with EMFILE (#244). `storyMatch` stays on the replace rule:
255
+ // narrowing it is a legitimate use.
256
+ if (obj[key] && key === 'storyIgnored') {
257
+ obj[key] = [...new Set([...obj[key], ...value])]
258
+ return true
259
+ }
260
+
254
261
  // By default, arrays should be replaced
255
262
  if (obj[key] && Array.isArray(obj[key])) {
256
263
  obj[key] = value
package/src/node/index.ts CHANGED
@@ -1,4 +1,7 @@
1
1
  export { defaultColors } from './colors.js'
2
2
  export * from './config.js'
3
3
  export * from './plugin.js'
4
+ // A plugin that generates story source needs to reference poveste's own
5
+ // vendored copies of Vue and the controls from inside that generated code.
6
+ export { getInjectedImport } from './util/vendors.js'
4
7
  export * from '@poveste/shared'
@@ -10,12 +10,12 @@ import MarkdownIt from 'markdown-it'
10
10
  import anchor from 'markdown-it-anchor'
11
11
  import attrs from 'markdown-it-attrs'
12
12
  import { full as emoji } from 'markdown-it-emoji'
13
- import micromatch from 'micromatch'
14
13
  import path from 'pathe'
15
14
  import pc from 'picocolors'
16
15
  import { bundledLanguages, createHighlighter } from 'shiki'
17
16
  import { addStory, notifyStoryChange, removeStory } from './stories.js'
18
17
  import { slugify } from './util/slugify.js'
18
+ import { createWatchIgnore } from './util/watch-ignore.js'
19
19
 
20
20
  const onMarkdownListChangeHandlers: (() => unknown)[] = []
21
21
 
@@ -161,16 +161,7 @@ export async function createMarkdownFilesWatcher(ctx: Context) {
161
161
 
162
162
  const watcher = chokidar.watch('.', {
163
163
  cwd: ctx.root,
164
- ignored: (path, stats) => {
165
- if (ctx.config.storyIgnored.some(pattern => micromatch.isMatch(path, pattern))) {
166
- return true
167
- }
168
- if (micromatch.isMatch(path, '**/*.story.md')) {
169
- return false
170
- }
171
-
172
- return stats?.isFile()
173
- },
164
+ ignored: createWatchIgnore(ctx.config.storyIgnored, ['**/*.story.md']),
174
165
  })
175
166
 
176
167
  /**
@@ -26,7 +26,12 @@ export async function startPreview(options: StartPreviewOptions, ctx: Context):
26
26
  port,
27
27
  host: options.host,
28
28
  open: options.open,
29
- strictPort: false,
29
+ // A port asked for by name has to be that port or nothing. Moving to the
30
+ // next free one serves the book where nobody is looking, and lands it on
31
+ // whatever example owns that number, whose suite then adopts it (#175).
32
+ // Without an explicit port there is no such expectation, and `build`'s
33
+ // internal preview reads the port back off the server.
34
+ strictPort: options.port != null,
30
35
  },
31
36
  })
32
37
 
@@ -4,6 +4,7 @@ import { performance } from 'node:perf_hooks'
4
4
  import pc from 'picocolors'
5
5
  import { createServer as createViteServer, mergeConfig as mergeViteConfig } from 'vite'
6
6
  import { useCollectStories } from './collect/index.js'
7
+ import { hmrPortFor } from './commands/port.js'
7
8
  import { useModuleLoader } from './load.js'
8
9
  import { createMarkdownFilesWatcher, onMarkdownListChange } from './markdown.js'
9
10
  import { DevEventPluginApi, DevPluginApi } from './plugin.js'
@@ -22,7 +23,22 @@ export async function createServer(ctx: Context, options: CreateServerOptions =
22
23
  const getViteServer = async (collecting: boolean) => {
23
24
  const { viteConfig, viteConfigFile } = await getViteConfigWithPlugins(collecting, ctx)
24
25
 
25
- if (!collecting) {
26
+ if (collecting) {
27
+ // The collection server drives vite-node and has no browser, so it needs
28
+ // no HMR socket. Left enabled, `@nuxt/vite-builder` gives it one on the
29
+ // framework default port (24678) — see the book server below.
30
+ viteConfig.server.hmr = false
31
+ }
32
+ else {
33
+ // `@nuxt/vite-builder` pins the HMR socket to the constant 24678 on every
34
+ // server it configures, so two poveste dev servers on different `--port`s
35
+ // collide on it and the second silently loses HMR (#221). It only does
36
+ // this when `server.hmr` carries no port of its own (it fills the gap with
37
+ // `defu`), so pin the socket to a port derived from this book's `--port`,
38
+ // before the config is resolved: each dev server then owns a distinct one
39
+ // (#175).
40
+ viteConfig.server.hmr = { port: hmrPortFor(options.port ?? viteConfig.server.port) }
41
+
26
42
  if (options.open) {
27
43
  viteConfig.server.open = true
28
44
  }
@@ -30,6 +46,13 @@ export async function createServer(ctx: Context, options: CreateServerOptions =
30
46
  if (options.host) {
31
47
  viteConfig.server.host = options.host
32
48
  }
49
+
50
+ // Same rule as `preview`: a port asked for by name is that port or
51
+ // nothing. Floating to the next free one hands back a server nobody asked
52
+ // for, and whoever opens the port they typed reads a different book.
53
+ if (options.port != null) {
54
+ viteConfig.server.strictPort = true
55
+ }
33
56
  }
34
57
 
35
58
  const server = await createViteServer(
@@ -5,6 +5,7 @@ import chokidar from 'chokidar'
5
5
  import { globby } from 'globby'
6
6
  import micromatch from 'micromatch'
7
7
  import { basename, resolve } from 'pathe'
8
+ import { createWatchIgnore } from './util/watch-ignore.js'
8
9
 
9
10
  type StoryChangeHandler = (file?: ServerStoryFile) => unknown
10
11
  const storyChangeHandlers: StoryChangeHandler[] = []
@@ -60,17 +61,7 @@ export async function watchStories(newContext: Context) {
60
61
 
61
62
  const watcher = chokidar.watch(baseWatchPaths, {
62
63
  cwd: context.root,
63
- ignored: (path, stats) => {
64
- if (resolvedStoryIgnored.some(pattern => micromatch.isMatch(path, pattern))) {
65
- return true
66
- }
67
- if (resolveStoryMatch.some(pattern => micromatch.isMatch(path, pattern))) {
68
- return false
69
- }
70
-
71
- // Allow directories to be traversed, only ignore files that don't match
72
- return stats?.isFile() ?? false
73
- },
64
+ ignored: createWatchIgnore(resolvedStoryIgnored, resolveStoryMatch),
74
65
  })
75
66
 
76
67
  watcher
@@ -0,0 +1,13 @@
1
+ import type { Stats } from 'node:fs'
2
+ import micromatch from 'micromatch'
3
+
4
+ // chokidar 4 passes absolute paths, so `**` must cross dot segments (`.claude/`, `node_modules/.pnpm`).
5
+ const options = { dot: true }
6
+
7
+ export function createWatchIgnore(ignored: string[], included: string[]) {
8
+ return (path: string, stats?: Stats): boolean => {
9
+ if (micromatch.isMatch(path, ignored, options)) return true
10
+ if (micromatch.isMatch(path, included, options)) return false
11
+ return stats?.isFile() ?? false
12
+ }
13
+ }
@@ -1,22 +1,30 @@
1
1
  import type { Context } from '../context.js'
2
2
  import { GENERATED_SETUP_CODE } from './index.js'
3
- import { declareEmptySetupFns, getSetupFnNames, ID_SEPARATOR } from './util.js'
3
+ import { declareEmptySetupFns, getSetupFnGroups, ID_SEPARATOR } from './util.js'
4
4
 
5
5
  export function resolvedGeneratedGlobalSetup(ctx: Context) {
6
6
  if (ctx.config.setupCode) {
7
+ const groups = getSetupFnGroups(ctx)
8
+ const canonicals = new Set(groups.map(g => g.canonical))
7
9
  return [
8
10
  // Import
9
11
  `${ctx.config.setupCode.map((c, index) => `import * as setup_${index} from '${GENERATED_SETUP_CODE}${ID_SEPARATOR}${index}'`).join('\n')}`,
10
12
  // List
11
13
  `const setupList = [${ctx.config.setupCode.map((c, index) => `setup_${index}`).join(', ')}]`,
12
- // Setups
13
- ...getSetupFnNames(ctx).map(fnName => `export async function ${fnName} (payload) {
14
+ // Setups: one real function per plugin, under its canonical name only.
15
+ ...groups.map(g => `export async function ${g.canonical} (payload) {
14
16
  for (const setup of setupList) {
15
- if (setup?.${fnName}) {
16
- await setup.${fnName}(payload)
17
+ if (setup?.${g.canonical}) {
18
+ await setup.${g.canonical}(payload)
17
19
  }
18
20
  }
19
21
  }`),
22
+ // Aliases are declared `undefined`, not emitted as functions: only the
23
+ // canonical hook is callable, so `getSetupHook` sees one hook in our own
24
+ // generated module rather than warning that it exports two aliases (#231).
25
+ ...[...new Set(groups.flatMap(g => g.aliases))]
26
+ .filter(name => !canonicals.has(name))
27
+ .map(name => `export const ${name} = undefined`),
20
28
  ].join('\n')
21
29
  }
22
30
  else {
@@ -10,6 +10,20 @@ export function getSetupFnNames(ctx: Context) {
10
10
  return [...new Set(ctx.supportPlugins.flatMap(p => p.setupFn))]
11
11
  }
12
12
 
13
+ /**
14
+ * Setup hooks grouped by the plugin that contributes them, canonical name first.
15
+ * A plugin's `setupFn` lists one hook under several names — `['setupVue3',
16
+ * 'setupVue']` is a single hook whose established name is `setupVue3` and whose
17
+ * alias is `setupVue`. The first is canonical; the rest are aliases kept so
18
+ * older setup files still resolve.
19
+ */
20
+ export function getSetupFnGroups(ctx: Context) {
21
+ return ctx.supportPlugins.map((p) => {
22
+ const names = typeof p.setupFn === 'string' ? [p.setupFn] : p.setupFn
23
+ return { canonical: names[0], aliases: names.slice(1) }
24
+ })
25
+ }
26
+
13
27
  /**
14
28
  * Declares the setup hooks as named exports set to `undefined`.
15
29
  *
package/src/node/vite.ts CHANGED
@@ -11,6 +11,7 @@ import { dirname, join, relative } from 'pathe'
11
11
  import {
12
12
  loadConfigFromFile as loadViteConfigFromFile,
13
13
  mergeConfig as mergeViteConfig,
14
+ searchForWorkspaceRoot,
14
15
  } from 'vite'
15
16
  import { APP_PATH, TEMP_PATH } from './alias.js'
16
17
  import { createMarkdownPlugins } from './markdown.js'
@@ -172,6 +173,10 @@ export async function getViteConfigWithPlugins(isServer: boolean, ctx: Context):
172
173
  TEMP_PATH,
173
174
  ctx.resolvedViteConfig.root,
174
175
  process.cwd(),
176
+ // Vite's own default, which setting `allow` at all replaces. In a
177
+ // workspace the app's deps resolve above the project — the
178
+ // bundled font under the root `node_modules` was refused (#252).
179
+ searchForWorkspaceRoot(process.cwd()),
175
180
  ...supportPluginAllowPaths,
176
181
  ...process.env.POVESTE_DEV
177
182
  ? [
@@ -188,7 +193,13 @@ export async function getViteConfigWithPlugins(isServer: boolean, ctx: Context):
188
193
  define: {
189
194
  // We need to force this to be able to use `devtoolsRawSetupState`
190
195
  '__VUE_PROD_DEVTOOLS__': 'true',
191
- // Disable warnings
196
+ // Vue's esm-bundler build expects every feature flag to be injected;
197
+ // in a production build an undefined one logs a warning per realm.
198
+ '__VUE_OPTIONS_API__': 'true',
199
+ '__VUE_PROD_HYDRATION_MISMATCH_DETAILS__': 'false',
200
+ // `bin.ts` sets this per command: development for `dev`, production
201
+ // for `build` and `preview`. Every built book used to ship the
202
+ // development build of Vue, Pinia, vue-router and VueUse.
192
203
  'process.env.NODE_ENV': JSON.stringify(isServer ? 'production' : process.env.NODE_ENV ?? 'development'),
193
204
  ...!isServer
194
205
  ? {
@@ -1,11 +0,0 @@
1
- import type { Plugin } from '@poveste/shared';
2
- export interface TailwindTokensOptions {
3
- /**
4
- * Path to the CSS entrypoint that imports Tailwind and declares `@theme`.
5
- * Tailwind v4 is CSS-first, so the design tokens are read from CSS rather
6
- * than from a JS config. Auto-detected when omitted.
7
- */
8
- cssFile?: string;
9
- }
10
- export declare function tailwindTokens(options?: TailwindTokensOptions): Plugin;
11
- //# sourceMappingURL=tailwind-tokens.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"tailwind-tokens.d.ts","sourceRoot":"","sources":["../../../src/node/builtin-plugins/tailwind-tokens.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,iBAAiB,CAAA;AAI5D,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAqBD,wBAAgB,cAAc,CAAC,OAAO,GAAE,qBAA0B,GAAG,MAAM,CAgE1E"}