chore: use sveld to generate types, component docs

This commit is contained in:
Eric Liu 2020-11-16 11:13:18 -08:00
commit 380a780b65
15 changed files with 13298 additions and 4888 deletions

View file

@ -1,117 +0,0 @@
const toLink = (text) => text.toLowerCase().replace(/\s+/g, "-");
const toMdLink = (text) => `[${text}](#${toLink(text)})`;
const formatType = (type) => `<code>${type.replace(/\|/g, "&#124;")}</code>`;
const escapeHtml = (text) => text.replace(/\</g, "&lt;").replace(/\>/g, "&gt;");
const HEADER_PROPS =
"| Prop name | Type | Default value | Description |\n| :- | :- | :- | :- |\n";
/**
* Use library component metadata to generate component documentation in markdown format.
* @param {Map<string, { component: { exported_props: Map<string, any>; slots: Map<string, any>; } typedefs: {name: string; type: string;}[] }>} components
* @param {Map<string, string[]>} groups
* @param {{name: string; version: string; homepage: string;}} pkg
*/
export function generateIndex(components, groups, pkg) {
let code = `# Component Index\n\n`;
code += `> ${components.size} components exported from ${pkg.name} ${pkg.version}\n\n`;
groups.forEach((group, component_group) => {
if (group.length > 1) {
code += `- ${component_group}\n`;
group.sort().forEach((component) => {
code += ` - ${toMdLink(component)}\n`;
});
} else {
code += `- ${toMdLink(component_group)}\n`;
}
});
code += "---\n";
components.forEach((component, moduleName) => {
const {
typedefs,
component: { exported_props, slots, forwarded_events, dispatched_events },
} = component;
code += `## ${moduleName}\n\n`;
code += `### Import path\n\n`;
code += `\`\`\`js\nimport { ${moduleName} } from "${pkg.name}";\n\`\`\`\n\n`;
code += "### Props\n\n";
if (exported_props.size > 0) {
if (typedefs.length > 0) {
let definitions = "";
typedefs.forEach(({ name, type }) => {
const typedef = type.startsWith("{")
? `interface ${name} ${type}`
: `type ${name} = ${type};`;
definitions += `${typedef}\n\n`;
});
code += `\`\`\`ts\n// \`${moduleName}\` type definitions\n\n${definitions}\n\`\`\`\n\n`;
}
code += HEADER_PROPS;
exported_props.forEach((prop, name) => {
code += `| ${name}${
prop.kind === "const" ? " (`constant`)" : ""
} | ${formatType(prop.type)} | ${
prop.value ? "`" + prop.value + "`" : "--"
} | ${escapeHtml(prop.description).replace(/\n/g, ". ")}. |\n`;
});
} else {
code += "No exported props.\n\n";
}
code += "### Slots\n\n";
if (slots.size > 0) {
if (slots.get("default")) {
code += "- **default**: `<div>...</div>`\n";
}
slots.forEach((slot, name) => {
if (slot.default) return;
code += `- **"${name}"**: \`<div name="${name}" ${slot.attributes
.map((attr) => `let:${attr.name}`)
.join(" ")}>...</div>\`\n`;
});
} else {
code += "No slots.\n\n";
}
code += "### Forwarded events\n\n";
if (forwarded_events.size > 0) {
forwarded_events.forEach((event, name) => {
code += `- \`on:${name}\`\n`;
});
} else {
code += "No forwarded events.\n\n";
}
code += "### Dispatched events\n\n";
if (dispatched_events.size > 0) {
dispatched_events.forEach((event, name) => {
code += `- \`on:${name}\`\n`;
});
} else {
code += "No dispatched events.\n\n";
}
code += "---\n";
});
return { code };
}

View file

