UNPKG

@paima/aiken-mdx

Version:
334 lines (333 loc) 13.4 kB
#!/usr/bin/env ts-node import Handlebars from 'handlebars'; import fs from 'fs'; import path from 'path'; import prettier from 'prettier'; import { Command } from 'commander'; import toml from 'toml'; import { fileURLToPath } from 'url'; /* eslint-disable @typescript-eslint/no-unsafe-argument */ const program = new Command(); program .requiredOption('-o, --output <path>', 'output file path (ex: ./out.mdx)') .option('-single, --single <path>', 'folder for a single Aiken project to use as input') .option('-multiple, --multiple <path>', 'file path for a handlebar (.hbs) file that describes which folders to use') .parse(process.argv); const options = program.opts(); if ((options.single && options.multiple) || (!options.single && !options.multiple)) { console.error('You must specify either --single or --multiple, but not both.'); process.exit(1); } if (!options.output) { console.error('Output path must be specified with -o <path>'); process.exit(1); } function relativeToFile(source) { return path.dirname(fileURLToPath(import.meta.url)) + source; } function getTemplate(source) { const template = fs.readFileSync(source, 'utf8'); const compiled = Handlebars.compile(template); return compiled; } const plutusTemplate = getTemplate(relativeToFile('/templates/plutus.hbs')); const preludeTemplate = getTemplate(relativeToFile('/templates/prelude.hbs')); /** * plutus.json doesn't keep track of which file is a dependency and which is local * so to differentiate this, we first find all the local files */ async function getDirectories(srcPath) { const directories = []; const items = await fs.promises.readdir(srcPath, { withFileTypes: true }); for (const item of items) { if (item.isDirectory()) { directories.push(item.name); // get sub-folders as well const fullPath = path.join(srcPath, item.name); const subDirectories = await getDirectories(fullPath); for (const subDir of subDirectories) { // plutus.json uses / to represent paths directories.push(item.name + '/' + subDir); } } } return directories; } const AllTypes = ['constructor', 'list', 'map', 'bytes', 'integer']; function filterDefinitions(directories, context) { const categories = new Map(); for (const key of Object.keys(context)) { for (const dir of directories) { // wrapper types in plutus.json have a special syntax // local_type $ wrapped_type const wrapperEndIndex = key.indexOf('$'); const adjustedKey = wrapperEndIndex === -1 ? key : key.substring(0, wrapperEndIndex); // data types native to Aiken (ex: ByteArray) if (context[key].title == null || context[key].title === 'Tuple') { continue; } const endOfScope = adjustedKey.lastIndexOf('/'); const scope = endOfScope === -1 ? adjustedKey : adjustedKey.substring(0, endOfScope); const category = context[key].anyOf != null ? 'constructor' : context[key].dataType; let scopes = categories.get(category); if (scopes == null) { scopes = new Map(); categories.set(category, scopes); } let scopeEntry = scopes.get(scope); if (scopeEntry == null) { scopeEntry = { scope, isLocal: adjustedKey.startsWith(dir), names: [], }; scopes.set(scope, scopeEntry); } // remove duplicates (in practice there are few elements and using a set complicates things) if (scopeEntry.names.find(entry => entry.original === key) != null) { continue; } scopeEntry.names.push({ local: adjustedKey, original: key, }); } } return categories; } /** * Turn a name like #/definitions/ByteArray -> ByteArray */ function parseType(ref, definitions, depth) { const noPrefix = ref.substring('#/definitions/'.length); // Note: JSON pointer specification indicates that '/' must be escaped as '~1 const fixed = noPrefix.replaceAll('~1', '/'); const nextType = definitions[fixed]; if (nextType == null) { return { type: 'BaseType', value: fixed.substring(fixed.lastIndexOf('/') + 1, fixed.length), }; } return definitionToType(nextType, definitions, depth); } function definitionToType(context, definitions, depth) { // handle constructors if ('anyOf' in context) { const title = context.title.replaceAll(' ', ''); if (depth > 0) { return { type: 'BaseType', value: title, }; } return { type: 'EnumType', title, description: context.description ?? '', children: context.anyOf.map((ctor) => definitionToType(ctor, definitions, depth + 1)), }; } switch (context.dataType) { case 'constructor': { if (context.fields.length === 0) { return { type: 'ConstructorSimple', title: context.title, description: context.description ?? '', }; } if (context.fields.some((field) => field.title == null)) { return { type: 'ConstructorTuple', title: context.title, description: context.description ?? '', children: context.fields.map((field) => parseType(field.$ref, definitions, depth + 1)), }; } return { type: 'ConstructorMap', title: context.title, description: context.description ?? '', children: context.fields.map((field) => ({ key: field.title, type: parseType(field.$ref, definitions, depth + 1), })), }; } case 'list': { if (context.title === 'Tuple') { return { type: 'TupleType', children: context.items.map((item) => parseType(item.$ref, definitions, depth + 1)), }; } return { type: 'ListType', children: parseType(context.items.$ref, definitions, depth + 1), }; } case 'map': { return { type: 'MapType', keys: parseType(context.keys.$ref, definitions, depth + 1), values: parseType(context.values.$ref, definitions, depth + 1), }; } case 'bytes': { return { type: 'BaseType', value: 'ByteArray', }; } case 'integer': { return { type: 'BaseType', value: 'Int', }; } default: { // according to CIP57, // When missing, the instance is implicitly typed as an opaque Plutus Data return { type: 'BaseType', value: 'Data', }; } } } function parseTypeName(ref, definitions) { const noPrefix = ref.substring('#/definitions/'.length); // Note: JSON pointer specification indicates that '/' must be escaped as '~1 const fixed = noPrefix.replaceAll('~1', '/'); const nextType = definitions[fixed]; if (nextType == null) { return fixed.substring(fixed.lastIndexOf('/') + 1, fixed.length); } return definitionToShortname(nextType, definitions); } function definitionToShortname(context, definitions) { switch (context.type) { case 'ConstructorSimple': return context.title; case 'ConstructorTuple': { const tuples = context.children.map(field => definitionToShortname(field, definitions)); return `${context.title}(${tuples.join(', ')})`; } case 'ConstructorMap': { const tuples = context.children.map(field => `${field.key}: ${definitionToShortname(field.type, definitions)}`); return `${context.title}(${tuples.join(', ')})`; } case 'TupleType': return `(${context.children.map((child) => definitionToShortname(child, definitions)).join(', ')})`; case 'ListType': return `${definitionToShortname(context.children, definitions)}[]`; case 'MapType': return `Map<${definitionToShortname(context.keys, definitions)}, ${definitionToShortname(context.values, definitions)}>`; case 'BaseType': return context.value; default: throw new Error(`Unhandled type: ${JSON.stringify(context)}`); } } async function genFileMarkdown(folderPath, startDepth) { const directories = await getDirectories(`${folderPath}/lib`); const jsonData = JSON.parse(fs.readFileSync(`${folderPath}/plutus.json`, 'utf8')); const projectToml = toml.parse(fs.readFileSync(`${folderPath}/aiken.toml`, 'utf8')); const { user, project, platform } = projectToml.repository; const fullPath = path.resolve(folderPath); // note: we assume that // 1. the repo is in folder whose name matches the project name // 2. the project name doesn't appear multiple times in the path (ex: my-project/aiken/my-projects) const pathToProject = fullPath.substring(fullPath.lastIndexOf(project) + project.length + 1); const projectLink = platform === 'github' ? `https://github.com/${user}/${project}/tree/master/${pathToProject}` : null; const categories = filterDefinitions(directories, jsonData.definitions); console.log(categories.get('constructor')); const withGlobals = { context: { githubLink: projectLink, }, blueprint: jsonData, startDepth, title: jsonData.preamble.title.substring(jsonData.preamble.title.lastIndexOf('/') + 1), anchor: jsonData.preamble.title.replaceAll('/', '-'), }; for (const type of AllTypes) { withGlobals[`has-${type}s`] = (() => { const val = categories.get(type); return val != null && val.size > 0; })(); } for (const type of AllTypes) { withGlobals[`all-${type}s`] = (() => { const result = Array.from((categories.get(type) ?? new Map())?.values()); // sort scopes to put local scopes first result.sort((t1, t2) => { if (t1.isLocal === t2.isLocal) return 0; if (t1.isLocal && !t2.isLocal) return -1; return 1; }); return result; })(); } Handlebars.registerHelper('header', function (title, depth, _options) { return `${'#'.repeat(startDepth + depth - 1)} ${title}`; }); Handlebars.registerHelper('typeToSignature', function (originalName, options) { // https://github.com/cardano-foundation/CIPs/blob/master/CIP-0057/schemas/plutus-data.json const context = withGlobals.blueprint.definitions[originalName]; return options.fn(definitionToType(context, withGlobals.blueprint.definitions, 0)); }); Handlebars.registerHelper('typeFromRef', function (ref, options) { return options.fn(parseType(ref, withGlobals.blueprint.definitions, 1)); }); Handlebars.registerHelper('isType', function (typeName, type, _options) { return type.type === typeName; }); // render template const result = plutusTemplate(withGlobals); // format template const prettierOutput = await prettier.format(result, { parser: 'mdx', }); return prettierOutput; } async function getAllFiles() { const prelude = preludeTemplate({}); if (options.single) { const fileOutput = await genFileMarkdown(options.single, 1); const combinedOutput = prelude + '\n' + fileOutput; fs.writeFileSync(options.output, combinedOutput); } if (options.multiple) { const paths = []; // since handlebar doesn't support async calls, we do 2 passes // 1) find all the files we have to parse { Handlebars.registerHelper('import', function (path, _options) { paths.push(path); return ''; }); getTemplate(options.multiple)({}); // execute the dummy run to get all the imports } const generatedDocs = new Map(); for (const path of paths) { generatedDocs.set(path, await genFileMarkdown(path, 3)); } // 2) properly fill the import calls with the data we've fetched { Handlebars.registerHelper('import', function (path, _options) { return generatedDocs.get(path); }); const multiTemplate = getTemplate(options.multiple); const result = multiTemplate({}); const combinedOutput = prelude + '\n' + result; fs.writeFileSync(options.output, combinedOutput); } } } void getAllFiles();