@poveste/plugin-vue 0.13.1 → 0.15.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 (52) hide show
  1. package/README.md +1 -1
  2. package/dist/bundled/client/app/MountStory.js +3 -3
  3. package/dist/bundled/client/app/RenderStory.js +15 -9
  4. package/dist/bundled/client/app/Story.js +56 -43
  5. package/dist/bundled/client/app/Variant.js +32 -27
  6. package/dist/bundled/client/app/global-components.js +11 -10
  7. package/dist/bundled/client/app/host.js +2 -2
  8. package/dist/bundled/client/app/util.js +31 -26
  9. package/dist/bundled/client/client.js +4 -4
  10. package/dist/bundled/client/codegen.js +79 -60
  11. package/dist/bundled/client/server/Story.js +9 -7
  12. package/dist/bundled/client/server/Variant.js +1 -0
  13. package/dist/bundled/client/server/run.js +2 -2
  14. package/dist/client/app/MountStory.d.ts +3 -3
  15. package/dist/client/app/RenderStory.d.ts +1 -1
  16. package/dist/client/app/Story.d.ts +10 -7
  17. package/dist/client/app/Variant.d.ts +14 -13
  18. package/dist/client/app/render-context.d.ts +1 -1
  19. package/dist/client/app/util.d.ts +6 -3
  20. package/dist/client/codegen.d.ts +29 -0
  21. package/dist/client/server/Story.d.ts +17 -17
  22. package/dist/client/server/Variant.d.ts +10 -10
  23. package/dist/commands/generate-story.client.js +4 -4
  24. package/dist/commands/generate-story.server.js +2 -2
  25. package/dist/helpers.d.ts +2 -2
  26. package/dist/index.node.js +2 -0
  27. package/dist/util/list-components.d.ts +1 -1
  28. package/dist/util/list-components.js +63 -6
  29. package/dist/util/list-components.spec.d.ts +1 -0
  30. package/dist/util/list-components.spec.js +46 -0
  31. package/package.json +12 -11
  32. package/src/client/__tests__/codegen-attrs.spec.ts +149 -0
  33. package/src/client/app/MountStory.ts +5 -1
  34. package/src/client/app/RenderStory.ts +6 -1
  35. package/src/client/app/RouterLinkStub.ts +1 -1
  36. package/src/client/app/Story.ts +78 -37
  37. package/src/client/app/Variant.ts +26 -23
  38. package/src/client/app/__tests__/preview-variants.spec.ts +76 -0
  39. package/src/client/app/auto-props.ts +2 -2
  40. package/src/client/app/global-components.ts +20 -14
  41. package/src/client/app/host.ts +9 -6
  42. package/src/client/app/util.ts +17 -7
  43. package/src/client/codegen.ts +81 -29
  44. package/src/client/server/Story.ts +10 -4
  45. package/src/client/server/Variant.ts +3 -0
  46. package/src/client/server/run.ts +5 -4
  47. package/src/commands/generate-story.client.ts +4 -4
  48. package/src/commands/generate-story.server.ts +2 -2
  49. package/src/helpers.ts +2 -2
  50. package/src/index.node.ts +2 -0
  51. package/src/util/list-components.spec.ts +54 -0
  52. package/src/util/list-components.ts +68 -6
@@ -1,10 +1,11 @@
1
1
  import type { Variant } from '@poveste/shared'
2
2
  import type { ComputedRef, PropType } from 'vue'
3
+ import type { PreviewRenderContext } from './render-context.js'
3
4
  import { applyState, autoPropsStateKeys } from '@poveste/shared'
4
- import { computed, defineComponent, getCurrentInstance, inject, onBeforeUnmount, useAttrs } from 'vue'
5
+ import { computed, defineComponent, inject, onBeforeUnmount, useAttrs } from 'vue'
5
6
  import { syncVariantAutoProps } from './auto-props.js'
6
7
  import { useRenderContext } from './render-context.js'
