From 9d0599635fed3f380ba99dfc3714719f9c5178f7 Mon Sep 17 00:00:00 2001 From: Taku Fukada Date: Tue, 9 Jul 2024 13:36:25 +0900 Subject: [PATCH] svelte: autodocs with svelte2tsx --- code/frameworks/svelte-vite/package.json | 5 +- .../svelte-vite/src/plugins/svelte-docgen.ts | 108 +++++---- .../svelte-vite/src/plugins/ts2doc.ts | 209 ++++++++++++++++++ .../ButtonTypeScriptRunes.svelte | 53 +++++ .../ts-runes-docs.stories.js | 13 ++ code/yarn.lock | 23 +- 6 files changed, 359 insertions(+), 52 deletions(-) create mode 100644 code/frameworks/svelte-vite/src/plugins/ts2doc.ts create mode 100644 code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ButtonTypeScriptRunes.svelte create mode 100644 code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ts-runes-docs.stories.js diff --git a/code/frameworks/svelte-vite/package.json b/code/frameworks/svelte-vite/package.json index 0d15aacccf19..b08f95da8c1c 100644 --- a/code/frameworks/svelte-vite/package.json +++ b/code/frameworks/svelte-vite/package.json @@ -51,14 +51,15 @@ "@storybook/svelte": "workspace:*", "magic-string": "^0.30.0", "svelte-preprocess": "^5.1.1", + "svelte2tsx": "^0.7.13", "sveltedoc-parser": "^4.2.1", - "ts-dedent": "^2.2.0" + "ts-dedent": "^2.2.0", + "typescript": "^5.3.2" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.1", "@types/node": "^18.0.0", "svelte": "^5.0.0-next.65", - "typescript": "^5.3.2", "vite": "^4.0.0" }, "peerDependencies": { diff --git a/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts b/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts index 27db39238e70..923aed7617dc 100644 --- a/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts +++ b/code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts @@ -7,6 +7,7 @@ import type { SvelteComponentDoc, SvelteParserOptions } from 'sveltedoc-parser'; import { logger } from 'storybook/internal/node-logger'; import { preprocess } from 'svelte/compiler'; import { replace, typescript } from 'svelte-preprocess'; +import { ts2doc } from './ts2doc'; /* * Patch sveltedoc-parser internal options. @@ -73,69 +74,78 @@ export async function svelteDocgen(svelteOptions: Record = {}): Pro async transform(src: string, id: string) { if (!filter(id)) return undefined; - if (preprocessOptions && !docPreprocessOptions) { - /* - * We can't use vitePreprocess() for the documentation - * because it uses esbuild which removes jsdoc. - * - * By default, only typescript is transpiled, and style tags are removed. - * - * Note: these preprocessors are only used to make the component - * compatible to sveltedoc-parser (no ts), not to compile - * the component. - */ - docPreprocessOptions = [replace([[//gims, '']])]; - - try { - const ts = require.resolve('typescript'); - if (ts) { - docPreprocessOptions.unshift(typescript()); + const resource = path.relative(cwd, id); + const rawSource = fs.readFileSync(resource).toString(); + + // Use ts2doc to get props information + const { hasRuneProps, data } = ts2doc(rawSource); + + let componentDoc: SvelteComponentDoc & { keywords?: string[] } = {}; + + if (!hasRuneProps) { + // legacy mode (slots and events) + + if (preprocessOptions && !docPreprocessOptions) { + /* + * We can't use vitePreprocess() for the documentation + * because it uses esbuild which removes jsdoc. + * + * By default, only typescript is transpiled, and style tags are removed. + * + * Note: these preprocessors are only used to make the component + * compatible to sveltedoc-parser (no ts), not to compile + * the component. + */ + docPreprocessOptions = [replace([[//gims, '']])]; + + try { + const ts = require.resolve('typescript'); + if (ts) { + docPreprocessOptions.unshift(typescript()); + } + } catch { + // this will error in JavaScript-only projects, this is okay } - } catch { - // this will error in JavaScript-only projects, this is okay } - } - const resource = path.relative(cwd, id); - - let docOptions; - if (docPreprocessOptions) { - const rawSource = fs.readFileSync(resource).toString(); - - const { code: fileContent } = await preprocess(rawSource, docPreprocessOptions, { - filename: resource, - }); + let docOptions; + if (docPreprocessOptions) { + const { code: fileContent } = await preprocess(rawSource, docPreprocessOptions, { + filename: resource, + }); + + docOptions = { + fileContent, + }; + } else { + docOptions = { filename: resource }; + } - docOptions = { - fileContent, + // set SvelteDoc options + const options: SvelteParserOptions = { + ...docOptions, + version: 3, }; - } else { - docOptions = { filename: resource }; - } - // set SvelteDoc options - const options: SvelteParserOptions = { - ...docOptions, - version: 3, - }; - - const s = new MagicString(src); - - let componentDoc: SvelteComponentDoc & { keywords?: string[] }; - try { - componentDoc = await svelteDoc.parse(options); - } catch (error: any) { - componentDoc = { keywords: [], data: [] }; - if (logDocgen) { - logger.error(error); + try { + componentDoc = await svelteDoc.parse(options); + } catch (error: any) { + componentDoc = { keywords: [], data: [] }; + if (logDocgen) { + logger.error(error); + } } } + // Always use props info from ts2doc + componentDoc.data = data; + // get filename for source content const file = path.basename(resource); componentDoc.name = path.basename(file); + const s = new MagicString(src); const componentName = getNameFromFilename(resource); s.append(`;${componentName}.__docgen = ${JSON.stringify(componentDoc)}`); diff --git a/code/frameworks/svelte-vite/src/plugins/ts2doc.ts b/code/frameworks/svelte-vite/src/plugins/ts2doc.ts new file mode 100644 index 000000000000..a1096724b02a --- /dev/null +++ b/code/frameworks/svelte-vite/src/plugins/ts2doc.ts @@ -0,0 +1,209 @@ +import ts from 'typescript'; +import { svelte2tsx } from 'svelte2tsx'; +import { VERSION } from 'svelte/compiler'; +import { type SvelteDataItem } from 'sveltedoc-parser'; + +function getInitializerValue(initializer?: ts.Node) { + if (initializer === undefined) { + return undefined; + } + if (ts.isNumericLiteral(initializer)) { + return Number(initializer.text); + } + if (ts.isStringLiteral(initializer)) { + return `"${initializer.text}"`; + } + if (initializer.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + if (initializer.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + return initializer.getText(); +} + +/* + * Make sveltedoc-parser compatible data from .svelte file with svelte2tsx and TypeScript. + */ +export function ts2doc(fileContent: string) { + const shimFilename = require.resolve('svelte2tsx/svelte-shims-v4.d.ts'); + const shimContent = ts.sys.readFile(shimFilename) || ''; + const shimSourceFile = ts.createSourceFile( + shimFilename, + shimContent, + ts.ScriptTarget.Latest, + true + ); + + const tsx = svelte2tsx(fileContent, { + version: VERSION, + isTsFile: true, + mode: 'dts', + }); + const currentSourceFile = ts.createSourceFile('tmp.ts', tsx.code, ts.ScriptTarget.Latest, true); + + const host = ts.createCompilerHost({}); + host.getSourceFile = (fileName, languageVersion, onError) => { + if (fileName === 'tmp.ts') { + return currentSourceFile; + } else if (fileName === shimContent) { + return shimSourceFile; + } else { + // ignore other files + return; + } + }; + + // Create a program with the custom compiler host + const program = ts.createProgram([currentSourceFile.fileName, shimSourceFile.fileName], {}, host); + const checker = program.getTypeChecker(); + + const propMap: Map = new Map(); + + const renderFunction = currentSourceFile.statements.find((statement) => { + return ts.isFunctionDeclaration(statement) && statement.name?.text === 'render'; + }) as ts.FunctionDeclaration | undefined; + if (renderFunction === undefined) { + return { + runeUsed: false, + data: [], + }; + } + + function getPropsFromTypeLiteral(type: ts.TypeLiteralNode) { + const members = type.members; + + members.forEach((member) => { + if (ts.isPropertySignature(member)) { + const name = member.name.getText(); + let typeString = ''; + if (member.type !== undefined) { + const memberType = checker.getTypeFromTypeNode(member.type); + typeString = checker.typeToString(memberType); + } + const jsDoc = ts.getJSDocCommentsAndTags(member); + const docComments = jsDoc + .map((doc) => { + let s = ts.getTextOfJSDocComment(doc.comment) || ''; + doc.forEachChild((child) => { + // Type information from JSDoc comment + if (ts.isJSDocTypeTag(child)) { + let t = ''; + child.typeExpression.forEachChild((ty) => { + t += ty.getText(); + }); + if (t.length > 0) { + typeString = t; + } + s += ts.getTextOfJSDocComment(child.comment); + } + }); + return s; + }) + .join('\n'); + + // mimic the structure of sveltedoc-parser. + propMap.set(name, { + name: name, + visibility: 'public', + description: docComments, + keywords: [], + kind: 'let', + type: { kind: 'type', text: typeString, type: typeString }, + static: false, + readonly: false, + importPath: undefined, + originalName: undefined, + localName: undefined, + defaultValue: undefined, + }); + } + }); + } + + const hasRuneProps = tsx.code.includes('$$ComponentProps'); + if (hasRuneProps) { + // Rune props + + // Try to get prop types from 'type $$ComponentProps = { ... }' + function visitPropsTypeAlias(node: ts.Node) { + if (ts.isTypeAliasDeclaration(node) && node.name.text === '$$ComponentProps') { + const typeAlias = node as ts.TypeAliasDeclaration; + getPropsFromTypeLiteral(typeAlias.type as ts.TypeLiteralNode); + } + ts.forEachChild(node, visitPropsTypeAlias); + } + visitPropsTypeAlias(renderFunction); + + // Obtain default values from 'let { ... }: $$ComponentProps = ...' + function visitObjectBinding(node: ts.Node) { + if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) { + const type = node.type; + if (type && ts.isTypeReferenceNode(type) && type.getText() === '$$ComponentProps') { + const bindingPattern = node.name; + bindingPattern.elements.forEach((element) => { + if (ts.isBindingElement(element)) { + const name = element.propertyName?.getText() || element.name.getText(); + const initializer = getInitializerValue(element.initializer); + const prop = propMap.get(name); + if (initializer !== undefined && prop) { + prop.defaultValue = initializer; + } + } + }); + } + } + ts.forEachChild(node, visitObjectBinding); + } + visitObjectBinding(currentSourceFile); + } else { + // Legacy props (data) + + // Try to get prop types from 'return { ... } as { props: ... }' + renderFunction.body?.forEachChild((statement) => { + if (ts.isReturnStatement(statement)) { + const returnExpression = statement.expression; + if (returnExpression === undefined) { + return; + } + if (ts.isObjectLiteralExpression(returnExpression)) { + returnExpression.properties.forEach((property) => { + if (ts.isPropertyAssignment(property) && property.name.getText() === 'props') { + const propsObject = property.initializer; + if ( + ts.isAsExpression(propsObject) && + ts.isObjectLiteralExpression(propsObject.expression) + ) { + const type = propsObject.type; + if (ts.isTypeLiteralNode(type)) { + getPropsFromTypeLiteral(type); + } + } + } + }); + } + } + }); + + // Try to get default values from 'let = ...' + renderFunction.body?.forEachChild((statement) => { + if (ts.isVariableStatement(statement)) { + statement.declarationList.declarations.forEach((declaration) => { + if (ts.isVariableDeclaration(declaration) && ts.isIdentifier(declaration.name)) { + const name = declaration.name.getText(); + const prop = propMap.get(name); + if (prop && prop.defaultValue === undefined) { + const initializer = getInitializerValue(declaration.initializer); + prop.defaultValue = initializer; + } + } + }); + } + }); + } + + return { + hasRuneProps, + data: Array.from(propMap.values()), + }; +} diff --git a/code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ButtonTypeScriptRunes.svelte b/code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ButtonTypeScriptRunes.svelte new file mode 100644 index 000000000000..8eb7aeccf7f5 --- /dev/null +++ b/code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ButtonTypeScriptRunes.svelte @@ -0,0 +1,53 @@ + + + diff --git a/code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ts-runes-docs.stories.js b/code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ts-runes-docs.stories.js new file mode 100644 index 000000000000..daa8ccc4db8f --- /dev/null +++ b/code/frameworks/sveltekit/template/stories_svelte-kit-prerelease-ts/ts-runes-docs.stories.js @@ -0,0 +1,13 @@ +import ButtonTypescriptRunes from './ButtonTypeScriptRunes.svelte'; + +export default { + title: 'stories/renderers/svelte/ts-runes-docs', + component: ButtonTypescriptRunes, + args: { + primary: true, + label: 'Button', + }, + tags: ['autodocs'], +}; + +export const Primary = {}; diff --git a/code/yarn.lock b/code/yarn.lock index f22ee0dc024f..6b5525e5330f 100644 --- a/code/yarn.lock +++ b/code/yarn.lock @@ -6644,6 +6644,7 @@ __metadata: magic-string: "npm:^0.30.0" svelte: "npm:^5.0.0-next.65" svelte-preprocess: "npm:^5.1.1" + svelte2tsx: "npm:^0.7.13" sveltedoc-parser: "npm:^4.2.1" ts-dedent: "npm:^2.2.0" typescript: "npm:^5.3.2" @@ -12437,6 +12438,13 @@ __metadata: languageName: node linkType: hard +"dedent-js@npm:^1.0.1": + version: 1.0.1 + resolution: "dedent-js@npm:1.0.1" + checksum: 10c0/a8cff2e02d5a1ce64615c5c53c9789e7ef1abb9ae7bf2322dc991fcbaf08d901ace1a679c1e021de15a85db7787b8ccfb02011e1f394afef0f698fc857a47009 + languageName: node + linkType: hard + "dedent@npm:^0.7.0": version: 0.7.0 resolution: "dedent@npm:0.7.0" @@ -21898,7 +21906,7 @@ __metadata: languageName: node linkType: hard -"pascal-case@npm:^3.1.2": +"pascal-case@npm:^3.1.1, pascal-case@npm:^3.1.2": version: 3.1.2 resolution: "pascal-case@npm:3.1.2" dependencies: @@ -26155,6 +26163,19 @@ __metadata: languageName: node linkType: hard +"svelte2tsx@npm:^0.7.13": + version: 0.7.13 + resolution: "svelte2tsx@npm:0.7.13" + dependencies: + dedent-js: "npm:^1.0.1" + pascal-case: "npm:^3.1.1" + peerDependencies: + svelte: ^3.55 || ^4.0.0-next.0 || ^4.0 || ^5.0.0-next.0 + typescript: ^4.9.4 || ^5.0.0 + checksum: 10c0/a6df2873e551d116bae8b4664b1838a52c6aa50ad50e30e41611e8bf390c525ceb97b53d034f2e0030a640e157e1f88ac33d52b6e4abb54861b3fc120aae82f7 + languageName: node + linkType: hard + "svelte@npm:^4.0.0": version: 4.2.8 resolution: "svelte@npm:4.2.8"