@inglorious/create-app 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +21 -7
  2. package/bin/index.js +64 -88
  3. package/bin/lib/app-jsx.js +280 -0
  4. package/bin/lib/app-lit.js +275 -0
  5. package/bin/lib/app-vue.js +239 -0
  6. package/bin/lib/app.js +402 -0
  7. package/bin/lib/cli.js +34 -0
  8. package/bin/lib/constants.js +6 -4
  9. package/bin/lib/features.js +171 -0
  10. package/bin/lib/imports.js +64 -0
  11. package/bin/lib/mobile.js +19 -1
  12. package/bin/lib/pwa.js +50 -4
  13. package/bin/lib/questions.js +184 -0
  14. package/bin/lib/source.js +58 -0
  15. package/package.json +4 -4
  16. package/templates/js/package.json +4 -4
  17. package/templates/js/vite.config.js +2 -1
  18. package/templates/js-jsx/package.json +4 -4
  19. package/templates/js-jsx/vite.config.js +2 -1
  20. package/templates/js-vue/package.json +4 -4
  21. package/templates/js-vue/vite.config.js +2 -1
  22. package/templates/ssx-js/package.json +8 -7
  23. package/templates/ssx-js/src/site.config.js +2 -1
  24. package/templates/ssx-ts/package.json +9 -7
  25. package/templates/ssx-ts/src/site.config.ts +1 -0
  26. package/templates/ts/package.json +8 -6
  27. package/templates/ts/src/app.ts +4 -2
  28. package/templates/ts/tsconfig.app.json +2 -0
  29. package/templates/ts/tsconfig.node.json +2 -0
  30. package/templates/ts/types/index.d.ts +4 -1
  31. package/templates/ts/vite.config.ts +2 -1
  32. package/templates/ts-jsx/package.json +8 -6
  33. package/templates/ts-jsx/src/app.tsx +2 -3
  34. package/templates/ts-jsx/tsconfig.app.json +2 -0
  35. package/templates/ts-jsx/tsconfig.node.json +2 -0
  36. package/templates/ts-jsx/types/index.d.ts +3 -1
  37. package/templates/ts-jsx/types/vite-end.d.ts +1 -0
  38. package/templates/ts-jsx/vite.config.ts +2 -1
  39. package/templates/ts-vue/eslint.config.ts +1 -1
  40. package/templates/ts-vue/package.json +8 -6
  41. package/templates/ts-vue/src/main.ts +1 -1
  42. package/templates/ts-vue/src/store/middlewares.ts +5 -2
  43. package/templates/ts-vue/tsconfig.app.json +2 -0
  44. package/templates/ts-vue/tsconfig.node.json +2 -0
  45. package/templates/ts-vue/types/index.d.ts +3 -1
  46. package/templates/ts-vue/vite.config.ts +2 -1
package/README.md CHANGED
@@ -23,17 +23,31 @@ pnpm create @inglorious/app
23
23
 
24
24
  The CLI will guide you through the setup process, asking for a project name and which template you'd like to use.
25
25
 
26
- For JavaScript and TypeScript Vite app templates, the CLI can also add optional app targets:
26
+ For the JavaScript and TypeScript Vite templates, the CLI then asks about the app's features:
27
+
28
+ | Question | Flag | Adds |
29
+ | ------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
30
+ | Use Inglorious UI? | `--ui` / `--no-ui` | `@inglorious/ui`, its base styles and an app bar |
31
+ | Which theme? | `--theme=inglorious\|material\|bootstrap` | The theme's stylesheet and class |
32
+ | Is it a mobile app? | `--mobile` / `--no-mobile` | Capacitor for Android and iOS; with Inglorious UI, a bottom tab bar and safe-area spacing |
33
+ | Add routing? | `--routing` / `--no-routing` | The Router, a home and an about page, and navigation between them |
34
+ | Animate page transitions? | `--transitions` / `--no-transitions` | View transitions between pages (with routing and Inglorious UI) |
35
+ | Which icon packs? | `--icons=material,fontawesome,lucide` / `--no-icons` | Iconify icon sets, drawn by Inglorious UI's `Icon` (with Inglorious UI) |
36
+ | Translate the app? | `--i18n` / `--no-i18n`, `--languages=en,it` | `@inglorious/i18n`, a catalogue per language and a language picker; the first language is the default |
37
+ | Enable PWA support? | `--pwa` / `--no-pwa` | `vite-plugin-pwa`, a manifest, icons and service worker registration |
38
+
39
+ The app is generated in the syntax you chose: lit-html, JSX or Vue. Every question answered on the command line is skipped, so a project can be created without any prompt:
40
+
41
+ | Question | Argument or flag |
42
+ | --------------------------- | -------------------------------------------- |
43
+ | What is your project named? | The first argument, such as `my-app` |
44
+ | Select a template | `--template=minimal\|js\|ts\|ssx-js\|ssx-ts` |
45
+ | Select a rendering syntax | `--syntax=lit-html\|jsx\|vue` |
27
46
 
