Skip to content

Commit 52abeef

Browse files
authored
Merge pull request #76 from retejs/feature/virtual-templates
feat(app): support extra templates via features object
2 parents e604eeb + b9c0378 commit 52abeef

7 files changed

Lines changed: 225 additions & 31 deletions

File tree

.npmignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,4 @@ release.config.js
3737
release.config.js
3838
release.config.js
3939
release.config.js
40+
tmp

assets/app/entry_ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createEditor as createPerfEditor } from './perf'
44
import { createEditor as createCustomEditor } from './customization'
55
import { createEditor as create3DEditor } from './3d'
66
import { createEditor as createScopesEditor } from './scopes'
7+
/* {{virtual-imports}} */
78
import { isHeadless } from '../headless'
89

910
const factory = {
@@ -12,6 +13,7 @@ const factory = {
1213
'customization': createCustomEditor,
1314
'3d': create3DEditor,
1415
'scopes': createScopesEditor
16+
/* {{virtual-factory}} */
1517
}
1618

1719
const query = typeof location !== 'undefined' && new URLSearchParams(location.search)

src/app/features.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { throwError } from '../shared/throw'
12
import { AppStack } from '.'
23
import { AngularVersion } from './stack/angular'
34
import { SvelteVersion } from './stack/svelte'
@@ -245,6 +246,114 @@ export function validateFeatures(features: Feature[], options: { stack: AppStack
245246
}
246247
}
247248

249+
export function findFeature(name: string, pool: Feature[]) {
250+
return pool.find(f => f.name.toLocaleLowerCase() === name.toLocaleLowerCase())
251+
}
252+
253+
export function resolveFeatures(names: string[], pool: Feature[]) {
254+
return names.map(name => {
255+
const feature = findFeature(name, pool)
256+
257+
if (!feature) throwError(`feature ${name} not found`)
258+
259+
return feature
260+
})
261+
}
262+
263+
export function featureKeys(features: Feature[]) {
264+
return features.map(({ templateKeys }) => templateKeys ?? []).flat()
265+
}
266+
248267
export function getDependencies(features: Feature[]) {
249-
return features.map(feature => feature.requiredDependencies || []).flat()
268+
const unique = features.filter((feature, index, list) => {
269+
return list.findIndex(item => item.name === feature.name) === index
270+
})
271+
272+
return unique.map(feature => feature.requiredDependencies ?? []).flat()
273+
}
274+
275+
/** `string[]` — shared features. Object — one base `string[]` + optional extras `{ from, features }`. */
276+
export type FeaturesInput = string[] | Record<string, string[] | { from: string, features: string[] }>
277+
278+
export type TemplateJob = {
279+
name: string
280+
from: string
281+
features: Feature[]
282+
}
283+
284+
function parseFeaturesObject(
285+
features: Exclude<FeaturesInput, string[]>,
286+
sources: string[]
287+
) {
288+
let base: string[] | null = null
289+
const extras: { name: string, from: string, features: string[] }[] = []
290+
291+
for (const [name, value] of Object.entries(features)) {
292+
if (Array.isArray(value)) {
293+
if (base) throwError('features object can contain only one base features array')
294+
base = value
295+
} else {
296+
if (sources.includes(name)) throwError(`template "${name}" already exists`)
297+
if (!sources.includes(value.from)) throwError(`template "${name}" from unknown "${value.from}"`)
298+
extras.push({ name, from: value.from, features: value.features })
299+
}
300+
}
301+
302+
if (!base) throwError('features object must contain a base features array')
303+
304+
return { base, extras }
305+
}
306+
307+
function resolveValidated(
308+
names: string[],
309+
optionalFeatures: Feature[],
310+
stack: AppStack,
311+
label?: string
312+
) {
313+
const resolved = resolveFeatures(names, optionalFeatures)
314+
const { issue } = validateFeatures(resolved, { stack })
315+
316+
if (issue) {
317+
throwError(label
318+
? `template "${label}": ${issue}`
319+
: issue)
320+
}
321+
322+
return resolved
323+
}
324+
325+
/** Build template jobs with resolved features. */
326+
export function resolveJobs(
327+
sources: string[],
328+
optionalFeatures: Feature[],
329+
stack: AppStack,
330+
input: { features: FeaturesInput } | { selected: Feature[] }
331+
): TemplateJob[] {
332+
if ('selected' in input) {
333+
const { issue } = validateFeatures(input.selected, { stack })
334+
335+
if (issue) throwError(issue)
336+
337+
return sources.map(name => ({ name, from: name, features: input.selected }))
338+
}
339+
340+
const { features } = input
341+
342+
if (Array.isArray(features)) {
343+
const resolved = resolveValidated(features, optionalFeatures, stack)
344+
345+
return sources.map(name => ({ name, from: name, features: resolved }))
346+
}
347+
348+
const { base, extras } = parseFeaturesObject(features, sources)
349+
const shared = resolveValidated(base, optionalFeatures, stack)
350+
351+
return [
352+
...sources.map(name => ({ name, from: name, features: shared })),
353+
...extras.map(extra => ({
354+
name: extra.name,
355+
from: extra.from,
356+
features: resolveValidated(extra.features, optionalFeatures, stack, extra.name)
357+
}))
358+
]
250359
}

src/app/index.ts

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,13 @@ export const builders = {
2929
export type AppStack = keyof typeof builders
3030
export const appStacks = Object.keys(builders) as AppStack[]
3131
export { Features }
32+
export type { FeaturesInput } from './features'
3233

3334
type Options = {
3435
name?: string
3536
stack?: AppStack
3637
version?: number
37-
features?: string[]
38+
features?: Features.FeaturesInput
3839
depsAlias?: string
3940
depsLabel?: string
4041
next?: boolean
@@ -93,47 +94,48 @@ export async function createApp({ name, stack, version, features, depsAlias, for
9394
]
9495
const mandatoryFeatures = featuresList.filter(feature => feature.mandatory)
9596
const optionalFeatures = featuresList.filter(feature => !feature.mandatory)
96-
const selectedFeatures = features?.length
97-
? features.map(featureName => {
98-
const feature = optionalFeatures.find(f => f.name.toLocaleLowerCase() === featureName.toLocaleLowerCase())
99-
100-
if (!feature) throw throwError(`feature ${featureName} not found`)
101-
102-
return feature
103-
})
104-
: await select('Select features', optionalFeatures.map(feature => ({
105-
name: feature.name,
106-
value: feature
107-
})), true)
108-
109-
const { issue } = Features.validateFeatures(selectedFeatures, { stack: selectedStack })
110-
111-
if (issue) throwError(issue)
97+
const sources = await TemplateBuilder.getTemplates()
98+
const jobs = Features.resolveJobs(
99+
sources,
100+
optionalFeatures,
101+
selectedStack,
102+
features
103+
? { features }
104+
: {
105+
selected: await select('Select features', optionalFeatures.map(feature => ({
106+
name: feature.name,
107+
value: feature
108+
})), true)
109+
}
110+
)
112111

113112
const { exists } = await Patch.ensure(appName, selectedStack, selectedVersion)
114113

115114
if (!exists) await builder.create(appName, selectedVersion)
116115
await Patch.commit(appName, selectedStack, selectedVersion)
117116

118-
const activeFeatures = [...mandatoryFeatures, ...selectedFeatures]
119-
const activeFeaturesKeys = activeFeatures.map(({ templateKeys }) => templateKeys || []).flat()
120-
const templateBuilder = new TemplateBuilder<DefaultTemplateKey>(activeFeaturesKeys)
117+
const activeFeatures = [...mandatoryFeatures, ...jobs.flatMap(job => job.features)]
118+
const templateBuilder = new TemplateBuilder<DefaultTemplateKey>(Features.featureKeys(activeFeatures))
119+
const extras = jobs.filter(job => job.name !== job.from).map(job => job.name)
121120

122121
await builder.putAssets(appName, selectedVersion, templateBuilder)
123122

124-
for (const templateName of await templateBuilder.getTemplates()) {
125-
const template = await templateBuilder.load(templateName)
123+
for (const job of jobs) {
124+
const currentBuilder = new TemplateBuilder<DefaultTemplateKey>(Features.featureKeys([
125+
...mandatoryFeatures,
126+
...job.features
127+
]))
126128

127129
try {
128-
const code = await templateBuilder.build(template)
130+
const code = await currentBuilder.build(await currentBuilder.load(job.from))
129131

130-
await builder.putScript(appName, `${templateName}.ts`, code, selectedVersion)
132+
await builder.putScript(appName, `${job.name}.ts`, code, selectedVersion)
131133
} catch (e) {
132134
console.error(e)
133-
throwError(`failed to build template "${templateName}"`)
135+
throwError(`failed to build template "${job.name}"`)
134136
}
135137
}
136-
await builder.putScript(appName, `index.ts`, await templateBuilder.getEntryScript(), selectedVersion)
138+
await builder.putScript(appName, `index.ts`, await templateBuilder.getEntryScript(extras), selectedVersion)
137139

138140
await install(appName, Features.getDependencies(activeFeatures), depsAlias, forceInstall)
139141
}

src/app/template-builder.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2+
import Case from 'case'
23
import fs from 'fs'
34
import { join } from 'path'
45
import prettier from 'prettier'
@@ -15,6 +16,10 @@ export type DefaultTemplateKey = 'zoom-at' | 'react-render' | `react${number}` |
1516
| 'dataflow' | 'arrange' | 'sizes' | 'readonly' | 'order-nodes' | 'selectable'
1617
| 'context-menu' | 'import-area-extensions' | 'minimap' | 'reroute' | `stack-${string}`
1718

19+
function toCreateEditorName(id: string) {
20+
return `create${Case.pascal(id)}Editor`
21+
}
22+
1823
export class TemplateBuilder<Key extends string> {
1924
blockCommentRegex = /\/\* \[(!?[\w-]+)\][\r\n ]+(.*?)?[\r\n ]?\[\/\1\] \*\//gs
2025

@@ -28,7 +33,7 @@ export class TemplateBuilder<Key extends string> {
2833
return this.normalizeLineEndings(await fs.promises.readFile(join(templatesPath, name), { encoding: 'utf-8' }))
2934
}
3035

31-
async getTemplates() {
36+
static getTemplates() {
3237
return fs.promises.readdir(templatesPath)
3338
}
3439

@@ -49,7 +54,17 @@ export class TemplateBuilder<Key extends string> {
4954
: code
5055
}
5156

52-
async getEntryScript() {
53-
return fs.promises.readFile(entryScriptPath, { encoding: 'utf-8' })
57+
async getEntryScript(templateIds: string[] = []) {
58+
const template = this.normalizeLineEndings(await fs.promises.readFile(entryScriptPath, { encoding: 'utf-8' }))
59+
const imports = templateIds
60+
.map(id => `import { createEditor as ${toCreateEditorName(id)} } from './${id}'`)
61+
.join('\n')
62+
const factoryEntries = templateIds
63+
.map(id => ` ,'${id}': ${toCreateEditorName(id)}`)
64+
.join('\n')
65+
66+
return template
67+
.replace('/* {{virtual-imports}} */', imports)
68+
.replace('/* {{virtual-factory}} */', factoryEntries)
5469
}
5570
}

src/shared/throw.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import chalk from 'chalk'
22

3-
export function throwError(message: string) {
3+
export function throwError(message: string): never {
44
const label = chalk.black.bgRgb(220, 50, 50)(' FAIL ')
55

66
console.warn('\n', label, chalk.red(message.replace(/( {1,})/g, ' ').replace(/^ /, '')))

test/features-input.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from '@jest/globals'
2+
3+
import { React, resolveJobs } from '../src/app/features'
4+
import { TemplateBuilder } from '../src/app/template-builder'
5+
6+
describe('Template jobs', () => {
7+
const sources = ['default', 'scopes']
8+
const react = new React(18, 'react-vite', false)
9+
const optional = [react]
10+
11+
it('maps array features to all sources', () => {
12+
const jobs = resolveJobs(sources, optional, 'react-vite', { features: ['React render'] })
13+
14+
expect(jobs.map(job => ({ name: job.name, from: job.from }))).toEqual([
15+
{ name: 'default', from: 'default' },
16+
{ name: 'scopes', from: 'scopes' }
17+
])
18+
expect(jobs.every(job => job.features[0] === react)).toBe(true)
19+
})
20+
21+
it('maps object form to source jobs plus extras', () => {
22+
const jobs = resolveJobs(sources, optional, 'react-vite', {
23+
features: {
24+
base: ['React render'],
25+
minimap: { from: 'default', features: ['React render'] }
26+
}
27+
})
28+
29+
expect(jobs.map(job => ({ name: job.name, from: job.from }))).toEqual([
30+
{ name: 'default', from: 'default' },
31+
{ name: 'scopes', from: 'scopes' },
32+
{ name: 'minimap', from: 'default' }
33+
])
34+
})
35+
36+
it('uses selected features when input is omitted', () => {
37+
const jobs = resolveJobs(sources, optional, 'react-vite', { selected: [react] })
38+
39+
expect(jobs).toHaveLength(2)
40+
expect(jobs.every(job => job.features[0] === react)).toBe(true)
41+
})
42+
})
43+
44+
describe('Entry script', () => {
45+
it('keeps base entry script without extras', async () => {
46+
const code = await new TemplateBuilder([]).getEntryScript()
47+
48+
expect(code).toContain("import { createEditor as createDefaultEditor } from './default'")
49+
expect(code).toContain("'scopes': createScopesEditor")
50+
expect(code).not.toContain('{{virtual-imports}}')
51+
expect(code).not.toContain('{{virtual-factory}}')
52+
expect(code).not.toContain('minimap')
53+
})
54+
55+
it('fills placeholders with dynamic template snippets', async () => {
56+
const code = await new TemplateBuilder([]).getEntryScript(['minimap', 'my-feature'])
57+
58+
expect(code).toContain("import { createEditor as createMinimapEditor } from './minimap'")
59+
expect(code).toContain("import { createEditor as createMyFeatureEditor } from './my-feature'")
60+
expect(code).toContain("'minimap': createMinimapEditor")
61+
expect(code).toContain("'my-feature': createMyFeatureEditor")
62+
expect(code).not.toContain('{{virtual-imports}}')
63+
expect(code).not.toContain('{{virtual-factory}}')
64+
})
65+
})

0 commit comments

Comments
 (0)