@inglorious/create-app 1.7.0 → 1.8.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 (59) hide show
  1. package/README.md +6 -2
  2. package/bin/index.js +19 -4
  3. package/bin/lib/app-jsx.js +0 -11
  4. package/bin/lib/app-lit.js +0 -5
  5. package/bin/lib/app-vue.js +0 -6
  6. package/bin/lib/app.js +128 -35
  7. package/bin/lib/cli.js +2 -2
  8. package/bin/lib/constants.js +3 -2
  9. package/bin/lib/features.js +103 -6
  10. package/bin/lib/firebase.js +177 -0
  11. package/bin/lib/imports.js +0 -9
  12. package/bin/lib/mobile.js +0 -5
  13. package/bin/lib/prompts.js +311 -0
  14. package/bin/lib/pwa.js +21 -16
  15. package/bin/lib/questions.js +29 -7
  16. package/bin/lib/skills.js +11 -2
  17. package/bin/lib/source.js +4 -11
  18. package/package.json +1 -1
  19. package/templates/features/analytics/scripts/analytics-off-native.mjs +31 -0
  20. package/templates/features/push/public/sw-push.js +23 -0
  21. package/templates/features/push/scripts/push-vault-secrets.mjs +60 -0
  22. package/templates/features/push/supabase/functions/deno.json +6 -0
  23. package/templates/features/push/supabase/functions/push-notify/index.ts +187 -0
  24. package/templates/features/push/supabase/migrations/push.sql +115 -0
  25. package/templates/features/push/supabase/seed.sql +20 -0
  26. package/templates/js/package.json +1 -1
  27. package/templates/js/vite.config.js +0 -1
  28. package/templates/js-jsx/package.json +1 -1
  29. package/templates/js-jsx/vite.config.js +0 -1
  30. package/templates/js-vue/package.json +1 -1
  31. package/templates/js-vue/vite.config.js +0 -1
  32. package/templates/ssx-js/package.json +1 -1
  33. package/templates/ssx-js/src/site.config.js +0 -1
  34. package/templates/ssx-js-jsx/package.json +1 -1
  35. package/templates/ssx-js-jsx/src/site.config.js +0 -1
  36. package/templates/ssx-js-vue/package.json +1 -1
  37. package/templates/ssx-js-vue/src/pages/about.js +0 -1
  38. package/templates/ssx-js-vue/src/pages/index.js +0 -1
  39. package/templates/ssx-js-vue/src/site.config.js +0 -1
  40. package/templates/ssx-ts/package.json +1 -1
  41. package/templates/ssx-ts/src/site.config.ts +0 -1
  42. package/templates/ssx-ts/types/index.d.ts +0 -7
  43. package/templates/ssx-ts-jsx/package.json +1 -1
  44. package/templates/ssx-ts-jsx/src/site.config.ts +0 -1
  45. package/templates/ssx-ts-jsx/types/index.d.ts +0 -7
  46. package/templates/ssx-ts-vue/package.json +1 -1
  47. package/templates/ssx-ts-vue/src/pages/about.ts +0 -1
  48. package/templates/ssx-ts-vue/src/pages/index.ts +0 -1
  49. package/templates/ssx-ts-vue/src/site.config.ts +0 -1
  50. package/templates/ssx-ts-vue/types/index.d.ts +0 -6
  51. package/templates/ts/package.json +1 -1
  52. package/templates/ts/types/index.d.ts +0 -7
  53. package/templates/ts/vite.config.ts +0 -2
  54. package/templates/ts-jsx/package.json +1 -1
  55. package/templates/ts-jsx/types/index.d.ts +0 -7
  56. package/templates/ts-jsx/vite.config.ts +0 -2
  57. package/templates/ts-vue/package.json +1 -1
  58. package/templates/ts-vue/types/index.d.ts +0 -6
  59. package/templates/ts-vue/vite.config.ts +0 -2
