@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
|
@@ -14,13 +14,13 @@ export default {
|
|
|
14
14
|
label: 'File name',
|
|
15
15
|
type: 'text',
|
|
16
16
|
required: true,
|
|
17
|
-
defaultValue: answers => answers
|
|
17
|
+
defaultValue: answers => answers['component']?.replace(/[^/]+\/([^/]+)\.vue$/, '$1.story.vue'),
|
|
18
18
|
},
|
|
19
19
|
],
|
|
20
20
|
clientAction: (params) => {
|
|
21
|
-
const index = params
|
|
22
|
-
const dirname = params
|
|
23
|
-
const file = `${dirname}${params
|
|
21
|
+
const index = params['component'].lastIndexOf('/');
|
|
22
|
+
const dirname = params['component'].substring(0, index + 1);
|
|
23
|
+
const file = `${dirname}${params['fileName']}`;
|
|
24
24
|
const storyId = kebabCase(file.toLowerCase());
|
|
25
25
|
openStory(storyId);
|
|
26
26
|
},
|
|
@@ -7,11 +7,11 @@ export default {
|
|
|
7
7
|
icon: 'https://vuejs.org/logo.svg',
|
|
8
8
|
searchText: 'generate create',
|
|
9
9
|
async serverAction(params) {
|
|
10
|
-
const targetFile = path.join(path.dirname(params
|
|
10
|
+
const targetFile = path.join(path.dirname(params['component']), params['fileName']);
|
|
11
11
|
if (fs.existsSync(targetFile)) {
|
|
12
12
|
throw new Error(`File ${targetFile} already exists`);
|
|
13
13
|
}
|
|
14
|
-
const { component, componentName, isTs } = await getComponentInfo(params
|
|
14
|
+
const { component, componentName, isTs } = await getComponentInfo(params['component']);
|
|
15
15
|
const content = `<script setup${isTs ? ' lang="ts"' : ''}>
|
|
16
16
|
import ${componentName} from './${component}'
|
|
17
17
|
</script>
|
package/dist/helpers.d.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { Story, Variant } from '@poveste/shared';
|
|
|
2
2
|
import type { App, Component } from 'vue';
|
|
3
3
|
export interface Vue3StorySetupApi {
|
|
4
4
|
app: App;
|
|
5
|
-
story?: Story;
|
|
6
|
-
variant?: Variant;
|
|
5
|
+
story?: Story | undefined;
|
|
6
|
+
variant?: Variant | undefined;
|
|
7
7
|
addWrapper: (wrapper: Component) => void;
|
|
8
8
|
}
|
|
9
9
|
export type Vue3StorySetupHandler = (api: Vue3StorySetupApi) => Promise<void> | void;
|
package/dist/index.node.js
CHANGED
|
@@ -27,6 +27,7 @@ export function HstVue() {
|
|
|
27
27
|
if (this.meta.poveste?.isCollecting && id.endsWith('.vue')) {
|
|
28
28
|
return `const _stubComponent = (name) => ['Story','Variant'].some(validName => name.toLowerCase() === validName.toLowerCase()) ? _resolveComponent(name) : ({ render: () => null });${code?.replaceAll('_resolveComponent(', '_stubComponent(') ?? ''}`;
|
|
29
29
|
}
|
|
30
|
+
return undefined;
|
|
30
31
|
},
|
|
31
32
|
},
|
|
32
33
|
],
|
|
@@ -49,6 +50,7 @@ export function HstVue() {
|
|
|
49
50
|
return listComponentFiles(api.payload.search, api.getConfig().storyMatch);
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
return undefined;
|
|
52
54
|
},
|
|
53
55
|
};
|
|
54
56
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function listComponentFiles(search?: string,
|
|
1
|
+
export declare function listComponentFiles(search?: string, ignoredPatterns?: string[], limit?: number, cwd?: string): Promise<string[]>;
|
|
@@ -1,12 +1,69 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import ignore from 'ignore';
|
|
4
|
+
import { glob } from 'tinyglobby';
|
|
5
|
+
function toPosix(path) {
|
|
6
|
+
return path.split(sep).join('/');
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Whether a file under `cwd` is git-ignored, the way globby's `gitignore: true`
|
|
10
|
+
* decided it: every `.gitignore` from the repository root down, each pattern
|
|
11
|
+
* relative to its own file. tinyglobby reads none of them (#839).
|
|
12
|
+
*/
|
|
13
|
+
async function gitIgnoredFilter(cwd) {
|
|
14
|
+
let root = cwd;
|
|
15
|
+
while (!existsSync(join(root, '.git'))) {
|
|
16
|
+
const parent = dirname(root);
|
|
17
|
+
if (parent === root) {
|
|
18
|
+
root = cwd;
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
root = parent;
|
|
22
|
+
}
|
|
23
|
+
const ancestors = [];
|
|
24
|
+
for (let dir = cwd; dir !== root;) {
|
|
25
|
+
dir = dirname(dir);
|
|
26
|
+
ancestors.unshift(join(dir, '.gitignore'));
|
|
27
|
+
}
|
|
28
|
+
// Shallow files first, so a nested `.gitignore` can override its parent.
|
|
29
|
+
const nested = (await glob('**/.gitignore', { cwd, absolute: true, dot: true, ignore: ['**/node_modules/**', '**/.git/**'] }))
|
|
30
|
+
.sort((a, b) => a.split('/').length - b.split('/').length || a.localeCompare(b));
|
|
31
|
+
const rules = ignore();
|
|
32
|
+
for (const file of [...ancestors, ...nested]) {
|
|
33
|
+
if (!existsSync(file)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const base = toPosix(relative(root, dirname(file)));
|
|
37
|
+
for (const raw of readFileSync(file, 'utf8').split(/\r?\n/)) {
|
|
38
|
+
const line = raw.trimEnd();
|
|
39
|
+
if (!line || line.startsWith('#')) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const negated = line.startsWith('!');
|
|
43
|
+
let pattern = negated ? line.slice(1) : line;
|
|
44
|
+
if (base) {
|
|
45
|
+
// A pattern with a slash before its end is anchored to its own directory;
|
|
46
|
+
// one without matches at any depth below it.
|
|
47
|
+
const anchored = pattern.replace(/\/$/, '').includes('/');
|
|
48
|
+
pattern = anchored ? `${base}/${pattern.replace(/^\//, '')}` : `${base}/**/${pattern}`;
|
|
49
|
+
}
|
|
50
|
+
rules.add(negated ? `!${pattern}` : pattern);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return (file) => {
|
|
54
|
+
const path = toPosix(relative(root, resolve(cwd, file)));
|
|
55
|
+
return !path.startsWith('..') && rules.ignores(path);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export async function listComponentFiles(search = '', ignoredPatterns = [], limit = 10, cwd = process.cwd()) {
|
|
59
|
+
const isIgnored = await gitIgnoredFilter(cwd);
|
|
60
|
+
let files = (await glob('**/*.vue', {
|
|
61
|
+
cwd,
|
|
5
62
|
ignore: [
|
|
6
63
|
'node_modules',
|
|
7
|
-
...
|
|
64
|
+
...ignoredPatterns,
|
|
8
65
|
],
|
|
9
|
-
});
|
|
66
|
+
})).filter(file => !isIgnored(file));
|
|
10
67
|
if (search) {
|
|
11
68
|
const searchText = search.toLowerCase();
|
|
12
69
|
files = files.filter(file => file.toLowerCase().includes(searchText));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
const roots = [];
|
|
7
|
+
function tree(files) {
|
|
8
|
+
const root = mkdtempSync(join(tmpdir(), 'list-components-'));
|
|
9
|
+
roots.push(root);
|
|
10
|
+
for (const [path, content] of Object.entries(files)) {
|
|
11
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
12
|
+
writeFileSync(join(root, path), content);
|
|
13
|
+
}
|
|
14
|
+
return root;
|
|
15
|
+
}
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
for (const root of roots.splice(0)) {
|
|
18
|
+
rmSync(root, { recursive: true, force: true });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
// Listing honoured .gitignore through globby's `gitignore: true`, and tinyglobby
|
|
22
|
+
// has no such option, so the filtering is ours now (#839).
|
|
23
|
+
describe('listComponentFiles', () => {
|
|
24
|
+
it('leaves out components git ignores, from the root .gitignore and a nested one', async () => {
|
|
25
|
+
const root = tree({
|
|
26
|
+
'.git/HEAD': 'ref: refs/heads/main\n',
|
|
27
|
+
'.gitignore': 'generated/\n',
|
|
28
|
+
'app/.gitignore': 'scratch/*\n!scratch/Kept.vue\n',
|
|
29
|
+
'app/src/Button.vue': '',
|
|
30
|
+
'app/src/generated/Output.vue': '',
|
|
31
|
+
'app/scratch/Draft.vue': '',
|
|
32
|
+
'app/scratch/Kept.vue': '',
|
|
33
|
+
});
|
|
34
|
+
const files = await listComponentFiles('', [], 10, join(root, 'app'));
|
|
35
|
+
expect(files.sort()).toEqual(['scratch/Kept.vue', 'src/Button.vue']);
|
|
36
|
+
});
|
|
37
|
+
it('still skips node_modules and the patterns it is given', async () => {
|
|
38
|
+
const root = tree({
|
|
39
|
+
'.git/HEAD': 'ref: refs/heads/main\n',
|
|
40
|
+
'src/Button.vue': '',
|
|
41
|
+
'src/Button.story.vue': '',
|
|
42
|
+
'node_modules/pkg/Dep.vue': '',
|
|
43
|
+
});
|
|
44
|
+
expect(await listComponentFiles('', ['**/*.story.vue'], 10, root)).toEqual(['src/Button.vue']);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poveste/plugin-vue",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.15.0",
|
|
5
5
|
"description": "Poveste plugin for Vue.js support",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Sorin Gitlan",
|
|
@@ -70,23 +70,24 @@
|
|
|
70
70
|
"node": "^22.22.2 || ^24.15.0 || >=26.0.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
|
-
"poveste": "^0.
|
|
73
|
+
"poveste": "^0.15.0",
|
|
74
74
|
"vue": "^3.5.26"
|
|
75
75
|
},
|
|
76
76
|
"dependencies": {
|
|
77
|
-
"@poveste/controls": "^0.
|
|
78
|
-
"@poveste/shared": "^0.
|
|
79
|
-
"@poveste/vendors": "^0.
|
|
77
|
+
"@poveste/controls": "^0.15.0",
|
|
78
|
+
"@poveste/shared": "^0.15.0",
|
|
79
|
+
"@poveste/vendors": "^0.15.0",
|
|
80
80
|
"change-case": "^5.4.4",
|
|
81
|
-
"
|
|
81
|
+
"ignore": "^7.0.6",
|
|
82
82
|
"launch-editor": "^2.9.1",
|
|
83
|
-
"pathe": "^
|
|
83
|
+
"pathe": "^2.0.3",
|
|
84
|
+
"tinyglobby": "^0.2.17"
|
|
84
85
|
},
|
|
85
86
|
"devDependencies": {
|
|
86
87
|
"@types/node": "^22.10.1",
|
|
87
|
-
"concurrently": "^
|
|
88
|
-
"poveste": "0.
|
|
89
|
-
"typescript": "
|
|
88
|
+
"concurrently": "^10.0.5",
|
|
89
|
+
"poveste": "0.15.0",
|
|
90
|
+
"typescript": "6.0.3",
|
|
90
91
|
"vite": "^8.2.0",
|
|
91
92
|
"vitest": "^5.0.0",
|
|
92
93
|
"vue": "^3.5.26"
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Variant } from '@poveste/shared'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { h } from 'vue'
|
|
4
|
+
import { delimitAttr, generateSourceCode } from '../codegen.js'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The source pane shows the template that produced the variant, so a handler
|
|
8
|
+
* has to appear as its author wrote it. The pass this replaces escaped `'` for
|
|
9
|
+
* a JavaScript string and rewrote `"` into `'` — neither of which is the
|
|
10
|
+
* attribute the result lands in — and each half reached the reader alone (#602).
|
|
11
|
+
*
|
|
12
|
+
* A case carrying both quote characters is the one that matters: each half
|
|
13
|
+
* fails on its own, so a fixture with only one of them passes a half-fix.
|
|
14
|
+
*/
|
|
15
|
+
describe('delimitAttr', () => {
|
|
16
|
+
it('leaves code with no quotes in it alone, under the usual delimiter', () => {
|
|
17
|
+
expect(delimitAttr(['onClick'])).toEqual({ quote: '"', lines: ['onClick'] })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Was shown as `() => alert('hi')` — the reader's double quotes rewritten.
|
|
21
|
+
it('takes the other delimiter rather than rewriting a double quote', () => {
|
|
22
|
+
expect(delimitAttr(['() => alert("hi")'])).toEqual({
|
|
23
|
+
quote: '\'',
|
|
24
|
+
lines: ['() => alert("hi")'],
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
// Was shown as `() => alert(\'hi\')`, backslashes and all.
|
|
29
|
+
it('leaves a single quote unescaped, since the attribute is not a JS string', () => {
|
|
30
|
+
expect(delimitAttr(['() => alert(\'hi\')'])).toEqual({
|
|
31
|
+
quote: '"',
|
|
32
|
+
lines: ['() => alert(\'hi\')'],
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// Both characters, so whichever delimiter is chosen has to be escaped inside
|
|
37
|
+
// it. Two doubles against one single here, so the single is the cheaper one.
|
|
38
|
+
it('escapes the quote that appears less often when the code carries both', () => {
|
|
39
|
+
expect(delimitAttr(['() => console.log("it\'s here")'])).toEqual({
|
|
40
|
+
quote: '\'',
|
|
41
|
+
lines: ['() => console.log("it's here")'],
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// Four singles against two doubles, so the double is the cheaper one here.
|
|
46
|
+
it('escapes the double quote instead when that is the rarer one', () => {
|
|
47
|
+
expect(delimitAttr(['() => alert(\'a\', \'b\', "c")'])).toEqual({
|
|
48
|
+
quote: '"',
|
|
49
|
+
lines: ['() => alert(\'a\', \'b\', "c")'],
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
// The alert CodeQL raised: the escaping pass never escaped a backslash, so
|
|
54
|
+
// `() => alert('it\'s')` went in carrying one and came out carrying four.
|
|
55
|
+
it('passes a backslash through untouched', () => {
|
|
56
|
+
const written = '() => alert(\'it\\\'s\')'
|
|
57
|
+
|
|
58
|
+
expect(delimitAttr([written]).lines[0]).toBe(written)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reads the whole value when deciding, not one line of it', () => {
|
|
62
|
+
expect(delimitAttr(['() => {', ' alert("hi")', '}']).quote).toBe('\'')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('escapes on every line, not only the one that decided it', () => {
|
|
66
|
+
expect(delimitAttr(['() => {', ' alert("a")', ' alert(\'b\')', ' alert(\'c\')', '}']).lines).toEqual([
|
|
67
|
+
'() => {',
|
|
68
|
+
' alert("a")',
|
|
69
|
+
' alert(\'b\')',
|
|
70
|
+
' alert(\'c\')',
|
|
71
|
+
'}',
|
|
72
|
+
])
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
* `delimitAttr` could be right and unused, so these drive the generator itself.
|
|
78
|
+
*
|
|
79
|
+
* The handler states its own source rather than being written as one. A `.vue`
|
|
80
|
+
* ships to the browser untransformed, but this spec does not: esbuild rewrote
|
|
81
|
+
* `alert('it\'s')` in the fixture to `alert("it's")` before `toString()` ever
|
|
82
|
+
* saw it, so a handler written here is not the text the generator receives.
|
|
83
|
+
* Overriding `toString` is what makes the input exact.
|
|
84
|
+
*/
|
|
85
|
+
const WITH_DOUBLE = '() => alert("hi")'
|
|
86
|
+
const WITH_SINGLE = '() => alert(\'hi\')'
|
|
87
|
+
const WITH_BOTH = '() => console.log("it\'s here")'
|
|
88
|
+
const WITH_BACKSLASH = String.raw`() => alert('it\'s')`
|
|
89
|
+
|
|
90
|
+
function handlerWritten(source: string): () => void {
|
|
91
|
+
const handler = (): void => {}
|
|
92
|
+
handler.toString = () => source
|
|
93
|
+
return handler
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function variantWith(source: string): Variant {
|
|
97
|
+
return {
|
|
98
|
+
state: {},
|
|
99
|
+
slots: () => ({ default: () => h('button', { onClick: handlerWritten(source) }, 'go') }),
|
|
100
|
+
} as unknown as Variant
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
describe('generateSourceCode, for an event handler', () => {
|
|
104
|
+
it('shows a double quote the author wrote, under the other delimiter', async () => {
|
|
105
|
+
expect(await generateSourceCode(variantWith(WITH_DOUBLE))).toContain(`@click='${WITH_DOUBLE}'`)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('shows a single quote unescaped, under the usual delimiter', async () => {
|
|
109
|
+
expect(await generateSourceCode(variantWith(WITH_SINGLE))).toContain(`@click="${WITH_SINGLE}"`)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('escapes only the rarer quote when the handler carries both', async () => {
|
|
113
|
+
expect(await generateSourceCode(variantWith(WITH_BOTH)))
|
|
114
|
+
.toContain('@click=\'() => console.log("it's here")\'')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('shows a backslash the author wrote exactly as written', async () => {
|
|
118
|
+
expect(await generateSourceCode(variantWith(WITH_BACKSLASH))).toContain(WITH_BACKSLASH)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
// The alert itself: one backslash in, four out.
|
|
122
|
+
it('does not multiply the backslash it was given', async () => {
|
|
123
|
+
const source = await generateSourceCode(variantWith(WITH_BACKSLASH))
|
|
124
|
+
|
|
125
|
+
expect(source.match(/\\/g)).toHaveLength(1)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/*
|
|
130
|
+
* A string prop is the other way a `"` reaches an attribute, and it took the
|
|
131
|
+
* delimiter fix later than the handlers did — the element below rendered its
|
|
132
|
+
* directives correctly and mangled this one.
|
|
133
|
+
*/
|
|
134
|
+
describe('generateSourceCode, for a string prop', () => {
|
|
135
|
+
function variantWithTitle(title: string): Variant {
|
|
136
|
+
return {
|
|
137
|
+
state: {},
|
|
138
|
+
slots: () => ({ default: () => h('div', { title }, 'go') }),
|
|
139
|
+
} as unknown as Variant
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
it('leaves a plain value under the usual delimiter', async () => {
|
|
143
|
+
expect(await generateSourceCode(variantWithTitle('a calm title'))).toContain('title="a calm title"')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('takes the other delimiter rather than closing the attribute early', async () => {
|
|
147
|
+
expect(await generateSourceCode(variantWithTitle('He said "hi"'))).toContain(`title='He said "hi"'`)
|
|
148
|
+
})
|
|
149
|
+
})
|
|
@@ -40,9 +40,13 @@ export default _defineComponent({
|
|
|
40
40
|
value: 0,
|
|
41
41
|
},
|
|
42
42
|
})
|
|
43
|
-
let host: ReturnType<typeof createPreviewHost>
|
|
43
|
+
let host: ReturnType<typeof createPreviewHost> | null = null
|
|
44
44
|
|
|
45
45
|
async function mountStory() {
|
|
46
|
+
if (!el.value) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
46
50
|
host = createPreviewHost({
|
|
47
51
|
name: 'MountStorySubApp',
|
|
48
52
|
el: el.value,
|
|
@@ -40,7 +40,7 @@ export default _defineComponent({
|
|
|
40
40
|
|
|
41
41
|
setup(props, { emit }) {
|
|
42
42
|
const sandbox = _ref<HTMLDivElement>()
|
|
43
|
-
let host: ReturnType<typeof createPreviewHost>
|
|
43
|
+
let host: ReturnType<typeof createPreviewHost> | null = null
|
|
44
44
|
let mounting = false
|
|
45
45
|
|
|
46
46
|
const externalState = reactive<Variant['state']>({})
|
|
@@ -73,6 +73,11 @@ export default _defineComponent({
|
|
|
73
73
|
renderContext.currentVariant = props.variant
|
|
74
74
|
renderContext.slotName = props.slotName
|
|
75
75
|
|
|
76
|
+
if (!sandbox.value) {
|
|
77
|
+
mounting = false
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
76
81
|
host = createPreviewHost({
|
|
77
82
|
name: 'RenderStorySubApp',
|
|
78
83
|
el: sandbox.value,
|
|
@@ -36,7 +36,7 @@ export const RouterLinkStub = defineComponent({
|
|
|
36
36
|
render() {
|
|
37
37
|
const route = computed(() => defaultRoute)
|
|
38
38
|
// mock reasonable return values to mimic vue-router's useLink
|
|
39
|
-
const children = this.$slots?.default?.({
|
|
39
|
+
const children = this.$slots?.['default']?.({
|
|
40
40
|
route,
|
|
41
41
|
href: computed(() => route.value.href),
|
|
42
42
|
isActive: computed(() => false),
|
package/src/client/app/Story.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { Story } from '@poveste/shared'
|
|
2
2
|
import type { PropType, VNode } from 'vue'
|
|
3
|
+
import type { PreviewRenderContext } from './render-context.js'
|
|
3
4
|
import { omitInheritStoryProps } from '@poveste/shared'
|
|
4
|
-
import { cloneVNode, computed, defineComponent,
|
|
5
|
+
import { cloneVNode, computed, defineComponent, h, isRef, provide, reactive, useAttrs } from 'vue'
|
|
5
6
|
import { useRenderContext } from './render-context.js'
|
|
7
|
+
import { useInstance } from './util.js'
|
|
6
8
|
import Variant from './Variant.js'
|
|
7
9
|
|
|
8
10
|
export default defineComponent({
|
|
@@ -25,7 +27,7 @@ export default defineComponent({
|
|
|
25
27
|
},
|
|
26
28
|
|
|
27
29
|
setup(props) {
|
|
28
|
-
const vm =
|
|
30
|
+
const vm = useInstance('Story')
|
|
29
31
|
|
|
30
32
|
const attrs = useAttrs() as {
|
|
31
33
|
story: Story
|
|
@@ -100,7 +102,7 @@ export default defineComponent({
|
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
Object.assign(attrs.story, {
|
|
103
|
-
slots: () => vm.
|
|
105
|
+
slots: () => vm.slots,
|
|
104
106
|
})
|
|
105
107
|
}
|
|
106
108
|
|
|
@@ -112,6 +114,32 @@ export default defineComponent({
|
|
|
112
114
|
updateStory,
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* What a `<Variant>` takes from its story: the attrs the story was given, then
|
|
119
|
+
* what the story carries, neither overriding what the variant set itself.
|
|
120
|
+
*
|
|
121
|
+
* Both render passes need it. `autoPropsDisabled` set on a story reached only
|
|
122
|
+
* the mount pass, so a story turning auto-props off still offered controls in
|
|
123
|
+
* the realm that renders it (#890).
|
|
124
|
+
*/
|
|
125
|
+
function inheritedFromStory(vnode: VNode) {
|
|
126
|
+
const inherited: Record<string, any> = {}
|
|
127
|
+
|
|
128
|
+
for (const attr in vm.attrs) {
|
|
129
|
+
if (typeof vnode.props?.[attr] === 'undefined') {
|
|
130
|
+
inherited[attr] = vm.attrs[attr]
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const attr in attrs.story) {
|
|
135
|
+
if (!omitInheritStoryProps.includes(attr) && typeof vnode.props?.[attr] === 'undefined') {
|
|
136
|
+
inherited[attr] = Reflect.get(attrs.story, attr)
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return inherited
|
|
141
|
+
}
|
|
142
|
+
|
|
115
143
|
function renderMountStory() {
|
|
116
144
|
let index = 0
|
|
117
145
|
|
|
@@ -126,8 +154,9 @@ export default defineComponent({
|
|
|
126
154
|
// that as it registers, so the gate held for the first render only and
|
|
127
155
|
// turned the re-render a retarget needs into a full mount of every
|
|
128
156
|
// sibling.
|
|
129
|
-
const
|
|
130
|
-
|
|
157
|
+
const targetVariantId = renderContext?.targetVariantId
|
|
158
|
+
const targetIndex = targetVariantId
|
|
159
|
+
? attrs.story.variants.findIndex(v => v.id === targetVariantId)
|
|
131
160
|
: -1
|
|
132
161
|
|
|
133
162
|
const applyAttrs = (vnodes: VNode[]) => {
|
|
@@ -159,21 +188,11 @@ export default defineComponent({
|
|
|
159
188
|
nextProps.key = variant.id
|
|
160
189
|
}
|
|
161
190
|
|
|
162
|
-
if (!vnode.props?.initState && !vnode.props?.['init-state']) {
|
|
191
|
+
if (!vnode.props?.['initState'] && !vnode.props?.['init-state']) {
|
|
163
192
|
nextProps.initState = props.initState
|
|
164
193
|
}
|
|
165
194
|
|
|
166
|
-
|
|
167
|
-
if (typeof vnode.props?.[attr] === 'undefined') {
|
|
168
|
-
nextProps[attr] = vm.proxy.$attrs[attr]
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
for (const attr in attrs.story) {
|
|
173
|
-
if (!omitInheritStoryProps.includes(attr) && typeof vnode.props?.[attr] === 'undefined') {
|
|
174
|
-
nextProps[attr] = attrs.story[attr]
|
|
175
|
-
}
|
|
176
|
-
}
|
|
195
|
+
Object.assign(nextProps, inheritedFromStory(vnode))
|
|
177
196
|
|
|
178
197
|
result.push(cloneVNode(vnode, nextProps))
|
|
179
198
|
continue
|
|
@@ -189,38 +208,59 @@ export default defineComponent({
|
|
|
189
208
|
return result
|
|
190
209
|
}
|
|
191
210
|
|
|
192
|
-
return applyAttrs(vm.
|
|
211
|
+
return applyAttrs(vm.slots['default']?.() ?? [])
|
|
193
212
|
}
|
|
194
213
|
|
|
195
|
-
function
|
|
196
|
-
|
|
214
|
+
function countVariantVnodes(vnodes: VNode[]): number {
|
|
215
|
+
let count = 0
|
|
216
|
+
for (const vnode of vnodes) {
|
|
217
|
+
// @ts-expect-error custom option
|
|
218
|
+
if (vnode?.type?.__povesteType === 'variant') {
|
|
219
|
+
count++
|
|
220
|
+
}
|
|
221
|
+
else if (Array.isArray(vnode?.children)) {
|
|
222
|
+
count += countVariantVnodes(vnode.children as VNode[])
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return count
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function renderPreviewStory(context: PreviewRenderContext) {
|
|
229
|
+
context.nextVariantIndex.value = 0
|
|
197
230
|
|
|
198
231
|
const slotProps = {
|
|
199
|
-
state:
|
|
232
|
+
state: context.externalState,
|
|
200
233
|
}
|
|
201
234
|
|
|
202
|
-
const children = []
|
|
235
|
+
const children: VNode[] = []
|
|
203
236
|
|
|
204
|
-
if (
|
|
205
|
-
children.push(...(vm.
|
|
237
|
+
if (context.slotName === 'controls') {
|
|
238
|
+
children.push(...(vm.slots['controls']?.(slotProps) ?? []))
|
|
206
239
|
}
|
|
207
240
|
|
|
208
|
-
if (
|
|
209
|
-
children.push(...(vm.
|
|
241
|
+
if (context.slotName === 'default' || attrs.story.meta?.hasVariantChildComponents) {
|
|
242
|
+
children.push(...(vm.slots['default']?.(slotProps) ?? []))
|
|
210
243
|
}
|
|
211
244
|
|
|
212
245
|
// Same story as the mount pass above: every Variant component here runs
|
|
213
246
|
// its full setup and then all but the current one render null (#197).
|
|
214
247
|
// Keep only the current one, and seed the positional index so it still
|
|
215
248
|
// resolves to the right variant data. Filtering only the default slot —
|
|
216
|
-
// the controls slot carries controls
|
|
217
|
-
//
|
|
249
|
+
// the controls slot carries controls.
|
|
250
|
+
//
|
|
251
|
+
// Gated on the walk seeing every variant, not on `hasVariantChildComponents`,
|
|
252
|
+
// for the reason the mount pass gives: every explicit <Variant> sets that flag
|
|
253
|
+
// as it renders, so from the second render on the filter never ran, and each
|
|
254
|
+
// retarget rendered all N Variant components to show one (#873). A story
|
|
255
|
+
// whose variants live in child components still renders them all, because
|
|
256
|
+
// its walk finds fewer than `variants.length`.
|
|
257
|
+
const currentVariant = context.currentVariant
|
|
218
258
|
if (
|
|
219
|
-
|
|
220
|
-
&&
|
|
221
|
-
&&
|
|
259
|
+
context.slotName === 'default'
|
|
260
|
+
&& currentVariant
|
|
261
|
+
&& countVariantVnodes(children) === attrs.story.variants.length
|
|
222
262
|
) {
|
|
223
|
-
const targetIndex = attrs.story.variants.findIndex(v => v.id ===
|
|
263
|
+
const targetIndex = attrs.story.variants.findIndex(v => v.id === currentVariant.id)
|
|
224
264
|
if (targetIndex !== -1) {
|
|
225
265
|
let seen = 0
|
|
226
266
|
let kept = false
|
|
@@ -230,7 +270,7 @@ export default defineComponent({
|
|
|
230
270
|
// @ts-expect-error custom option
|
|
231
271
|
if (vnode?.type?.__povesteType === 'variant') {
|
|
232
272
|
if (seen === targetIndex) {
|
|
233
|
-
result.push(vnode)
|
|
273
|
+
result.push(cloneVNode(vnode, inheritedFromStory(vnode)))
|
|
234
274
|
kept = true
|
|
235
275
|
}
|
|
236
276
|
seen++
|
|
@@ -245,7 +285,7 @@ export default defineComponent({
|
|
|
245
285
|
}
|
|
246
286
|
const filtered = keepTargetVariant(children)
|
|
247
287
|
if (kept) {
|
|
248
|
-
|
|
288
|
+
context.nextVariantIndex.value = targetIndex
|
|
249
289
|
return filtered
|
|
250
290
|
}
|
|
251
291
|
}
|
|
@@ -263,14 +303,15 @@ export default defineComponent({
|
|
|
263
303
|
if (firstVariant?.id === '_default') {
|
|
264
304
|
return h(Variant, {
|
|
265
305
|
variant: firstVariant,
|
|
266
|
-
initState: this.initState,
|
|
306
|
+
...this.initState ? { initState: this.initState } : {},
|
|
267
307
|
implicit: true,
|
|
268
308
|
...this.$attrs,
|
|
269
309
|
}, this.$slots)
|
|
270
310
|
}
|
|
271
311
|
|
|
272
|
-
|
|
273
|
-
|
|
312
|
+
const renderContext = this.renderContext
|
|
313
|
+
if (renderContext?.mode === 'render') {
|
|
314
|
+
return this.renderPreviewStory(renderContext)
|
|
274
315
|
}
|
|
275
316
|
|
|
276
317
|
return this.renderMountStory()
|