28
47
  ```bash
29
- npm create @inglorious/app@latest -- --pwa
30
- npm create @inglorious/app@latest -- --mobile
31
- npm create @inglorious/app@latest -- --pwa --mobile
48
+ npm create @inglorious/app@latest -- my-app --template=ts --syntax=jsx --ui --theme=material --routing --transitions --icons=material --languages=en,it --no-mobile --no-pwa
32
49
  ```
33
50
 
34
- - `--pwa` adds `vite-plugin-pwa`, a basic web app manifest configuration, PWA icons, and browser-only service worker registration.
35
- - `--mobile` adds Capacitor dependencies, config, and mobile scripts for iOS and Android.
36
-
37
51
  ## Templates
38
52
 
39
53
  The following templates are available to get you started:
package/bin/index.js CHANGED
@@ -8,22 +8,26 @@ import path from "path"
8
8
  import prompts from "prompts"
9
9
  import { fileURLToPath } from "url"
10
10
 
11
+ import { addAppFeatures } from "./lib/app.js"
11
12
  import { parseCliOptions } from "./lib/cli.js"
12
13
  import {
13
- EMPTY_LENGTH,
14
14
  INDENTATION,
15
15
  NPM_VIEW_TIMEOUT_MS,
16
16
  USER_ARG_START_INDEX,
17
17
  VITE_APP_TEMPLATES,
18
18
  } from "./lib/constants.js"
19
+ import { SYNTAXES, TEMPLATES } from "./lib/features.js"
19
20
  import { copyDir } from "./lib/file.js"
20
21
  import { addMobileSupport } from "./lib/mobile.js"
21
22
  import { addPwaSupport } from "./lib/pwa.js"
23
+ import { askAppFeatures } from "./lib/questions.js"
22
24
 
