UNPKG

@knapsack/app

Version:

Build Design Systems on top of knapsack, by Basalt

755 lines (686 loc) • 22.5 kB
/** * Copyright (C) 2018 Basalt This file is part of Knapsack. Knapsack is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. Knapsack is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Knapsack; if not, see <https://www.gnu.org/licenses>. */ import { readJSON } from 'fs-extra'; import { join } from 'path'; import globby from 'globby'; import produce from 'immer'; import chokidar from 'chokidar'; import { validateDataAgainstSchema } from '@knapsack/schema-utils'; import { KnapsackFile } from '@knapsack/core'; import md5 from 'md5'; import { fileExists, fileExistsOrExit, formatCode, resolvePath, } from './server-utils'; import { KnapsackRendererBase } from './renderer-base'; import { emitPatternsDataReady, EVENTS, knapsackEvents } from './events'; import { FileDb2 } from './dbs/file-db'; import * as log from '../cli/log'; import { KnapsackPattern, KnapsackTemplateStatus, KnapsackPatternsConfig, KnapsackTemplateDemo, isTemplateDemo, isDataDemo, } from '../schemas/patterns'; import { KnapsackTemplateRenderer, KsRenderResults, TemplateRendererMeta, } from '../schemas/knapsack-config'; import { KnapsackDb } from '../schemas/misc'; import { timer } from '../lib/utils'; import { KsRendererClientMeta } from '../client/renderer-client/renderer-client-types'; type PatternsState = import('../client/store').AppState['patternsState']; // type Old = { // patterns: { // [id: string]: KnapsackPattern; // }; // templateStatuses: KnapsackTemplateStatus[]; // }; export class Patterns implements KnapsackDb<PatternsState> { configDb: FileDb2<KnapsackPatternsConfig>; dataDir: string; templateRenderers: { [id: string]: KnapsackTemplateRenderer; }; byId: { [id: string]: KnapsackPattern; }; private assetSets: import('./asset-sets').AssetSets; isReady: boolean; filePathsThatTriggerNewData: Map<string, string>; private watcher: chokidar.FSWatcher; cacheDir: string; constructor({ dataDir, templateRenderers, assetSets, }: { dataDir: string; templateRenderers: KnapsackTemplateRenderer[]; assetSets: import('./asset-sets').AssetSets; }) { this.configDb = new FileDb2<KnapsackPatternsConfig>({ filePath: join(dataDir, 'knapsack.patterns.json'), defaults: { templateStatuses: [ { id: 'draft', title: 'Draft', color: '#9b9b9b', }, { id: 'inProgress', title: 'In Progress', color: '#FC0', }, { id: 'ready', title: 'Ready', color: '#2ECC40', }, ], }, }); this.assetSets = assetSets; this.dataDir = dataDir; this.templateRenderers = {}; this.byId = {}; this.isReady = false; this.filePathsThatTriggerNewData = new Map<string, string>(); templateRenderers.forEach(templateRenderer => { this.templateRenderers[templateRenderer.id] = templateRenderer; }); this.watcher = chokidar.watch([], { ignoreInitial: true, }); this.watcher.on('change', async path => { const patternConfigFilePath = this.filePathsThatTriggerNewData.get(path); log.verbose( `changed file - path: ${path} patternConfigFilePath: ${patternConfigFilePath}`, null, 'pattern data', ); await this.updatePatternData(patternConfigFilePath); emitPatternsDataReady(this.allPatterns); }); knapsackEvents.on(EVENTS.SHUTDOWN, () => this.watcher.close()); } async init({ cacheDir }: { cacheDir: string }): Promise<void> { this.cacheDir = cacheDir; try { await this.updatePatternsData(); } catch (error) { console.log(); console.log(error); log.error('Pattern Init failed', error.message); console.log(); log.verbose('', error); process.exit(1); } } get allPatterns(): KnapsackPattern[] { return Object.values(this.byId); } getRendererMeta(): { [id: string]: { meta: TemplateRendererMeta } } { const results: { [id: string]: { meta: TemplateRendererMeta } } = {}; Object.entries(this.templateRenderers).forEach(([id, renderer]) => { const meta = renderer.getMeta(); results[id] = { meta, }; }); return results; } async getData(): Promise< import('../client/store').AppState['patternsState'] > { if (!this.byId) { await this.updatePatternsData(); } const templateStatuses = await this.getTemplateStatuses(); return { templateStatuses, patterns: this.byId, renderers: this.getRendererMeta(), }; } async savePrep(data: { patterns: { [id: string]: KnapsackPattern }; templateStatuses?: KnapsackTemplateStatus[]; }): Promise<KnapsackFile[]> { const patternIdsToDelete = new Set(Object.keys(this.byId)); this.byId = {}; const allFiles: KnapsackFile[] = []; await Promise.all( Object.keys(data.patterns).map(async id => { const pattern = data.patterns[id]; pattern.templates.forEach(template => { if (template?.spec?.isInferred) { // if it's inferred, we don't want to save `spec.props` or `spec.slots` template.spec = { isInferred: template?.spec?.isInferred, }; } }); this.byId[id] = pattern; patternIdsToDelete.delete(id); const db = new FileDb2<KnapsackPattern>({ filePath: join(this.dataDir, `knapsack.pattern.${id}.json`), type: 'json', watch: false, writeFileIfAbsent: false, }); const files = await db.savePrep(pattern); files.forEach(file => allFiles.push(file)); }), ); patternIdsToDelete.forEach(id => { allFiles.push({ isDeleted: true, contents: '', encoding: 'utf8', path: join(this.dataDir, `knapsack.pattern.${id}.json`), }); }); return allFiles; } async updatePatternData(patternConfigPath: string): Promise<void> { const finish = timer(); const pattern: KnapsackPattern = await readJSON(patternConfigPath); let { templates = [] } = pattern; // @todo validate: has template render that exists, using assetSets that exist templates = await Promise.all( templates.map(async template => { let { spec = {} } = template; // if we come across `{ typeof: 'function' }` in JSON Schema, the demo won't validate since we store as a string - i.e. `"() => alert('hi')"`, so we'll turn it into a string: const propsValidationSchema = produce(spec?.props, draft => { Object.values(draft?.properties || {}).forEach(prop => { if ('typeof' in prop && prop.typeof === 'function') { delete prop.typeof; // eslint-disable-next-line @typescript-eslint/ban-ts-ignore // @ts-ignore prop.type = 'string'; } }); }); if (template.demosById) { // validating data demos against spec Object.values(template.demosById).forEach((demo, i) => { if (isDataDemo(demo) && spec?.props) { const results = validateDataAgainstSchema( propsValidationSchema, demo.data.props, ); if (!results.ok) { log.inspect( { propsSpec: spec.props, demo, results }, 'invalid demo info', ); log.warn( `invalid demo: patternId: "${pattern.id}", templateId: "${template.id}", demoId: "${demo.id}" ^^^`, 'pattern data', ); } } if (isTemplateDemo(demo)) { const { exists, absolutePath, relativePathFromCwd } = resolvePath( { path: template.path, resolveFromDirs: [this.dataDir], }, ); if (!exists) { log.error('Template demo file does not exist!', { patternId: pattern.id, templateId: template.id, demoId: demo.id, path: template.path, resolvedAbsolutePath: absolutePath, }); throw new Error(`Template demo file does not exist!`); } this.filePathsThatTriggerNewData.set( absolutePath, patternConfigPath, ); } }); } // inferring specs if (spec?.isInferred) { const renderer = this.templateRenderers[template.templateLanguageId]; if (renderer?.inferSpec) { const pathToInferSpecFrom = typeof spec.isInferred === 'string' ? spec.isInferred : template.path; const { exists, absolutePath } = resolvePath({ path: pathToInferSpecFrom, resolveFromDirs: [this.dataDir], }); if (!exists) { throw new Error(`File does not exist: "${pathToInferSpecFrom}"`); } this.filePathsThatTriggerNewData.set( absolutePath, patternConfigPath, ); try { const inferredSpec = await renderer.inferSpec({ templatePath: absolutePath, template, }); if (inferredSpec === false) { log.warn( `Could not infer spec of pattern "${pattern.id}", template "${template.id}"`, { absolutePath }, ); } else { const { ok, message } = KnapsackRendererBase.validateSpec( inferredSpec, ); if (!ok) { throw new Error(message); } log.silly( `Success inferring spec of pattern "${pattern.id}", template "${template.id}"`, inferredSpec, ); spec = { ...spec, ...inferredSpec, }; } } catch (err) { console.log(err); console.log(); log.error( `Error inferring spec of pattern "${pattern.id}", template "${template.id}": ${err.message}`, { absolutePath, }, ); process.exit(1); } } } const { ok, message } = KnapsackRendererBase.validateSpec(spec); if (!ok) { const msg = [ `Spec did not validate for pattern "${pattern.id}" template "${template.id}"`, message, ].join('\n'); log.error('Spec that failed', { spec, }); throw new Error(msg); } return { ...template, spec, }; }), ); this.byId[pattern.id] = { ...pattern, templates, }; log.silly(`${finish()}s for ${pattern.id}`, null, 'pattern data'); } async updatePatternsData() { const s = timer(); this.watcher.unwatch([...this.filePathsThatTriggerNewData.values()]); this.filePathsThatTriggerNewData.clear(); const patternDataFiles = await globby( `${join(this.dataDir, 'knapsack.pattern.*.json')}`, { expandDirectories: false, onlyFiles: true, }, ); // Initially creating the patterns `this.byId` object in alphabetical order so that everywhere else patterns are listed they are alphabetical patternDataFiles .map(file => { // turns this: `data/knapsack.pattern.card-grid.json` // into this: `[ 'data/', 'card-grid.json' ]` const [, lastPart] = file.split('knapsack.pattern.'); // now we have `card-grid` const id = lastPart.replace('.json', ''); return id; }) .sort() .forEach(id => { this.byId[id] = { id, title: id, templates: [], }; }); await Promise.all( patternDataFiles.map(async file => { this.filePathsThatTriggerNewData.set(file, file); return this.updatePatternData(file); }), ); this.getAllTemplatePaths().forEach(path => { fileExistsOrExit( path, `This file should exist but it doesn't: Resolved absolute path: ${path} `, ); }); this.watcher.add([...this.filePathsThatTriggerNewData.values()]); this.isReady = true; log.verbose(`updatePatternsData took: ${s()}`, null, 'pattern data'); emitPatternsDataReady(this.allPatterns); } getPattern(id: string): KnapsackPattern { return this.byId[id]; } getPatterns(): KnapsackPattern[] { return this.allPatterns; } /** * Get all the pattern's template file paths * @return - paths to all template files */ getAllTemplatePaths({ templateLanguageId = '', includeTemplateDemos = true, }: { /** * If provided, only templates for these languages will be provided. * @see {import('./renderer-base').KnapsackRendererBase} */ templateLanguageId?: string; includeTemplateDemos?: boolean; } = {}): string[] { const allTemplatePaths = []; this.allPatterns.forEach(pattern => { pattern.templates .filter(t => t.path) // some just use `alias` .forEach(template => { if ( templateLanguageId === '' || template.templateLanguageId === templateLanguageId ) { allTemplatePaths.push( this.getTemplateAbsolutePath({ patternId: pattern.id, templateId: template.id, }), ); if (includeTemplateDemos) { Object.values(template?.demosById || {}) .filter(isTemplateDemo) .forEach(demo => { allTemplatePaths.push( this.getTemplateDemoAbsolutePath({ patternId: pattern.id, templateId: template.id, demoId: demo.id, }), ); }); } } }); }); return allTemplatePaths; } getTemplateAbsolutePath({ patternId, templateId }): string { const pattern = this.byId[patternId]; if (!pattern) throw new Error(`Could not find pattern "${patternId}"`); const template = pattern.templates.find(t => t.id === templateId); if (!template) { throw new Error( `Could not find template "${templateId}" in pattern "${patternId}"`, ); } const { exists, absolutePath } = resolvePath({ path: template.path, resolveFromDirs: [this.dataDir], }); if (!exists) throw new Error(`File does not exist: "${template.path}"`); return absolutePath; } getTemplateDemoAbsolutePath({ patternId, templateId, demoId }): string { const pattern = this.byId[patternId]; if (!pattern) throw new Error(`Could not find pattern ${patternId}`); const template = pattern.templates.find(t => t.id === templateId); if (!template) throw new Error( `Could not find template "${templateId}" in pattern "${patternId}"`, ); const demo = template.demosById[demoId]; if (!demo) throw new Error( `Could not find demo "${demoId}" in template ${templateId} in pattern ${patternId}`, ); if (!isTemplateDemo(demo)) { throw new Error( `Demo is not a "template" type of demo; cannot retrieve path for demo "${demoId}" in template "${templateId}" in pattern "${patternId}"`, ); } if (!demo.templateInfo?.path) { throw new Error( `No "path" in demo "${demoId}" in template "${templateId}" in pattern "${patternId}"`, ); } const relPath = join(this.dataDir, demo.templateInfo.path); const path = join(process.cwd(), relPath); if (!fileExists(path)) throw new Error(`File does not exist: "${path}"`); return path; } async getTemplateStatuses(): Promise<KnapsackTemplateStatus[]> { const config = await this.configDb.getData(); return config.templateStatuses; } /** * Render template */ async render({ patternId, templateId = '', demo, isInIframe = false, websocketsPort, assetSetId, }: { patternId: string; templateId: string; /** * Demo data to pass to template * Either whole demo object OR demoId (string) */ demo?: KnapsackTemplateDemo | string; /** * Will this be in an iFrame? */ isInIframe?: boolean; websocketsPort?: number; assetSetId?: string; }): Promise<KsRenderResults> { try { const pattern = this.getPattern(patternId); if (!pattern) { const message = `Pattern not found: '${patternId}'`; return { ok: false, html: `<p>${message}</p>`, wrappedHtml: `<p>${message}</p>`, message, dataId: '', }; } const template = pattern.templates.find(t => t.id === templateId); if (!template) { throw new Error( `Could not find template ${templateId} in pattern ${patternId}`, ); } const renderer = this.templateRenderers[template.templateLanguageId]; demo = typeof demo === 'string' ? template.demosById[demo] : demo; if (!demo) { const [firstDemoId] = template.demos ?? []; if (!firstDemoId) { const msg = `No demo provided nor first demo to fallback on while trying to render pattern "${pattern.id}" template "${template.id}"`; throw new Error(msg); } demo = template.demosById[template.demos[0]]; } const dataId = md5(JSON.stringify(demo)); const renderedTemplate = await renderer .render({ pattern, template, demo, patternManifest: this, }) .catch(e => { log.error('Error', e, 'pattern render'); const html = `<p>${e.message}</p>`; return { ok: false, html, wrappedHtml: html, usage: html, message: e.message, }; }); if (!renderedTemplate?.ok) { return { ...renderedTemplate, wrappedHtml: renderedTemplate.html, // many times error messages are in the html for users dataId, }; } const globalAssetSets = this.assetSets.getGlobalAssetSets(); let assetSet = globalAssetSets ? globalAssetSets[0] : globalAssetSets[0]; if (assetSetId) { assetSet = this.assetSets.getAssetSet(assetSetId); } const { assets = [], inlineJs = '', inlineCss = '', inlineFoot = '', inlineHead = '', } = assetSet ?? {}; const inlineFoots = [inlineFoot]; const inlineJSs = [inlineJs]; const inlineHeads = [inlineHead]; inlineHeads.push(` <script type="module" src="/renderer-client/renderer-client.mjs"></script> <script nomodule> const systemJsLoaderTag = document.createElement('script'); systemJsLoaderTag.src = 'https://unpkg.com/systemjs@2.0.0/dist/s.min.js'; systemJsLoaderTag.addEventListener('load', function () { System.import('/renderer-client/renderer-client.js'); }); document.head.appendChild(systemJsLoaderTag); </script> `); if (isInIframe) { // Need just a little bit of space around the pattern inlineHeads.push(` <style> .knapsack-wrapper { padding: 5px; } </style> `); } const meta: KsRendererClientMeta = { patternId, templateId, demoId: demo.id, assetSetId, isInIframe, websocketsPort, }; inlineFoots.push( `<script id="ks-meta" type="application/json">${JSON.stringify( meta, null, ' ', )}</script>`, ); const jsUrls = assets .filter(asset => asset.type === 'js') .filter(asset => asset.tagLocation !== 'head') .map(asset => this.assetSets.getAssetPublicPath(asset.src)); const headJsUrls = assets .filter(asset => asset.type === 'js') .filter(asset => asset.tagLocation === 'head') .map(asset => this.assetSets.getAssetPublicPath(asset.src)); const wrappedHtml = renderer.wrapHtml({ html: renderedTemplate.html, headJsUrls, cssUrls: assets .filter(asset => asset.type === 'css') // .map(asset => asset.publicPath), .map(asset => this.assetSets.getAssetPublicPath(asset.src)), jsUrls, inlineJs: inlineJSs.join('\n'), inlineCss, inlineHead: inlineHeads.join('\n'), inlineFoot: inlineFoots.join('\n'), isInIframe, }); return { ...renderedTemplate, usage: renderer.formatCode(renderedTemplate.usage), html: formatCode({ code: renderedTemplate.html, language: 'html', }), wrappedHtml: formatCode({ code: wrappedHtml, language: 'html', }), dataId, }; } catch (error) { log.error( error.message, { patternId, templateId, demo, isInIframe, assetSetId, error, }, 'pattern render', ); const html = `<h1>Error in Pattern Render</h1> <pre><code>${error.toString()}</pre></code>`; return { ok: false, html, message: html, wrappedHtml: html, dataId: '', }; } } }