@@ -0,0 +1,177 @@
1
+ import fs from "fs"
2
+ import { EOL } from "os"
3
+ import path from "path"
4
+
5
+ import { FIRST_ITEM_INDEX } from "./constants.js"
6
+ import { copyDir } from "./file.js"
7
+
8
+ const MIGRATION_STAMP_LENGTH = 14
9
+ const FIRST_NUMBER = 1
10
+
11
+ /**
12
+ * Adds what analytics and push need outside src/: the service worker, the
13
+ * Supabase side of push, the hook that keeps native apps from measuring before
14
+ * consent, and the setup steps in the README.
15
+ *
16
+ * @param {string} targetDir
17
+ * @param {object} pkg - The package.json being built; scripts are added.
18
+ * @param {object} options
19
+ * @param {string} options.templatesRoot
20
+ * @param {boolean} options.analytics
21
+ * @param {boolean} options.push
22
+ * @param {boolean} options.mobile
23
+ */
24
+ export function addFirebaseSupport(targetDir, pkg, options) {
25
+ const { templatesRoot, analytics, push, mobile } = options
26
+ pkg.scripts ??= {}
27
+
28
+ if (push) {
29
+ copyDir(path.join(templatesRoot, "features/push"), targetDir)
30
+ stampMigration(targetDir)
31
+ pkg.scripts["vault:push"] = "node scripts/push-vault-secrets.mjs"
32
+ keepSupabaseOutOfTypeScript(targetDir)
33
+ }
34
+
35
+ if (analytics && mobile) {
36
+ copyDir(path.join(templatesRoot, "features/analytics"), targetDir)
37
+ pkg.scripts["capacitor:sync:after"] =
38
+ "node scripts/analytics-off-native.mjs"
39
+ }
40
+
41
+ appendSetupToReadme(targetDir, options)
42
+ }
43
+
44
+ // The Deno function is in no TS project, and Supabase's typings do not compile.
45
+ function keepSupabaseOutOfTypeScript(targetDir) {
46
+ replaceInFile(
47
+ path.join(targetDir, "eslint.config.ts"),
48
+ "defineConfig([\n",
49
+ 'defineConfig([\n { ignores: ["supabase/"] },\n',
50
+ )
51
+ replaceInFile(
52
+ path.join(targetDir, "tsconfig.app.json"),
53
+ '"noEmit": true,',
54
+ '"noEmit": true,\n "skipLibCheck": true,',
55
+ )
56
+ }
57
+
58
+ function replaceInFile(file, search, replacement) {
59
+ if (!fs.existsSync(file)) return
60
+ fs.writeFileSync(
61
+ file,
62
+ fs.readFileSync(file, "utf-8").replace(search, replacement),
63
+ )
64
+ }
65
+
66
+ function stampMigration(targetDir) {
67
+ const migrations = path.join(targetDir, "supabase/migrations")
68
+ const stamp = new Date()
69
+ .toISOString()
70
+ .replace(/\D/g, "")
71
+ .slice(FIRST_ITEM_INDEX, MIGRATION_STAMP_LENGTH)
72
+ fs.renameSync(
73
+ path.join(migrations, "push.sql"),
74
+ path.join(migrations, `${stamp}_push.sql`),
75
+ )
76
+ }
77
+
78
+ function appendSetupToReadme(targetDir, { analytics, push, mobile }) {
79
+ const readmePath = path.join(targetDir, "README.md")
80
+ if (!fs.existsSync(readmePath)) return
81
+
82
+ const sections = [firebaseSection({ analytics, push, mobile })]
83
+ if (analytics) sections.push(analyticsSection(mobile))
84
+ if (push) sections.push(pushSection(mobile))
85
+
86
+ const readme = fs.readFileSync(readmePath, "utf-8")
87
+ fs.writeFileSync(
88
+ readmePath,
89
+ `${readme.trimEnd()}${EOL}${EOL}${sections.join(EOL + EOL)}${EOL}`,
90
+ )
91
+ }
92
+
93
+ function firebaseSection({ analytics, push, mobile }) {
94
+ const steps = [
95
+ "Create a project in the [Firebase console](https://console.firebase.google.com) and add a web app to it.",
96
+ "Copy `.env.example` to `.env.local` and fill it in from Project settings → General → your web app. These values end up in the built app, so they are not secret.",
97
+ ]
98
+ if (analytics) {
99
+ steps.push(
100
+ "`VITE_FIREBASE_MEASUREMENT_ID` is the web app's `measurementId`. Do not paste Google's `gtag.js` snippet into `index.html`: it would start before anyone agreed.",
101
+ )
102
+ }
103
+ if (push) {
104
+ steps.push(
105
+ "`VITE_FIREBASE_VAPID_KEY` is the public half of a Web Push key pair, from Project settings → Cloud Messaging → Web configuration.",
106
+ )
107
+ }
108
+ if (mobile) {
109
+ steps.push(
110
+ "Add an Android and an iOS app too, then put `google-services.json` in `android/app/` and add `GoogleService-Info.plist` to the App target in Xcode, once `pnpm mobile:init:android` and `pnpm mobile:init:ios` have created the native projects. iOS needs some more setup: see the [Capacitor Firebase plugins](https://github.com/capawesome-team/capacitor-firebase).",
111
+ )
112
+ }
113
+ return `## Firebase Setup
114
+
115
+ ${numbered(steps)}`
116
+ }
117
+
118
+ function analyticsSection(mobile) {
119
+ const native = mobile
120
+ ? `
121
+
122
+ The native apps must start with collection off, or they measure from launch. \`scripts/analytics-off-native.mjs\` writes that setting into the native projects after every \`cap sync\`.`
123
+ : ""
124
+ return `## Analytics
125
+
126
+ Usage is measured with Google Analytics for Firebase, and only for people who said yes: until then, no analytics code loads. Check that your privacy policy covers it, naming Google as the processor.
127
+
128
+ Page views follow the router by themselves. The app's own events go through the entity, and are dropped without consent:
129
+
130
+ \`\`\`js
131
+ api.notify("#analytics:analyticsTrack", { name: "sign_up" })
132
+ \`\`\`
133
+
134
+ To let people change their answer, for example from a settings page, notify \`#analytics:analyticsAsk\` to bring the prompt back.${native}`
135
+ }
136
+
137
+ function pushSection(mobile) {
138
+ const devices = mobile ? "the PWA and the native apps" : "the PWA"
139
+ return `## Push Notifications
140
+
141
+ Pushes reach ${devices} through Firebase Cloud Messaging. Supabase keeps each device's token and sends: inserting a row in \`notifications\` is what sends a push.
142
+
143
+ \`\`\`sql
144
+ insert into public.notifications (user_id, title, body, url)
145
+ values ('<user id>', 'Hello', 'You have a new message', '/');
146
+ \`\`\`
147
+
148
+ The permission prompt appears once someone signs in with Supabase Auth, so the app needs a sign-in screen. To sign out, call \`signOut\` from \`src/api/push\`, which deletes the device's token first.
149
+
150
+ To set it up, with the [Supabase CLI](https://supabase.com/docs/guides/cli) installed:
151
+
152
+ ${numbered([
153
+ "In the Firebase console, go to Project settings → Service accounts → Generate new private key. The file is a real secret: never commit it.",
154
+ "In the Google Cloud console, give that service account the **Firebase Cloud Messaging API Admin** role. The similarly named Firebase Cloud Messaging Admin cannot send.",
155
+ "Run `supabase init` here, then `supabase start` to try it locally, with the file's JSON on one line as `FCM_SERVICE_ACCOUNT` in `supabase/functions/.env`. `supabase status` gives the values of `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY`.",
156
+ ])}
157
+
158
+ To deploy:
159
+
160
+ \`\`\`bash
161
+ supabase link --project-ref <your project ref>
162
+ supabase db push
163
+ supabase secrets set FCM_SERVICE_ACCOUNT="$(cat path/to/service-account.json)"
164
+ supabase functions deploy push-notify
165
+ pnpm vault:push
166
+ \`\`\`
167
+
168
+ \`pnpm vault:push\` tells the database how to reach the function. It stores the project's legacy \`service_role\` key in Vault, which is the key \`push-notify\` can read a role from.
169
+
170
+ A browser's service worker only exists in a build, so try web pushes with \`pnpm build && pnpm preview\`.`
171
+ }
172
+
173
+ function numbered(steps) {
174
+ return steps
175
+ .map((step, index) => `${FIRST_NUMBER + index}. ${step}`)
176
+ .join("\n")
177
+ }
@@ -1,8 +1,3 @@
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
1
  const collator = new Intl.Collator("en", { sensitivity: "base", numeric: true })