@ -1,29 +0,0 @@
/**
* Use library component metadata to generate component documentation in JSON format.
* @param {Map<string, { component: { exported_props: Map<string, any>; slots: Map<string, any>; } typedefs: {name: string; type: string;}[] }>} components
* @param {Map<string, string[]>} groups
* @param {{name: string; version: string; homepage: string;}} pkg
*/
export function generatePublicAPI(components, groups, pkg) {
const code = {
version: pkg.version,
components: {},
};
components.forEach((component, moduleName) => {
const props = Array.from(component.component.exported_props);
const slots = Array.from(component.component.slots);
const forwarded_events = Array.from(component.component.forwarded_events);
const dispatched_events = Array.from(component.component.dispatched_events);
code.components[moduleName] = {
moduleName,
props,
slots,
forwarded_events,
dispatched_events,
};
});
return { code };
}

View file

@ -1,82 +0,0 @@
/**
* Use library component metadata to generate TypeScript definitions.
* @param {Map<string, { component: { exported_props: Map<string, any>; slots: Map<string, any>; } typedefs: {name: string; type: string;}[] }>} components
* @param {{name: string; version: string; homepage: string;}} pkg
*/
export function generateTypes(components, pkg) {
let code = `
// Type definitions for ${pkg.name} ${pkg.version}
// Project: ${pkg.homepage}
export class CarbonSvelteComponent {
$$prop_def: {};
$$slot_def: {};
// stub all \`on:{eventname}\` directives
$on(eventname: string, handler: (e: Event) => any): () => void;
}\n\n`;
components.forEach((component, moduleName) => {
let $$prop_def = "";
let $$slot_def = "";
component.typedefs.forEach(({ name, type }) => {
const typedef = type.startsWith("{")
? `interface ${name} ${type}`
: `type ${name} = ${type};`;
code += typedef + "\n\n";
});
component.component.exported_props.forEach((prop, name) => {
$$prop_def += "/**\n";
prop.description.split("\n").forEach((line) => {
$$prop_def += "* " + line + "\n";
});
if (prop.kind === "const") {
$$prop_def += "* @constant\n";
}
if (prop.value !== undefined) {
$$prop_def += "* @default " + prop.value + "\n";
}
$$prop_def += "*/\n";
let value = "undefined";
if (prop.type) {
value = prop.type;
}
$$prop_def += name + "?: " + value + ";" + "\n\n";
});
component.component.slots.forEach((slot, slot_name) => {
let value = "";
slot.attributes.forEach((attribute) => {
if (attribute.name !== "name") {
value += attribute.name + ": any;";
}
});
if (slot.default) {
$$slot_def += "default: {" + value + "};" + "\n";
} else {
$$slot_def += JSON.stringify(slot_name) + ": {" + value + "};" + "\n";
}
});
code += `
export class ${moduleName} extends CarbonSvelteComponent {
${!!$$prop_def ? "$$prop_def: {" + $$prop_def + "}\n" : ""}
${!!$$slot_def ? "$$slot_def: {" + $$slot_def + "}\n" : ""}
}\n\n`;
});
return { code };
}

View file

