Skip to content

Commit

Permalink
svelte: autodocs with svelte2tsx
Browse files Browse the repository at this point in the history
  • Loading branch information
ciscorn committed Jul 9, 2024
1 parent ef343c7 commit 9d05996
Show file tree
Hide file tree
Showing 6 changed files with 359 additions and 52 deletions.
5 changes: 3 additions & 2 deletions code/frameworks/svelte-vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
108 changes: 59 additions & 49 deletions code/frameworks/svelte-vite/src/plugins/svelte-docgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -73,69 +74,78 @@ export async function svelteDocgen(svelteOptions: Record<string, any> = {}): 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([[/<style.+<\/style>/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([[/<style.+<\/style>/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)}`);

Expand Down
209 changes: 209 additions & 0 deletions code/frameworks/svelte-vite/src/plugins/ts2doc.ts
Original file line number Diff line number Diff line change
@@ -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<string, SvelteDataItem> = 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 <prop> = ...'
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()),
};
}
Loading

0 comments on commit 9d05996

Please sign in to comment.