UNPKG

@knapsack/app

Version:

Build Design Systems with Knapsack

540 lines (538 loc) • 21.9 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Patterns = void 0; /* eslint-disable @typescript-eslint/consistent-type-imports */ /** * 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>. */ const path_1 = require("path"); const globby_1 = __importDefault(require("globby")); const immer_1 = require("immer"); const utils_1 = require("@knapsack/utils"); const types_1 = require("@knapsack/types"); const file_utils_1 = require("@knapsack/file-utils"); const events_1 = require("../../server/events"); const file_db_1 = require("../../server/dbs/file-db"); const log_1 = require("../../cli/log"); const misc_1 = require("../../types/misc"); const wrap_html_render_result_1 = require("./renderers/wrap-html-render-result"); class Patterns { missingFileVerbosity; #db; demosById; userConfig; configDb; /** * directory path where data files (json/yml) can be found * non-absolute paths stored in these files will be relative from this */ dataDir; templateRenderers; byId; /** Keys are pattern IDs, values are path to `knapsack.pattern.ID.json` */ patternDataFiles; assetSets; hasUpdatePatternsDataRan; constructor({ dataDir, templateRenderers, assetSets, db, config, }) { this.#db = db; this.userConfig = config; this.demosById = {}; this.hasUpdatePatternsDataRan = false; this.configDb = new file_db_1.FileDb({ filePath: (0, path_1.join)(dataDir, 'knapsack.patterns.json'), defaults: { statusSets: [ { id: 'main', title: 'Status', statuses: [ { id: 'draft', title: 'Draft', color: '#9b9b9b', }, { id: 'needsDesign', title: 'Needs Design', color: '#FC0', }, { id: 'needsDev', title: 'Needs Development', color: '#FC0', }, { id: 'needsReview', title: 'Needs Review', color: '#FC0', }, { id: 'ready', title: 'Ready', color: '#2ECC40', }, ], }, ], }, }); this.assetSets = assetSets; this.dataDir = dataDir; this.templateRenderers = {}; this.byId = {}; this.missingFileVerbosity = 'error'; // without this extra check + default value assignment in the constructor, this breaks when running ks:serve if ((0, misc_1.isValidVerbosityOption)(process.env.MISSING_FILE_VERBOSITY)) { this.missingFileVerbosity = process.env.MISSING_FILE_VERBOSITY; } this.patternDataFiles = new Map(); templateRenderers.forEach((templateRenderer) => { this.templateRenderers[templateRenderer.id] = templateRenderer; }); } getRenderer(rendererId) { const renderer = this.templateRenderers[rendererId]; if (!renderer) { throw new Error(`Renderer "${rendererId}" does not exist, available renderers: ${Object.keys(this.templateRenderers).join(', ')}`); } return renderer; } init = async ({ missingFileVerbosity, }) => { this.missingFileVerbosity = missingFileVerbosity; const { demos } = await this.#db.getData(); this.demosById = demos.byId; try { await this.updatePatternsData(); } catch (error) { log_1.log.error(error); process.exit(1); } }; hydrate = async ({ appClientData: { patternsState, db } }) => { if (!Object.keys(patternsState).includes('patterns')) { throw new Error(`patternsState.patterns is missing`); } this.byId = patternsState.patterns; this.demosById = db.demos.byId; }; build = async () => { await Promise.all(Object.values(this.templateRenderers).map(async (renderer) => { if ((0, types_1.isRendererIdForNativeMobile)(renderer.id)) { // skiping the `resolvePath` step since it will fail since package paths are not on local filesystem return; } await Promise.all([...renderer.getCodeSrcs()].map(async ({ path }) => { const { exists } = await renderer.resolvePath({ path }); if (exists) return; const msg = `This file should exist but it doesn't: ${path} `; if (this.missingFileVerbosity === 'error') { throw new Error(msg); } else if (this.missingFileVerbosity === 'warn') { log_1.log.warn(msg); } })); })); }; get allPatterns() { return Object.values(this.byId); } getData = async () => { /** * originally this was checking if this.byId wasn't set which caused local changes to not show up * (https://linear.app/knapsack/issue/KSP-2231/local-changes-arent-triggering-rebuild) * * then we tried omitting this check which caused recompiling loops in some instances * (https://linear.app/knapsack/issue/KSP-2704/accutech-workspace-infinite-re-render-ks-version-3582-and-on) * * hence how we got to needing to set / check something else, this.hasUpdatePatternsDataRan * Run _at least_ once (to avoid caching issues) but also not too frequently (to avoid recompiling loops) */ if (this.hasUpdatePatternsDataRan === false) { this.hasUpdatePatternsDataRan = true; await this.updatePatternsData(); } const patternsConfig = await this.getPatternsConfig(); return { patterns: this.byId, renderers: (0, utils_1.entries)(this.templateRenderers).reduce((acc, [id, renderer]) => { const meta = renderer.getMeta(); acc[id] = { meta, }; return acc; }, {}), ...patternsConfig, }; }; clearCache = async () => { await this.updatePatternsData(); }; savePrep = async (data) => { const { patterns, renderers, ...rest } = data; const patternIdsToDelete = new Set(Object.keys(this.byId)); const changedPatternIds = []; const allFiles = []; await Promise.all(Object.keys(patterns).map(async (id) => { const pattern = patterns[id]; // if nothing has changed, let's not add it to the files to write list // const isDifferent = // JSON.stringify(pattern) !== JSON.stringify(prevPattern); // @todo restore this const isDifferent = true; patternIdsToDelete.delete(id); // delete from delete list - i.e. we keep it if (isDifferent) { // update the internal data store if the pattern has changed changedPatternIds.push(pattern.id); const patternData = (0, immer_1.produce)(pattern, (draftPattern) => { draftPattern.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, }; } }); }); // pattern.templates.forEach((template) => { // const prevTemplate = prevPattern?.templates?.find( // (t) => t.id === template.id, // ); // Object.values(template?.demosById ?? {}).forEach((demo) => { // const prevDemo = prevTemplate?.demosById?.[demo.id]; // const isDiff = !deepEqual(prevDemo, demo); // if (isDiff) { // // @todo consider emitting an event from this // log.verbose( // `savePrep(${id}) template ${template.id} demo ${demo.id} is different, clearing render cache...`, // ); // } // }); // }); const db = new file_db_1.FileDb({ filePath: (0, path_1.join)(this.dataDir, `knapsack.pattern.${id}.json`), type: 'json', writeFileIfAbsent: false, }); const files = await db.savePrep(patternData); files.forEach((file) => allFiles.push(file)); } })); patternIdsToDelete.forEach((id) => { allFiles.push({ isDeleted: true, contents: '', encoding: 'utf8', path: (0, path_1.join)(this.dataDir, `knapsack.pattern.${id}.json`), }); }); const files = await this.configDb.savePrep(rest); files.forEach((file) => allFiles.push(file)); log_1.log.verbose(`savePrep(${changedPatternIds.length}) ${changedPatternIds.join(', ')}`, null, 'pattern data'); return allFiles; }; async #registerTemplateInCodeSrcs(template) { const renderer = this.getRenderer(template.templateLanguageId); renderer.addCodeSrc({ path: template.path, }); template.demoIds?.forEach?.((demoId) => { const demo = this.demosById[demoId]; if (demo?.type === 'template') { renderer.addCodeSrc({ path: demo.templateInfo.path }); } else if (demo?.type === 'data-w-template-info' && (0, types_1.isTemplateInfoWithCodeSrcPath)(demo.templateInfo)) { renderer.addCodeSrc({ path: demo.templateInfo.codeSrcPath }); } }); } async updatePatternData({ patternConfigPath, patternData, }) { const finish = (0, utils_1.timerInSeconds)(); let pattern; if (patternConfigPath) { pattern = await (0, file_utils_1.readJSON)(patternConfigPath); } else if (patternData) { pattern = patternData; } else { log_1.log.error(`Pattern data updates require a path pointing to the pattern's data file OR the data itself.`); return; } await Promise.all(pattern.templates.map(async (template) => { await this.#registerTemplateInCodeSrcs(template); })); this.byId[pattern.id] = pattern; const duration = finish(); const isSlow = duration > 5; if (isSlow) { log_1.log.warn(`Slow: ${duration}s updatePatternData(${pattern.id})`, null, 'pattern data'); } } async updatePatternsData() { const s = (0, utils_1.timerInSeconds)(); const priorPatternIds = new Set(Object.keys(this.byId)); const { demos } = await this.#db.getData(); this.demosById = demos.byId; const patternDataFiles = await (0, globby_1.default)(`${(0, path_1.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', ''); this.patternDataFiles.set(id, file); return id; }) .sort() .forEach((id) => { priorPatternIds.delete(id); this.byId[id] = { id, title: id, templates: [], }; }); await Promise.all(patternDataFiles.map(async (file) => { return this.updatePatternData({ patternConfigPath: file, }); })); priorPatternIds.forEach((priorId) => { delete this.byId[priorId]; }); log_1.log.verbose(`updatePatternsData took: ${s()}`, null, 'pattern data'); events_1.knapsackEvents.emitPatternsDataReady({ patterns: this.allPatterns, }); } getPatterns() { return this.allPatterns; } async getPatternsConfig() { const config = await this.configDb.getData(); return config; } async getContentStateFromLocalJsonFiles() { const { getContentStateFromAppClientData } = await import('@knapsack/rendering-utils'); return getContentStateFromAppClientData({ patterns: this.byId, demosById: this.demosById, }); } async inspect(templateInfo) { const renderer = this.templateRenderers[templateInfo.rendererId]; if (!renderer) { return { type: 'renderer.notFound', }; } if (!renderer.inspect) { return { type: 'renderer.noInspectSupported', }; } try { return await renderer.inspect(templateInfo); } catch (e) { return { type: 'error.unknown', message: e.message, }; } } /** * Render template */ async render({ patternId, templateId, demo, state, isInIframe = false, websocketsPort, assetSetId, }) { try { if (!demo) { throw new Error(`No demo provided`); } const rendererId = (() => { if (demo.type === 'data-w-template-info') { return demo.templateInfo.rendererId; } // only purpose for `patternId` and `templateId` is to retrieve the `rendererId` const pattern = state.patterns[patternId]; if (!pattern) { throw new Error(`Pattern not found: '${patternId}'`); } const template = pattern.templates?.find((t) => t.id === templateId); if (!template) { throw new Error(`Could not find template "${templateId}" in pattern "${patternId}"`); } return template.templateLanguageId; })(); const renderer = this.getRenderer(rendererId); const { bodyAttributes, inlineStyles } = demo; const assetSet = assetSetId && this.assetSets.getAssetSet(assetSetId); const assets = assetSet?.assets?.filter?.((asset) => { if (Array.isArray(asset.includedRenderers)) { return asset.includedRenderers.includes(rendererId); } if (Array.isArray(asset.excludedRenderers)) { return !asset.excludedRenderers.includes(rendererId); } return true; }) ?? []; const { inlineJs = '', inlineCss = '', inlineFoot = '', inlineHead = '', } = assetSet ?? {}; const inlineFoots = [inlineFoot]; const inlineJSs = [inlineJs]; const inlineHeads = [ `<title>{K} Pattern: ${patternId} ~ Template: ${templateId}</title>`, inlineHead, ]; inlineHeads.push(` <script type="module" src="/renderer-client/renderer-client.mjs"></script> `); if (isInIframe) { inlineHeads.push(` <style> html, body { display: flex; align-items: safe center; justify-content: safe center; margin: 0; min-height: 100%; } .knapsack-pattern-direct-parent { max-width: 100vw; } </style> `); } const meta = { patternId, templateId, demoId: demo.id, assetSetId, isInIframe, websocketsPort, }; const metaScript = `<script id="${types_1.ksRendererClientMetaId}" type="application/json">${JSON.stringify(meta, null, ' ')}</script>`; const { enableDataDemos, enableTemplateDemos } = renderer.getMeta(); if (!enableDataDemos && (0, types_1.isDataDemo)(demo)) { throw new Error(`The template language renderer "${renderer.id}" does not support "Data Demos/Examples"`); } if (!enableTemplateDemos && (0, types_1.isTemplateDemo)(demo)) { throw new Error(`The template language renderer "${renderer.id}" does not support "Template Demos/Examples"`); } const renderedTemplate = await renderer .render({ state, demo, }) .catch((e) => { log_1.log.error('Error', e, 'pattern render'); const html = ` <h3>Error requesting render from ${renderer.id}</h3> <p>${e.message}</p>`; return { ok: false, html, wrappedHtml: html, usage: html, message: e.message, }; }); if (!renderedTemplate) { // filter out verbose demo data to clean up error message const { inlineStyles: _, bodyAttributes: __, ...restOfDemo } = demo; throw new Error(`Did not receive result from renderer ${renderer.id}, likely due to a missing demo - recieved '${JSON.stringify(restOfDemo)}'`); } const wrappedHtml = (0, wrap_html_render_result_1.wrapHtmlRenderResult)({ html: `${metaScript}${renderedTemplate.html}`, assets, inlineJs: inlineJSs.join('\n'), inlineCss: `${inlineCss}${inlineStyles}`, inlineHead: inlineHeads.join('\n'), inlineFoot: inlineFoots.join('\n'), isInIframe, bodyAttributes, }); const results = { ...renderedTemplate, usage: renderedTemplate.usage, html: renderedTemplate.html, wrappedHtml, }; return results; } catch (error) { const html = `<div style="max-width: 300px;"> <h4>Error in Pattern Render</h4> <pre><code>${error.toString()}</pre></code> </div>`; return { ok: false, html, message: error.message, wrappedHtml: html, }; } } async getTemplateSuggestions({ rendererId, state, newPath, }) { const renderer = this.getRenderer(rendererId); if (!renderer.getTemplateSuggestions) { return { suggestions: [], }; } const proto = renderer.getMeta().prototypingTemplate; const suggestions = await renderer.getTemplateSuggestions({ rendererId, state, newPath, }); return { ...suggestions, suggestions: suggestions.suggestions .filter(({ path, alias }) => { if (!proto) return true; // don't suggest the prototyping template if (proto.path) { return path !== proto.path; } if (proto.alias) { return alias !== proto.alias; } return true; }) .map((suggestion) => { return { ...suggestion, path: (0, path_1.isAbsolute)(suggestion.path) ? (0, path_1.relative)(this.dataDir, suggestion.path) : suggestion.path, }; }), }; } } exports.Patterns = Patterns; //# sourceMappingURL=patterns.js.map