@ -1,137 +0,0 @@
import { parse, walk } from "svelte/compiler";
import commentParser from "comment-parser";
/**
* Parse Svelte component for metadata using the Svelte compiler
* @param {string} source - raw Svelte component code
* @param {{ component: string; onTypeDef: (tag: { type: "typedef"; tag: string; name: string; }) => void;}} hooks
*/
export function parseComponent(source, hooks) {
const exported_props = new Map();
const slots = new Map();
const forwarded_events = new Map();
const dispatched_events = new Map();
let hasDispatchedEvents = false;
let dispatcher_name = null;
let callee = [];
walk(parse(source), {
enter(node, parent, prop) {
switch (node.type) {
case "CallExpression":
if (hasDispatchedEvents) {
if (node.callee.name === "createEventDispatcher") {
dispatcher_name = parent.id.name;
}
}
break;
case "Identifier":
if (node.name === "createEventDispatcher") {
hasDispatchedEvents = true;
}
if (prop === "callee") {
callee.push({ name: node.name, parent });
}
break;
case "ExportNamedDeclaration":
const { id, init } = node.declaration.declarations[0];
let value = undefined;
let type = undefined;
let kind = node.declaration.kind;
let description = null;
if (init != null) {
if (
init.type === "ObjectExpression" ||
init.type === "ArrayExpression"
) {
value = source.slice(init.start, init.end).replace(/\n/g, " ");
type = value;
} else {
value = init.raw;
}
}
const general_comments = commentParser(source);
general_comments.forEach((comment) => {
comment.tags.forEach((tag) => {
if (tag.tag === "typedef") hooks.onTypeDef(tag);
});
});
if (node.leadingComments) {
const comment = commentParser(
"/*" + node.leadingComments[0].value + "*/"
);
description = comment[0].description;
type = comment[0].tags[comment[0].tags.length - 1].type;
} else {
throw Error(
`[${hooks.component}] property \`${id.name}\` should be annotated.`
);
}
exported_props.set(id.name, { kind, value, type, description });
break;
case "Slot":
let slot_name = null;
const slot_attributes = [];
node.attributes.forEach((attribute) => {
if (attribute.name === "name") {
slot_name = attribute.value[0].raw;
} else {
slot_attributes.push(attribute);
}
});
let default_value = "";
node.children.forEach((child) => {
default_value += `${source.slice(child.start, child.end)}\n`;
});
slots.set(slot_name == null ? "default" : slot_name, {
attributes: node.attributes,
children: node.children,
default: slot_name == null,
default_value,
});
break;
case "EventHandler":
if (node.expression == null) {
forwarded_events.set(node.name, node);
}
break;
}
},
});
if (hasDispatchedEvents) {
callee.forEach((node) => {
if (node.name === dispatcher_name) {
const [name, detail] = node.parent.arguments;
if (name.value !== undefined) {
dispatched_events.set(name.value, {
detail: detail ? source.slice(detail.start, detail.end) : undefined,
});
}
}
});
}
return {
exported_props,
slots,
forwarded_events,
dispatched_events,
};
}

View file

@ -1,84 +0,0 @@
import fs from "fs";
import path from "path";
import { promisify } from "util";
import pkg from "../../package.json";
import { format } from "prettier";
import { parseComponent } from "./parse-component";
import { generateTypes } from "./generate-types";
import { generateIndex } from "./generate-index";
import { generatePublicAPI } from "./generate-public-api";
const writeFile = promisify(fs.writeFile);
/**
* Rollup plugin to generate library TypeScript definitions and documentation.
*/
function pluginGenerateDocs() {
const groups = new Map();
const components = new Map();
return {
name: "generate-docs",
async generateBundle(options, bundle) {
for (const filename in bundle) {
const chunkOrAsset = bundle[filename];
if (chunkOrAsset.type !== "asset" && chunkOrAsset.isEntry) {
chunkOrAsset.exports.forEach((exportee) => {
components.set(exportee, {});
});
const shared_types = new Set();
Object.keys(chunkOrAsset.modules)
.sort()
.forEach(async (filename) => {
const { dir, ext, name } = path.parse(filename);
const moduleName = name.replace(/\./g, "");
if (ext === ".svelte" && components.has(moduleName)) {
const group = dir.split("src/").pop().split("/")[0];
if (groups.has(group)) {
groups.set(group, [...groups.get(group), moduleName]);
} else {
groups.set(group, [moduleName]);
}
const typedefs = [];
const source = fs.readFileSync(filename, "utf-8");
const component = parseComponent(source, {
component: moduleName,
onTypeDef: (tag) => {
if (shared_types.has(tag.name)) return;
shared_types.add(tag.name);
typedefs.push(tag);
},
});
components.set(moduleName, { typedefs, component });
}
});
}
}
},
async writeBundle() {
const { code: types } = generateTypes(components, pkg);
await writeFile(pkg.types, format(types, { parser: "typescript" }));
const { code: index } = generateIndex(components, groups, pkg);
await writeFile(
"./COMPONENT_INDEX.md",
format(index, { parser: "markdown" })
);
const { code: json } = generatePublicAPI(components, groups, pkg);
await writeFile(
"./docs/src/PUBLIC_API.json",
JSON.stringify(json, null, 2)
);
},
};
}
export default pluginGenerateDocs;