From 6d05ffbe46681908d52f69d260a7959da013e6d0 Mon Sep 17 00:00:00 2001 From: Matt McCormick Date: Mon, 26 Jun 2023 16:42:09 -0400 Subject: [PATCH] WIP: feat(bindgen): Generate typescript demo app --- .../typescript/cypress/tsconfig.json | 12 + .../typescript-resources/demo-index.html | 31 +- src/bindgen/typescript-resources/demo.css | 4 - src/bindgen/typescript-resources/demo.ts | 42 +- .../template.package.json | 7 +- .../typescript-resources/vite.config.js | 6 + src/bindgen/typescript.js | 405 +++++++++++++++++- 7 files changed, 460 insertions(+), 47 deletions(-) create mode 100644 packages/compress-stringify/typescript/cypress/tsconfig.json diff --git a/packages/compress-stringify/typescript/cypress/tsconfig.json b/packages/compress-stringify/typescript/cypress/tsconfig.json new file mode 100644 index 000000000..212405809 --- /dev/null +++ b/packages/compress-stringify/typescript/cypress/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../tsconfig.json", + "include": [ + "**/*.ts" + ], + "compilerOptions": { + "noEmit": false, + "sourceMap": false, + "inlineSourceMap": true, + "types": ["cypress"] + }, +} diff --git a/src/bindgen/typescript-resources/demo-index.html b/src/bindgen/typescript-resources/demo-index.html index 293bb5f05..1be79a6d3 100644 --- a/src/bindgen/typescript-resources/demo-index.html +++ b/src/bindgen/typescript-resources/demo-index.html @@ -3,23 +3,36 @@ - - <bindgenPackageName> + + + + + @bindgenPackageName@
-

+

@bindgenPackageName@

package -
-

functions

-
    - -
-
+ +

functions

+ +@pipelinesFunctionsTabs@ +@bindgenFunctions@ + +

Click on the ITK Wasm logo to learn more

diff --git a/src/bindgen/typescript-resources/demo.css b/src/bindgen/typescript-resources/demo.css index 33e58852f..c8651cd85 100644 --- a/src/bindgen/typescript-resources/demo.css +++ b/src/bindgen/typescript-resources/demo.css @@ -64,10 +64,6 @@ li { padding: 2em; } -.read-the-docs { - color: #888; -} - @media (prefers-color-scheme: light) { :root { color: #213547; diff --git a/src/bindgen/typescript-resources/demo.ts b/src/bindgen/typescript-resources/demo.ts index 069b53ffc..2ce7eb0d4 100644 --- a/src/bindgen/typescript-resources/demo.ts +++ b/src/bindgen/typescript-resources/demo.ts @@ -1,18 +1,30 @@ -import * as from '../../dist/bundles/.js' -.setPipelinesBaseUrl('/pipelines') +import * as @bindgenBundleNameCamelCase@ from '../../dist/bundles/@bindgenBundleName@.js' -const packageFunctions = [] -for (const [key, val] of Object.entries()) { - if (typeof val == 'function') { - packageFunctions.push(key) - } +// Use local, vendored WebAssembly module assets +const pipelinesBaseUrl: string | URL = new URL('/pipelines', document.location.origin).href +@bindgenBundleNameCamelCase@.setPipelinesBaseUrl(pipelinesBaseUrl) +const pipelineWorkerUrl: string | URL | null = new URL('/web-workers/pipeline.worker.js', document.location.origin).href +@bindgenBundleNameCamelCase@.setPipelineWorkerUrl(pipelineWorkerUrl) + + +// ------------------------------------------------------------------------------------ +// Utilities + +function downloadFile(content, filename) { + const url = URL.createObjectURL(new Blob([content])) + const a = document.createElement('a') + a.href = url + a.download = filename || 'download' + document.body.appendChild(a) + function clickHandler(event) { + setTimeout(() => { + URL.revokeObjectURL(url) + a.removeEventListener('click', clickHandler) + }, 200) + }; + a.addEventListener('click', clickHandler, false) + a.click() + return a } -const pipelineFunctionsList = document.getElementById('pipeline-functions-list') -pipelineFunctionsList.innerHTML = ` -
  • - ${packageFunctions.join('
  • \n
  • ')} -
  • -` -console.log(packageFunctions) -console.log() +@bindgenFunctionLogic@ diff --git a/src/bindgen/typescript-resources/template.package.json b/src/bindgen/typescript-resources/template.package.json index c66e59963..919cd27c3 100644 --- a/src/bindgen/typescript-resources/template.package.json +++ b/src/bindgen/typescript-resources/template.package.json @@ -30,7 +30,7 @@ "author": "", "license": "Apache-2.0", "dependencies": { - "itk-wasm": "^1.0.0-b.117" + "itk-wasm": "^1.0.0-b.118" }, "devDependencies": { "@rollup/plugin-commonjs": "^24.0.0", @@ -38,6 +38,7 @@ "@rollup/plugin-node-resolve": "^15.1.0", "@rollup/plugin-terser": "^0.4.0", "@rollup/plugin-typescript": "^11.1.1", + "@shoelace-style/shoelace": "^2.5.2", "@types/node": "^20.2.5", "debug": "^4.3.4", "rollup": "^3.9.0", @@ -47,7 +48,7 @@ "supports-color": "^9.3.1", "tslib": "^2.5.2", "typescript": "^5.0.4", - "vite": "^4.0.4", - "vite-plugin-static-copy": "^0.13.0" + "vite": "^4.3.3", + "vite-plugin-static-copy": "^0.14.0" } } diff --git a/src/bindgen/typescript-resources/vite.config.js b/src/bindgen/typescript-resources/vite.config.js index c8a55d36e..b359a0d24 100644 --- a/src/bindgen/typescript-resources/vite.config.js +++ b/src/bindgen/typescript-resources/vite.config.js @@ -8,12 +8,18 @@ export default defineConfig({ outDir: '../../demo', emptyOutDir: true, }, + rollupOptions: { + external: [ + "/shoelace/shoelace.js", + ] + }, plugins: [ // put lazy loaded JavaScript and Wasm bundles in dist directory viteStaticCopy({ targets: [ { src: '../../dist/pipelines/*', dest: 'pipelines' }, { src: '../../dist/web-workers/*', dest: 'web-workers' }, + { src: '../../node_modules/@shoelace-style/shoelace/dist/*', dest: 'shoelace/' }, ], }) ], diff --git a/src/bindgen/typescript.js b/src/bindgen/typescript.js index ee0431684..c8da8ca8f 100644 --- a/src/bindgen/typescript.js +++ b/src/bindgen/typescript.js @@ -34,6 +34,361 @@ const interfaceJsonTypeToTypeScriptType = new Map([ ['OUTPUT_JSON', 'Object'], ]) +function packageToBundleName(packageName) { + return path.basename(packageName.replace('@', '-')) +} + +function inputParametersDemoHtml(prefix, indent, parameter, required) { + let result = '' + const requiredAttr = required ? 'required ' : '' + switch(parameter.type) { + case 'INPUT_TEXT_FILE:FILE': + case 'INPUT_TEXT_STREAM': + result += `${prefix}${indent}\n` + result += `${prefix}${indent}\n` + result += `

    \n` + break + case 'INPUT_BINARY_FILE:FILE': + case 'INPUT_BINARY_STREAM': + result += `${prefix}${indent}\n` + result += `${prefix}${indent}\n` + result += `

    \n` + break + case 'TEXT': + result += `${prefix}${indent}\n` + break + case 'INT': + if (parameter.itemsExpected !== 1 || parameter.itemsExpectedMin !== 1 || parameter.itemsExpectedMax !== 1) { + // TODO + console.error(`INT items != 1 are currently not supported`) + process.exit(1) + } + result += `${prefix}${indent}\n` + result += `
    \n` + break + case 'BOOL': + result += `${prefix}${indent}${camelCase(parameter.name)} - ${parameter.description}\n` + result += `
    \n` + break + default: + console.error(`Unexpected interface type: ${parameter.type}`) + process.exit(1) + } + return result +} + +function outputDemoHtml(prefix, indent, parameter) { + let result = '' + switch(parameter.type) { + case 'OUTPUT_TEXT_FILE:FILE': + case 'OUTPUT_TEXT_STREAM': + result += `${prefix}${indent}\n` + result += `${prefix}${indent}${camelCase(parameter.name)}\n` + result += `

    \n` + break + case 'OUTPUT_BINARY_FILE:FILE': + case 'OUTPUT_BINARY_STREAM': + result += `${prefix}${indent}\n` + result += `${prefix}${indent}${camelCase(parameter.name)}\n` + result += `

    \n` + break + case 'TEXT': + result += `${prefix}${indent}\n` + break + case 'INT': + case 'UINT': + if (parameter.itemsExpected !== 1 || parameter.itemsExpectedMin !== 1 || parameter.itemsExpectedMax !== 1) { + // TODO + console.error(`INT items != 1 are currently not supported`) + process.exit(1) + } + result += `${prefix}${indent}\n` + result += `
    \n` + break + case 'BOOL': + result += `${prefix}${indent}${camelCase(parameter.name)} - ${parameter.description}\n` + result += `
    \n` + break + case 'OUTPUT_JSON': + result += `${prefix}${indent}${camelCase(parameter.name)} - ${parameter.description}\n` + result += `${prefix}${indent}${camelCase(parameter.name)}\n` + result += `

    \n` + break + default: + console.error(`Unexpected interface type: ${parameter.type}`) + process.exit(1) + } + return result +} + +function outputDemoTypeScript(functionName, indent, parameter) { + let result = '' + console.log(parameter) + + // context.outputs.output = output + // const outputTextArea = document.querySelector('#parseStringDecompressOutputs sl-textarea[name=output]') + // outputTextArea.value = output.toString() + // }) + return result + switch(parameter.type) { + case 'OUTPUT_TEXT_FILE:FILE': + case 'OUTPUT_TEXT_STREAM': + result += `${prefix}${indent}\n` + result += `${prefix}${indent}${camelCase(parameter.name)}\n` + result += `

    \n` + break + case 'OUTPUT_BINARY_FILE:FILE': + case 'OUTPUT_BINARY_STREAM': + result += `${prefix}${indent}\n` + result += `${prefix}${indent}${camelCase(parameter.name)}\n` + result += `

    \n` + break + case 'TEXT': + result += `${prefix}${indent}\n` + break + case 'INT': + case 'UINT': + if (parameter.itemsExpected !== 1 || parameter.itemsExpectedMin !== 1 || parameter.itemsExpectedMax !== 1) { + // TODO + console.error(`INT items != 1 are currently not supported`) + process.exit(1) + } + result += `${prefix}${indent}\n` + result += `
    \n` + break + case 'BOOL': + result += `${prefix}${indent}${camelCase(parameter.name)} - ${parameter.description}\n` + result += `
    \n` + break + case 'OUTPUT_JSON': + result += `${prefix}${indent}${camelCase(parameter.name)} - ${parameter.description}\n` + result += `${prefix}${indent}${camelCase(parameter.name)}\n` + result += `

    \n` + break + default: + console.error(`Unexpected interface type: ${parameter.type}`) + process.exit(1) + } + return result + +} + +// Evenutally we will support them all +const demoSupportedInputTypes = new Set([ + 'INPUT_TEXT_FILE:FILE', + 'INPUT_TEXT_STREAM', + 'INPUT_BINARY_FILE:FILE', + 'INPUT_BINARY_STREAM', + 'TEXT', + 'INT', + 'UINT', + 'BOOL', +]) +const demoSupportedOutputTypes = new Set([ + 'OUTPUT_TEXT_FILE:FILE', + 'OUTPUT_TEXT_STREAM', + 'OUTPUT_BINARY_FILE:FILE', + 'OUTPUT_BINARY_STREAM', + 'TEXT', + 'INT', + 'UINT', + 'BOOL', + 'OUTPUT_JSON', +]) + +function allDemoTypesSupported(interfaceJson) { + let allTypesSupported = true + allTypesSupported = allTypesSupported && interfaceJson.inputs.every((input) => demoSupportedInputTypes.has(input.type)) + allTypesSupported = allTypesSupported && interfaceJson.parameters.every((parameter) => demoSupportedInputTypes.has(parameter.type)) + allTypesSupported = allTypesSupported && interfaceJson.outputs.every((parameter) => demoSupportedOutputTypes.has(parameter.type)) + return allTypesSupported +} + +function interfaceFunctionsDemoHtml(interfaceJson) { + let prefix = ' ' + let indent = ' ' + let result = '' + + const allTypesSupported = allDemoTypesSupported(interfaceJson) + if (!allTypesSupported) { + return result + } + + const nameCamelCase = camelCase(interfaceJson.name) + result += `\n${prefix}\n` + result += `${prefix}

    ${nameCamelCase}

    \n` + result += `${prefix}${interfaceJson.description}\n` + + result += `${prefix}

    Inputs

    \n` + interfaceJson.inputs.forEach((input) => { + result += inputParametersDemoHtml(prefix, indent, input, true) + }) + + if (interfaceJson.parameters.length > 1) { + interfaceJson.parameters.forEach((parameter) => { + // Internal + if (parameter.name === "memory-io" || parameter.name === "version") { + return + } + result += inputParametersDemoHtml(prefix, indent, parameter, false) + }) + } + + result += `${prefix} Load sample inputs\n` + result += `${prefix} Run\n` + result += `${prefix}
    \n` // id="${nameCamelCase}Inputs" + + result += `${prefix}

    Outputs

    \n` + interfaceJson.outputs.forEach((output) => { + result += outputDemoHtml(prefix, indent, output) + }) + result += `${prefix}
    \n` // id="${nameCamelCase}Outputs" + result += `${prefix}
    \n` + + return result +} + +function inputParametersDemoTypeScript(functionName, indent, parameter, required) { + let result = '' + const contextProperty = required ? 'inputs' : 'options' + const inputIdentifier = `${camelCase(parameter.name)}Input` + switch(parameter.type) { + case 'INPUT_TEXT_FILE:FILE': + case 'INPUT_TEXT_STREAM': + result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n` + result += `${indent}${inputIdentifier}.addEventListener('change', (event) => {\n` + result += `${indent}${indent}const dataTransfer = event.dataTransfer\n` + result += `${indent}${indent}const files = event.target.files || dataTransfer.files\n\n` + result += `${indent}${indent}readAsArrayBuffer(files[0]).then((arrayBuffer) => {\n` + result += `${indent}${indent}${indent}context.${contextProperty}.set("${parameter.name}", new TextDecoder().decode(new Uint8Array(arrayBuffer)))\n` + result += `${indent}${indent}${indent}const input = document.querySelector("#${functionName}Inputs [name=${parameter.name}]")\n` + result += `${indent}${indent}${indent}input.value = context.${contextProperty}.get("${parameter.name}")\n` + result += `${indent}${indent}})\n` + result += `${indent}})\n\n` + break + case 'INPUT_BINARY_FILE:FILE': + case 'INPUT_BINARY_STREAM': + result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs input[name=${parameter.name}-file]')\n` + result += `${indent}${inputIdentifier}.addEventListener('change', (event) => {\n` + result += `${indent}${indent}const dataTransfer = event.dataTransfer\n` + result += `${indent}${indent}const files = event.target.files || dataTransfer.files\n\n` + result += `${indent}${indent}readAsArrayBuffer(files[0]).then((arrayBuffer) => {\n` + result += `${indent}${indent}${indent}context.${contextProperty}.set("${parameter.name}", new Uint8Array(arrayBuffer))\n` + result += `${indent}${indent}${indent}const input = document.querySelector("#${functionName}Inputs [name=${parameter.name}]")\n` + result += `${indent}${indent}${indent}input.value = context.${contextProperty}.get("${parameter.name}").toString()\n` + result += `${indent}${indent}})\n` + result += `${indent}})\n\n` + break + case 'TEXT': + result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs sl-input[name=${parameter.name}]')\n` + result += `${indent}${inputIdentifier}.addEventListener('sl-change', (event) => {\n` + result += `${indent}${indent}context.${contextProperty}.set("${parameter.name}", ${inputIdentifier}.value)\n` + result += `${indent}})\n\n` + break + case 'BOOL': + result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs sl-checkbox[name=${parameter.name}]')\n` + result += `${indent}${inputIdentifier}.addEventListener('sl-change', (event) => {\n` + result += `${indent}${indent}context.${contextProperty}.set("${parameter.name}", ${inputIdentifier}.checked)\n` + result += `${indent}})\n\n` + break + case 'INT': + case 'UINT': + result += `${indent}const ${inputIdentifier} = document.querySelector('#${functionName}Inputs sl-input[name=${parameter.name}]')\n` + result += `${indent}${inputIdentifier}.addEventListener('sl-change', (event) => {\n` + result += `${indent}${indent}context.${contextProperty}.set("${parameter.name}", parseInt(${inputIdentifier}.value))\n` + result += `${indent}})\n\n` + break + default: + console.error(`Unexpected interface type: ${parameter.type}`) + process.exit(1) + } + return result +} + +function interfaceFunctionsDemoTypeScript(packageName, interfaceJson, outputPath) { + let indent = ' ' + let result = '' + + const functionName = camelCase(interfaceJson.name) + result += ` +// ------------------------------------------------------------------------------------ +// ${functionName} +// +` + + const setupFunctionName = camelCase(`setup-${interfaceJson.name}`) + result += `function ${setupFunctionName}(loadSampleInputsDefined, loadSampleInputs) { + // Data context + const context = { + inputs: new Map(), + options: new Map(), + outputs: new Map(), + } + // Inputs +` + + interfaceJson.inputs.forEach((input) => { + result += inputParametersDemoTypeScript(functionName, indent, input, true) + }) + + if (interfaceJson.parameters.length > 1) { + result += ' // Options\n' + interfaceJson.parameters.forEach((parameter) => { + // Internal + if (parameter.name === "memory-io" || parameter.name === "version") { + return + } + result += inputParametersDemoTypeScript(functionName, indent, parameter, false) + }) + } + + const bundleName = packageToBundleName(packageName) + result += ' // Outputs\n' + result += `const form = document.querySelector(\`#${functionName}Inputs form\`) +form.addEventListener('submit', async (event) => { + event.preventDefault() + const { webWorker, output } = await ${camelCase(bundleName)}.${functionName}(null,context.inputs[0].slice(), context.options) + webWorker.terminate() +` + + + interfaceJson.outputs.forEach((output) => { + result += outputDemoTypeScript(functionName, indent, output) + }) + + result += ' })\n' +// const loadSampleInputsModulePath = path.join(outputPath, `${functionName}LoadSampleInputs.ts`) +// if (!fs.existsSync(loadSampleInputsModulePath)) { +// const loadSampleInputsModuleContent = `// Define the ${functionName}LoadSampleInputs function and return \`true\` from +// // the \`${functionName}LoadSampleInputsDefined\` function. +// export const ${functionName}LoadSampleInputsDefined = false +// export function ${functionName}LoadSampleInputs (context) { +// // Load sample inputs for the function. +// // +// // +// // Example for an input named \`exampleInput\`: +// // +// // const exampleInput = document.querySelector("#${functionName}Inputs [name=exampleInput]") +// // exampleInput.value = 5 +// // context.inputs.set("example-input", 5) +// } +// ` +// fs.writeFileSync(loadSampleInputsModulePath, loadSampleInputsModuleContent) +// } + + // result += `\n if (loadSampleInputsDefined) { + // const loadSampleInputsButton = document.querySelector("#${functionName}Inputs [name=loadSampleInputs]") + // loadSampleInputsButton.setAttribute('style', 'visibility: visible;') + // loadSampleInputsButton.addEventListener('click', (event) => { + // loadSampleInputs(context) + // }) + // }` + result += '}\n' + // result += `import { ${functionName}LoadSampleInputs, ${functionName}LoadSampleInputsDefined } from "./${functionName}LoadSampleInputs.js"\n` + // result += `${setupFunctionName}(${functionName}LoadSampleInputsDefined, ${functionName}LoadSampleInputs)\n` + return result +} + // Array of types that will require an import from itk-wasm const typesRequireImport = ['Image', 'Mesh', 'PolyData', 'TextFile', 'BinaryFile', 'TextFile', 'BinaryFile'] @@ -69,9 +424,12 @@ function typescriptBindings(outputDir, buildDir, wasmBinaries, options, forNode= let readmeInterface = '' let readmePipelines = '' + let demoFunctionsHtml = '' + let pipelinesFunctionsTabs = '' + let demoFunctionsTypeScript = '' const packageName = options.packageName - const bundleName = path.basename(packageName) + const bundleName = packageToBundleName(packageName) const packageJsonPath = path.join(outputDir, 'package.json') if (!fs.existsSync(packageJsonPath)) { const packageJson = JSON.parse(fs.readFileSync(bindgenResource('template.package.json'))) @@ -136,21 +494,6 @@ function typescriptBindings(outputDir, buildDir, wasmBinaries, options, forNode= fs.copyFileSync(bindgenResource('demo.css'), demoStylePath) } - const indexPath = path.join(outputDir, 'test', 'browser', 'index.html') - if (!fs.existsSync(indexPath)) { - let demoIndexContent = fs.readFileSync(bindgenResource('demo-index.html'), { encoding: 'utf8', flag: 'r' }) - demoIndexContent = demoIndexContent.replaceAll('', packageName) - fs.writeFileSync(indexPath, demoIndexContent) - } - - const demoPath = path.join(outputDir, 'test', 'browser', 'app.ts') - if (!fs.existsSync(demoPath)) { - let demoContent = fs.readFileSync(bindgenResource('demo.ts'), { encoding: 'utf8', flag: 'r' }) - demoContent = demoContent.replaceAll('', bundleName) - demoContent = demoContent.replaceAll('', camelCase(bundleName)) - fs.writeFileSync(demoPath, demoContent) - } - const rollupConfigPath = path.join(outputDir, 'build', 'rollup.browser.config.js') if (!fs.existsSync(rollupConfigPath)) { fs.copyFileSync(bindgenResource('rollup.browser.config.js'), rollupConfigPath) @@ -195,6 +538,17 @@ function typescriptBindings(outputDir, buildDir, wasmBinaries, options, forNode= const moduleKebabCase = parsedPath.name const moduleCamelCase = camelCase(parsedPath.name) const modulePascalCase = `${moduleCamelCase[0].toUpperCase()}${moduleCamelCase.substring(1)}` + const functionName = camelCase(interfaceJson.name) + + const functionDemoHtml = interfaceFunctionsDemoHtml(interfaceJson) + if (functionDemoHtml) { + demoFunctionsHtml += functionDemoHtml + pipelinesFunctionsTabs += ` ${functionName}\n` + const demoTypeScriptOutputPath = path.join(outputDir, 'test', 'browser') + demoFunctionsTypeScript += interfaceFunctionsDemoTypeScript(packageName, interfaceJson, demoTypeScriptOutputPath) + } else { + pipelinesFunctionsTabs += `${functionName}\n` + } readmeInterface += ` ${moduleCamelCase}${nodeTextCamel},\n` let readmeFunction = '' @@ -666,6 +1020,25 @@ import {\n` fs.writeFileSync(path.join(srcOutputDir, `index${nodeTextKebab}.ts`), indexContent) + if (!forNode) { + const demoIndexPath = path.join(outputDir, 'test', 'browser', 'index.html') + // if (!fs.existsSync(demoIndexPath)) { + let demoIndexContent = fs.readFileSync(bindgenResource('demo-index.html'), { encoding: 'utf8', flag: 'r' }) + demoIndexContent = demoIndexContent.replaceAll('@bindgenPackageName@', packageName) + demoIndexContent = demoIndexContent.replaceAll('@bindgenFunctions@', demoFunctionsHtml) + demoIndexContent = demoIndexContent.replaceAll('@pipelinesFunctionsTabs@', pipelinesFunctionsTabs) + fs.writeFileSync(demoIndexPath, demoIndexContent) + // } + const demoTypeScriptPath = path.join(outputDir, 'test', 'browser', 'app.ts') + // if (!fs.existsSync(demoTypeScriptPath)) { + let demoTypeScriptContent = fs.readFileSync(bindgenResource('demo.ts'), { encoding: 'utf8', flag: 'r' }) + demoTypeScriptContent = demoTypeScriptContent.replaceAll('@bindgenBundleName@', bundleName) + demoTypeScriptContent = demoTypeScriptContent.replaceAll('@bindgenBundleNameCamelCase@', camelCase(bundleName)) + demoTypeScriptContent = demoTypeScriptContent.replaceAll('@bindgenFunctionLogic@', demoFunctionsTypeScript) + fs.writeFileSync(demoTypeScriptPath, demoTypeScriptContent) + // } + } + return readmeInterface }