@poveste/plugin-vue 0.14.0 → 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.
- package/README.md +1 -1
- package/dist/bundled/client/app/MountStory.js +3 -3
- package/dist/bundled/client/app/RenderStory.js +15 -9
- package/dist/bundled/client/app/Story.js +56 -43
- package/dist/bundled/client/app/Variant.js +32 -27
- package/dist/bundled/client/app/global-components.js +11 -10
- package/dist/bundled/client/app/host.js +2 -2
- package/dist/bundled/client/app/util.js +31 -26
- package/dist/bundled/client/client.js +4 -4
- package/dist/bundled/client/codegen.js +79 -60
- package/dist/bundled/client/server/Story.js +9 -7
- package/dist/bundled/client/server/Variant.js +1 -0
- package/dist/bundled/client/server/run.js +2 -2
- package/dist/client/app/MountStory.d.ts +3 -3
- package/dist/client/app/RenderStory.d.ts +1 -1
- package/dist/client/app/Story.d.ts +10 -7
- package/dist/client/app/Variant.d.ts +14 -13
- package/dist/client/app/render-context.d.ts +1 -1
- package/dist/client/app/util.d.ts +6 -3
- package/dist/client/codegen.d.ts +29 -0
- package/dist/client/server/Story.d.ts +17 -17
- package/dist/client/server/Variant.d.ts +10 -10
- package/dist/commands/generate-story.client.js +4 -4
- package/dist/commands/generate-story.server.js +2 -2
- package/dist/helpers.d.ts +2 -2
- package/dist/index.node.js +2 -0
- package/dist/util/list-components.d.ts +1 -1
- package/dist/util/list-components.js +63 -6
- package/dist/util/list-components.spec.d.ts +1 -0
- package/dist/util/list-components.spec.js +46 -0
- package/package.json +11 -10
- package/src/client/__tests__/codegen-attrs.spec.ts +149 -0
- package/src/client/app/MountStory.ts +5 -1
- package/src/client/app/RenderStory.ts +6 -1
- package/src/client/app/RouterLinkStub.ts +1 -1
- package/src/client/app/Story.ts +78 -37
- package/src/client/app/Variant.ts +26 -23
- package/src/client/app/__tests__/preview-variants.spec.ts +76 -0
- package/src/client/app/auto-props.ts +2 -2
- package/src/client/app/global-components.ts +20 -14
- package/src/client/app/host.ts +9 -6
- package/src/client/app/util.ts +17 -7
- package/src/client/codegen.ts +81 -29
- package/src/client/server/Story.ts +10 -4
- package/src/client/server/Variant.ts +3 -0
- package/src/client/server/run.ts +5 -4
- package/src/commands/generate-story.client.ts +4 -4
- package/src/commands/generate-story.server.ts +2 -2
- package/src/helpers.ts +2 -2
- package/src/index.node.ts +2 -0
- package/src/util/list-components.spec.ts +54 -0
- 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,
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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(
|
|
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
|
|
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 (
|
|
131
|
-
return vm.
|
|
132
|
-
state:
|
|
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 (
|
|
138
|
+
if (context.slotName !== 'default') {
|
|
137
139
|
return null
|
|
138
140
|
}
|
|
139
141
|
|
|
140
|
-
const vnodes = vm.
|
|
141
|
-
state:
|
|
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
|
-
|
|
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
|
-
|
|
198
|
+
const renderContext = this.renderContext
|
|
199
|
+
if (renderContext?.mode !== 'render') {
|
|
197
200
|
return null
|
|
198
201
|
}
|
|
199
202
|
|
|
200
|
-
if (
|
|
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
|
|
31
|
-
app.component(key, wrapControlComponent(
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
while (targetEl.
|
|
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(
|
|
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
|
|
133
|
+
}, this.$slots['default']?.(props)))),
|
|
128
134
|
]
|
|
129
135
|
},
|
|
130
136
|
})
|
package/src/client/app/host.ts
CHANGED
|
@@ -45,15 +45,17 @@ const PreviewHostRoot = defineComponent({
|
|
|
45
45
|
setup(props) {
|
|
46
46
|
provideRenderContext(props.renderContext)
|
|
47
47
|
|
|
48
|
-
return () =>
|
|
49
|
-
|
|
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
|
|
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
|
},
|
package/src/client/app/util.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/codegen.ts
CHANGED
|
@@ -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
|
|
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
|
|
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()
|
|
173
|
-
const
|
|
174
|
-
if (
|
|
172
|
+
let code = cleanupExpression(value.toString())
|
|
173
|
+
const functionName = /function (\S+)\(/.exec(code)?.[1]
|
|
174
|
+
if (functionName) {
|
|
175
175
|
// Function name only
|
|
176
|
-
serialized = [
|
|
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
|
-
|
|
189
|
+
const { quote, lines } = delimitAttr(serialized)
|
|
190
|
+
if (lines.length > 1) {
|
|
190
191
|
multilineAttrs = true
|
|
191
|
-
const indented: string[] = [`${directive}${arg}
|
|
192
|
-
indented.push(...
|
|
193
|
-
indented.push(`${
|
|
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}
|
|
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
|
-
|
|
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 `'` as often as
|
|
384
|
+
* `"`: 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, ''')) }
|
|
404
|
+
: { quote: '"', lines: lines.map(line => line.replace(/"/g, '"')) }
|
|
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
|
|
380
|
-
if (
|
|
381
|
-
return pascalCase(
|
|
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
|
|
60
|
-
title
|
|
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
|
|
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
|
|