@inglorious/create-app 1.1.2 → 1.2.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 (45) hide show
  1. package/README.md +15 -7
  2. package/bin/index.js +34 -39
  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 +31 -0
  8. package/bin/lib/constants.js +3 -3
  9. package/bin/lib/features.js +142 -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 +3 -3
  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 +7 -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 +7 -6
  33. package/templates/ts-jsx/src/app.tsx +2 -2
  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 +7 -6
  41. package/templates/ts-vue/src/store/middlewares.ts +5 -2
  42. package/templates/ts-vue/tsconfig.app.json +2 -0
  43. package/templates/ts-vue/tsconfig.node.json +2 -0
  44. package/templates/ts-vue/types/index.d.ts +3 -1
  45. package/templates/ts-vue/vite.config.ts +2 -1
package/bin/lib/app.js ADDED
@@ -0,0 +1,402 @@
1
+ import fs from "fs"
2
+ import { EOL } from "os"
3
+ import path from "path"
4
+
5
+ import * as jsxComponents from "./app-jsx.js"
6
+ import * as litComponents from "./app-lit.js"
7
+ import * as vueComponents from "./app-vue.js"
8
+ import { INDENTATION } from "./constants.js"
9
+ import { ICON_PACKS, LANGUAGES } from "./features.js"
10
+ import { importBlock } from "./imports.js"
11
+ import { appModel, call, LINE_WIDTH, union } from "./source.js"
12
+
13
+ const COMPONENTS = {
14
+ "lit-html": litComponents,
15
+ jsx: jsxComponents,
16
+ vue: vueComponents,
17
+ }
18
+
19
+ /**
20
+ * Writes the source of an app with the features chosen: Inglorious UI,
21
+ * routing, page transitions, icon packs, internationalization and a mobile
22
+ * layout, in lit-html, JSX or Vue. It replaces the template's hello world,
23
+ * keeping its message type as the example on the home page. The files come
24
+ * out as Prettier and the template's ESLint config would leave them.
25
+ *
26
+ * @param {string} targetDir
27
+ * @param {object} pkg - The package.json being built; dependencies are added.
28
+ * @param {object} options
29
+ * @param {string} options.projectName
30
+ * @param {boolean} options.isTypeScript
31
+ * @param {"lit-html" | "jsx" | "vue"} options.renderer
32
+ * @param {boolean} options.ui
33
+ * @param {string} options.theme - A key of THEMES.
34
+ * @param {boolean} options.mobile
35
+ * @param {boolean} options.routing
36
+ * @param {boolean} options.transitions
37
+ * @param {string[]} options.icons - Keys of ICON_PACKS.
38
+ * @param {string[]} options.languages - Keys of LANGUAGES, the default first.
39
+ */
40
+ export function addAppFeatures(targetDir, pkg, options) {
41
+ const app = { ...options, ext: options.isTypeScript ? "ts" : "js" }
42
+ app.i18n = Boolean(app.languages.length)
43
+ // On a phone the pages are tabs at the bottom; elsewhere, links on top.
44
+ app.tabs = app.ui && app.routing && app.mobile
45
+ app.links = app.routing && !app.tabs
46
+ app.isVue = app.renderer === "vue"
47
+
48
+ const components = COMPONENTS[app.renderer]
49
+ const componentExt = components.extension(app)
50
+ const model = appModel(app)
51
+
52
+ addDependencies(pkg, app)
53
+
54
+ const files = {
55
+ [`src/main.${app.ext}`]: mainSource(app),
56
+ [`src/app.${componentExt}`]: components.appSource(app, model),
57
+ "src/app.css": appStyles(app),
58
+ [`src/pages/home.${componentExt}`]: components.homeSource(app, model),
59
+ [`src/types/message.${componentExt}`]: components.messageSource(app),
60
+ [`src/store/index.${app.ext}`]: storeSource(app),
61
+ [`src/store/entities.${app.ext}`]: entitiesSource(app),
62
+ }
63
+ if (app.routing) {
64
+ files[`src/pages/about.${componentExt}`] = components.aboutSource(app)
65
+ files[`src/store/routes.${app.ext}`] = routesSource(app)
66
+ }
67
+ if (app.i18n) {
68
+ files[`src/i18n/index.${app.ext}`] = i18nSource(app)
69
+ for (const language of app.languages) {
70
+ files[`src/i18n/locales/${language}.json`] =
71
+ JSON.stringify(LANGUAGES[language].texts, null, INDENTATION) + EOL
72
+ }
73
+ }
74
+ if (app.isTypeScript) {
75
+ files["types/index.d.ts"] = typesSource(app)
76
+ }
77
+
78
+ for (const [file, source] of Object.entries(files)) {
79
+ const filePath = path.join(targetDir, file)
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true })
81
+ fs.writeFileSync(filePath, source)
82
+ }
83
+
84
+ if (app.ui) applyTheme(targetDir, app)
85
+ }
86
+
87
+ function addDependencies(pkg, app) {
88
+ pkg.dependencies ??= {}
89
+ if (app.ui) pkg.dependencies["@inglorious/ui"] = "workspace:*"
90
+ if (app.i18n) pkg.dependencies["@inglorious/i18n"] = "workspace:*"
91
+ for (const key of app.icons) {
92
+ Object.assign(pkg.dependencies, ICON_PACKS[key].packages)
93
+ }
94
+ }
95
+
96
+ // The theme is a class on <body>, which the theme's stylesheet is scoped to.
97
+ function applyTheme(targetDir, app) {
98
+ const htmlPath = path.join(targetDir, "index.html")
99
+ if (!fs.existsSync(htmlPath)) return
100
+ const page = fs.readFileSync(htmlPath, "utf-8")
101
+ fs.writeFileSync(
102
+ htmlPath,
103
+ page.replace("<body>", `<body class="iw-theme-${app.theme}">`),
104
+ )
105
+ }
106
+
107
+ // An import of one of the app's components: Vue's name their file, and
108
+ // TypeScript cannot read .vue files, so it is told to expect that.
109
+ function componentImport(app, names, from) {
110
+ if (!app.isVue) return `import { ${names} } from "${from}"`
111
+ const line = `import { ${names} } from "${from}.vue"`
112
+ return app.isTypeScript
113
+ ? `// @ts-expect-error - Handled by @inglorious/vite-plugin-vue\n${line}`
114
+ : line
115
+ }
116
+
117
+ // ---------------------------------------------------------------- main
118
+
119
+ function mainSource(app) {
120
+ const imports = []
121
+ if (app.ui) {
122
+ imports.push(
123
+ 'import "@inglorious/ui/base.css"',
124
+ `import "@inglorious/ui/themes/${app.theme}.css"`,
125
+ ...uiComponents(app).map((name) => `import "@inglorious/ui/${name}.css"`),
126
+ )
127
+ if (app.transitions) {
128
+ imports.push('import "@inglorious/ui/view-transitions.css"')
129
+ }
130
+ }
131
+ imports.push('import "./app.css"')
132
+ if (app.transitions) {
133
+ imports.push(
134
+ 'import { withTransitions } from "@inglorious/ui/view-transitions"',
135
+ )
136
+ }
137
+ imports.push(
138
+ 'import { mount } from "@inglorious/web"',
139
+ componentImport(app, "App", "./app"),
140
+ 'import { store } from "./store"',
141
+ )
142
+ if (app.i18n) imports.push('import { ready } from "./i18n"')
143
+
144
+ const args = [
145
+ app.transitions ? "withTransitions(store)" : "store",
146
+ "(api) => App.render(null, api)",
147
+ app.isTypeScript
148
+ ? 'document.getElementById("root")!'
149
+ : 'document.getElementById("root")',
150
+ ]
151
+
152
+ const body = app.i18n
153
+ ? `// Mounts once the starting language has loaded, so the first render is
154
+ // already translated.
155
+ ready.then(() =>
156
+ ${call("mount", args, " ", ",")}
157
+ )`
158
+ : call("mount", args, "", "")
159
+
160
+ return `${importBlock(imports)}
161
+
162
+ ${body}
163
+ `
164
+ }
165
+
166
+ // The Inglorious UI components the app uses, for their stylesheets.
167
+ function uiComponents(app) {
168
+ const names = ["app-bar", "container"]
169
+ if (app.links) names.push("link")
170
+ if (app.tabs) names.push("bottom-navigation")
171
+ if (app.icons.length) names.push("icon")
172
+ if (app.i18n) names.push("select")
173
+ return names
174
+ }
175
+
176
+ function appStyles(app) {
177
+ const layout = `body {
178
+ min-height: 100dvh;
179
+ display: flex;
180
+ flex-direction: column;
181
+ }
182
+
183
+ #root {
184
+ flex: 1;
185
+ display: flex;
186
+ flex-direction: column;
187
+ }
188
+
189
+ main {
190
+ flex: 1;
191
+ padding-block: 1.5rem;
192
+ }
193
+
194
+ .app-nav {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 1rem;
198
+ }
199
+ `
200
+ if (app.ui) {
201
+ // In Vue the navigation is the app bar's content rather than its trailing
202
+ // slot, so it pushes itself to the end.
203
+ return app.isVue
204
+ ? `${layout}
205
+ .iw-app-bar .app-nav {
206
+ margin-inline-start: auto;
207
+ }
208
+ `
209
+ : layout
210
+ }
211
+ return `${layout}
212
+ header {
213
+ display: flex;
214
+ align-items: center;
215
+ justify-content: space-between;
216
+ padding: 1rem 1.5rem;
217
+ border-block-end: 1px solid #ddd;
218
+ }
219
+
220
+ main {
221
+ padding-inline: 1.5rem;
222
+ }
223
+ `
224
+ }
225
+
226
+ // ---------------------------------------------------------------- store
227
+
228
+ function storeSource(app) {
229
+ const imports = [
230
+ app.isTypeScript
231
+ ? 'import { createStore, type TypesConfig } from "@inglorious/store"'
232
+ : 'import { createStore } from "@inglorious/store"',
233
+ componentImport(app, "Home", "../pages/home"),
234
+ componentImport(app, "Message", "../types/message"),
235
+ 'import { entities } from "./entities"',
236
+ 'import { middlewares } from "./middlewares"',
237
+ ]
238
+ if (app.routing) {
239
+ imports.push(
240
+ 'import { Router, setRoutes } from "@inglorious/web/router"',
241
+ componentImport(app, "About", "../pages/about"),
242
+ 'import { routes } from "./routes"',
243
+ )
244
+ }
245
+ if (app.isTypeScript) {
246
+ imports.push('import type { AppEntity, AppState } from "../../types"')
247
+ }
248
+ if (app.i18n) imports.push('import { I18n } from "../i18n"')
249
+
250
+ const types = ["Message", "Home"]
251
+ if (app.routing) types.push("About", "Router")
252
+ if (app.i18n) types.push("I18n")
253
+ const cast = app.isTypeScript ? " as unknown as TypesConfig<AppEntity>" : ""
254
+ const oneLine = ` types: { ${types.join(", ")} }${cast},`
255
+ const typesValue =
256
+ oneLine.length <= LINE_WIDTH
257
+ ? oneLine
258
+ : ` types: {
259
+ ${types.map((type) => ` ${type},`).join("\n")}
260
+ }${cast},`
261
+ const generics = app.isTypeScript ? "<AppEntity, AppState>" : ""
262
+
263
+ return `${importBlock(imports)}
264
+ ${app.routing ? "\nsetRoutes(routes)\n" : ""}
265
+ export const store = createStore${generics}({
266
+ ${typesValue}
267
+ entities,
268
+ middlewares,
269
+ // Creates an entity for each type that has none: ${singletons(app).join(", ")}.
270
+ autoCreateEntities: true,
271
+ })
272
+ `
273
+ }
274
+
275
+ // The entities the store creates on its own, as the comment in the store
276
+ // lists them.
277
+ function singletons(app) {
278
+ const ids = ["home"]
279
+ if (app.routing) ids.push("about", "router")
280
+ if (app.i18n) ids.push("i18n")
281
+ return ids
282
+ }
283
+
284
+ // Only the entities that need data, or come more than one to a type; the
285
+ // store creates the rest.
286
+ function entitiesSource(app) {
287
+ const head = app.isTypeScript
288
+ ? `import type { AppState } from "../../types"
289
+
290
+ export const entities: Partial<AppState> = {`
291
+ : "export const entities = {"
292
+ return `${head}
293
+ message1: { type: "Message", who: "world" },
294
+ message2: { type: "Message", who: "wide" },
295
+ message3: { type: "Message", who: "web" },
296
+ }
297
+ `
298
+ }
299
+
300
+ function routesSource(app) {
301
+ const head = app.isTypeScript
302
+ ? `import type { RoutesConfig } from "@inglorious/web/router"
303
+
304
+ // Each path names the entity its page renders.
305
+ export const routes: RoutesConfig = {`
306
+ : `// Each path names the entity its page renders.
307
+ export const routes = {`
308
+ return `${head}
309
+ "/": "home",
310
+ "/about": "about",
311
+ }
312
+ `
313
+ }
314
+
315
+ // ---------------------------------------------------------------- i18n
316
+
317
+ function i18nSource(app) {
318
+ const [defaultLocale] = app.languages
319
+ return `import { createI18n } from "@inglorious/i18n"
320
+
321
+ // One catalogue per language in ./locales, loaded when it is needed. Adding a
322
+ // language is adding its file.
323
+ export const { I18n, t, ready } = createI18n({
324
+ catalogues: import.meta.glob("./locales/*.json"),
325
+ defaultLocale: "${defaultLocale}",
326
+ storageKey: "${app.projectName}-locale",
327
+ })
328
+ `
329
+ }
330
+
331
+ // ---------------------------------------------------------------- types
332
+
333
+ function typesSource(app) {
334
+ const imports = [
335
+ 'import "./vite-end"',
336
+ 'import type { BaseEntity, EntitiesState } from "@inglorious/store"',
337
+ 'import type { Api, TemplateResult } from "@inglorious/web"',
338
+ ]
339
+ const entities = ["MessageEntity", "HomeEntity"]
340
+ const declarations = [
341
+ `export interface MessageEntity extends BaseEntity {
342
+ type: "Message"
343
+ id?: string
344
+ who: string
345
+ isUpperCase?: boolean
346
+ }`,
347
+ `export interface HomeEntity extends BaseEntity {
348
+ type: "Home"
349
+ }`,
350
+ ]
351
+ const state = [
352
+ " home: HomeEntity",
353
+ " message1: MessageEntity",
354
+ " message2: MessageEntity",
355
+ " message3: MessageEntity",
356
+ ]
357
+
358
+ if (app.routing) {
359
+ imports.push('import type { RouterEntity } from "@inglorious/web/router"')
360
+ entities.push("AboutEntity", "AppRouterEntity")
361
+ declarations.push(
362
+ `export interface AboutEntity extends BaseEntity {
363
+ type: "About"
364
+ }`,
365
+ `export interface AppRouterEntity extends BaseEntity, Omit<RouterEntity, "id"> {
366
+ type: "Router"
367
+ }`,
368
+ )
369
+ state.unshift(" router: AppRouterEntity")
370
+ state.push(" about: AboutEntity")
371
+ }
372
+ if (app.i18n) {
373
+ imports.push('import type { I18nEntity } from "@inglorious/i18n"')
374
+ entities.push("AppI18nEntity")
375
+ declarations.push(
376
+ `export interface AppI18nEntity extends BaseEntity, Omit<I18nEntity, "id"> {
377
+ type: "I18n"
378
+ }`,
379
+ )
380
+ state.unshift(" i18n: AppI18nEntity")
381
+ }
382
+
383
+ return `${importBlock(imports)}
384
+
385
+ ${declarations.join("\n\n")}
386
+
387
+ // Union type of all entities
388
+ ${union("AppEntity", entities)}
389
+
390
+ // State type with known entity IDs
391
+ export interface AppState extends EntitiesState<AppEntity> {
392
+ ${state.join("\n")}
393
+ }
394
+
395
+ export type AppApi = Api<AppEntity, AppState>
396
+
397
+ export interface MessageType {
398
+ click: (entity: MessageEntity) => void
399
+ render: (entity: MessageEntity, api: AppApi) => TemplateResult | null
400
+ }
401
+ `
402
+ }
package/bin/lib/cli.js CHANGED
@@ -2,6 +2,13 @@ export function parseCliOptions(args) {
2
2
  return {
3
3
  mobile: readBooleanFlag(args, "mobile"),
4
4
  pwa: readBooleanFlag(args, "pwa"),
5
+ ui: readBooleanFlag(args, "ui"),
6
+ theme: readValueFlag(args, "theme"),
7
+ routing: readBooleanFlag(args, "routing"),
8
+ transitions: readBooleanFlag(args, "transitions"),
9
+ icons: readListFlag(args, "icons"),
10
+ i18n: readBooleanFlag(args, "i18n"),
11
+ languages: readListFlag(args, "languages"),
5
12
  }
6
13
  }
