@poveste/plugin-vue 0.14.0 → 0.16.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 (68) hide show
  1. package/README.md +2 -2
  2. package/components.d.ts +4 -0
  3. package/dist/bundled/client/app/MountStory.js +8 -3
  4. package/dist/bundled/client/app/RenderStory.js +15 -9
  5. package/dist/bundled/client/app/Story.js +76 -62
  6. package/dist/bundled/client/app/Variant.js +48 -42
  7. package/dist/bundled/client/app/context.js +5 -0
  8. package/dist/bundled/client/app/global-components.js +11 -10
  9. package/dist/bundled/client/app/host.js +2 -2
  10. package/dist/bundled/client/app/render-context.js +7 -7
  11. package/dist/bundled/client/app/util.js +36 -26
  12. package/dist/bundled/client/client.js +4 -4
  13. package/dist/bundled/client/codegen.js +79 -60
  14. package/dist/bundled/client/context.js +20 -0
  15. package/dist/bundled/client/server/Story.js +28 -25
  16. package/dist/bundled/client/server/Variant.js +13 -16
  17. package/dist/bundled/client/server/context.js +5 -0
  18. package/dist/bundled/client/server/run.js +29 -29
  19. package/dist/client/app/MountStory.d.ts +12 -3
  20. package/dist/client/app/RenderStory.d.ts +1 -1
  21. package/dist/client/app/Story.d.ts +10 -7
  22. package/dist/client/app/Variant.d.ts +14 -13
  23. package/dist/client/app/context.d.ts +9 -0
  24. package/dist/client/app/render-context.d.ts +16 -1
  25. package/dist/client/app/util.d.ts +38 -3
  26. package/dist/client/codegen.d.ts +29 -0
  27. package/dist/client/context.d.ts +40 -0
  28. package/dist/client/server/Story.d.ts +17 -17
  29. package/dist/client/server/Variant.d.ts +10 -10
  30. package/dist/client/server/context.d.ts +7 -0
  31. package/dist/commands/generate-story.client.js +4 -4
  32. package/dist/commands/generate-story.server.js +2 -2
  33. package/dist/helpers.d.ts +2 -2
  34. package/dist/index.node.js +2 -0
  35. package/dist/util/list-components.d.ts +1 -1
  36. package/dist/util/list-components.js +63 -6
  37. package/dist/util/list-components.spec.d.ts +1 -0
  38. package/dist/util/list-components.spec.js +46 -0
  39. package/package.json +17 -16
  40. package/src/client/__tests__/codegen-attrs.spec.ts +149 -0
  41. package/src/client/__tests__/context.spec.ts +50 -0
  42. package/src/client/app/MountStory.ts +11 -1
  43. package/src/client/app/RenderStory.ts +6 -1
  44. package/src/client/app/RouterLinkStub.ts +1 -1
  45. package/src/client/app/Story.ts +85 -39
  46. package/src/client/app/Variant.ts +35 -27
  47. package/src/client/app/__tests__/preview-variants.spec.ts +76 -0
  48. package/src/client/app/__tests__/state-sync.spec.ts +113 -2
  49. package/src/client/app/__tests__/writable-binding.spec.ts +39 -0
  50. package/src/client/app/auto-props.ts +2 -2
  51. package/src/client/app/context.ts +17 -0
  52. package/src/client/app/global-components.ts +20 -14
  53. package/src/client/app/host.ts +9 -6
  54. package/src/client/app/render-context.ts +25 -5
  55. package/src/client/app/util.ts +104 -9
  56. package/src/client/codegen.ts +81 -29
  57. package/src/client/context.ts +66 -0
  58. package/src/client/server/Story.ts +16 -10
  59. package/src/client/server/Variant.ts +13 -14
  60. package/src/client/server/__tests__/nesting-errors.spec.ts +48 -0
  61. package/src/client/server/context.ts +18 -0
  62. package/src/client/server/run.ts +11 -7
  63. package/src/commands/generate-story.client.ts +4 -4
  64. package/src/commands/generate-story.server.ts +2 -2
  65. package/src/helpers.ts +2 -2
  66. package/src/index.node.ts +2 -0
  67. package/src/util/list-components.spec.ts +54 -0
  68. package/src/util/list-components.ts +68 -6