7
2
 
8
3
  /**
@@ -34,15 +29,12 @@ function sortImports(lines) {
34
29
  (a, b) =>
35
30
  compare(normalize(a.source), normalize(b.source)) ||
36
31
  compare(a.source, b.source) ||
37
- // Type imports before regular ones from the same module.
38
32
  Number(b.line.startsWith("import type")) -
39
33
  Number(a.line.startsWith("import type")),
40
34
  )
41
35
  .map(({ line }) => line)
42
36
  }
43
37
 
44
- // The plugin's own normalization: by directory level, `.` and `/` before any
45
- // other punctuation.
46
38
  function normalize(source) {
47
39
  return source
48
40
  .replace(/^[./]*\.$/, "$&/")
@@ -57,7 +49,6 @@ function compare(a, b) {
57
49
  return collator.compare(a, b) || Number(a > b) - Number(a < b)
58
50
  }
59
51
 
60
- // The module an import statement imports from.
61
52
  function sourceOf(line) {
62
53
  const [, source] = line.match(/"([^"]+)"/)
63
54
  return source
package/bin/lib/mobile.js CHANGED
@@ -14,8 +14,6 @@ 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
- // For capacitor.config.ts. A TypeScript template already has its own, which
18
- // its code is written against.
19
17
  pkg.devDependencies["typescript"] ??= TYPESCRIPT_VERSION
20
18
 
21
19
  pkg.scripts.cap = "cap"
@@ -124,9 +122,6 @@ export default config
124
122
  `
125
123
  }
126
124
 
127
- // A TypeScript template type-checks and lints its config files through
128
- // tsconfig.node.json, so a new one has to be listed there. Edited as text, so
129
- // the `include` line keeps the one-line layout Prettier gives it.
130
125
  function includeInNodeProject(targetDir, file) {
131
126
  const tsconfigPath = path.join(targetDir, "tsconfig.node.json")
132
127
  if (!fs.existsSync(tsconfigPath)) return
@@ -0,0 +1,311 @@
1
+ import { importBlock } from "./imports.js"
2
+ import { LINE_WIDTH } from "./source.js"
3
+
4
+ const FIREBASE_CONFIG_KEYS = {
5
+ apiKey: "VITE_FIREBASE_API_KEY",
6
+ projectId: "VITE_FIREBASE_PROJECT_ID",
7
+ messagingSenderId: "VITE_FIREBASE_MESSAGING_SENDER_ID",
8
+ appId: "VITE_FIREBASE_APP_ID",
9
+ }
10
+
11
+ /**
12
+ * The source files of the analytics and push prompts. They are lit-html in
13
+ * every syntax: src/main renders them next to the app, whatever it is written
14
+ * in.
15
+ *
16
+ * @param {{ analytics: boolean, push: boolean, ui: boolean, i18n: boolean, ext: string, isTypeScript: boolean, projectName: string }} app
17
+ * @returns {Record<string, string>} Sources by file path.
18
+ */
19
+ export function promptFiles(app) {
20
+ const files = {
21
+ [`src/api/firebase.${app.ext}`]: firebaseSource(app),
22
+ [`src/common/prompt.${app.ext}`]: promptSource(app),
23
+ }
24
+ if (app.analytics) {
25
+ files[`src/types/analytics.${app.ext}`] = analyticsSource(app)
26
+ }
27
+ if (app.push) {
28
+ files[`src/api/supabase.${app.ext}`] = supabaseSource()
29
+ files[`src/api/push.${app.ext}`] = pushSource(app)
30
+ files[`src/types/push-prompt.${app.ext}`] = pushPromptSource(app)
31
+ }
32
+ return files
33
+ }
34
+
35
+ export function envExample(app) {
36
+ const keys = Object.values(FIREBASE_CONFIG_KEYS)
37
+ if (app.analytics) keys.push("VITE_FIREBASE_MEASUREMENT_ID")
38
+ if (app.push) {
39
+ keys.push(
40
+ "VITE_FIREBASE_VAPID_KEY",
41
+ "VITE_SUPABASE_URL",
42
+ "VITE_SUPABASE_ANON_KEY",
43
+ )
44
+ }
45
+ return keys.map((key) => `${key}=`).join("\n") + "\n"
46
+ }
47
+
48
+ function firebaseSource(app) {
49
+ const entries = Object.entries(FIREBASE_CONFIG_KEYS).map(
50
+ ([key, env]) => ` ${key}: import.meta.env.${env},`,
51
+ )
52
+ if (app.analytics) {
53
+ entries.push(
54
+ " measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID,",
55
+ )
56
+ }
57
+ return `export const firebaseConfig = {
58
+ ${entries.join("\n")}
59
+ }
60
+ `
61
+ }
62
+
63
+ function promptSource(app) {
64
+ const props = app.isTypeScript
65
+ ? `interface PromptProps {
66
+ message: string
67
+ askLabel: string
68
+ declineLabel: string
69
+ onAsk: () => void
70
+ onDecline: () => void
71
+ }
72
+
73
+ `
74
+ : ""
75
+ const annotation = app.isTypeScript ? ": PromptProps" : ""
76
+ const params = `{ message, askLabel, declineLabel, onAsk, onDecline }${annotation}`
77
+ const signature =
78
+ ` render(${params}) {`.length <= LINE_WIDTH
79
+ ? ` render(${params}) {`
80
+ : ` render({
81
+ message,
82
+ askLabel,
83
+ declineLabel,
84
+ onAsk,
85
+ onDecline,
86
+ }${annotation}) {`
87
+
88
+ if (app.ui) {
89
+ return `${importBlock(
90
+ promptImports(app, [
91
+ 'import { Button } from "@inglorious/ui/button"',
92
+ 'import { Flex } from "@inglorious/ui/flex"',
93
+ 'import { Snackbar } from "@inglorious/ui/snackbar"',
94
+ ]),
95
+ )}
96
+
97
+ ${props}export const Prompt = {
98
+ ${signature}
99
+ return Snackbar.render({
100
+ isOpen: true,
101
+ position: "bottom-center",
102
+ message,
103
+ action: Flex.render({
104
+ direction: "row",
105
+ align: "center",
106
+ gap: "sm",
107
+ children: [
108
+ Button.render({
109
+ children: askLabel,
110
+ color: "primary",
111
+ size: "sm",
112
+ onClick: onAsk,
113
+ }),
114
+ Button.render({
115
+ children: declineLabel,
116
+ variant: "ghost",
117
+ size: "sm",
118
+ onClick: onDecline,
119
+ }),
120
+ ],
121
+ }),
122
+ })
123
+ },
124
+ }
125
+ ${renderPromptsSource(app)}`
126
+ }
127
+
128
+ return `${importBlock(promptImports(app, []))}
129
+
130
+ ${props}export const Prompt = {
131
+ ${signature}
132
+ return html\`<div class="app-prompt">
133
+ <p>\${message}</p>
134
+ <button @click=\${onAsk}>\${askLabel}</button>
135
+ <button @click=\${onDecline}>\${declineLabel}</button>
136
+ </div>\`
137
+ },
138
+ }
139
+ ${renderPromptsSource(app)}`
140
+ }
141
+
142
+ function promptImports(app, imports) {
143
+ return [
144
+ ...imports,
145
+ 'import { html } from "@inglorious/web"',
146
+ ...(app.isTypeScript ? ['import type { AppApi } from "../../types"'] : []),
147
+ ]
148
+ }
149
+
150
+ // Prettier moves a value to its own line when the property would not fit.
151
+ function prop(name, value, indent) {
152
+ const line = `${indent}${name}: ${value},`
153
+ if (line.length <= LINE_WIDTH) return line
154
+ return `${indent}${name}:\n${indent} ${value},`
155
+ }
156
+
157
+ function renderPromptsSource(app) {
158
+ const annotation = app.isTypeScript ? ": AppApi" : ""
159
+ const prompts = [
160
+ ...(app.analytics ? ['${api.render("analytics")}'] : []),
161
+ ...(app.push ? ['${api.render("pushPrompt")}'] : []),
162
+ ]
163
+ return `
164
+ export function renderPrompts(api${annotation}) {
165
+ return html\`${prompts.join(" ")}\`
166
+ }
167
+ `
168
+ }
169
+
170
+ function analyticsSource(app) {
171
+ const imports = [
172
+ 'import { firebaseAnalytics } from "@inglorious/firebase/analytics"',
173
+ 'import { createAnalytics } from "@inglorious/web/analytics"',
174
+ 'import { when } from "@inglorious/web/directives/when"',
175
+ 'import { firebaseConfig } from "../api/firebase"',
176
+ 'import { Prompt } from "../common/prompt"',
177
+ ]
178
+ if (app.i18n) imports.push('import { t } from "../i18n"')
179
+ if (app.isTypeScript) {
180
+ imports.push(
181
+ 'import type { AppAnalyticsEntity, AppApi } from "../../types"',
182
+ )
183
+ }
184
+ const label = labelFor(app)
185
+ const params = app.isTypeScript
186
+ ? "entity: AppAnalyticsEntity, api: AppApi"
187
+ : "entity, api"
188
+
189
+ return `${importBlock(imports)}
190
+
191
+ export const Analytics = {
192
+ ...createAnalytics({
193
+ adapter: firebaseAnalytics({ firebase: firebaseConfig }),
194
+ consentKey: "${app.projectName}-analytics-consent",
195
+ }),
196
+
197
+ render(${params}) {
198
+ return when(entity.isAsking, () =>
199
+ Prompt.render({
200
+ ${prop("message", label("analytics.prompt", "May we measure how you use this app? It helps us make it better."), " ")}
201
+ ${prop("askLabel", label("analytics.accept", "Allow"), " ")}
202
+ ${prop("declineLabel", label("analytics.decline", "No thanks"), " ")}
203
+ onAsk: () => api.notify(\`#\${entity.id}:analyticsConsent\`, true),
204
+ onDecline: () => api.notify(\`#\${entity.id}:analyticsConsent\`, false),
205
+ }),
206
+ )
207
+ },
208
+ }
209
+ `
210
+ }
211
+
212
+ function supabaseSource() {
213
+ return `import { createClient } from "@supabase/supabase-js"
214
+
215
+ export const supabase = createClient(
216
+ import.meta.env.VITE_SUPABASE_URL,
217
+ import.meta.env.VITE_SUPABASE_ANON_KEY,
218
+ )
219
+ `
220
+ }
221
+
222
+ function pushSource(app) {
223
+ const token = app.isTypeScript ? "token: string" : "token"
224
+ const platform = app.isTypeScript ? "platform: string" : "platform"
225
+ return `import { firebasePush } from "@inglorious/firebase/push"
226
+
227
+ import { firebaseConfig } from "./firebase"
228
+ import { supabase } from "./supabase"
229
+
230
+ export const push = firebasePush({
231
+ firebase: firebaseConfig,
232
+ vapidKey: import.meta.env.VITE_FIREBASE_VAPID_KEY,
233
+ saveToken,
234
+ deleteToken,
235
+ tokenKey: "${app.projectName}-push-token",
236
+ })
237
+
238
+ async function saveToken(${token}, ${platform}) {
239
+ const { error } = await supabase.rpc("save_push_token", {
240
+ p_token: token,
241
+ p_platform: platform,
242
+ })
243
+ if (error) throw error
244
+ }
245
+
246
+ async function deleteToken(${token}) {
247
+ const { error } = await supabase
248
+ .from("push_tokens")
249
+ .delete()
250
+ .eq("token", token)
251
+ if (error) throw error
252
+ }
253
+
254
+ // The token goes first: only its signed-in user may delete it.
255
+ export async function signOut() {
256
+ await push.unregister().catch(() => {})
257
+ await supabase.auth.signOut()
258
+ }
259
+ `
260
+ }
261
+
262
+ function pushPromptSource(app) {
263
+ const imports = [
264
+ 'import { when } from "@inglorious/web/directives/when"',
265
+ 'import { createPushPrompt } from "@inglorious/web/push"',
266
+ 'import { push } from "../api/push"',
267
+ 'import { Prompt } from "../common/prompt"',
268
+ ]
269
+ if (app.i18n) imports.push('import { t } from "../i18n"')
270
+ if (app.isTypeScript) {
271
+ imports.push(
272
+ 'import type { AppApi, AppPushPromptEntity } from "../../types"',
273
+ )
274
+ }
275
+ const label = labelFor(app)
276
+ const params = app.isTypeScript
277
+ ? "entity: AppPushPromptEntity, api: AppApi"
278
+ : "entity, api"
279
+ const isShown = app.analytics
280
+ ? `entity.isOpen && !api.getEntity("analytics")${app.isTypeScript ? "!" : ""}.isAsking`
281
+ : "entity.isOpen"
282
+ const open = app.analytics
283
+ ? " // One question at a time, the consent first.\n"
284
+ : ""
285
+
286
+ return `${importBlock(imports)}
287
+
288
+ export const PushPrompt = {
289
+ ...createPushPrompt({
290
+ adapter: push,
291
+ declineKey: "${app.projectName}-push-declined",
292
+ }),
293
+
294
+ render(${params}) {
295
+ ${open} return when(${isShown}, () =>
296
+ Prompt.render({
297
+ message: ${label("push.prompt", "Turn on push notifications?")},
298
+ askLabel: ${label("push.ask", "Notify me")},
299
+ declineLabel: ${label("push.decline", "Not now")},
300
+ onAsk: () => api.notify(\`#\${entity.id}:pushAsk\`),
301
+ onDecline: () => api.notify(\`#\${entity.id}:pushDecline\`),
302
+ }),
303
+ )
304
+ },
305
+ }
306
+ `
307
+ }
308
+
309
+ function labelFor(app) {
310
+ return (key, text) => (app.i18n ? `t("${key}")` : JSON.stringify(text))
311
+ }
package/bin/lib/pwa.js CHANGED
@@ -9,7 +9,12 @@ import {
9
9
  } from "./constants.js"