7
14
 
@@ -16,3 +23,27 @@ export function readBooleanFlag(args, name) {
16
23
 
17
24
  return null
18
25
  }
26
+
27
+ // `--name=value`, or null when the flag is not given.
28
+ export function readValueFlag(args, name) {
29
+ const prefix = `--${name}=`
30
+ const arg = args.find((value) => value.startsWith(prefix))
31
+ return arg ? arg.slice(prefix.length) : null
32
+ }
33
+
34
+ // `--name=a,b`, `--no-name` for an empty list, or null when neither is given.
35
+ export function readListFlag(args, name) {
36
+ if (args.includes(`--no-${name}`)) {
37
+ return []
38
+ }
39
+
40
+ const value = readValueFlag(args, name)
41
+ if (value === null) {
42
+ return null
43
+ }
44
+
45
+ return value
46
+ .split(",")
47
+ .map((item) => item.trim())
48
+ .filter(Boolean)
49
+ }
@@ -1,6 +1,6 @@
1
- export const CAPACITOR_VERSION = "^8.5.1"
2
- export const VITE_PLUGIN_PWA_VERSION = "^1.3.0"
3
- export const VITE_PWA_ASSETS_GENERATOR_VERSION = "^1.0.2"
1
+ export const CAPACITOR_VERSION = "^8.5.3"
2
+ export const VITE_PLUGIN_PWA_VERSION = "^2.0.0"
3
+ export const VITE_PWA_ASSETS_GENERATOR_VERSION = "^2.0.0"
4
4
  export const TYPESCRIPT_VERSION = "^7.0.2"