23
25
  async function main() {
24
26
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
25
27
  const templatesRoot = path.join(__dirname, "../templates")
26
28
  const cliOptions = parseCliOptions(process.argv.slice(USER_ARG_START_INDEX))
29
+ validateChoice("template", cliOptions.template, TEMPLATES)
30
+ validateChoice("syntax", cliOptions.syntax, SYNTAXES)
27
31
 
28
32
  let canceled = false
29
33
 
@@ -32,45 +36,19 @@ async function main() {
32
36
  return false
33
37
  }
34
38
 
35
- const { projectName, baseTemplate } = await prompts(
39
+ const answers = await prompts(
36
40
  [
37
41
  {
38
- type: "text",
42
+ type: cliOptions.projectName ? null : "text",
39
43
  name: "projectName",
40
44
  message: "What is your project named?",
41
45
  initial: "my-app",
42
46
  },
43
47
  {
44
- type: "select",
48
+ type: cliOptions.template ? null : "select",
45
49
  name: "baseTemplate",
46
50
  message: "Select a template",
47
- choices: [
48
- {
49
- title: "Minimal",
50
- description: "A single HTML file for a zero-build setup",
51
- value: "minimal",
52
- },
53
- {
54
- title: "JavaScript",
55
- description: "A vanilla JavaScript setup with Vite",
56
- value: "js",
57
- },
58
- {
59
- title: "TypeScript",
60
- description: "A TypeScript setup with Vite",
61
- value: "ts",
62
- },
63
- {
64
- title: "SSX (JavaScript)",
65
- description: "Static Site Xecution with JavaScript",
66
- value: "ssx-js",
67
- },
68
- {
69
- title: "SSX (TypeScript)",
70
- description: "Static Site Xecution with TypeScript",
71
- value: "ssx-ts",
72
- },
73
- ],
51
+ choices: toChoices(TEMPLATES),
74
52
  },
75
53
  ],
76
54
  { onCancel },
@@ -81,33 +59,18 @@ async function main() {
81
59
  return
82
60
  }
83
61
 
62
+ const projectName = cliOptions.projectName ?? answers.projectName
63
+ const baseTemplate = cliOptions.template ?? answers.baseTemplate
64
+
84
65
  let renderer = "lit-html"
85
- let pwa = cliOptions.pwa === true
86
- let mobile = cliOptions.mobile === true
87
66
 
88
67
  if (baseTemplate !== "minimal") {
89
68
  const { selectedRenderer } = await prompts(
90
69
  {
91
- type: "select",
70
+ type: cliOptions.syntax ? null : "select",
92
71
  name: "selectedRenderer",
93
72
  message: "Select a rendering syntax",
94
- choices: [
95
- {
96
- title: "lit-html (default)",
97
- description: "Tagged template literals (html`<div>...</div>`)",
98
- value: "lit-html",
99
- },
100
- {
101
- title: "JSX",
102
- description: "JSX/TSX syntax (<div>...</div>)",
103
- value: "jsx",
104
- },
105
- {
106
- title: "Vue",
107
- description: "Vue SFC-style templates",
108
- value: "vue",
109
- },
110
- ],
73
+ choices: toChoices(SYNTAXES),
111
74
  },
112
75
  { onCancel },
113
76
  )
@@ -117,49 +80,31 @@ async function main() {
117
80
  return
118
81
  }
119
82
 
120
- renderer = selectedRenderer
83
+ renderer = cliOptions.syntax ?? selectedRenderer
84
+ } else if (cliOptions.syntax && cliOptions.syntax !== "lit-html") {
85
+ console.warn("The minimal template is only available with lit-html.")
121
86
  }
122
87
 
123
- const supportsAppFeatures = VITE_APP_TEMPLATES.has(baseTemplate)
124
-
125
- if (supportsAppFeatures) {
126
- const featureQuestions = []
127
-
128
- if (cliOptions.pwa === null) {
129
- featureQuestions.push({
130
- type: "confirm",
131
- name: "pwa",
132
- message: "Enable PWA support?",
133
- initial: false,
134
- })
135
- }
136
-
137
- if (cliOptions.mobile === null) {
138
- featureQuestions.push({
139
- type: "confirm",
140
- name: "mobile",
141
- message: "Add hybrid mobile support with Capacitor?",
142
- initial: false,
143
- })
144
- }
145
-
146
- if (featureQuestions.length > EMPTY_LENGTH) {
147
- const answers = await prompts(featureQuestions, { onCancel })
88
+ let features = {
89
+ ui: false,
90
+ routing: false,
91
+ icons: [],
92
+ languages: [],
93
+ mobile: false,
94
+ pwa: false,
95
+ }
148
96
 
149
- if (canceled) {
150
- console.log("Operation canceled.")
151
- return
152
- }
97
+ if (VITE_APP_TEMPLATES.has(baseTemplate)) {
98
+ features = await askAppFeatures(cliOptions)
153
99
 
154
- pwa = cliOptions.pwa === true ? true : answers.pwa === true
155
- mobile = cliOptions.mobile === true ? true : answers.mobile === true
100
+ if (!features) {
101
+ console.log("Operation canceled.")
102
+ return
156
103
  }
157
- } else if (pwa || mobile) {
104
+ } else if (cliOptions.pwa || cliOptions.mobile) {
158
105
  console.warn(
159
106
  "PWA and mobile options are only available for JavaScript and TypeScript Vite app templates.",
160
107
  )
161
- pwa = false
162
- mobile = false
163
108
  }
164
109
 
165
110
  const spinner = ora(`Creating project "${projectName}"...`).start()
@@ -194,12 +139,28 @@ async function main() {
194
139
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"))
195
140
  pkg.name = projectName
196
141
 
197
- if (pwa) {
142
+ const hasAppFeatures =
143
+ features.ui ||
144
+ features.routing ||
145
+ Boolean(features.icons.length) ||
146
+ Boolean(features.languages.length)
147
+
148
+ if (hasAppFeatures) {
149
+ spinner.text = "Adding app features..."
150
+ addAppFeatures(targetDir, pkg, {
151
+ ...features,
152
+ projectName,
153
+ isTypeScript: baseTemplate === "ts",
154
+ renderer,
155
+ })
156
+ }
157
+
158
+ if (features.pwa) {
198
159
  spinner.text = "Adding PWA support..."
199
160
  addPwaSupport(targetDir, pkg, projectName)
200
161
  }
201
162
 
202
- if (mobile) {
163
+ if (features.mobile) {
203
164
  spinner.text = "Adding Capacitor support..."
204
165
  addMobileSupport(targetDir, pkg, projectName)
205
166
  }
@@ -228,6 +189,21 @@ async function main() {
228
189
  }
229
190
  }
230
191
 
192
+ function toChoices(options) {
193
+ return Object.entries(options).map(([value, option]) => ({
194
+ value,
195
+ ...option,
196
+ }))
197
+ }
198
+
199
+ function validateChoice(flag, value, options) {
200
+ if (value !== null && !options[value]) {
201
+ throw new Error(
202
+ `Unknown ${flag} "${value}". Choose one of: ${Object.keys(options).join(", ")}.`,
203
+ )
204
+ }
205
+ }
206
+
231
207
  function getLatestVersion(packageName) {
232
208
  try {
233
209
  const version = execSync(`npm view ${packageName} version`, {
@@ -0,0 +1,280 @@
1
+ // The app's components in JSX: .jsx or .tsx files, compiled to lit-html by
2
+ // @inglorious/vite-plugin-jsx. A capitalized tag with props renders a
3
+ // component; `<Message entityId="message1" />` renders an entity. TypeScript
4
+ // expects components to be functions, so in .tsx each capitalized tag is
5
+ // preceded by a `@ts-expect-error`.
6
+
7
+ import { renderLocals, selectChange } from "./app-lit.js"
8
+ import { importBlock } from "./imports.js"
9
+
10
+ export const extension = (app) => (app.isTypeScript ? "tsx" : "jsx")
11
+
12
+ const EXPECT_ERROR =
13
+ "@ts-expect-error - Inglorious components are objects, not functions"
14
+
15
+ // The comment line before a capitalized tag in .tsx: `{/* */}` among JSX
16
+ // children, `//` inside code.
17
+ function expectError(app, indent, isChild = true) {
18
+ if (!app.isTypeScript) return ""
19
+ const comment = isChild ? `{/* ${EXPECT_ERROR} */}` : `// ${EXPECT_ERROR}`
20
+ return `${indent}${comment}\n`
21
+ }
22
+
23
+ export function appSource(app, { pages, navIcons }) {
24
+ const imports = []
25
+ if (navIcons) {
26
+ for (const icon of Object.values(navIcons)) {
27
+ imports.push(`import ${icon.name} from "${icon.from}"`)
28
+ }
29
+ imports.push('import { Icon } from "@inglorious/ui/icon"')
30
+ }
31
+ if (app.ui) {
32
+ imports.push(
33
+ 'import { AppBar } from "@inglorious/ui/app-bar"',
34
+ 'import { Container } from "@inglorious/ui/container"',
35
+ )
36
+ if (app.tabs) {
37
+ imports.push(
38
+ 'import { BottomNavigation } from "@inglorious/ui/bottom-navigation"',
39
+ )
40
+ }
41
+ if (app.links) imports.push('import { Link } from "@inglorious/ui/link"')
42
+ if (app.i18n) imports.push('import { Select } from "@inglorious/ui/select"')
43
+ }
44
+ if (app.isTypeScript) imports.push('import type { AppApi } from "../types"')
45
+ if (app.i18n) imports.push('import { t } from "./i18n"')
46
+
47
+ const signature = app.isTypeScript
48
+ ? "render(props: Record<string, unknown> | null, api: AppApi)"
49
+ : "render(props, api)"
50
+ const content = app.routing
51
+ ? "api.render(router.route)"
52
+ : 'api.render("home")'
53
+
54
+ const shell = app.ui
55
+ ? uiShell(app, { pages, content })
56
+ : plainShell(app, { pages, content })
57
+
58
+ return `${importBlock(imports)}
59
+
60
+ export const App = {
61
+ ${signature} {
62
+ ${renderLocals(app)} return (
63
+ <>
64
+ ${shell}
65
+ </>
66
+ )
67
+ },
68
+ }
69
+ `
70
+ }
71
+
72
+ // A callback's parameter. The components' props are not typed in .tsx, so
73
+ // TypeScript cannot infer it.
74
+ function typed(app, name, type) {
75
+ return app.isTypeScript ? `${name}: ${type}` : name
76
+ }
77
+
78
+ // A page's label: translated, or the English text.
79
+ function labelText(app, page) {
80
+ return app.i18n ? `{t("${page.key}")}` : page.text
81
+ }
82
+
83
+ function labelValue(app, page) {
84
+ return app.i18n ? `t("${page.key}")` : `"${page.text}"`
85
+ }
86
+
87
+ function uiShell(app, { pages, content }) {
88
+ const nav = []
89
+ if (app.links) {
90
+ nav.push(
91
+ ...pages.map(
92
+ (page) =>
93
+ `${expectError(app, " ")} <Link href="${page.path}">${labelText(app, page)}</Link>`,
94
+ ),
95
+ )
96
+ }
97
+ if (app.i18n) {
98
+ nav.push(`${expectError(app, " ")} <Select
99
+ value={i18n.locale}
100
+ options={i18n.languages}
101
+ size="sm"
102
+ ariaLabel={t("language")}
103
+ onChange={(${typed(app, "locale", "string")}) =>
104
+ api.notify("#i18n:localeChange", locale)
105
+ }
106
+ />`)
107
+ }
108
+
109
+ const appBar = nav.length
110
+ ? `${expectError(app, " ")} <AppBar
111
+ title="${app.projectName}"
112
+ trailing={
113
+ <nav className="app-nav">
114
+ ${nav.join("\n")}
115
+ </nav>
116
+ }
117
+ />`
118
+ : `${expectError(app, " ")} <AppBar title="${app.projectName}" />`
119
+
120
+ const header = ` <header${app.mobile ? ' className="pt-safe"' : ""}>
121
+ ${appBar}
122
+ </header>
123
+ <main>
124
+ ${expectError(app, " ")} <Container maxWidth="md">{${content}}</Container>
125
+ </main>`
126
+
127
+ if (!app.tabs) return header
128
+
129
+ const actions = pages.map((page) => {
130
+ const icon = page.icon
131
+ ? `
132
+ ${expectError(app, " ", false)} icon: <Icon icon={${page.icon.name}} size="lg" />,`
133
+ : ""
134
+ return ` {
135
+ value: "${page.path}",
136
+ label: ${labelValue(app, page)},${icon}
137
+ },`
138
+ })
139
+
140
+ return `${header}
141
+ <footer className="pb-safe">
142
+ ${expectError(app, " ")} <BottomNavigation
143
+ value={router.path}
144
+ actions={[
145
+ ${actions.join("\n")}
146
+ ]}
147
+ onChange={(${typed(app, "path", "string")}) => api.notify("#router:navigate", path)}
148
+ />
149
+ </footer>`
150
+ }
151
+
152
+ function plainShell(app, { pages, content }) {
153
+ const nav = app.routing
154
+ ? pages.map(
155
+ (page) =>
156
+ ` <a href="${page.path}">${labelText(app, page)}</a>`,
157
+ )
158
+ : []
159
+ if (app.i18n) {
160
+ nav.push(` <select
161
+ aria-label={t("language")}
162
+ onChange={${selectChange(app, " ")}
163
+ }
164
+ >
165
+ {i18n.languages${app.isTypeScript ? "?." : "."}map((language) => (
166
+ <option
167
+ key={language.value}
168
+ value={language.value}
169
+ selected={language.value === i18n.locale}
170
+ >
171
+ {language.label}
172
+ </option>
173
+ ))}
174
+ </select>`)
175
+ }
176
+ const navBlock = nav.length
177
+ ? `
178
+ <nav className="app-nav">
179
+ ${nav.join("\n")}
180
+ </nav>`
181
+ : ""
182
+
183
+ return ` <header>
184
+ <strong>${app.projectName}</strong>${navBlock}
185
+ </header>
186
+ <main>{${content}}</main>`
187
+ }
188
+
189
+ export function homeSource(app, { showcase }) {
190
+ const imports = ['import { Message } from "../types/message"']
191
+ for (const icon of showcase) {
192
+ imports.push(`import ${icon.name} from "${icon.from}"`)
193
+ }
194
+ if (showcase.length) {
195
+ imports.push('import { Icon } from "@inglorious/ui/icon"')
196
+ }
197
+ if (app.i18n) imports.push('import { t } from "../i18n"')
198
+
199
+ const text = (key, fallback) => (app.i18n ? `{t("${key}")}` : fallback)
200
+ const icons = showcase.length
201
+ ? `
202
+ <p className="app-icons">
203
+ ${showcase
204
+ .map(
205
+ (icon) =>
206
+ `${expectError(app, " ")} <Icon icon={${icon.name}} size="lg" label="${icon.label}" />`,
207
+ )
208
+ .join("\n")}
209
+ </p>`
210
+ : ""
211
+
212
+ return `${importBlock(imports)}
213
+
214
+ export const Home = {
215
+ render() {
216
+ return (
217
+ <>
218
+ <h1>${text("home.title", "Welcome")}</h1>
219
+ <p>
220
+ ${expectError(app, " ")} <Message entityId="message1" />, <Message entityId="message2" />,{" "}
221
+ ${expectError(app, " ")} <Message entityId="message3" />!
222
+ </p>
223
+ <p>${text("home.hint", "Click a greeting to shout it.")}</p>${icons}
224
+ </>
225
+ )
226
+ },
227
+ }
228
+ `
229
+ }
230
+
231
+ export function aboutSource(app) {
232
+ const head = app.i18n ? 'import { t } from "../i18n"\n\n' : ""
233
+ const text = (key, fallback) => (app.i18n ? `{t("${key}")}` : fallback)
234
+
235
+ return `${head}export const About = {
236
+ render() {
237
+ return (
238
+ <>
239
+ <h1>${text("about.title", "About")}</h1>
240
+ <p>${text("about.body", "A page of its own, at its own address.")}</p>
241
+ </>
242
+ )
243
+ },
244
+ }
245
+ `
246
+ }
247
+
248
+ export function messageSource(app) {
249
+ const imports = []
250
+ if (app.isTypeScript) {
251
+ imports.push('import type { MessageType } from "../../types"')
252
+ }
253
+ if (app.i18n) imports.push('import { t } from "../i18n"')
254
+ const greeting = app.i18n ? '{t("message.hello", { who })}' : "Hello {who}"
255
+ const declaration = app.isTypeScript
256
+ ? "export const Message: MessageType = {"
257
+ : "export const Message = {"
258
+
259
+ const span = app.i18n
260
+ ? ` <span onClick={() => api.notify(\`#\${entity.id}:click\`)}>
261
+ ${greeting}
262
+ </span>`
263
+ : ` <span onClick={() => api.notify(\`#\${entity.id}:click\`)}>${greeting}</span>`
264
+
265
+ const head = imports.length ? `${importBlock(imports)}\n\n` : ""
266
+
267
+ return `${head}${declaration}
268
+ click(entity) {
269
+ entity.isUpperCase = !entity.isUpperCase
270
+ },
271
+
272
+ render(entity, api) {
273
+ const who = entity.isUpperCase ? entity.who.toUpperCase() : entity.who
274
+ return (
275
+ ${span}
276
+ )
277
+ },
278
+ }
279
+ `
280
+ }