7
- import { syncStateBundledAndExternal, toRawDeep } from './util.js'
8
+ import { syncStateBundledAndExternal, toRawDeep, useInstance } from './util.js'
8
9
 
9
10
  // const logLocation = location.href.includes('__sandbox') ? '[Sandbox]' : '[Host]'
10
11
 
@@ -54,14 +55,14 @@ export default defineComponent({
54
55
  const attrs = useAttrs() as {
55
56
  variant?: Variant
56
57
  }
57
- const vm = getCurrentInstance()
58
+ const vm = useInstance('Variant')
58
59
  const story = inject<ComputedRef<any>>('story')
59
60
  const implicitState = inject<() => any>('implicitState')
60
61
  const renderContext = useRenderContext()
61
62
  let lastPropsTypesSnapshot: string
62
- let renderVariant: Variant
63
- let renderStateSync: ReturnType<typeof syncStateBundledAndExternal>
64
- let mountStateSync: ReturnType<typeof syncStateBundledAndExternal>
63
+ let renderVariant: Variant | undefined
64
+ let renderStateSync: ReturnType<typeof syncStateBundledAndExternal> | null = null
65
+ let mountStateSync: ReturnType<typeof syncStateBundledAndExternal> | undefined
65
66
 
66
67
  // Registered before the first `await` below, and deliberately so. This is
67
68
  // an async setup(), and Vue drops any lifecycle hook registered after an
@@ -89,7 +90,7 @@ export default defineComponent({
89
90
 
90
91
  function updateVariant(variant: Variant) {
91
92
  Object.assign(variant, {
92
- slots: () => vm.proxy.$slots,
93
+ slots: () => vm.slots,
93
94
  source: props.source,
94
95
  responsiveDisabled: props.responsiveDisabled,
95
96
  autoPropsDisabled: props.autoPropsDisabled,
@@ -98,12 +99,13 @@ export default defineComponent({
98
99
  configReady: true,
99
100
  })
100
101
 
101
- if (!props.implicit && !story.value?.meta?.hasVariantChildComponents) {
102
- if (!story.value.meta) {
103
- story.value.meta = {}
102
+ const storyValue = story?.value
103
+ if (storyValue && !props.implicit && !storyValue.meta?.hasVariantChildComponents) {
104
+ if (!storyValue.meta) {
105
+ storyValue.meta = {}
104
106
  }
105
107
 
106
- Object.assign(story.value.meta, {
108
+ Object.assign(storyValue.meta, {
107
109
  hasVariantChildComponents: true,
108
110
  })
109
111
  }
@@ -119,33 +121,33 @@ export default defineComponent({
119
121
  }
120
122
 
121
123
  if (!renderVariant) {
122
- renderVariant = story.value?.variants[renderContext.nextVariantIndex.value]
124
+ renderVariant = story?.value?.variants[renderContext.nextVariantIndex.value]
123
125
  renderContext.nextVariantIndex.value++
124
126
  }
125
127
 
126
128
  return renderVariant
127
129
  }
128
130
 
129
- function renderVariantSlot(variant: Variant) {
130
- if (renderContext.slotName === 'controls') {
131
- return vm.proxy.$slots.controls?.({
132
- state: renderContext.externalState,
131
+ function renderVariantSlot(variant: Variant, context: PreviewRenderContext) {
132
+ if (context.slotName === 'controls') {
133
+ return vm.slots['controls']?.({
134
+ state: context.externalState,
133
135
  }) ?? null
134
136
  }
135
137
 
136
- if (renderContext.slotName !== 'default') {
138
+ if (context.slotName !== 'default') {
137
139
  return null
138
140
  }
139
141
 
140
- const vnodes = vm.proxy.$slots.default?.({
141
- state: renderContext.externalState,
142
+ const vnodes = vm.slots['default']?.({
143
+ state: context.externalState,
142
144
  }) ?? null
143
145
 
144
146
  if (vnodes && !variant.autoPropsDisabled) {
145
147
  lastPropsTypesSnapshot = syncVariantAutoProps(
146
148
  variant,
147
149
  vnodes,
148
- renderContext.externalState,
150
+ context.externalState,
149
151
  lastPropsTypesSnapshot,
150
152
  )
151
153
  }
@@ -193,14 +195,15 @@ export default defineComponent({
193
195
  this.updateVariant(variant)
194
196
  this.syncRenderVariantState(variant)
195
197
 
196
- if (this.renderContext?.mode !== 'render') {
198
+ const renderContext = this.renderContext
199
+ if (renderContext?.mode !== 'render') {
197
200
  return null
198
201
  }
199
202
 
200
- if (this.renderContext.currentVariant?.id !== variant.id) {
203
+ if (renderContext.currentVariant?.id !== variant.id) {
201
204
  return null
202
205
  }
203
206
 
204
- return this.renderVariantSlot(variant)
207
+ return this.renderVariantSlot(variant, renderContext)
205
208
  },
206
209
  })
@@ -0,0 +1,76 @@
1
+ import type { Story as StoryData, Variant as VariantData } from '@poveste/shared'
2
+ import type { Component } from 'vue'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { createSSRApp, defineComponent, h, reactive } from 'vue'
5
+ import { renderToString } from 'vue/server-renderer'
6
+ import { provideRenderContext } from '../render-context.js'
7
+ import Story from '../Story.js'
8
+ import Variant from '../Variant.js'
9
+
10
+ const COUNT = 50
11
+
12
+ function storyData(): StoryData {
13
+ const variants = Array.from({ length: COUNT }, (_, i) => ({ id: `v${i + 1}`, title: `Variant ${i + 1}`, state: {} })) as VariantData[]
14
+ // `hasVariantChildComponents` is what every explicit <Variant> sets as it renders,
15
+ // so this is the story as a retarget finds it: past its first render.
16
+ return reactive({ id: 'grid', title: 'Grid', variants, meta: { hasVariantChildComponents: true } }) as unknown as StoryData
17
+ }
18
+
19
+ /** Renders `storyComponent` for `target` in a render context, and counts the Variant components created. */
20
+ async function renderPreview(storyComponent: Component, story: StoryData, target: string) {
21
+ let variantsCreated = 0
22
+ const currentVariant = story.variants.find(variant => variant.id === target) ?? null
23
+ const app = createSSRApp(defineComponent({
24
+ setup() {
25
+ provideRenderContext(reactive({ mode: 'render', slotName: 'default', currentVariant, externalState: {}, nextVariantIndex: { value: 0 } }))
26
+ return () => h(storyComponent, { story })
27
+ },
28
+ }))
29
+ app.mixin({
30
+ beforeCreate() {
31
+ if (this.$options.name === 'Variant') {
32
+ variantsCreated++
33
+ }
34
+ },
35
+ })
36
+ const html = await renderToString(app)
37
+ return { html, variantsCreated }
38
+ }
39
+
40
+ const button = (n: number) => h('button', `Button ${n}`)
41
+
42
+ describe('a preview render of one variant', () => {
43
+ // #873: gated on `hasVariantChildComponents`, the filter below never ran after a
44
+ // story's first render, and every retarget rendered all N Variant components.
45
+ it('creates only the target Variant when every variant is a direct child', async () => {
46
+ const DirectStory = defineComponent({
47
+ props: { story: { type: Object, required: true } },
48
+ setup: props => () => h(Story, { story: props.story }, {
49
+ default: () => Array.from({ length: COUNT }, (_, i) => h(Variant, { key: i }, { default: () => button(i + 1) })),
50
+ }),
51
+ })
52
+
53
+ const { html, variantsCreated } = await renderPreview(DirectStory, storyData(), 'v37')
54
+
55
+ expect(variantsCreated).toBe(1)
56
+ expect(html).toContain('Button 37')
57
+ expect(html).not.toContain('Button 36<')
58
+ })
59
+
60
+ // A walk of the slot's vnodes cannot see a <Variant> inside a child component,
61
+ // so positions would not line up with `story.variants`, and every one renders.
62
+ it('still renders every Variant when they live in a child component', async () => {
63
+ const Rows = defineComponent({
64
+ setup: () => () => Array.from({ length: COUNT }, (_, i) => h(Variant, { key: i }, { default: () => button(i + 1) })),
65
+ })
66
+ const WrappedStory = defineComponent({
67
+ props: { story: { type: Object, required: true } },
68
+ setup: props => () => h(Story, { story: props.story }, { default: () => h(Rows) }),
69
+ })
70
+
71
+ const { html, variantsCreated } = await renderPreview(WrappedStory, storyData(), 'v37')
72
+
73
+ expect(variantsCreated).toBe(COUNT)
74
+ expect(html).toContain('Button 37')
75
+ })
76
+ })
@@ -52,7 +52,7 @@ function visitVNodes(vnodes: any, externalState: Variant['state'], traversalStat
52
52
 
53
53
  for (const key of names) {
54
54
  const prop = Array.isArray(declared) ? undefined : declared[key]
55
- let types
55
+ let types: string[] | undefined
56
56
  let defaultValue
57
57
 
58
58
  if (prop) {
@@ -169,7 +169,7 @@ function normalizeVNodes(vnodes: any) {
169
169
  return vnodes == null ? [] : [vnodes]
170
170
  }
171
171
 
172
- const result = []
172
+ const result: any[] = []
173
173
 
174
174
  for (const vnode of vnodes) {
175
175
  if (Array.isArray(vnode)) {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  App as _App,
3
+ Component as _Component,
3
4
  } from '@poveste/vendors/vue'
4
5
  import type {
5
6
  App,
@@ -27,14 +28,19 @@ export function registerGlobalComponents(app: App) {
27
28
 
28
29
  app.component('Variant', Variant)
29
30
 
30
- for (const key in components) {
31
- app.component(key, wrapControlComponent(components[key]))
31
+ for (const [key, component] of Object.entries(components)) {
32
+ app.component(key, wrapControlComponent(component))
32
33
  }
33
34
  }
34
35
 
35
- function wrapControlComponent(controlComponent) {
36
+ function wrapControlComponent(controlComponent: (typeof components)[keyof typeof components]) {
37
+ // Which control this is decides at runtime, and its props are the story's untyped
38
+ // attrs, so it renders through Vue's dynamic `Component` rather than a union of every
39
+ // control's props, where a slider would be claimed renderable without `min` (#835).
40
+ const dynamicControl: _Component = controlComponent
41
+
36
42
  return defineComponent({
37
- name: controlComponent.name,
43
+ ...controlComponent.name ? { name: controlComponent.name } : {},
38
44
  inheritAttrs: false,
39
45
  setup(props, { attrs }) {
40
46
  const el = ref<HTMLDivElement>()
@@ -44,7 +50,7 @@ function wrapControlComponent(controlComponent) {
44
50
 
45
51
  const state = _reactive({})
46
52
 
47
- function applyState(data) {
53
+ function applyState(data: Record<string, unknown>) {
48
54
  Object.assign(state, data)
49
55
  }
50
56
 
@@ -55,15 +61,15 @@ function wrapControlComponent(controlComponent) {
55
61
 
56
62
  // Slots
57
63
 
58
- let newSlotCalls = []
59
- const slotCalls = ref([])
64
+ let newSlotCalls: Record<string, unknown>[] = []
65
+ const slotCalls = ref<Record<string, unknown>[]>([])
60
66
 
61
67
  function moveSlotContent() {
62
68
  slotCalls.value.forEach((props, index) => {
63
- const renderedEl = slotEl.value.querySelector(`[renderslotid="${index}"]`)
64
- if (!renderedEl) return
65
- const targetEl = el.value.querySelector(`[slotid="${index}"]`)
66
- while (targetEl.firstChild) {
69
+ const renderedEl = slotEl.value?.querySelector(`[renderslotid="${index}"]`)
70
+ const targetEl = el.value?.querySelector(`[slotid="${index}"]`)
71
+ if (!renderedEl || !targetEl) return
72
+ while (targetEl.lastChild) {
67
73
  targetEl.removeChild(targetEl.lastChild)
68
74
  }
69
75
  targetEl.appendChild(renderedEl)
@@ -85,11 +91,11 @@ function wrapControlComponent(controlComponent) {
85
91
  newSlotCalls = []
86
92
  },
87
93
  render() {
88
- return _h(controlComponent, {
94
+ return _h(dynamicControl, {
89
95
  ...state,
90
96
  key: 'component',
91
97
  }, {
92
- default: (props) => {
98
+ default: (props: Record<string, unknown>) => {
93
99
  newSlotCalls.push(props)
94
100
  return _h('div', {
95
101
  slotId: newSlotCalls.length - 1,
@@ -124,7 +130,7 @@ function wrapControlComponent(controlComponent) {
124
130
  ref: 'slotEl',
125
131
  }, this.slotCalls.map((props, index) => h('div', {
126
132
  renderSlotId: index,
127
- }, this.$slots.default(props)))),
133
+ }, this.$slots['default']?.(props)))),
128
134
  ]
129
135
  },
130
136
  })
@@ -45,15 +45,17 @@ const PreviewHostRoot = defineComponent({
45
45
  setup(props) {
46
46
  provideRenderContext(props.renderContext)
47
47
 
48
- return () => h(props.story.file.component, {
49
- story: props.story,
50
- })
48
+ return () => props.story.file
49
+ ? h(props.story.file.component, {
50
+ story: props.story,
51
+ })
52
+ : null
51
53
  },
52
54
  })
53
55
 
54
56
  export function createPreviewHost(options: PreviewHostOptions) {
55
- let app: App = null
56
- let target: HTMLDivElement = null
57
+ let app: App | null = null
58
+ let target: HTMLDivElement | null = null
57
59
 
58
60
  async function mount() {
59
61
  const wrappers: Component[] = []
@@ -103,7 +105,8 @@ export function createPreviewHost(options: PreviewHostOptions) {
103
105
  const setupApi: Vue3StorySetupApi = {
104
106
  app,
105
107
  story: options.getStory(),
106
- variant: options.getVariant(),
108
+ // The setup API declares `variant` optional, not nullable.
109
+ variant: options.getVariant() ?? undefined,
107
110
  addWrapper: (wrapper) => {
108
111
  wrappers.unshift(wrapper)
109
112
  },
@@ -1,17 +1,18 @@
1
+ import type { ComponentInternalInstance } from 'vue'
1
2
  import { applyState, createStateBaseline } from '@poveste/shared'
2
3
  import {
3
4
  isRef as _isRef,
4
5
  unref as _unref,
5
6
  watch as _watch,
6
7
  } from '@poveste/vendors/vue'
7
- import { isRef, unref, watch } from 'vue'
8
+ import { getCurrentInstance, isRef, unref, watch } from 'vue'
8
9
 
9
- const isObject = val => val !== null && typeof val === 'object'
10
+ const isObject = (val: unknown): val is object => val !== null && typeof val === 'object'
10
11
 
11
12
  /**
12
13
  * Using external/user Vue
13
14
  */
14
- export function toRawDeep(val, seen = new WeakMap()) {
15
+ export function toRawDeep(val: unknown, seen = new WeakMap()): any {
15
16
  const unwrappedValue = isRef(val) ? unref(val) : val
16
17
 
17
18
  if (typeof unwrappedValue === 'symbol') {
@@ -27,7 +28,7 @@ export function toRawDeep(val, seen = new WeakMap()) {
27
28
  }
28
29
 
29
30
  if (Array.isArray(unwrappedValue)) {
30
- const result = []
31
+ const result: unknown[] = []
31
32
  seen.set(unwrappedValue, result)
32
33
  result.push(...unwrappedValue.map(value => toRawDeep(value, seen)))
33
34
  return result
@@ -49,7 +50,7 @@ function toRawObject(obj: Record<any, any>, target: Record<any, any>, seen = new
49
50
  /**
50
51
  * Using bundled Vue
51
52
  */
52
- export function _toRawDeep(val, seen = new WeakMap()) {
53
+ export function _toRawDeep(val: unknown, seen = new WeakMap()): any {
53
54
  const unwrappedValue = _isRef(val) ? _unref(val) : val
54
55
 
55
56
  if (typeof unwrappedValue === 'symbol') {
@@ -65,7 +66,7 @@ export function _toRawDeep(val, seen = new WeakMap()) {
65
66
  }
66
67
 
67
68
  if (Array.isArray(unwrappedValue)) {
68
- const result = []
69
+ const result: unknown[] = []
69
70
  seen.set(unwrappedValue, result)
70
71
  result.push(...unwrappedValue.map(value => _toRawDeep(value, seen)))
71
72
  return result
@@ -103,7 +104,7 @@ function without(state: Record<string, any>, omit: string[]): Record<string, any
103
104
  * @param externalState Reactive state created with the external/user Vue
104
105
  * @param omit Keys neither side may learn from the other
105
106
  */
106
- export function syncStateBundledAndExternal(bundledState, externalState, omit: string[] = []) {
107
+ export function syncStateBundledAndExternal(bundledState: Record<string, any>, externalState: Record<string, any>, omit: string[] = []) {
107
108
  // Each side is asked only for what *it* changed, and that is all that crosses.
108
109
  //
109
110
  // Both watchers used to mirror the whole of their side. That works while the
@@ -146,3 +147,12 @@ export function syncStateBundledAndExternal(bundledState, externalState, omit: s
146
147
  },
147
148
  }
148
149
  }
150
+
151
+ /** The component being set up. Throws outside `setup`, where Vue returns null. */
152
+ export function useInstance(name: string): ComponentInternalInstance {
153
+ const vm = getCurrentInstance()
154
+ if (!vm) {
155
+ throw new Error(`[poveste] <${name}> can only be set up inside a component`)
156
+ }
157
+ return vm
158
+ }
@@ -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
  }
@@ -55,9 +55,15 @@ export default defineComponent({
55
55
 
56
56
  // Story
57
57
 
58
+ const id = props.id ?? file?.id
59
+ const title = props.title ?? file?.fileName
60
+ if (!id || title === undefined) {
61
+ throw new Error('[poveste] a <Story> collected outside a story file needs an `id` and a `title`')
62
+ }
63
+
58
64
  const story: ServerStory = {
59
- id: props.id ?? file?.id,
60
- title: props.title ?? file?.fileName,
65
+ id,
66
+ title,
61
67
  group: props.group,
62
68
  layout: props.layout,
63
69
  icon: props.icon,
@@ -66,7 +72,7 @@ export default defineComponent({
66
72
  meta: props.meta,
67
73
  variants: [],
68
74
  }
69
- const addStory = inject('addStory', null)
75
+ const addStory = inject<((story: ServerStory) => void) | null>('addStory', null)
70
76
  addStory?.(story)
71
77
 
72
78
  // Variants
@@ -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
@@ -36,6 +36,9 @@ export default defineComponent({
36
36
  const story = inject<ServerStory>('story')
37
37
 
38
38
  function generateId() {
39
+ if (!story) {
40
+ throw new Error('[poveste] a <Variant> with no `id` has to be inside a <Story>')
41
+ }
39
42
  return `${story.id}-${story.variants.length}`
40
43
  }
41
44