create-satin 0.0.0-stage → 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +53 -2
- package/bin/create-satin.mjs +197 -0
- package/package.json +37 -3
- package/template/AGENTS.md +327 -0
- package/template/GETTING-STARTED.md +323 -0
- package/template/IOS-DESIGN.md +83 -0
- package/template/README.md +69 -0
- package/template/_gitignore +3 -0
- package/template/index.html +10 -0
- package/template/package.json +29 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/icon-192.png +0 -0
- package/template/public/icon-512.png +0 -0
- package/template/public/icon-source.png +0 -0
- package/template/src/app.ts +13 -0
- package/template/src/data.ts +16 -0
- package/template/src/main.tsx +171 -0
- package/template/src/styles.css +66 -0
- package/template/tsconfig.json +18 -0
- package/template/vite.config.ts +8 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Satin contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# create-satin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Create a Vite + React app for the iPhone Home Screen runtime.
|
|
4
|
+
|
|
5
|
+
After the beta is published:
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm create satin@beta -- my-app --yes --name "My App" --id my-app
|
|
9
|
+
# or
|
|
10
|
+
pnpm create satin@beta my-app --yes --name "My App" --id my-app
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Run `create-satin --help` for all flags, including launch colours and a local
|
|
14
|
+
Satin tarball override. The command copies a bundled template, refuses to
|
|
15
|
+
overwrite a nonempty directory, and leaves installation to you. It has no
|
|
16
|
+
runtime dependencies. Node 22.12+ on the 22.x line, or Node 24+, is required.
|
|
17
|
+
|
|
18
|
+
Quote destination paths containing spaces. The folder keeps its requested name;
|
|
19
|
+
the npm package name and default app ID use a lowercase name with other characters
|
|
20
|
+
replaced by hyphens and edge hyphens removed. An empty result uses `my-app`.
|
|
21
|
+
For example, `"My App #%?"` creates that folder with package name `my-app` and ID `my-app`.
|
|
22
|
+
|
|
23
|
+
`--icon` copies one square PNG source; the build derives the install icon sizes.
|
|
24
|
+
`--accent` writes `appearance.tint`; Satin applies the tint at runtime. Without it no tint is written and the app uses the system blue.
|
|
25
|
+
Launch colours write the light/dark `appearance` backgrounds. If a bundled
|
|
26
|
+
template cannot apply a flag, the command fails before creating the destination.
|
|
27
|
+
|
|
28
|
+
The template has Home and Settings tabs with independent navigation stacks, a
|
|
29
|
+
saved-item detail route and a Your data route that exports JSON. It uses useQuery
|
|
30
|
+
and useRecord for local data. It does not import data. Add validation, a preview,
|
|
31
|
+
backup and confirmation before building an import flow.
|
|
32
|
+
|
|
33
|
+
The app includes README.md, AGENTS.md, IOS-DESIGN.md and GETTING-STARTED.md.
|
|
34
|
+
The packaged API reference documents each export and its limitations.
|
|
35
|
+
|
|
36
|
+
Maintainers edit `templates/basic/` in the repository. `npm run build` copies it
|
|
37
|
+
into this package before packing, so the published CLI works outside the repo.
|
|
38
|
+
|
|
39
|
+
Set `SATIN_DOCS_BASE_URL` when building or packing the CLI to choose where the
|
|
40
|
+
generated `GETTING-STARTED.md` links resolve. It is an HTTP(S) repository-root
|
|
41
|
+
URL containing both `docs/` and `packages/`; include the release revision for
|
|
42
|
+
versioned source documentation. For example:
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
SATIN_DOCS_BASE_URL=https://docs.example.com/satin/v0.1.0-beta.0/ npm run build
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The default uses the configured source repository at its default revision. Before publication,
|
|
49
|
+
set the docs base URL to the chosen public repository and release revision.
|
|
50
|
+
The build normalizes the trailing slash and rejects credentials, query strings,
|
|
51
|
+
fragments and non-HTTP schemes. External service links and page anchors keep
|
|
52
|
+
their original targets. This is a packaging option; app creators do not need to
|
|
53
|
+
set it. Packaging checks every shipped template Markdown file, the generated
|
|
54
|
+
guide and both package READMEs for private project-status language.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { cp, mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { basename, resolve } from 'node:path'
|
|
4
|
+
import { createInterface } from 'node:readline/promises'
|
|
5
|
+
import { parseArgs } from 'node:util'
|
|
6
|
+
|
|
7
|
+
const help = `Create a Satin app
|
|
8
|
+
|
|
9
|
+
npm create satin@beta -- my-app --yes
|
|
10
|
+
pnpm create satin@beta my-app --yes
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--name "My App" Display name, default My Satin App
|
|
14
|
+
--id my-app Stable app identity, default <directory name>
|
|
15
|
+
--background '#f2f2f7' Light launch and app background
|
|
16
|
+
--dark-background '#000000' Dark launch and app background
|
|
17
|
+
--icon <file.png> App icon, copied into the generated project
|
|
18
|
+
--accent '#007aff' App tint colour (appearance.tint); omit for the system blue
|
|
19
|
+
--satin-package <spec> Override dependency, e.g. /absolute/satin.tgz
|
|
20
|
+
--yes Use defaults for omitted answers
|
|
21
|
+
--help Show this help
|
|
22
|
+
|
|
23
|
+
The destination must be absent or empty. No packages are installed automatically.
|
|
24
|
+
`
|
|
25
|
+
const hex = /^#[\da-f]{6}$/iu
|
|
26
|
+
const jsString = value => JSON.stringify(value).replaceAll('<', '\\u003c')
|
|
27
|
+
|
|
28
|
+
function packageNameForDirectory(directory) {
|
|
29
|
+
return (
|
|
30
|
+
basename(resolve(directory))
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9-]+/gu, '-')
|
|
33
|
+
.replace(/^-+|-+$/gu, '') || 'my-app'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function appIdForDirectory(directory) {
|
|
38
|
+
const name = packageNameForDirectory(directory)
|
|
39
|
+
return /^[a-z]/u.test(name) ? name : `app-${name}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function replaceRequired(source, search, replacement, flag, expectedMatches = 1) {
|
|
43
|
+
const matches =
|
|
44
|
+
typeof search === 'string' ? source.split(search).length - 1 : source.match(search) ? 1 : 0
|
|
45
|
+
if (matches !== expectedMatches) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Cannot apply --${flag}: bundled src/app.ts expected ${expectedMatches} match(es) for ${search}, found ${matches}.`,
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
return typeof search === 'string'
|
|
51
|
+
? source.replaceAll(search, replacement)
|
|
52
|
+
: source.replace(search, replacement)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function main() {
|
|
56
|
+
const { values, positionals } = parseArgs({
|
|
57
|
+
allowPositionals: true,
|
|
58
|
+
options: {
|
|
59
|
+
name: { type: 'string' },
|
|
60
|
+
id: { type: 'string' },
|
|
61
|
+
background: { type: 'string', default: '#f2f2f7' },
|
|
62
|
+
'dark-background': { type: 'string', default: '#000000' },
|
|
63
|
+
icon: { type: 'string' },
|
|
64
|
+
accent: { type: 'string' },
|
|
65
|
+
'satin-package': { type: 'string' },
|
|
66
|
+
yes: { type: 'boolean', short: 'y' },
|
|
67
|
+
help: { type: 'boolean', short: 'h' },
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
if (values.help) {
|
|
71
|
+
console.log(help)
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (positionals.length > 1) throw new Error('Provide one destination directory.')
|
|
75
|
+
let destination = positionals[0]
|
|
76
|
+
let name = values.name
|
|
77
|
+
let id = values.id
|
|
78
|
+
if (!values.yes && process.stdin.isTTY && process.stdout.isTTY) {
|
|
79
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout })
|
|
80
|
+
try {
|
|
81
|
+
destination ??= (await prompt.question('Directory [my-app]: ')) || 'my-app'
|
|
82
|
+
name ??= (await prompt.question('App name [My Satin App]: ')) || 'My Satin App'
|
|
83
|
+
id ??= (await prompt.question(`App ID [${appIdForDirectory(destination)}]: `)) || undefined
|
|
84
|
+
} finally {
|
|
85
|
+
prompt.close()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
destination ??= 'my-app'
|
|
89
|
+
const target = resolve(destination)
|
|
90
|
+
const packageName = packageNameForDirectory(target)
|
|
91
|
+
name ??= 'My Satin App'
|
|
92
|
+
id ??= appIdForDirectory(destination)
|
|
93
|
+
if (packageName.length > 214) {
|
|
94
|
+
throw new Error('Package name derived from the directory must be at most 214 characters.')
|
|
95
|
+
}
|
|
96
|
+
if (
|
|
97
|
+
!name.trim() ||
|
|
98
|
+
name !== name.trim() ||
|
|
99
|
+
name.length > 80 ||
|
|
100
|
+
[...name].some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)
|
|
101
|
+
) {
|
|
102
|
+
throw new Error('App name must be 1–80 characters, without padding or control characters.')
|
|
103
|
+
}
|
|
104
|
+
if (!/^[a-z][a-z0-9-]*$/u.test(id))
|
|
105
|
+
throw new Error('App ID must be a lowercase slug such as my-app.')
|
|
106
|
+
for (const key of ['background', 'dark-background', 'accent']) {
|
|
107
|
+
// Without --accent the app keeps the system blue, which differs between light and dark.
|
|
108
|
+
if (key === 'accent' && values.accent === undefined) continue
|
|
109
|
+
if (!hex.test(values[key])) throw new Error(`--${key} must be a six-digit hex colour.`)
|
|
110
|
+
}
|
|
111
|
+
let suppliedIcon
|
|
112
|
+
if (values.icon) {
|
|
113
|
+
const bytes = await readFile(resolve(values.icon))
|
|
114
|
+
if (bytes.length < 24 || bytes.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a')
|
|
115
|
+
throw new Error('--icon must be a PNG file.')
|
|
116
|
+
suppliedIcon = { bytes, width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }
|
|
117
|
+
if (!suppliedIcon.width || suppliedIcon.width !== suppliedIcon.height)
|
|
118
|
+
throw new Error('--icon must be square.')
|
|
119
|
+
}
|
|
120
|
+
if (values['satin-package'] !== undefined && !values['satin-package'].trim())
|
|
121
|
+
throw new Error('--satin-package cannot be empty.')
|
|
122
|
+
try {
|
|
123
|
+
if ((await readdir(target)).length)
|
|
124
|
+
throw new Error('Destination is not empty; no files were changed.')
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error.code !== 'ENOENT') throw error
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const template = resolve(import.meta.dirname, '../template')
|
|
130
|
+
const read = path => readFile(resolve(template, path), 'utf8')
|
|
131
|
+
const write = (path, text) => writeFile(resolve(target, path), text)
|
|
132
|
+
const pkg = JSON.parse(await read('package.json'))
|
|
133
|
+
pkg.name = packageName
|
|
134
|
+
if (values['satin-package']) {
|
|
135
|
+
if (!pkg.dependencies?.['@satinhq/kit']) {
|
|
136
|
+
throw new Error('Cannot apply --satin-package: bundled package.json has no satin dependency.')
|
|
137
|
+
}
|
|
138
|
+
pkg.dependencies['@satinhq/kit'] = values['satin-package']
|
|
139
|
+
}
|
|
140
|
+
// Identity and both appearances have one definition. Encode literals safely.
|
|
141
|
+
let definition = await read('src/app.ts')
|
|
142
|
+
const replacements = [
|
|
143
|
+
["id: 'my-satin-app'", `id: ${jsString(id)}`, 'id', 1],
|
|
144
|
+
["'My Satin App'", jsString(name), 'name', 2],
|
|
145
|
+
["background: '#f2f2f7'", `background: ${jsString(values.background)}`, 'background', 1],
|
|
146
|
+
[
|
|
147
|
+
"background: '#000000'",
|
|
148
|
+
`background: ${jsString(values['dark-background'])}`,
|
|
149
|
+
'dark-background',
|
|
150
|
+
1,
|
|
151
|
+
],
|
|
152
|
+
]
|
|
153
|
+
if (values.accent !== undefined) {
|
|
154
|
+
replacements.push([
|
|
155
|
+
'appearance: {',
|
|
156
|
+
`appearance: {\n tint: ${jsString(values.accent)},`,
|
|
157
|
+
'accent',
|
|
158
|
+
1,
|
|
159
|
+
])
|
|
160
|
+
}
|
|
161
|
+
for (const [search, , flag, count] of replacements) {
|
|
162
|
+
replaceRequired(definition, search, search, flag, count)
|
|
163
|
+
}
|
|
164
|
+
// Substitute in one pass so user text cannot become another template match.
|
|
165
|
+
const replacementByToken = new Map(
|
|
166
|
+
replacements.map(([search, replacement]) => [search, replacement]),
|
|
167
|
+
)
|
|
168
|
+
const tokenPattern = replacements
|
|
169
|
+
.map(([search]) => search.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
|
|
170
|
+
.join('|')
|
|
171
|
+
definition = definition.replace(new RegExp(tokenPattern, 'gu'), token =>
|
|
172
|
+
replacementByToken.get(token),
|
|
173
|
+
)
|
|
174
|
+
// Check every substitution before creating any destination files.
|
|
175
|
+
await mkdir(target, { recursive: true })
|
|
176
|
+
for (const entry of await readdir(template)) {
|
|
177
|
+
await cp(resolve(template, entry), resolve(target, entry), {
|
|
178
|
+
recursive: true,
|
|
179
|
+
force: false,
|
|
180
|
+
errorOnExist: true,
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
await rename(resolve(target, '_gitignore'), resolve(target, '.gitignore'))
|
|
184
|
+
await write('package.json', `${JSON.stringify(pkg, null, 2)}\n`)
|
|
185
|
+
if (suppliedIcon) {
|
|
186
|
+
await writeFile(resolve(target, 'public/icon-source.png'), suppliedIcon.bytes)
|
|
187
|
+
}
|
|
188
|
+
await write('src/app.ts', definition)
|
|
189
|
+
console.log(
|
|
190
|
+
`Created ${name} in ${target}.\n\nNext, in that directory:\n npm install\n npm run dev\n\nOpen the printed address at /app/ in iPhone or iPad Safari, or iOS Simulator Safari.\nFor a physical device, use a reachable trusted HTTPS address. See README.md for installed builds.`,
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
main().catch(error => {
|
|
195
|
+
console.error(`create-satin: ${error.message}`)
|
|
196
|
+
process.exitCode = 1
|
|
197
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-satin",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Create a Satin iPhone Home Screen app.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": "^22.12.0 || >=24.0.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"create-satin": "./bin/create-satin.mjs"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin",
|
|
15
|
+
"template",
|
|
16
|
+
"README.md",
|
|
17
|
+
"LICENSE",
|
|
18
|
+
"NOTICE",
|
|
19
|
+
"THIRD_PARTY_NOTICES.md"
|
|
20
|
+
],
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public",
|
|
23
|
+
"registry": "https://registry.npmjs.org/",
|
|
24
|
+
"tag": "beta"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/satinhq/satin.git",
|
|
29
|
+
"directory": "packages/create-pwakit"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"oxlint": "^1.79.0"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "node scripts/build.mjs",
|
|
36
|
+
"typecheck": "node --check bin/create-satin.mjs && node --check scripts/build.mjs",
|
|
37
|
+
"lint": "oxlint bin scripts test",
|
|
38
|
+
"test": "node --test test/*.test.mjs"
|
|
39
|
+
}
|
|
6
40
|
}
|