10
10
  import { findMainPath, findViteConfigPath, injectAfterImport } from "./file.js"
11
11
 
12
- export function addPwaSupport(targetDir, pkg, projectName) {
12
+ export function addPwaSupport(
13
+ targetDir,
14
+ pkg,
15
+ projectName,
16
+ { push = false } = {},
17
+ ) {
13
18
  pkg.devDependencies ??= {}
14
19
  pkg.devDependencies["vite-plugin-pwa"] = VITE_PLUGIN_PWA_VERSION
15
20
  pkg.devDependencies["@vite-pwa/assets-generator"] =
@@ -19,7 +24,7 @@ export function addPwaSupport(targetDir, pkg, projectName) {
19
24
  const publicDir = path.join(targetDir, "public")
20
25
  fs.mkdirSync(publicDir, { recursive: true })
21
26
 
22
- injectPwaPluginConfig(targetDir, projectName)
27
+ injectPwaPluginConfig(targetDir, projectName, { push })
23
28
  writePwaRegistration(targetDir)
24
29
  injectServiceWorkerRegistration(targetDir)
25
30
  }
@@ -40,7 +45,11 @@ export function injectServiceWorkerRegistration(targetDir) {
40
45
  fs.writeFileSync(mainPath, main)
41
46
  }
42
47
 
43
- export function injectPwaPluginConfig(targetDir, projectName) {
48
+ export function injectPwaPluginConfig(
49
+ targetDir,
50
+ projectName,
51
+ { push = false } = {},
52
+ ) {
44
53
  const viteConfigPath = findViteConfigPath(targetDir)
45
54
 
46
55
  if (!viteConfigPath) {
@@ -50,8 +59,6 @@ export function injectPwaPluginConfig(targetDir, projectName) {
50
59
  let viteConfig = fs.readFileSync(viteConfigPath, "utf-8")
51
60
 
52
61
  if (!viteConfig.includes("vite-plugin-pwa")) {
53
- // Right after the image optimizer, where the imports' alphabetical order
54
- // puts it.
55
62
  viteConfig = injectAfterImport(
56
63
  viteConfig,
57
64
  viteConfig.includes("vite-plugin-image-optimizer")
@@ -62,12 +69,11 @@ export function injectPwaPluginConfig(targetDir, projectName) {
62
69
  }
63
70
 
64
71
  if (!viteConfig.includes("VitePWA(")) {
65
- // One plugin per line, as Prettier prints a list this long.
66
72
  viteConfig = viteConfig.replace(
67
73
  /plugins: \[(.*)\],/,
68
74
  (_, plugins) =>
69
75
  `plugins: [${EOL}${[
70
- createVitePwaConfig(projectName),
76
+ createVitePwaConfig(projectName, { push }),
71
77
  ...splitPlugins(plugins),
72
78
  ]
73
79
  .map((plugin) => ` ${plugin},`)
@@ -91,9 +97,6 @@ export function writePwaRegistration(targetDir) {
91
97
  fs.writeFileSync(pwaPath, createPwaRegistrationSource(extension))
92
98
  }
93
99
 
94
- // The worker waits for the SKIP_WAITING message `watchPwaUpdate` sends: on
95
- // its own, a new version would wait for every tab of the app to close, which
96
- // for a PWA installed on a phone is never.
97
100
  export function createPwaRegistrationSource(extension) {
98
101
  const registration = `import { isNative } from "@inglorious/web/mobile"
99
102
  import { watchPwaUpdate } from "@inglorious/web/pwa"
@@ -118,7 +121,13 @@ ${registration}`
118
121
  return registration
119
122
  }
120
123
 
121
- export function createVitePwaConfig(projectName) {
124
+ export function createVitePwaConfig(projectName, { push = false } = {}) {
125
+ const workbox = push
126
+ ? `
127
+ workbox: {
128
+ importScripts: ["sw-push.js"],
129
+ },`
130
+ : ""
122
131
  return `VitePWA({
123
132
  injectRegister: false,
124
133
  registerType: "prompt",
@@ -134,12 +143,10 @@ export function createVitePwaConfig(projectName) {
134
143
  background_color: "#ffffff",
135
144
  display: "standalone",
136
145
  start_url: "/",
137
- },
146
+ },${workbox}
138
147
  })`
139
148
  }
140
149
 
141
- // The plugins of a one-line `plugins: [a(), b({ c: 1 })]`, split at the
142
- // top-level commas.
143
150
  function splitPlugins(source) {
144
151
  const plugins = []
145
152
  let depth = 0
@@ -158,8 +165,6 @@ function splitPlugins(source) {
158
165
  return plugins
159
166
  }
160
167
 
161
- // A side-effect import goes with the others at the top, in the order they
162
- // come, as eslint-plugin-simple-import-sort keeps them.
163
168
  function injectSideEffectImport(source, newImport) {
164
169
  const lastSideEffect = source
165
170
  .split(EOL)