5
5
  export const WORKBOX_WINDOW_VERSION = "^7.4.1"
6
6
 
@@ -0,0 +1,142 @@
1
+ // The choices create-app offers for lit-html apps, and what each one brings.
2
+
3
+ export const THEMES = {
4
+ inglorious: { title: "Inglorious", description: "Neon, videogame-inspired" },
5
+ material: { title: "Material", description: "Material Design inspired" },
6
+ bootstrap: { title: "Bootstrap", description: "Bootstrap inspired" },
7
+ }
8
+
9
+ // Icon sets from Iconify: one module per icon, so only the icons an app
10
+ // imports are bundled. `nav` names the icons for the two example pages,
11
+ // `showcase` the one shown on the home page.
12
+ export const ICON_PACKS = {
13
+ material: {
14
+ title: "Material Symbols",
15
+ packages: { "@iconify-icons/material-symbols": "^2.0.6" },
16
+ nav: {
17
+ home: from("homeOutline", "@iconify-icons/material-symbols/home-outline"),
18
+ about: from(
19
+ "infoOutline",
20
+ "@iconify-icons/material-symbols/info-outline",
21
+ ),
22
+ },
23
+ showcase: {
24
+ ...from(
25
+ "favoriteOutline",
26
+ "@iconify-icons/material-symbols/favorite-outline",
27
+ ),
28
+ label: "Material Symbols",
29
+ },
30
+ },
31
+ fontawesome: {
32
+ title: "Font Awesome",
33
+ packages: {
34
+ "@iconify-icons/fa7-solid": "^2.0.0",
35
+ "@iconify-icons/fa7-regular": "^2.0.0",
36
+ "@iconify-icons/fa7-brands": "^2.0.0",
37
+ },
38
+ nav: {
39
+ home: from("house", "@iconify-icons/fa7-solid/house"),
40
+ about: from("circleInfo", "@iconify-icons/fa7-solid/circle-info"),
41
+ },
42
+ showcase: {
43
+ ...from("fontAwesome", "@iconify-icons/fa7-brands/font-awesome"),
44
+ label: "Font Awesome",
45
+ },
46
+ },
47
+ lucide: {
48
+ title: "Lucide",
49
+ packages: { "@iconify-icons/lucide": "^2.0.22" },
50
+ nav: {
51
+ home: from("house", "@iconify-icons/lucide/house"),
52
+ about: from("info", "@iconify-icons/lucide/info"),
53
+ },
54
+ showcase: {
55
+ ...from("sparkles", "@iconify-icons/lucide/sparkles"),
56
+ label: "Lucide",
57
+ },
58
+ },
59
+ }
60
+
61
+ // The languages offered for the example app, with its few texts in each.
62
+ export const LANGUAGES = {
63
+ en: language("English", {
64
+ nav: { home: "Home", about: "About" },
65
+ language: "Language",
66
+ home: { title: "Welcome", hint: "Click a greeting to shout it." },
67
+ about: { title: "About", body: "A page of its own, at its own address." },
68
+ message: { hello: "Hello {{who}}" },
69
+ }),
70
+ it: language("Italiano", {
71
+ nav: { home: "Home", about: "Info" },
72
+ language: "Lingua",
73
+ home: { title: "Benvenuto", hint: "Clicca un saluto per urlarlo." },
74
+ about: { title: "Info", body: "Una pagina tutta sua, al suo indirizzo." },
75
+ message: { hello: "Ciao {{who}}" },
76
+ }),
77
+ fr: language("Français", {
78
+ nav: { home: "Accueil", about: "À propos" },
79
+ language: "Langue",
80
+ home: { title: "Bienvenue", hint: "Cliquez sur un salut pour le crier." },
81
+ about: {
82
+ title: "À propos",
83
+ body: "Une page à part entière, à sa propre adresse.",
84
+ },
85
+ message: { hello: "Bonjour {{who}}" },
86
+ }),
87
+ de: language("Deutsch", {
88
+ nav: { home: "Start", about: "Über" },
89
+ language: "Sprache",
90
+ home: {
91
+ title: "Willkommen",
92
+ hint: "Klicke auf einen Gruß, um ihn zu rufen.",
93
+ },
94
+ about: { title: "Über", body: "Eine eigene Seite unter eigener Adresse." },
95
+ message: { hello: "Hallo {{who}}" },
96
+ }),
97
+ es: language("Español", {
98
+ nav: { home: "Inicio", about: "Acerca de" },
99
+ language: "Idioma",
100
+ home: { title: "Bienvenido", hint: "Haz clic en un saludo para gritarlo." },
101
+ about: {
102
+ title: "Acerca de",
103
+ body: "Una página propia, en su propia dirección.",
104
+ },
105
+ message: { hello: "Hola {{who}}" },
106
+ }),
107
+ pt: language("Português", {
108
+ nav: { home: "Início", about: "Sobre" },
109
+ language: "Idioma",
110
+ home: { title: "Bem-vindo", hint: "Clique numa saudação para gritá-la." },
111
+ about: {
112
+ title: "Sobre",
113
+ body: "Uma página própria, no seu próprio endereço.",
114
+ },
115
+ message: { hello: "Olá {{who}}" },
116
+ }),
117
+ ja: language("日本語", {
118
+ nav: { home: "ホーム", about: "概要" },
119
+ language: "言語",
120
+ home: {
121
+ title: "ようこそ",
122
+ hint: "あいさつをクリックすると大きくなります。",
123
+ },
124
+ about: { title: "概要", body: "専用のアドレスを持つ独立したページです。" },
125
+ message: { hello: "こんにちは、{{who}}" },
126
+ }),
127
+ ar: language("العربية", {
128
+ nav: { home: "الرئيسية", about: "حول" },
129
+ language: "اللغة",
130
+ home: { title: "مرحبًا", hint: "انقر على تحية لتكبيرها." },
131
+ about: { title: "حول", body: "صفحة مستقلة بعنوانها الخاص." },
132
+ message: { hello: "مرحبًا {{who}}" },
133
+ }),
134
+ }
135
+
136
+ function from(name, source) {
137
+ return { name, from: source }
138
+ }
139
+
140
+ function language(title, texts) {
141
+ return { title, texts }
142
+ }
@@ -0,0 +1,64 @@
1
+ // Import statements in the order eslint-plugin-simple-import-sort gives them,
2
+ // so a generated project passes `eslint` as it is: side-effect imports first,
3
+ // in the order given, then packages, then relative modules, a blank line
4
+ // between groups.
5
+
6
+ const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
7
+
8
+ /**
9
+ * @param {string[]} lines - One import statement each.
10
+ * @returns {string}
11
+ */
12
+ export function importBlock(lines) {
13
+ const sideEffects = []
14
+ const packages = []
15
+ const relative = []
16
+
17
+ for (const line of lines) {
18
+ const source = sourceOf(line)
19
+ if (!line.includes(" from ")) sideEffects.push(line)
20
+ else if (source.startsWith(".")) relative.push(line)
21
+ else packages.push(line)
22
+ }
23
+
24
+ return [sideEffects, sortImports(packages), sortImports(relative)]
25
+ .filter((group) => group.length)
26
+ .map((group) => group.join("\n"))
27
+ .join("\n\n")
28
+ }
29
+
30
+ function sortImports(lines) {
31
+ return lines
32
+ .map((line) => ({ line, source: sourceOf(line) }))
33
+ .sort(
34
+ (a, b) =>
35
+ compare(normalize(a.source), normalize(b.source)) ||
36
+ compare(a.source, b.source) ||
37
+ // Type imports before regular ones from the same module.
38
+ Number(b.line.startsWith("import type")) -
39
+ Number(a.line.startsWith("import type")),
40
+ )
41
+ .map(({ line }) => line)
42
+ }
43
+
44
+ // The plugin's own normalization: by directory level, `.` and `/` before any
45
+ // other punctuation.
46
+ function normalize(source) {
47
+ return source
48
+ .replace(/^[./]*\.$/, "$&/")
49
+ .replace(/^[./]*\/$/, "$&,")
50
+ .replace(
51
+ /[./_-]/g,
52
+ (char) => ({ ".": "_", "/": "-", _: ".", "-": "/" })[char],
53
+ )
54
+ }
55
+
56
+ function compare(a, b) {
57
+ return collator.compare(a, b) || Number(a > b) - Number(a < b)
58
+ }
59
+
60
+ // The module an import statement imports from.
61
+ function sourceOf(line) {
62
+ const [, source] = line.match(/"([^"]+)"/)
63
+ return source
64
+ }
package/bin/lib/mobile.js CHANGED
@@ -14,7 +14,9 @@ export function addMobileSupport(targetDir, pkg, projectName) {
14
14
  pkg.devDependencies["@capacitor/android"] = CAPACITOR_VERSION
15
15
  pkg.devDependencies["@capacitor/cli"] = CAPACITOR_VERSION
16
16
  pkg.devDependencies["@capacitor/ios"] = CAPACITOR_VERSION
17
- pkg.devDependencies["typescript"] = TYPESCRIPT_VERSION
17
+ // For capacitor.config.ts. A TypeScript template already has its own, which
18
+ // its code is written against.
19
+ pkg.devDependencies["typescript"] ??= TYPESCRIPT_VERSION
18
20
 
19
21
  pkg.scripts.cap = "cap"
20
22
  pkg.scripts["mobile:init:ios"] = "cap add ios"
@@ -27,10 +29,26 @@ export function addMobileSupport(targetDir, pkg, projectName) {
27
29
  path.join(targetDir, "capacitor.config.ts"),
28
30
  createCapacitorConfigSource(projectName),
29
31
  )
32
+ includeInNodeProject(targetDir, "capacitor.config.ts")
30
33
 
31
34
  appendMobileSetupToReadme(targetDir)
32
35
  }
33
36
 
37
+ // A TypeScript template type-checks and lints its config files through
38
+ // tsconfig.node.json, so a new one has to be listed there. Edited as text, so
39
+ // the `include` line keeps the one-line layout Prettier gives it.
40
+ function includeInNodeProject(targetDir, file) {
41
+ const tsconfigPath = path.join(targetDir, "tsconfig.node.json")
42
+ if (!fs.existsSync(tsconfigPath)) return
43
+
44
+ const tsconfig = fs.readFileSync(tsconfigPath, "utf-8")
45
+ if (tsconfig.includes(`"${file}"`)) return
46
+ fs.writeFileSync(
47
+ tsconfigPath,
48
+ tsconfig.replace(/("include": \[[^\]]*)\]/, `$1, "${file}"]`),
49
+ )
50
+ }
51
+
34
52
  export function appendMobileSetupToReadme(targetDir) {
35
53
  const readmePath = path.join(targetDir, "README.md")
36
54
  if (!fs.existsSync(readmePath)) {