@inglorious/create-app 1.7.1 → 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.
- package/README.md +6 -2
- package/bin/index.js +19 -4
- package/bin/lib/app-jsx.js +0 -11
- package/bin/lib/app-lit.js +0 -5
- package/bin/lib/app-vue.js +0 -6
- package/bin/lib/app.js +121 -28
- package/bin/lib/cli.js +2 -2
- package/bin/lib/constants.js +3 -2
- package/bin/lib/features.js +103 -6
- package/bin/lib/firebase.js +177 -0
- package/bin/lib/imports.js +0 -9
- package/bin/lib/mobile.js +0 -5
- package/bin/lib/prompts.js +311 -0
- package/bin/lib/pwa.js +21 -16
- package/bin/lib/questions.js +29 -7
- package/bin/lib/skills.js +11 -2
- package/bin/lib/source.js +4 -11
- package/package.json +1 -1
- package/templates/features/analytics/scripts/analytics-off-native.mjs +31 -0
- package/templates/features/push/public/sw-push.js +23 -0
- package/templates/features/push/scripts/push-vault-secrets.mjs +60 -0
- package/templates/features/push/supabase/functions/deno.json +6 -0
- package/templates/features/push/supabase/functions/push-notify/index.ts +187 -0
- package/templates/features/push/supabase/migrations/push.sql +115 -0
- package/templates/features/push/supabase/seed.sql +20 -0
- package/templates/js/package.json +1 -1
- package/templates/js/vite.config.js +0 -1
- package/templates/js-jsx/package.json +1 -1
- package/templates/js-jsx/vite.config.js +0 -1
- package/templates/js-vue/package.json +1 -1
- package/templates/js-vue/vite.config.js +0 -1
- package/templates/ssx-js/package.json +1 -1
- package/templates/ssx-js/src/site.config.js +0 -1
- package/templates/ssx-js-jsx/package.json +1 -1
- package/templates/ssx-js-jsx/src/site.config.js +0 -1
- package/templates/ssx-js-vue/package.json +1 -1
- package/templates/ssx-js-vue/src/pages/about.js +0 -1
- package/templates/ssx-js-vue/src/pages/index.js +0 -1
- package/templates/ssx-js-vue/src/site.config.js +0 -1
- package/templates/ssx-ts/package.json +1 -1
- package/templates/ssx-ts/src/site.config.ts +0 -1
- package/templates/ssx-ts/types/index.d.ts +0 -7
- package/templates/ssx-ts-jsx/package.json +1 -1
- package/templates/ssx-ts-jsx/src/site.config.ts +0 -1
- package/templates/ssx-ts-jsx/types/index.d.ts +0 -7
- package/templates/ssx-ts-vue/package.json +1 -1
- package/templates/ssx-ts-vue/src/pages/about.ts +0 -1
- package/templates/ssx-ts-vue/src/pages/index.ts +0 -1
- package/templates/ssx-ts-vue/src/site.config.ts +0 -1
- package/templates/ssx-ts-vue/types/index.d.ts +0 -6
- package/templates/ts/package.json +1 -1
- package/templates/ts/types/index.d.ts +0 -7
- package/templates/ts/vite.config.ts +0 -2
- package/templates/ts-jsx/package.json +1 -1
- package/templates/ts-jsx/types/index.d.ts +0 -7
- package/templates/ts-jsx/vite.config.ts +0 -2
- package/templates/ts-vue/package.json +1 -1
- package/templates/ts-vue/types/index.d.ts +0 -6
- 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
|
+
}
|
package/bin/lib/imports.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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)
|