@@ -7,7 +7,7 @@ import { camelCase, pascalCase } from 'change-case'
7
7
  import { Text, vModelCheckbox, vModelDynamic, vModelRadio, vModelSelect, vModelText } from 'vue'
8
8
 
9
9
  export async function generateSourceCode(variant: Variant) {
10
- const vnode = variant.slots().default?.({ state: variant.state ?? {} }) ?? []
10
+ const vnode = variant.slots?.().default?.({ state: variant.state ?? {} }) ?? []
11
11
  const list = Array.isArray(vnode) ? vnode : [vnode]
12
12
  const lines: string[] = []
13
13
  for (const n in list) {
@@ -17,7 +17,7 @@ export async function generateSourceCode(variant: Variant) {
17
17
  return lines.join('\n')
18
18
  }
19
19
 
20
- async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = null): Promise<{ lines: string[], isText?: boolean }> {
20
+ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> | null = null): Promise<{ lines: string[], isText?: boolean }> {
21
21
  if (vnode.type === Text) {
22
22
  return {
23
23
  // @ts-expect-error TODO
@@ -43,7 +43,7 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
43
43
  ]
44
44
 
45
45
  // Directives
46
- function genDirective(dirName: string, dir, valueCode: string = null) {
46
+ function genDirective(dirName: string, dir: { arg?: string | null, modifiers?: Partial<Record<string, boolean>>, value: unknown }, valueCode: string | null = null) {
47
47
  let modifiers = ''
48
48
  for (const key in dir.modifiers) {
49
49
  if (dir.modifiers[key]) {
@@ -77,15 +77,15 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
77
77
  // Vmodel
78
78
  if (dir.dir === vModelText || dir.dir === vModelSelect || dir.dir === vModelRadio || dir.dir === vModelCheckbox || dir.dir === vModelDynamic) {
79
79
  const listenerKeys = [`onUpdate:${dir.arg ?? 'modelValue'}`, `onUpdate:${camelCase(dir.arg ?? 'modelValue')}`]
80
- const listenerKey = listenerKeys.find(key => vnode.props[key])
81
- const listener = vnode.props[listenerKey]
82
- let valueCode: string = null
83
- if (listener) {
80
+ const listenerKey = listenerKeys.find(key => vnode.props?.[key])
81
+ const listener = listenerKey ? vnode.props?.[listenerKey] : undefined
82
+ let valueCode: string | null = null
83
+ if (listenerKey && listener) {
84
84
  skipProps.push(listenerKey)
85
85
  const listenerSource = listener.toString()
86
86
  const result = /\(\$event\) => (.*?) = \$event/.exec(listenerSource)
87
87
  if (result) {
88
- valueCode = result[1]
88
+ valueCode = result[1] ?? null
89
89
  }
90
90
  }
91
91
  genDirective('model', dir, valueCode)
@@ -94,7 +94,7 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
94
94
  else if (dir.instance._ || dir.instance.$) {
95
95
  // @ts-expect-error TODO
96
96
  const target = dir.instance.$ ?? dir.instance._
97
- let dirName: string
97
+ let dirName: string | undefined
98
98
  for (const directives of [target.directives, target.appContext.directives]) {
99
99
  for (const key in directives) {
100
100
  if (directives[key] === dir.dir) {
@@ -128,7 +128,7 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
128
128
  if (prop.startsWith('on')) {
129
129
  directive = '@'
130
130
  }
131
- const arg = directive === '@' ? `${prop[2].toLowerCase()}${prop.slice(3)}` : prop
131
+ const arg = directive === '@' ? `${prop.charAt(2).toLowerCase()}${prop.slice(3)}` : prop
132
132
 
133
133
  // v-model on component
134
134
  const vmodelListeners = [`onUpdate:${prop}`, `onUpdate:${camelCase(prop)}`]
@@ -137,17 +137,17 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
137
137
  if (directive === ':' && vmodelListener) {
138
138
  // Listener
139
139
  skipProps.push(vmodelListener)
140
- const listener = vnode.props[vmodelListener]
141
- const listenerSource = listener.toString()
142
- let valueCode: string
140
+ const listener = vnode.props?.[vmodelListener]
141
+ const listenerSource = String(listener)
142
+ let valueCode: string | null = null
143
143
  const result = /\(\$event\) => (.*?) = \$event/.exec(listenerSource)
144
144
  if (result) {
145
- valueCode = result[1]
145
+ valueCode = result[1] ?? null
146
146
  }
147
147
 
148
148
  // Modifiers
149
149
  const modifiersKey = `${prop === 'modelValue' ? 'model' : prop}Modifiers`
150
- const modifiers = vnode.props[modifiersKey] ?? {}
150
+ const modifiers = vnode.props?.[modifiersKey] ?? {}
151
151
  skipProps.push(modifiersKey)
152
152
 
153
153
  // Directive
@@ -169,11 +169,11 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
169
169
  serialized = cleanupExpression(value.substring(2, value.length - 2).trim()).split('\n')
170
170
  }
171
171
  else if (typeof value === 'function') {
172
- let code = cleanupExpression(value.toString().replace(/'/g, '\\\'').replace(/"/g, '\''))
173
- const testResult = /function (\S+)\(/.exec(code)
174
- if (testResult) {
172
+ let code = cleanupExpression(value.toString())
173
+ const functionName = /function (\S+)\(/.exec(code)?.[1]
174
+ if (functionName) {
175
175
  // Function name only
176
- serialized = [testResult[1]]
176
+ serialized = [functionName]
177
177
  }
178
178
  else {
179
179
  if (code.startsWith('($event) => ')) {
@@ -186,15 +186,16 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
186
186
  else {
187
187
  serialized = serializeAndCleanJs(value)
188
188
  }
189
- if (serialized.length > 1) {
189
+ const { quote, lines } = delimitAttr(serialized)
190
+ if (lines.length > 1) {
190
191
  multilineAttrs = true
191
- const indented: string[] = [`${directive}${arg}="${serialized[0]}`]
192
- indented.push(...serialized.slice(1, serialized.length - 1))
193
- indented.push(`${serialized[serialized.length - 1]}"`)
192
+ const indented: string[] = [`${directive}${arg}=${quote}${lines[0]}`]
193
+ indented.push(...lines.slice(1, lines.length - 1))
194
+ indented.push(`${lines[lines.length - 1]}${quote}`)
194
195
  attrs.push(indented)
195
196
  }
196
197
  else {
197
- attrs.push([`${directive}${arg}="${serialized[0]}"`])
198
+ attrs.push([`${directive}${arg}=${quote}${lines[0]}${quote}`])
198
199
  }
199
200
  }
200
201
  // @ts-expect-error TODO
@@ -202,7 +203,12 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
202
203
  attrs.push([prop])
203
204
  }
204
205
  else {
205
- attrs.push([`${prop}="${value}"`])
206
+ // Same delimiting as the directive branch above. A string prop is the
207
+ // other way a `"` reaches an attribute — `title='He said "hi"'` closed
208
+ // the attribute early and turned the rest of the value into stray
209
+ // attributes, on an element whose directives rendered correctly.
210
+ const plain = delimitAttr([String(value)])
211
+ attrs.push([`${prop}=${plain.quote}${plain.lines[0]}${plain.quote}`])
206
212
  }
207
213
  }
208
214
 
@@ -248,7 +254,7 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
248
254
  if (isAllChildText === undefined) {
249
255
  isAllChildText = true
250
256
  }
251
- const text = result.lines[0]
257
+ const text = result.lines[0] ?? ''
252
258
  if (!childLines.length || /^\s/.test(text)) {
253
259
  childLines.push(text.trim())
254
260
  }
@@ -277,6 +283,7 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
277
283
  if (p === '__v_isRef') {
278
284
  return () => false
279
285
  }
286
+ return null
280
287
  })
281
288
  const children = vnode.children[key](autoObject.proxy)
282
289
  const slotLines: string[] = []
@@ -352,6 +359,51 @@ async function printVNode(vnode: VNode, propsOverrides: Record<string, any> = nu
352
359
  }
353
360
  }
354
361
 
362
+ /**
363
+ * How to delimit an attribute whose value is arbitrary code.
364
+ *
365
+ * The source pane shows template source a reader copies, so the code has to
366
+ * survive as written. The pass this replaces escaped `'` for a JavaScript
367
+ * string and rewrote `"` into `'`, and the consumer is neither — it is an
368
+ * attribute delimited by `"`. Both halves reached the reader: a handler written
369
+ * `() => alert("hi")` was shown as `() => alert('hi')`, and one written
370
+ * `() => alert('hi')` came out with visible backslashes, which reads as a bug
371
+ * in the reader's own code rather than as our rendering.
372
+ *
373
+ * The order made it worse. Every `'` the rewrite introduced arrived after the
374
+ * escaping pass, so an authored quote was escaped and a manufactured one was
375
+ * not. A backslash was never escaped at all, which is what CodeQL flagged as
376
+ * `js/incomplete-sanitization`: `() => alert('it\'s')` went in carrying one
377
+ * and came out carrying four.
378
+ *
379
+ * So nothing is escaped for a string context. Code containing `"` takes the
380
+ * other delimiter, the way someone writing the template by hand would. Code
381
+ * carrying both quote characters has no free delimiter, so whichever it takes
382
+ * is escaped inside it — and the one appearing less often wins, because every
383
+ * escape is a character the author did not write. That is `&#39;` as often as
384
+ * `&quot;`: the repository's own `get-name` fixture holds two doubles and one
385
+ * single, so the single is the cheaper one there (#602).
386
+ */
387
+ export function delimitAttr(lines: string[]): { quote: string, lines: string[] } {
388
+ const code = lines.join('\n')
389
+ const doubles = (code.match(/"/g) ?? []).length
390
+ const singles = (code.match(/'/g) ?? []).length
391
+
392
+ if (doubles === 0) {
393
+ return { quote: '"', lines }
394
+ }
395
+ if (singles === 0) {
396
+ return { quote: '\'', lines }
397
+ }
398
+
399
+ // Both appear, so whichever delimiter is chosen has to be escaped inside it.
400
+ // The one that appears less often wins, because every escape is a character
401
+ // the author did not write and this pane exists to show what they did.
402
+ return singles <= doubles
403
+ ? { quote: '\'', lines: lines.map(line => line.replace(/'/g, '&#39;')) }
404
+ : { quote: '"', lines: lines.map(line => line.replace(/"/g, '&quot;')) }
405
+ }
406
+
355
407
  export function getTagName(vnode: VNode) {
356
408
  if (typeof vnode.type === 'string') {
357
409
  return vnode.type
@@ -376,9 +428,9 @@ export function getTagName(vnode: VNode) {
376
428
  }
377
429
 
378
430
  function getNameFromFile(file: string) {
379
- const parts = /([^/]+)\.vue$/.exec(file)
380
- if (parts) {
381
- return pascalCase(parts[1])
431
+ const name = /([^/]+)\.vue$/.exec(file)?.[1]
432
+ if (name) {
433
+ return pascalCase(name)
382
434
  }
383
435
  return 'Anonymous'
384
436
  }
@@ -0,0 +1,66 @@
1
+ import type { InjectionKey } from 'vue'
2
+ import { inject as vueInject, provide as vueProvide } from 'vue'
3
+
4
+ /**
5
+ * A context keyed by a symbol, with its provide and its inject in one place.
6
+ *
7
+ * Six of these used to be string keys carrying `any`, and two of the six were
8
+ * the same string meaning two different things in two realms (#981). A symbol
9
+ * cannot collide and the type travels with it.
10
+ *
11
+ * `inject` and `injectOptional` are separate functions rather than one call with a
12
+ * default, because "nobody provided this" and "this is allowed to be absent"
13
+ * are different questions that used to arrive as the same `undefined` — and the
14
+ * first one is what an author hits when they nest a component wrongly.
15
+ *
16
+ * Reka's `createContext` is this shape and is already a dependency of the
17
+ * controls package. It is not one of this package's, and this is fifteen lines,
18
+ * so it is written here rather than taking a dependency for it.
19
+ */
20
+ export interface Context<T> {
21
+ provide: (value: T) => void
22
+ /** Throws, naming the provider, when nothing provided it. */
23
+ inject: (consumer: string) => T
24
+ /** `null` when nothing provided it, which the caller is expected to handle. */
25
+ injectOptional: () => T | null
26
+ }
27
+
28
+ /**
29
+ * `description` is what a symbol shows in a stack; `provider` is the component
30
+ * a reader has to go and add, and is the only part of the error they can act on.
31
+ *
32
+ * The key comes from the global registry rather than being minted here, because
33
+ * this module is evaluated more than once. A collection worker's module graph is
34
+ * invalidated while a story is mid-run, so `provide` and `inject` can each hold
35
+ * their own instance of this file — a plain `Symbol()` then differs between them
36
+ * and the injection misses, which reads as a `<Variant>` outside its `<Story>`.
37
+ * Every description here is already unique, so the registry cannot re-collide
38
+ * the way the string keys in #981 did.
39
+ *
40
+ * Absence is read as `null` rather than `undefined` so that a provider passing
41
+ * `undefined` on purpose is still a provider. None does today, and this is the
42
+ * cheaper of the two mistakes to have made.
43
+ */
44
+ export function createContext<T>(description: string, provider: string): Context<T> {
45
+ const key: InjectionKey<T> = Symbol.for(description)
46
+
47
+ return {
48
+ provide(value) {
49
+ vueProvide(key, value)
50
+ },
51
+
52
+ inject(consumer) {
53
+ const value = vueInject(key, null)
54
+
55
+ if (value === null) {
56
+ throw new Error(`[poveste] a ${consumer} has to be inside a ${provider}`)
57
+ }
58
+
59
+ return value
60
+ },
61
+
62
+ injectOptional() {
63
+ return vueInject(key, null)
64
+ },
65
+ }
66
+ }
@@ -1,6 +1,7 @@
1
- import type { ServerStory, ServerStoryFile, ServerVariant } from '@poveste/shared'
1
+ import type { ServerStory, ServerVariant } from '@poveste/shared'
2
2
  import type { PropType } from 'vue'
3
- import { defineComponent, inject, onMounted, provide } from 'vue'
3
+ import { defineComponent, onMounted } from 'vue'
4
+ import { addStoryContext, addVariantContext, serverStoryContext, storyFileContext } from './context.js'
4
5
  import { autoStubComponents } from './stub.js'
5
6
 
6
7
  export default defineComponent({
@@ -51,13 +52,19 @@ export default defineComponent({
51
52
  },
52
53
 
53
54
  setup(props) {
54
- const file = inject<ServerStoryFile>('hstStoryFile')
55
+ const file = storyFileContext.injectOptional()
55
56
 
56
57
  // Story
57
58
 
59
+ const id = props.id ?? file?.id
60
+ const title = props.title ?? file?.fileName
61
+ if (!id || title === undefined) {
62
+ throw new Error('[poveste] a <Story> collected outside a story file needs an `id` and a `title`')
63
+ }
64
+
58
65
  const story: ServerStory = {
59
- id: props.id ?? file?.id,
60
- title: props.title ?? file?.fileName,
66
+ id,
67
+ title,
61
68
  group: props.group,
62
69
  layout: props.layout,
63
70
  icon: props.icon,
@@ -66,14 +73,13 @@ export default defineComponent({
66
73
  meta: props.meta,
67
74
  variants: [],
68
75
  }
69
- const addStory = inject('addStory', null)
70
- addStory?.(story)
76
+ addStoryContext.injectOptional()?.(story)
71
77
 
72
78
  // Variants
73
79
 
74
- provide('story', story)
80
+ serverStoryContext.provide(story)
75
81
 
76
- provide('addVariant', (variant: ServerVariant) => {
82
+ addVariantContext.provide((variant: ServerVariant) => {
77
83
  story.variants.push(variant)
78
84
  })
79
85
 
@@ -94,7 +100,7 @@ export default defineComponent({
94
100
  render() {
95
101
  let suppressError = false
96
102
  try {
97
- const vnodes = this.$slots.default?.({
103
+ const vnodes = this.$slots['default']?.({
98
104
  get state() {
99
105
  // No variant tags
100
106
  suppressError = true
@@ -1,6 +1,7 @@
1
- import type { ServerStory, ServerVariant } from '@poveste/shared'
1
+ import type { ServerVariant } from '@poveste/shared'
2
2
  import type { PropType } from 'vue'
3
- import { defineComponent, inject } from 'vue'
3
+ import { defineComponent } from 'vue'
4
+ import { addVariantContext, serverStoryContext } from './context.js'
4
5
 
5
6
  export default defineComponent({
6
7
  name: 'PovesteVariant',
@@ -33,22 +34,20 @@ export default defineComponent({
33
34
  },
34
35
 
35
36
  setup(props) {
36
- const story = inject<ServerStory>('story')
37
-
38
- function generateId() {
39
- return `${story.id}-${story.variants.length}`
40
- }
41
-
42
- const variant: ServerVariant = {
43
- id: props.id ?? generateId(),
37
+ // Asked for before anything reads it, so a missing `<Story>` is reported
38
+ // once and the same way whether or not the variant carries an `id`. The
39
+ // guard this replaces sat inside the branch that generates one, so an
40
+ // explicit `id` skipped it and reached `undefined(variant)` instead (#981).
41
+ const story = serverStoryContext.inject('<Variant>')
42
+ const addVariant = addVariantContext.inject('<Variant>')
43
+
44
+ addVariant({
45
+ id: props.id ?? `${story.id}-${story.variants.length}`,
44
46
  title: props.title,
45
47
  icon: props.icon,
46
48
  iconColor: props.iconColor,
47
49
  meta: props.meta,
48
- }
49
-
50
- const addVariant = inject('addVariant') as (variant: ServerVariant) => void
51
- addVariant(variant)
50
+ })
52
51
  },
53
52
 
54
53
  render() {
@@ -0,0 +1,48 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createApp, h } from 'vue'
4
+ import Story from '../Story.js'
5
+ import Variant from '../Variant.js'
6
+
7
+ /*
8
+ * What a story author gets for a nesting mistake in collect mode, where the
9
+ * stack has nothing in it about stories (#981).
10
+ *
11
+ * Mounted the way `run.ts` mounts them — a bare `createApp` — rather than
12
+ * through a test-utils wrapper, because the missing provider is the whole
13
+ * subject and a harness that supplies one would prove nothing.
14
+ */
15
+ function mountAlone(component: any, props: Record<string, unknown> = {}) {
16
+ const app = createApp({ render: () => h(component, props) })
17
+ const el = document.createElement('div')
18
+ try {
19
+ app.mount(el)
20
+ }
21
+ finally {
22
+ app.unmount()
23
+ }
24
+ }
25
+
26
+ describe('a <Variant> outside a <Story>', () => {
27
+ it('says which component it needed, when it carries an id', () => {
28
+ // The branch that generates an id already threw a good error, so an
29
+ // explicit id was the case that reached `undefined(variant)` and said
30
+ // `addVariant is not a function`.
31
+ expect(() => mountAlone(Variant, { id: 'mine', title: 'Solo' })).toThrow(/<Variant>.*<Story>/)
32
+ })
33
+
34
+ it('says the same thing when it does not', () => {
35
+ expect(() => mountAlone(Variant, { title: 'Solo' })).toThrow(/<Variant>.*<Story>/)
36
+ })
37
+
38
+ it('never reports that a function is not a function', () => {
39
+ expect(() => mountAlone(Variant, { id: 'mine', title: 'Solo' })).not.toThrow(/is not a function/)
40
+ })
41
+ })
42
+
43
+ describe('a <Story> outside the collect runner', () => {
44
+ it('says which provider it needed', () => {
45
+ // `hstStoryFile` comes from `run.ts`, the only thing that mounts these.
46
+ expect(() => mountAlone(Story, { title: 'Solo' })).toThrow(/<Story>/)
47
+ })
48
+ })
@@ -0,0 +1,18 @@
1
+ import type { ServerStory, ServerStoryFile, ServerVariant } from '@poveste/shared'
2
+ import { createContext } from '../context.js'
3
+
4
+ /*
5
+ * Collect mode's contexts. The story one is deliberately not the app's: they
6
+ * carry different shapes in different realms and shared one string key, so
7
+ * nothing stopped one being injected where the other was expected (#981).
8
+ */
9
+
10
+ /** The file being collected, provided by `run.ts` rather than by a component. */
11
+ export const storyFileContext = createContext<ServerStoryFile>('poveste-server-story-file', 'story file')
12
+
13
+ /** Optional: a `<Story>` collected outside a run has nowhere to report to. */
14
+ export const addStoryContext = createContext<(story: ServerStory) => void>('poveste-server-add-story', 'story file')
15
+
16
+ export const serverStoryContext = createContext<ServerStory>('poveste-server-story', '<Story>')
17
+
18
+ export const addVariantContext = createContext<(variant: ServerVariant) => void>('poveste-server-add-variant', '<Story>')
@@ -1,4 +1,4 @@
1
- import type { ServerRunPayload } from '@poveste/shared'
1
+ import type { ServerRunPayload, ServerStory } from '@poveste/shared'
2
2
  import type { Vue3StorySetupApi, Vue3StorySetupHandler } from '../../helpers.js'
3
3
  import { getSetupHook } from '@poveste/shared'
4
4
  // @ts-expect-error virtual module id
@@ -7,6 +7,7 @@ import * as generatedSetup from 'virtual:$poveste-generated-global-setup'
7
7
  import * as setup from 'virtual:$poveste-setup'
8
8
  import { createApp, h } from 'vue'
9
9
  import { VUE_SETUP_HOOK_NAMES } from '../../setup-hooks.js'
10
+ import { addStoryContext, storyFileContext } from './context.js'
10
11
  import Story from './Story.js'
11
12
  import Variant from './Variant.js'
12
13
 
@@ -14,11 +15,13 @@ export async function run({ file, storyData, el }: ServerRunPayload) {
14
15
  const { default: Comp } = await import(/* @vite-ignore */ file.moduleId)
15
16
 
16
17
  const app = createApp({
17
- provide: {
18
- addStory(data) {
18
+ // Provided from `setup` rather than the options object so the keys can be
19
+ // the symbols the components inject; the options form takes string keys.
20
+ setup() {
21
+ addStoryContext.provide((data: ServerStory) => {
19
22
  storyData.push(data)
20
- },
21
- hstStoryFile: file,
23
+ })
24
+ storyFileContext.provide(file)
22
25
  },
23
26
  render() {
24
27
  return h(Comp)
@@ -33,8 +36,9 @@ export async function run({ file, storyData, el }: ServerRunPayload) {
33
36
 
34
37
  const setupApi: Vue3StorySetupApi = {
35
38
  app,
36
- story: null,
37
- variant: null,
39
+ // No story or variant exists while collecting.
40
+ story: undefined,
41
+ variant: undefined,
38
42
  addWrapper: () => { /* noop */ },
39
43
  }
40
44
 
@@ -16,13 +16,13 @@ export default {
16
16
  label: 'File name',
17
17
  type: 'text',
18
18
  required: true,
19
- defaultValue: answers => answers.component?.replace(/[^/]+\/([^/]+)\.vue$/, '$1.story.vue'),
19
+ defaultValue: answers => answers['component']?.replace(/[^/]+\/([^/]+)\.vue$/, '$1.story.vue'),
20
20
  },
21
21
  ],
22
22
  clientAction: (params) => {
23
- const index = params.component.lastIndexOf('/')
24
- const dirname = params.component.substring(0, index + 1)
25
- const file = `${dirname}${params.fileName}`
23
+ const index = params['component'].lastIndexOf('/')
24
+ const dirname = params['component'].substring(0, index + 1)
25
+ const file = `${dirname}${params['fileName']}`
26
26
  const storyId = kebabCase(file.toLowerCase())
27
27
  openStory(storyId)
28
28
  },
@@ -9,13 +9,13 @@ export default {
9
9
  icon: 'https://vuejs.org/logo.svg',
10
10
  searchText: 'generate create',
11
11
  async serverAction(params) {
12
- const targetFile = path.join(path.dirname(params.component), params.fileName)
12
+ const targetFile = path.join(path.dirname(params['component']), params['fileName'])
13
13
 
14
14
  if (fs.existsSync(targetFile)) {
15
15
  throw new Error(`File ${targetFile} already exists`)
16
16
  }
17
17
 
18
- const { component, componentName, isTs } = await getComponentInfo(params.component)
18
+ const { component, componentName, isTs } = await getComponentInfo(params['component'])
19
19
 
20
20
  const content = `<script setup${isTs ? ' lang="ts"' : ''}>
21
21
  import ${componentName} from './${component}'
package/src/helpers.ts CHANGED
@@ -3,8 +3,8 @@ import type { App, Component } from 'vue'
3
3
 
4
4
  export interface Vue3StorySetupApi {
5
5
  app: App
6
- story?: Story
7
- variant?: Variant
6
+ story?: Story | undefined
7
+ variant?: Variant | undefined
8
8
  addWrapper: (wrapper: Component) => void
9
9
  }
10
10
 
package/src/index.node.ts CHANGED
@@ -32,6 +32,7 @@ export function HstVue(): Plugin {
32
32
  if ((this.meta as any).poveste?.isCollecting && id.endsWith('.vue')) {
33
33
  return `const _stubComponent = (name) => ['Story','Variant'].some(validName => name.toLowerCase() === validName.toLowerCase()) ? _resolveComponent(name) : ({ render: () => null });${code?.replaceAll('_resolveComponent(', '_stubComponent(') ?? ''}`
34
34
  }
35
+ return undefined
35
36
  },
36
37
  },
37
38
  ],
@@ -57,6 +58,7 @@ export function HstVue(): Plugin {
57
58
  return listComponentFiles(api.payload.search, api.getConfig().storyMatch)
58
59
  }
59
60
  }
61
+ return undefined
60
62
  },
61
63
  }
62
64
  }
@@ -0,0 +1,54 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { dirname, join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { listComponentFiles } from './list-components.js'
6
+
7
+ const roots: string[] = []
8
+
9
+ function tree(files: Record<string, string>): string {
10
+ const root = mkdtempSync(join(tmpdir(), 'list-components-'))
11
+ roots.push(root)
12
+ for (const [path, content] of Object.entries(files)) {
13
+ mkdirSync(dirname(join(root, path)), { recursive: true })
14
+ writeFileSync(join(root, path), content)
15
+ }
16
+ return root
17
+ }
18
+
19
+ afterEach(() => {
20
+ for (const root of roots.splice(0)) {
21
+ rmSync(root, { recursive: true, force: true })
22
+ }
23
+ })
24
+
25
+ // Listing honoured .gitignore through globby's `gitignore: true`, and tinyglobby
26
+ // has no such option, so the filtering is ours now (#839).
27
+ describe('listComponentFiles', () => {
28
+ it('leaves out components git ignores, from the root .gitignore and a nested one', async () => {
29
+ const root = tree({
30
+ '.git/HEAD': 'ref: refs/heads/main\n',
31
+ '.gitignore': 'generated/\n',
32
+ 'app/.gitignore': 'scratch/*\n!scratch/Kept.vue\n',
33
+ 'app/src/Button.vue': '',
34
+ 'app/src/generated/Output.vue': '',
35
+ 'app/scratch/Draft.vue': '',
36
+ 'app/scratch/Kept.vue': '',
37
+ })
38
+
39
+ const files = await listComponentFiles('', [], 10, join(root, 'app'))
40
+
41
+ expect(files.sort()).toEqual(['scratch/Kept.vue', 'src/Button.vue'])
42
+ })
43
+
44
+ it('still skips node_modules and the patterns it is given', async () => {
45
+ const root = tree({
46
+ '.git/HEAD': 'ref: refs/heads/main\n',
47
+ 'src/Button.vue': '',
48
+ 'src/Button.story.vue': '',
49
+ 'node_modules/pkg/Dep.vue': '',
50
+ })
51
+
52
+ expect(await listComponentFiles('', ['**/*.story.vue'], 10, root)).toEqual(['src/Button.vue'])
53
+ })
54
+ })