@knapsack/app
Version:
Build Design Systems with Knapsack
540 lines (538 loc) • 21.9 kB
JavaScript
;
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