Skip to content

Commit 691630f

Browse files
committed
feat(create-app): add community templates support
1 parent d75ae45 commit 691630f

9 files changed

Lines changed: 318 additions & 182 deletions

File tree

docs/commands/create-app.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,15 @@ You can run `pnpm create @dhis2/app@alpha --help` for the list of options availa
4242
template) [boolean] [default: false]
4343
--typescript, --ts, --typeScript Use TypeScript or JS [boolean]
4444
--template Which template to use (Basic, With
45-
React Router, or GitHub
46-
template specifier) [string]
45+
React Router, community template
46+
source, or GitHub template
47+
specifier) [string]
4748
--packageManager, --package, Package Manager
4849
--packagemanager [string]
4950
```
5051

52+
In interactive mode, template selection includes built-in templates, configured community templates, and a `Custom template from Git` option. For non-interactive usage, `--template` accepts built-in values (`basic`, `react-router`), configured community template sources, and direct GitHub specifiers.
53+
5154
## Examples
5255

5356
Here are some examples of how you can use the CLI
@@ -59,6 +62,9 @@ pnpm create @dhis2/app my-app --yes
5962
# use the default settings but override the template
6063
pnpm create @dhis2/app my-app --yes --template react-router
6164

65+
# use a configured community template source
66+
pnpm create @dhis2/app my-app --template derrick-nuby/dhis2-ts-tailwind-react-router
67+
6268
# use a custom template from GitHub (owner/repo)
6369
pnpm create @dhis2/app my-app --template owner/repo
6470

packages/create-app/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
"@dhis2/cli-helpers-engine": "^3.2.2",
1414
"@inquirer/prompts": "^7.8.4",
1515
"fast-glob": "^3.3.3",
16-
"fs-extra": "^11.3.3"
16+
"fs-extra": "^11.3.3",
17+
"yaml": "^2.8.1"
1718
},
1819
"private": false,
1920
"keywords": [],
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
templates:
2+
- name: Tailwind + React Router
3+
description: React Router DHIS2 app with Tailwind CSS
4+
source: derrick-nuby/dhis2-ts-tailwind-react-router
5+
maintainer:
6+
name: Derrick Iradukunda
7+
url: https://github.com/derrick-nuby
8+
organisation:
9+
name: HISP Rwanda
10+
url: https://hisprwanda.org

packages/create-app/src/index.js

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1-
const { execSync } = require('child_process')
2-
const path = require('path')
1+
const { execSync } = require('node:child_process')
2+
const path = require('node:path')
33
const { reporter, exec, chalk } = require('@dhis2/cli-helpers-engine')
44
const { input, select } = require('@inquirer/prompts')
55
const fg = require('fast-glob')
66
const fs = require('fs-extra')
7+
const getCommunityTemplates = require('./utils/getCommunityTemplates')
78
const { default: getPackageManager } = require('./utils/getPackageManager')
9+
const { isGitTemplateSpecifier } = require('./utils/isGitTemplateSpecifier')
810
const resolveExternalTemplateSource = require('./utils/resolveExternalTemplateSource')
11+
const {
12+
resolveTemplateSourceInput,
13+
} = require('./utils/resolveTemplateSourceInput')
914

1015
process.on('uncaughtException', (error) => {
1116
if (error instanceof Error && error.name === 'ExitPromptError') {
@@ -28,6 +33,7 @@ const templates = {
2833
'../templates/template-ts-dataelements-react-router'
2934
),
3035
}
36+
const builtInTemplateKeys = ['basic', 'react-router']
3137

3238
const commandHandler = {
3339
command: '*', // default command
@@ -47,7 +53,7 @@ const commandHandler = {
4753
},
4854
template: {
4955
description:
50-
'Which template to use (Basic, With React Router, or GitHub template specifier)',
56+
'Which template to use (basic, react-router, community template source, or GitHub template specifier)',
5157
type: 'string',
5258
},
5359
packageManager: {
@@ -70,13 +76,35 @@ const getBuiltInTemplateDirectory = (templateName) => {
7076
return null
7177
}
7278

79+
const formatUnknownTemplateError = (templateSource, communityTemplates) => {
80+
const communitySources = communityTemplates.map(
81+
(template) => template.source
82+
)
83+
const availableTemplates = [...builtInTemplateKeys, ...communitySources]
84+
const formattedTemplateList = availableTemplates.length
85+
? availableTemplates.join(', ')
86+
: '(none)'
87+
88+
return `Unknown template "${templateSource}". Available templates: ${formattedTemplateList}. Or use a GitHub template specifier like "owner/repo#ref" or "https://github.com/owner/repo#ref".`
89+
}
90+
7391
const command = {
7492
command: '[app]',
7593
builder: (yargs) => {
7694
yargs.command(commandHandler)
7795
},
7896
handler: async (argv) => {
7997
let name = argv._[0] || argv.name
98+
let communityTemplates = []
99+
100+
try {
101+
communityTemplates = getCommunityTemplates()
102+
} catch (error) {
103+
reporter.error(
104+
error instanceof Error ? error.message : String(error)
105+
)
106+
process.exit(1)
107+
}
80108

81109
const useDefauls = argv.yes
82110

@@ -121,6 +149,10 @@ const command = {
121149
name: 'Template with React Router',
122150
value: 'react-router',
123151
},
152+
{
153+
name: 'Community templates',
154+
value: 'community',
155+
},
124156
{
125157
name: 'Custom template from Git',
126158
value: 'custom-git',
@@ -134,6 +166,30 @@ const command = {
134166
'Enter GitHub template specifier (e.g. owner/repo#main)',
135167
required: true,
136168
})
169+
} else if (template === 'community') {
170+
const communityTemplate = await select({
171+
message: 'Select a community template',
172+
choices: [
173+
...communityTemplates.map((communityEntry) => ({
174+
name: communityEntry.displayName,
175+
value: communityEntry.source,
176+
})),
177+
{
178+
name: 'Use a Git repository',
179+
value: 'custom-git',
180+
},
181+
],
182+
})
183+
184+
if (communityTemplate === 'custom-git') {
185+
selectedOptions.templateSource = await input({
186+
message:
187+
'Enter GitHub template specifier (e.g. owner/repo#main)',
188+
required: true,
189+
})
190+
} else {
191+
selectedOptions.templateSource = communityTemplate
192+
}
137193
} else {
138194
selectedOptions.templateSource = template
139195
}
@@ -174,21 +230,45 @@ const command = {
174230
})
175231
reporter.debug('Successfully ran git clean')
176232
} catch (err) {
177-
reporter.debug(err)
233+
reporter.debug(err instanceof Error ? err.message : String(err))
178234
}
179235

180236
reporter.info('Copying template files')
181237
let resolvedExternalTemplate
182238
try {
239+
const normalizedTemplateSource = String(
240+
selectedOptions.templateSource || ''
241+
).trim()
183242
const builtInTemplatePath = getBuiltInTemplateDirectory(
184-
selectedOptions.templateSource
243+
normalizedTemplateSource
185244
)
186245

187246
if (builtInTemplatePath) {
188247
fs.copySync(builtInTemplatePath, cwd)
189248
} else {
249+
const resolvedTemplateSource = resolveTemplateSourceInput(
250+
normalizedTemplateSource,
251+
communityTemplates
252+
)
253+
const externalTemplateSource = resolvedTemplateSource.source
254+
255+
if (!isGitTemplateSpecifier(externalTemplateSource)) {
256+
if (resolvedTemplateSource.kind === 'community') {
257+
throw new Error(
258+
`Community template "${resolvedTemplateSource.name}" has an invalid source "${externalTemplateSource}".`
259+
)
260+
}
261+
262+
throw new Error(
263+
formatUnknownTemplateError(
264+
normalizedTemplateSource,
265+
communityTemplates
266+
)
267+
)
268+
}
269+
190270
resolvedExternalTemplate = await resolveExternalTemplateSource(
191-
selectedOptions.templateSource
271+
externalTemplateSource
192272
)
193273
fs.copySync(resolvedExternalTemplate.templatePath, cwd)
194274
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
const path = require('node:path')
2+
const fs = require('fs-extra')
3+
const yaml = require('yaml')
4+
5+
const defaultRegistryPath = path.join(__dirname, '../community-templates.yaml')
6+
7+
const getRequiredString = (value, fieldPath, registryPath) => {
8+
if (typeof value !== 'string' || value.trim() === '') {
9+
throw new Error(
10+
`Invalid community template registry "${registryPath}": "${fieldPath}" must be a non-empty string.`
11+
)
12+
}
13+
14+
return value.trim()
15+
}
16+
17+
const getOptionalString = (value, fieldPath, registryPath) => {
18+
if (value === undefined || value === null) {
19+
return null
20+
}
21+
22+
if (typeof value !== 'string' || value.trim() === '') {
23+
throw new Error(
24+
`Invalid community template registry "${registryPath}": "${fieldPath}" must be a non-empty string when provided.`
25+
)
26+
}
27+
28+
return value.trim()
29+
}
30+
31+
const getOptionalPersonMetadata = (value, fieldPath, registryPath) => {
32+
if (value === undefined || value === null) {
33+
return null
34+
}
35+
36+
if (typeof value !== 'object' || Array.isArray(value)) {
37+
throw new TypeError(
38+
`Invalid community template registry "${registryPath}": "${fieldPath}" must be an object when provided.`
39+
)
40+
}
41+
42+
const name = getOptionalString(
43+
value.name,
44+
`${fieldPath}.name`,
45+
registryPath
46+
)
47+
const url = getOptionalString(value.url, `${fieldPath}.url`, registryPath)
48+
49+
if (!name && !url) {
50+
return null
51+
}
52+
53+
return {
54+
...(name ? { name } : {}),
55+
...(url ? { url } : {}),
56+
}
57+
}
58+
59+
const getCommunityTemplates = (registryPath = defaultRegistryPath) => {
60+
let registryFileContent
61+
62+
try {
63+
registryFileContent = fs.readFileSync(registryPath, 'utf8')
64+
} catch (error) {
65+
const detail =
66+
error instanceof Error && error.message ? ` ${error.message}` : ''
67+
throw new Error(
68+
`Failed to read community template registry "${registryPath}".${detail}`
69+
)
70+
}
71+
72+
let parsedRegistry
73+
try {
74+
parsedRegistry = yaml.parse(registryFileContent)
75+
} catch (error) {
76+
const detail =
77+
error instanceof Error && error.message ? ` ${error.message}` : ''
78+
throw new Error(
79+
`Failed to parse community template registry "${registryPath}".${detail}`
80+
)
81+
}
82+
83+
if (!parsedRegistry || !Array.isArray(parsedRegistry.templates)) {
84+
throw new Error(
85+
`Invalid community template registry "${registryPath}": "templates" must be an array.`
86+
)
87+
}
88+
89+
const knownTemplateSources = new Set()
90+
91+
return parsedRegistry.templates.map((template, index) => {
92+
const templateFieldPrefix = `templates[${index}]`
93+
if (
94+
!template ||
95+
typeof template !== 'object' ||
96+
Array.isArray(template)
97+
) {
98+
throw new Error(
99+
`Invalid community template registry "${registryPath}": "${templateFieldPrefix}" must be an object.`
100+
)
101+
}
102+
103+
const name = getRequiredString(
104+
template.name,
105+
`${templateFieldPrefix}.name`,
106+
registryPath
107+
)
108+
const source = getRequiredString(
109+
template.source,
110+
`${templateFieldPrefix}.source`,
111+
registryPath
112+
)
113+
114+
if (knownTemplateSources.has(source)) {
115+
throw new Error(
116+
`Invalid community template registry "${registryPath}": duplicate template source "${source}" in "${templateFieldPrefix}.source".`
117+
)
118+
}
119+
knownTemplateSources.add(source)
120+
121+
const description = getOptionalString(
122+
template.description,
123+
`${templateFieldPrefix}.description`,
124+
registryPath
125+
)
126+
const maintainer = getOptionalPersonMetadata(
127+
template.maintainer,
128+
`${templateFieldPrefix}.maintainer`,
129+
registryPath
130+
)
131+
const organisation = getOptionalPersonMetadata(
132+
template.organisation,
133+
`${templateFieldPrefix}.organisation`,
134+
registryPath
135+
)
136+
137+
const attributionParts = []
138+
if (maintainer?.name) {
139+
attributionParts.push(`by ${maintainer.name}`)
140+
}
141+
if (organisation?.name) {
142+
attributionParts.push(`org: ${organisation.name}`)
143+
}
144+
const displayName = attributionParts.length
145+
? `${name} (${attributionParts.join(', ')})`
146+
: name
147+
148+
return {
149+
name,
150+
source,
151+
...(description ? { description } : {}),
152+
...(maintainer ? { maintainer } : {}),
153+
...(organisation ? { organisation } : {}),
154+
displayName,
155+
}
156+
})
157+
}
158+
159+
module.exports = getCommunityTemplates
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
const resolveTemplateSourceInput = (
2+
templateSource,
3+
communityTemplates = []
4+
) => {
5+
const normalizedTemplateSource = String(templateSource || '').trim()
6+
7+
const matchedCommunityTemplate = communityTemplates.find(
8+
(communityTemplate) =>
9+
communityTemplate.source === normalizedTemplateSource
10+
)
11+
if (matchedCommunityTemplate) {
12+
return {
13+
kind: 'community',
14+
name: matchedCommunityTemplate.name,
15+
source: matchedCommunityTemplate.source,
16+
}
17+
}
18+
19+
return {
20+
kind: 'external',
21+
source: normalizedTemplateSource,
22+
}
23+
}
24+
25+
module.exports = {
26+
resolveTemplateSourceInput,
27+
}

0 commit comments

Comments
 (0)