@trypattern/sdk 0.1.12 → 0.1.14
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 +73 -12
- package/dist/access.d.ts +0 -4
- package/dist/access.js +0 -5
- package/dist/api.d.ts +3 -1
- package/dist/api.js +5 -23
- package/dist/app.d.ts +20 -5
- package/dist/app.js +32 -2
- package/dist/blocks.d.ts +18 -0
- package/dist/blocks.js +36 -0
- package/dist/bookings.d.ts +400 -0
- package/dist/bookings.js +300 -0
- package/dist/bookingsMemory.d.ts +9 -0
- package/dist/bookingsMemory.js +79 -0
- package/dist/bookingsStore.d.ts +698 -0
- package/dist/bookingsStore.js +129 -0
- package/dist/cli/app.js +144 -59
- package/dist/cli/publishing.d.ts +3 -1
- package/dist/cli/publishing.js +45 -10
- package/dist/cli/starter.d.ts +17 -0
- package/dist/cli/starter.js +140 -0
- package/dist/components/Calendar.d.ts +20 -0
- package/dist/components/Calendar.js +195 -0
- package/dist/components/Card.d.ts +1 -1
- package/dist/components/Card.js +7 -29
- package/dist/components/Choice.js +27 -13
- package/dist/components/ChoiceGroup.d.ts +3 -1
- package/dist/components/ChoiceGroup.js +3 -4
- package/dist/components/Dialog.d.ts +1 -1
- package/dist/components/Dialog.js +3 -1
- package/dist/components/ListRow.js +2 -1
- package/dist/components/RadioDescribed.d.ts +4 -0
- package/dist/components/RadioDescribed.js +20 -0
- package/dist/components/RadioGroup.d.ts +4 -1
- package/dist/components/RadioGroup.js +28 -3
- package/dist/components/Text.d.ts +1 -1
- package/dist/components/Text.js +43 -2
- package/dist/components/calendarDates.d.ts +14 -0
- package/dist/components/calendarDates.js +84 -0
- package/dist/components/controlLook.js +11 -11
- package/dist/components/icons.d.ts +2 -0
- package/dist/components/icons.js +2 -0
- package/dist/components/interaction.d.ts +1 -1
- package/dist/flows/browser.d.ts +7 -1
- package/dist/flows/browser.js +25 -7
- package/dist/flows/flowBrowser.d.ts +2 -2
- package/dist/flows/flowBrowser.js +2 -2
- package/dist/flows/flows.js +8 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -0
- package/dist/media.d.ts +11 -1
- package/dist/patternAccess.d.ts +11 -2
- package/dist/patternAccess.js +8 -4
- package/dist/publishWire.d.ts +35 -2
- package/dist/publishWire.js +26 -3
- package/dist/react.d.ts +2 -0
- package/dist/react.js +1 -0
- package/dist/rules/checkAllFiles.js +16 -2
- package/dist/rules/lintPlugin.js +1 -0
- package/dist/schema.d.ts +3 -1
- package/dist/schema.js +1 -0
- package/dist/server/bookings.d.ts +12 -0
- package/dist/server/bookings.js +142 -0
- package/dist/server/createServer.d.ts +3 -2
- package/dist/server/createServer.js +13 -4
- package/dist/server/env.d.ts +8 -0
- package/dist/server/index.d.ts +3 -1
- package/dist/server/index.js +1 -0
- package/dist/server/media.d.ts +16 -3
- package/dist/server/media.js +72 -23
- package/dist/server/patternViewer.js +16 -10
- package/dist/server/tables.d.ts +2 -0
- package/dist/server/tables.js +17 -1
- package/dist/start.js +12 -0
- package/dist/table.js +9 -3
- package/dist/theme/scales.d.ts +1 -1
- package/dist/theme/scales.js +1 -0
- package/dist/theme/tokens.stylex.d.ts +18 -0
- package/dist/theme/tokens.stylex.js +19 -1
- package/dist/vite/localWorker.d.ts +2 -0
- package/dist/vite/localWorker.js +48 -3
- package/dist/vite/projectFile.d.ts +5 -0
- package/dist/vite/projectFile.js +19 -1
- package/dist/vite/vite.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { bookingDay, bookingRequests, bookingRulesReply, bookingServices, bookingSlot, bookingTables } from "./bookings.js";
|
|
3
|
+
import { bookingMemory } from "./bookingsMemory.js";
|
|
4
|
+
import { ServiceRefused } from "./services.js";
|
|
5
|
+
import { defineStore } from "./store.js";
|
|
6
|
+
const Store = defineStore();
|
|
7
|
+
const OUTCOMES = {
|
|
8
|
+
401: 'forbidden',
|
|
9
|
+
403: 'forbidden',
|
|
10
|
+
404: 'missing',
|
|
11
|
+
409: 'conflict'
|
|
12
|
+
};
|
|
13
|
+
function outcomeOf(error) {
|
|
14
|
+
return error instanceof ServiceRefused
|
|
15
|
+
? (OUTCOMES[error.status] ?? 'failed')
|
|
16
|
+
: 'failed';
|
|
17
|
+
}
|
|
18
|
+
const loadStatus = z.enum(['idle', 'loading', 'ready', 'failed']);
|
|
19
|
+
export class Bookings extends Store({
|
|
20
|
+
state: {
|
|
21
|
+
days: z.record(z.string(), z.array(bookingDay)).default({}),
|
|
22
|
+
windows: z.record(z.string(), z.array(bookingSlot)).default({}),
|
|
23
|
+
requests: z.array(bookingRequests.row).default([]),
|
|
24
|
+
requestsStatus: loadStatus.default('idle'),
|
|
25
|
+
rules: bookingRulesReply.nullable().default(null),
|
|
26
|
+
sent: bookingRequests.row.nullable().default(null),
|
|
27
|
+
isSending: z.boolean().default(false)
|
|
28
|
+
}
|
|
29
|
+
}) {
|
|
30
|
+
async loadDays(month) {
|
|
31
|
+
try {
|
|
32
|
+
const days = await this.services.bookings.days({ month });
|
|
33
|
+
this.set({ days: { ...this.state.days, [month]: days } });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async loadWindows(date) {
|
|
40
|
+
try {
|
|
41
|
+
const windows = await this.services.bookings.windows({ date });
|
|
42
|
+
this.set({ windows: { ...this.state.windows, [date]: windows } });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async request(fields) {
|
|
49
|
+
this.set({ isSending: true });
|
|
50
|
+
try {
|
|
51
|
+
const sent = await this.services.bookings.request(fields);
|
|
52
|
+
this.set({ sent });
|
|
53
|
+
return 'saved';
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return outcomeOf(error);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
this.set({ isSending: false });
|
|
60
|
+
await Promise.all([
|
|
61
|
+
this.loadDays(fields.date.slice(0, 7)),
|
|
62
|
+
this.loadWindows(fields.date)
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
forgetSent() {
|
|
67
|
+
this.set({ sent: null });
|
|
68
|
+
}
|
|
69
|
+
async loadRequests() {
|
|
70
|
+
if (this.state.requestsStatus !== 'ready') {
|
|
71
|
+
this.set({ requestsStatus: 'loading' });
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const requests = await this.services.bookings.requests();
|
|
75
|
+
this.set({ requests, requestsStatus: 'ready' });
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
if (this.state.requestsStatus === 'loading') {
|
|
79
|
+
this.set({ requestsStatus: 'failed' });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
accept(id) {
|
|
84
|
+
return this.#decide(id, () => this.services.bookings.accept({ id }));
|
|
85
|
+
}
|
|
86
|
+
decline(id) {
|
|
87
|
+
return this.#decide(id, () => this.services.bookings.decline({ id }));
|
|
88
|
+
}
|
|
89
|
+
async loadRules() {
|
|
90
|
+
try {
|
|
91
|
+
this.set({ rules: await this.services.bookings.rules() });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async saveRules(rules) {
|
|
98
|
+
try {
|
|
99
|
+
this.set({
|
|
100
|
+
rules: await this.services.bookings.saveRules(rules),
|
|
101
|
+
days: {},
|
|
102
|
+
windows: {}
|
|
103
|
+
});
|
|
104
|
+
return 'saved';
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
return outcomeOf(error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async #decide(id, send) {
|
|
111
|
+
try {
|
|
112
|
+
const decided = await send();
|
|
113
|
+
this.set({
|
|
114
|
+
requests: this.state.requests.map((request) => request.id === id ? decided : request)
|
|
115
|
+
});
|
|
116
|
+
return 'saved';
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return outcomeOf(error);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export const bookings = {
|
|
124
|
+
name: 'bookings',
|
|
125
|
+
tables: bookingTables,
|
|
126
|
+
services: bookingServices,
|
|
127
|
+
memory: bookingMemory,
|
|
128
|
+
Store: Bookings
|
|
129
|
+
};
|
package/dist/cli/app.js
CHANGED
|
@@ -16,8 +16,10 @@ import { createHash } from 'node:crypto';
|
|
|
16
16
|
import { z } from 'zod';
|
|
17
17
|
import { MEDIA_LIMIT_BYTES } from "../media.js";
|
|
18
18
|
import { isHeic } from "../server/imageHeader.js";
|
|
19
|
-
import { resetLocalDatabase } from "../vite/localWorker.js";
|
|
20
|
-
import {
|
|
19
|
+
import { putLocalPhotos, resetLocalDatabase } from "../vite/localWorker.js";
|
|
20
|
+
import { readProjectFile } from "../vite/projectFile.js";
|
|
21
|
+
import { readStarter, starterContentOf, withStarter } from "./starter.js";
|
|
22
|
+
import { addLiveMedia, login, publish, pull, restore, versions as listVersions } from "./publishing.js";
|
|
21
23
|
import { toolBin } from "./tools.js";
|
|
22
24
|
function dependsOnPattern(directory) {
|
|
23
25
|
try {
|
|
@@ -93,7 +95,7 @@ const PHOTO_TYPES = {
|
|
|
93
95
|
'.avif': 'image/avif'
|
|
94
96
|
};
|
|
95
97
|
const HEIC_EXTENSIONS = new Set(['.heic', '.heif']);
|
|
96
|
-
const MEDIA_USAGE = ' usage app media add photo.jpg --alt "Pink peonies in a glass vase"';
|
|
98
|
+
const MEDIA_USAGE = ' usage app media add photo.jpg --alt "Pink peonies in a glass vase" [--live]';
|
|
97
99
|
const SHIM_SOURCE = `#!/bin/sh
|
|
98
100
|
${SHIM_MARKER_LINE}
|
|
99
101
|
dir=$PWD
|
|
@@ -193,10 +195,11 @@ app check format and app check lint pass for anything a tool can fix by itself.`
|
|
|
193
195
|
run: fix
|
|
194
196
|
},
|
|
195
197
|
reset: {
|
|
196
|
-
summary: 'Empty the local database and fill it again from seed.ts',
|
|
198
|
+
summary: 'Empty the local database and fill it again from seed.ts and starter/',
|
|
197
199
|
details: `Deletes every row in every table of the local database (signed-in users stay) and inserts
|
|
198
|
-
the rows in seed.ts again
|
|
199
|
-
|
|
200
|
+
the rows in seed.ts again, with the rows in starter/rows.ts and their photos from starter/photos,
|
|
201
|
+
which go into the local MEDIA bucket. app start only fills an empty table, so run this after
|
|
202
|
+
changing seed.ts or starter/. Works while the dev server runs; reload the page to see the new rows.`,
|
|
200
203
|
run: reset
|
|
201
204
|
},
|
|
202
205
|
migration: {
|
|
@@ -209,12 +212,18 @@ migrations to the local database. Prints "No schema changes" when the migrations
|
|
|
209
212
|
takesArgument: true
|
|
210
213
|
},
|
|
211
214
|
media: {
|
|
212
|
-
summary: 'Add a photo to the media table: app media add photo.jpg --alt "…"',
|
|
215
|
+
summary: 'Add a photo to the media table: app media add photo.jpg --alt "…" [--live]',
|
|
213
216
|
details: `app media add photo.jpg --alt "Pink peonies in a glass vase"
|
|
217
|
+
app media add photo.jpg --alt "Pink peonies in a glass vase" --live
|
|
214
218
|
Sends the photo to the dev server's POST /api/media, signed in as the first email in admins in
|
|
215
219
|
schema.ts (dev sign-in, so this adds to the local database and the local MEDIA bucket). The server
|
|
216
220
|
reads its width and height, stores it as images/<hash>.<ext> and adds a row to the media table;
|
|
217
|
-
this prints the row. Show it with <Image image={row} sizes="…" />.
|
|
221
|
+
this prints the row and its id. Show it with <Image image={row} sizes="…" />.
|
|
222
|
+
Without --live the photo stays on this computer, and this says so.
|
|
223
|
+
--live first sends it to the live site, <name>.onpattern.app, through onpattern.app (signing in
|
|
224
|
+
like app publish), whose own POST /api/media works out its size, placeholder and alt the same way,
|
|
225
|
+
then adds it to the local database too under the same id, so both show it at once. Putting it on a
|
|
226
|
+
live item is the owner's change to make on the live site; the local photo only shows it here.
|
|
218
227
|
--alt is required: describe the photo for someone who can't see it.
|
|
219
228
|
JPEG, PNG, WebP, GIF or AVIF, up to 10 MB. iPhone HEIC photos are refused: convert one first with
|
|
220
229
|
sips -s format jpeg photo.heic --out photo.jpg. The app's tables must include media from
|
|
@@ -222,7 +231,7 @@ sips -s format jpeg photo.heic --out photo.jpg. The app's tables must include me
|
|
|
222
231
|
run_worker_first. Uses the running dev server, or starts one and stops it afterwards.`,
|
|
223
232
|
run: (_argument, options, rest) => addMedia(rest, options),
|
|
224
233
|
argumentCount: 2,
|
|
225
|
-
flags: ['--alt']
|
|
234
|
+
flags: ['--alt', '--live']
|
|
226
235
|
},
|
|
227
236
|
build: {
|
|
228
237
|
summary: 'Build the app for production into dist/',
|
|
@@ -321,10 +330,22 @@ moves forward with any new migrations; nothing is ever deleted from it. If a ste
|
|
|
321
330
|
keeps running the previous version and the error says why. Run app check first.
|
|
322
331
|
--message '…' says what changed in a few plain words for the owner ("Pink brand colour and a welcome
|
|
323
332
|
line"); onpattern.app and app versions show it beside the version.
|
|
324
|
-
--starter (Pattern's admins only) publishes this project as the starter
|
|
325
|
-
|
|
333
|
+
--starter (Pattern's admins only) publishes this project as the starter new sites begin from: the one
|
|
334
|
+
for its "blueprint" in pattern.json (website or web-app), which a new business picks when it starts.
|
|
335
|
+
With a starter/ folder, its rows (starter/rows.ts) and photos (starter/photos) go with it, and a new
|
|
336
|
+
site's first publish puts them in its own database and photo bucket: the photos through the site's
|
|
337
|
+
own POST /api/media, then the rows. Later publishes never touch them; they are the owner's.`,
|
|
338
|
+
run: async (_argument, options) => {
|
|
326
339
|
const message = options.get('--message');
|
|
327
|
-
|
|
340
|
+
const isStarter = options.has('--starter');
|
|
341
|
+
let starter;
|
|
342
|
+
try {
|
|
343
|
+
starter = isStarter ? await starterToPublish() : undefined;
|
|
344
|
+
}
|
|
345
|
+
catch (error) {
|
|
346
|
+
return mediaRefusal(`The starter can't go out: ${error instanceof Error ? error.message : String(error)}`);
|
|
347
|
+
}
|
|
348
|
+
return publish(publishingIo(), ROOT, isStarter, typeof message === 'string' ? message : undefined, starter);
|
|
328
349
|
},
|
|
329
350
|
flags: ['--starter', '--message']
|
|
330
351
|
},
|
|
@@ -353,7 +374,7 @@ existing ${SHIM_PATH} that was not installed by Pattern.`,
|
|
|
353
374
|
},
|
|
354
375
|
help: {
|
|
355
376
|
summary: 'Show this help (`app <command> --help` for details)',
|
|
356
|
-
details: 'Lists commands. Flags: --json (machine-readable stdout), --help, --no-open (start only), --visual (test only), --commands, --name, --as and --signed-out (screenshot only), --alt (media only).',
|
|
377
|
+
details: 'Lists commands. Flags: --json (machine-readable stdout), --help, --no-open (start only), --visual (test only), --commands, --name, --as and --signed-out (screenshot only), --alt and --live (media only).',
|
|
357
378
|
run: help
|
|
358
379
|
}
|
|
359
380
|
};
|
|
@@ -693,7 +714,22 @@ async function projectData() {
|
|
|
693
714
|
if (typeof seed !== 'object' || seed === null) {
|
|
694
715
|
throw new Error('seed.ts must export `seed`');
|
|
695
716
|
}
|
|
696
|
-
|
|
717
|
+
const starter = await readStarter(ROOT, tables);
|
|
718
|
+
return {
|
|
719
|
+
tables: tables,
|
|
720
|
+
seed: withStarter(seed, starter),
|
|
721
|
+
photos: starter?.photos ?? []
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
async function starterToPublish() {
|
|
725
|
+
const tables = await importExport('schema.ts', 'tables');
|
|
726
|
+
if (typeof tables !== 'object' || tables === null) {
|
|
727
|
+
throw new Error('schema.ts must export `tables`');
|
|
728
|
+
}
|
|
729
|
+
const starter = await readStarter(ROOT, tables);
|
|
730
|
+
return starter === undefined
|
|
731
|
+
? undefined
|
|
732
|
+
: starterContentOf(tables, starter);
|
|
697
733
|
}
|
|
698
734
|
function fingerprint(seed) {
|
|
699
735
|
return createHash('sha256').update(JSON.stringify(seed)).digest('hex');
|
|
@@ -714,18 +750,22 @@ async function seedChangedNotice() {
|
|
|
714
750
|
: undefined;
|
|
715
751
|
return previous === fingerprint(seed)
|
|
716
752
|
? undefined
|
|
717
|
-
: ' seed seed.ts changed since the local database was filled; app reset fills it again';
|
|
753
|
+
: ' seed seed.ts or starter/ changed since the local database was filled; app reset fills it again';
|
|
718
754
|
}
|
|
719
755
|
async function reset() {
|
|
720
756
|
try {
|
|
721
|
-
const { tables, seed } = await projectData();
|
|
757
|
+
const { tables, seed, photos } = await projectData();
|
|
722
758
|
resetLocalDatabase(ROOT, tables, seed);
|
|
759
|
+
putLocalPhotos(ROOT, photos);
|
|
723
760
|
recordSeed(seed);
|
|
724
761
|
const rows = Object.values(seed).reduce((total, list) => total + (list?.length ?? 0), 0);
|
|
762
|
+
const from = existsSync(join(ROOT, 'starter'))
|
|
763
|
+
? 'seed.ts and starter/'
|
|
764
|
+
: 'seed.ts';
|
|
725
765
|
return {
|
|
726
766
|
code: 0,
|
|
727
|
-
data: { rows },
|
|
728
|
-
text: `✓ Local database emptied and filled with the ${rows} rows in
|
|
767
|
+
data: { rows, photos: photos.length },
|
|
768
|
+
text: `✓ Local database emptied and filled with the ${rows} rows in ${from}\n next reload the page`
|
|
729
769
|
};
|
|
730
770
|
}
|
|
731
771
|
catch (error) {
|
|
@@ -1255,35 +1295,20 @@ async function firstAdmin() {
|
|
|
1255
1295
|
const first = Array.isArray(admins) ? admins[0] : undefined;
|
|
1256
1296
|
return typeof first === 'string' ? first : undefined;
|
|
1257
1297
|
}
|
|
1258
|
-
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
const path = resolve(process.cwd(), file);
|
|
1268
|
-
if (!existsSync(path))
|
|
1269
|
-
return mediaRefusal(`There is no file at ${file}`);
|
|
1270
|
-
const bytes = readFileSync(path);
|
|
1271
|
-
const extension = extname(path).toLowerCase();
|
|
1272
|
-
if (HEIC_EXTENSIONS.has(extension) || isHeic(bytes)) {
|
|
1273
|
-
return mediaRefusal(`${file} is an iPhone HEIC photo, which browsers can't show. Convert it to JPEG, then add the JPEG:\n sips -s format jpeg ${file} --out ${file.replace(/\.[^.]+$/, '')}-converted.jpg`);
|
|
1274
|
-
}
|
|
1275
|
-
const type = PHOTO_TYPES[extension];
|
|
1276
|
-
if (type === undefined) {
|
|
1277
|
-
return mediaRefusal(`${file} is not a photo the web can show: use JPEG, PNG, WebP, GIF or AVIF`);
|
|
1278
|
-
}
|
|
1279
|
-
if (bytes.byteLength > MEDIA_LIMIT_BYTES) {
|
|
1280
|
-
return mediaRefusal(`${file} is over 10 MB: export it smaller first`);
|
|
1281
|
-
}
|
|
1298
|
+
function photoForm(bytes, type, path, alt, id) {
|
|
1299
|
+
const form = new FormData();
|
|
1300
|
+
form.set('file', new Blob([bytes], { type }), basename(path));
|
|
1301
|
+
form.set('alt', alt);
|
|
1302
|
+
if (id !== undefined)
|
|
1303
|
+
form.set('id', id);
|
|
1304
|
+
return form;
|
|
1305
|
+
}
|
|
1306
|
+
async function addLocalMedia(form, file) {
|
|
1282
1307
|
const admin = await firstAdmin();
|
|
1283
1308
|
if (admin === undefined) {
|
|
1284
|
-
return
|
|
1309
|
+
return "only an admin can add photos: put the owner's email in admins in schema.ts";
|
|
1285
1310
|
}
|
|
1286
|
-
|
|
1311
|
+
const outcome = await withDevServer(async (url) => {
|
|
1287
1312
|
const origin = new URL(url).origin;
|
|
1288
1313
|
const signedIn = await fetch(new URL('/api/auth/dev-sign-in', url), {
|
|
1289
1314
|
method: 'POST',
|
|
@@ -1291,15 +1316,12 @@ async function addMedia(args, options) {
|
|
|
1291
1316
|
body: JSON.stringify({ email: admin })
|
|
1292
1317
|
});
|
|
1293
1318
|
if (!signedIn.ok) {
|
|
1294
|
-
return mediaRefusal(`
|
|
1319
|
+
return mediaRefusal(`could not sign in as ${admin} (HTTP ${signedIn.status})`);
|
|
1295
1320
|
}
|
|
1296
1321
|
const cookie = signedIn.headers
|
|
1297
1322
|
.getSetCookie()
|
|
1298
1323
|
.map((line) => line.split(';')[0])
|
|
1299
1324
|
.join('; ');
|
|
1300
|
-
const form = new FormData();
|
|
1301
|
-
form.set('file', new Blob([bytes], { type }), basename(path));
|
|
1302
|
-
form.set('alt', alt.trim());
|
|
1303
1325
|
const response = await fetch(new URL('/api/media', url), {
|
|
1304
1326
|
method: 'POST',
|
|
1305
1327
|
headers: { origin, cookie },
|
|
@@ -1307,25 +1329,88 @@ async function addMedia(args, options) {
|
|
|
1307
1329
|
});
|
|
1308
1330
|
const reply = await response.json().catch(() => undefined);
|
|
1309
1331
|
if (response.status === 404) {
|
|
1310
|
-
return mediaRefusal("
|
|
1332
|
+
return mediaRefusal("this app has no media table: add media from '@trypattern/sdk/schema' to tables in schema.ts, run app migration add_media, and bind an R2 bucket as MEDIA in wrangler.jsonc");
|
|
1311
1333
|
}
|
|
1312
1334
|
if (!response.ok) {
|
|
1313
|
-
return mediaRefusal(`
|
|
1335
|
+
return mediaRefusal(`could not add ${file}: ${replyError(reply) ?? `HTTP ${response.status}`}`);
|
|
1314
1336
|
}
|
|
1315
|
-
|
|
1337
|
+
return { code: 0, data: { media: Object(reply) }, text: '' };
|
|
1338
|
+
});
|
|
1339
|
+
const row = outcome.data['media'];
|
|
1340
|
+
return outcome.code === 0 && typeof row === 'object' && row !== null
|
|
1341
|
+
? row
|
|
1342
|
+
: String(outcome.data['error'] ?? outcome.text);
|
|
1343
|
+
}
|
|
1344
|
+
function mediaLines(row) {
|
|
1345
|
+
return [
|
|
1346
|
+
` id ${String(row['id'])}`,
|
|
1347
|
+
` key ${String(row['key'])}`,
|
|
1348
|
+
` size ${String(row['width'])}×${String(row['height'])}`,
|
|
1349
|
+
` alt ${String(row['alt'])}`
|
|
1350
|
+
];
|
|
1351
|
+
}
|
|
1352
|
+
async function addMedia(args, options) {
|
|
1353
|
+
const [action, file] = args;
|
|
1354
|
+
if (action !== 'add' || file === undefined) {
|
|
1355
|
+
return mediaRefusal(`Say which photo to add\n${MEDIA_USAGE}`, 2);
|
|
1356
|
+
}
|
|
1357
|
+
const alt = options.get('--alt');
|
|
1358
|
+
if (typeof alt !== 'string' || alt.trim() === '') {
|
|
1359
|
+
return mediaRefusal(`--alt is required: describe this photo for someone who can't see it\n${MEDIA_USAGE}`, 2);
|
|
1360
|
+
}
|
|
1361
|
+
const path = resolve(process.cwd(), file);
|
|
1362
|
+
if (!existsSync(path))
|
|
1363
|
+
return mediaRefusal(`There is no file at ${file}`);
|
|
1364
|
+
const bytes = readFileSync(path);
|
|
1365
|
+
const extension = extname(path).toLowerCase();
|
|
1366
|
+
if (HEIC_EXTENSIONS.has(extension) || isHeic(bytes)) {
|
|
1367
|
+
return mediaRefusal(`${file} is an iPhone HEIC photo, which browsers can't show. Convert it to JPEG, then add the JPEG:\n sips -s format jpeg ${file} --out ${file.replace(/\.[^.]+$/, '')}-converted.jpg`);
|
|
1368
|
+
}
|
|
1369
|
+
const type = PHOTO_TYPES[extension];
|
|
1370
|
+
if (type === undefined) {
|
|
1371
|
+
return mediaRefusal(`${file} is not a photo the web can show: use JPEG, PNG, WebP, GIF or AVIF`);
|
|
1372
|
+
}
|
|
1373
|
+
if (bytes.byteLength > MEDIA_LIMIT_BYTES) {
|
|
1374
|
+
return mediaRefusal(`${file} is over 10 MB: export it smaller first`);
|
|
1375
|
+
}
|
|
1376
|
+
const said = alt.trim();
|
|
1377
|
+
const site = `${readProjectFile(ROOT).name}.onpattern.app`;
|
|
1378
|
+
if (!options.has('--live')) {
|
|
1379
|
+
const row = await addLocalMedia(photoForm(bytes, type, path, said, undefined), file);
|
|
1380
|
+
if (typeof row === 'string')
|
|
1381
|
+
return mediaRefusal(`Could not add ${file}: ${row}`);
|
|
1316
1382
|
return {
|
|
1317
1383
|
code: 0,
|
|
1318
|
-
data: { media: row },
|
|
1384
|
+
data: { media: row, live: false },
|
|
1319
1385
|
text: [
|
|
1320
|
-
`✓ Added ${file} to media`,
|
|
1321
|
-
|
|
1322
|
-
`
|
|
1323
|
-
` size ${String(row['width'])}×${String(row['height'])}`,
|
|
1324
|
-
` alt ${String(row['alt'])}`,
|
|
1386
|
+
`✓ Added ${file} to media on this computer`,
|
|
1387
|
+
...mediaLines(row),
|
|
1388
|
+
` live not on ${site}: add --live to put it there too`,
|
|
1325
1389
|
' show <Image image={row} sizes="…" /> with that media row'
|
|
1326
1390
|
].join('\n')
|
|
1327
1391
|
};
|
|
1328
|
-
}
|
|
1392
|
+
}
|
|
1393
|
+
const live = await addLiveMedia(publishingIo(), ROOT, photoForm(bytes, type, path, said, undefined), `app media add ${file} --alt ${JSON.stringify(said)} --live`);
|
|
1394
|
+
if (live.code !== 0)
|
|
1395
|
+
return live;
|
|
1396
|
+
const row = live.data['media'];
|
|
1397
|
+
const local = await addLocalMedia(photoForm(bytes, type, path, said, String(row['id'])), file);
|
|
1398
|
+
return {
|
|
1399
|
+
code: 0,
|
|
1400
|
+
data: {
|
|
1401
|
+
media: row,
|
|
1402
|
+
live: true,
|
|
1403
|
+
local: typeof local === 'string' ? { error: local } : local
|
|
1404
|
+
},
|
|
1405
|
+
text: [
|
|
1406
|
+
`✓ Added ${file} to media on ${site}${typeof local === 'string' ? '' : ' and on this computer'}`,
|
|
1407
|
+
...mediaLines(row),
|
|
1408
|
+
...(typeof local === 'string'
|
|
1409
|
+
? [` local not added on this computer: ${local}`]
|
|
1410
|
+
: []),
|
|
1411
|
+
` next the owner attaches it to an item on the live site's Manage page; Claude can't change live rows`
|
|
1412
|
+
].join('\n')
|
|
1413
|
+
};
|
|
1329
1414
|
}
|
|
1330
1415
|
async function build() {
|
|
1331
1416
|
let vite;
|
|
@@ -1441,7 +1526,7 @@ async function setup() {
|
|
|
1441
1526
|
function help() {
|
|
1442
1527
|
const width = Math.max(...Object.keys(COMMANDS).map((n) => n.length));
|
|
1443
1528
|
const lines = Object.entries(COMMANDS).map(([name, cmd]) => `app ${name.padEnd(width)} ${cmd.summary}`);
|
|
1444
|
-
lines.push('Flags: --json (machine-readable stdout), <command> --help (details and its own flags: start --no-open, test --visual, screenshot --commands --name --as --signed-out, pull --code, publish --message, media --alt)');
|
|
1529
|
+
lines.push('Flags: --json (machine-readable stdout), <command> --help (details and its own flags: start --no-open, test --visual, screenshot --commands --name --as --signed-out, pull --code, publish --message, media --alt --live)');
|
|
1445
1530
|
return {
|
|
1446
1531
|
code: 0,
|
|
1447
1532
|
data: {
|
package/dist/cli/publishing.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { StarterContent } from '../publishWire.ts';
|
|
1
2
|
type Outcome = {
|
|
2
3
|
code: number;
|
|
3
4
|
data: Record<string, unknown>;
|
|
@@ -15,7 +16,8 @@ export type PublishingIo = {
|
|
|
15
16
|
};
|
|
16
17
|
export declare function login(io: PublishingIo): Promise<Outcome>;
|
|
17
18
|
export declare function pull(io: PublishingIo, name: string | undefined, cwd: string, setupCode: string | undefined): Promise<Outcome>;
|
|
18
|
-
export declare function publish(io: PublishingIo, root: string, isStarter: boolean, message: string | undefined): Promise<Outcome>;
|
|
19
|
+
export declare function publish(io: PublishingIo, root: string, isStarter: boolean, message: string | undefined, starter: StarterContent | undefined): Promise<Outcome>;
|
|
20
|
+
export declare function addLiveMedia(io: PublishingIo, root: string, form: FormData, again: string): Promise<Outcome>;
|
|
19
21
|
export declare function versions(io: PublishingIo, root: string): Promise<Outcome>;
|
|
20
22
|
export declare function restore(io: PublishingIo, root: string, number: string | undefined): Promise<Outcome>;
|
|
21
23
|
export {};
|
package/dist/cli/publishing.js
CHANGED
|
@@ -3,10 +3,10 @@ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, st
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { basename, dirname, extname, join, matchesGlob, relative } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
-
import { DASHBOARD_URL, UPLOAD, appSummary, isPrivatePath, loginPoll, loginStarted, nameProblem, publishMessage, publishSummary, setupCodeExchanged, sourcePathProblem } from "../publishWire.js";
|
|
6
|
+
import { DASHBOARD_URL, UPLOAD, appSummary, isPrivatePath, loginPoll, loginStarted, mediaRow, nameProblem, publishMessage, publishSummary, setupCodeExchanged, sourcePathProblem } from "../publishWire.js";
|
|
7
7
|
import { appInfo } from "../appTitle.js";
|
|
8
8
|
import { SESSION_COOKIE } from "../auth/user.js";
|
|
9
|
-
import { PROJECT_FILE, readProjectFile } from "../vite/projectFile.js";
|
|
9
|
+
import { PROJECT_FILE, blueprintOf, readProjectFile } from "../vite/projectFile.js";
|
|
10
10
|
const CONFIG_DIR = join(homedir(), '.config', 'pattern');
|
|
11
11
|
const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
12
12
|
const PENDING_FILE = join(CONFIG_DIR, 'pending-login.json');
|
|
@@ -51,6 +51,9 @@ const STEP_WORDS = {
|
|
|
51
51
|
function dashboard() {
|
|
52
52
|
return (process.env['PATTERN_DASHBOARD_URL'] ?? DASHBOARD_URL).replace(/\/$/, '');
|
|
53
53
|
}
|
|
54
|
+
function sayHost(io) {
|
|
55
|
+
io.say(` via ${new URL(dashboard()).host}`);
|
|
56
|
+
}
|
|
54
57
|
const credentials = z.record(z.string(), z.object({ cookie: z.string() }));
|
|
55
58
|
const pendingLogin = z.object({
|
|
56
59
|
dashboard: z.string(),
|
|
@@ -280,6 +283,7 @@ export async function pull(io, name, cwd, setupCode) {
|
|
|
280
283
|
text: '✗ Which site? app pull acmeflower'
|
|
281
284
|
};
|
|
282
285
|
}
|
|
286
|
+
sayHost(io);
|
|
283
287
|
const target = folderFor(name, cwd);
|
|
284
288
|
if (target !== cwd && existsSync(target) && !isEmptyFolder(target)) {
|
|
285
289
|
return {
|
|
@@ -321,7 +325,8 @@ export async function pull(io, name, cwd, setupCode) {
|
|
|
321
325
|
}
|
|
322
326
|
if (existsSync(join(target, LAUNCHER)))
|
|
323
327
|
chmodSync(join(target, LAUNCHER), 0o755);
|
|
324
|
-
|
|
328
|
+
const blueprint = blueprintOf(target);
|
|
329
|
+
writeFileSync(join(target, PROJECT_FILE), `${JSON.stringify({ name, title: result.title, ...(blueprint === undefined ? {} : { blueprint }) }, null, 2)}\n`);
|
|
325
330
|
io.say(`✓ Downloaded ${files} files of version ${result.number}`);
|
|
326
331
|
if (!(await ensurePnpm(io, target))) {
|
|
327
332
|
return {
|
|
@@ -403,7 +408,7 @@ function workerDirectory(dist) {
|
|
|
403
408
|
function starterPackageJson(path, sdkVersion) {
|
|
404
409
|
return readFileSync(path, 'utf8').replace(/"@trypattern\/sdk": "workspace:[^"]*"/, `"@trypattern/sdk": "${sdkVersion}"`);
|
|
405
410
|
}
|
|
406
|
-
function uploadForm(root, io, isStarter) {
|
|
411
|
+
function uploadForm(root, io, isStarter, starter) {
|
|
407
412
|
const dist = join(root, 'dist');
|
|
408
413
|
const worker = workerDirectory(dist);
|
|
409
414
|
if (worker === undefined)
|
|
@@ -431,6 +436,13 @@ function uploadForm(root, io, isStarter) {
|
|
|
431
436
|
form.append(`${UPLOAD.migrations}${file}`, fileBlob(join(migrations, file)), file);
|
|
432
437
|
}
|
|
433
438
|
const sources = sourceFiles(root);
|
|
439
|
+
if (starter !== undefined) {
|
|
440
|
+
const left = starter.photos.find((photo) => !sources.includes(photo.path));
|
|
441
|
+
if (left !== undefined) {
|
|
442
|
+
throw new Error(`${left.path} is ignored by .gitignore, so it can't go with the starter`);
|
|
443
|
+
}
|
|
444
|
+
form.append(UPLOAD.starter, JSON.stringify(starter));
|
|
445
|
+
}
|
|
434
446
|
if (isStarter && !sources.includes(STARTER_WORKSPACE)) {
|
|
435
447
|
form.append(`${UPLOAD.source}${STARTER_WORKSPACE}`, new Blob([STARTER_BUILD_SETTINGS], { type: 'text/yaml' }), STARTER_WORKSPACE);
|
|
436
448
|
}
|
|
@@ -478,18 +490,25 @@ async function followPublish(io, name, started) {
|
|
|
478
490
|
text: `✗ Version ${publish.number} is still publishing after ${PUBLISH_WAIT_MS / 60_000} minutes\n next app versions shows how it ends`
|
|
479
491
|
};
|
|
480
492
|
}
|
|
481
|
-
export async function publish(io, root, isStarter, message) {
|
|
493
|
+
export async function publish(io, root, isStarter, message, starter) {
|
|
482
494
|
try {
|
|
483
|
-
|
|
495
|
+
sayHost(io);
|
|
496
|
+
const { name, blueprint } = readProjectFile(root);
|
|
497
|
+
if (isStarter && blueprint === undefined) {
|
|
498
|
+
throw new Error(`${PROJECT_FILE} needs "blueprint" ("website" or "web-app") to publish the starter for it`);
|
|
499
|
+
}
|
|
484
500
|
const built = await io.build();
|
|
485
501
|
if (built.code !== 0)
|
|
486
502
|
return built;
|
|
487
503
|
io.say('✓ Built');
|
|
488
|
-
const { form, sources } = uploadForm(root, io, isStarter);
|
|
504
|
+
const { form, sources } = uploadForm(root, io, isStarter, starter);
|
|
505
|
+
if (isStarter && blueprint !== undefined) {
|
|
506
|
+
form.append(UPLOAD.blueprint, blueprint);
|
|
507
|
+
}
|
|
489
508
|
const said = publishMessage(message ?? '');
|
|
490
509
|
if (said !== null)
|
|
491
510
|
form.append(UPLOAD.message, said);
|
|
492
|
-
io.say(`✓ Packed the build and ${sources} source files`);
|
|
511
|
+
io.say(`✓ Packed the build and ${sources} source files${starter === undefined ? '' : `, with ${starter.photos.length} starter photos`}`);
|
|
493
512
|
const again = isStarter ? 'app publish --starter' : 'app publish';
|
|
494
513
|
const result = await signedIn(io, again, async () => {
|
|
495
514
|
if (isStarter) {
|
|
@@ -509,8 +528,8 @@ export async function publish(io, root, isStarter, message) {
|
|
|
509
528
|
if (result === undefined) {
|
|
510
529
|
return {
|
|
511
530
|
code: 0,
|
|
512
|
-
data: { starter: io.sdkVersion },
|
|
513
|
-
text: `✓ Published the starter for SDK ${io.sdkVersion}`
|
|
531
|
+
data: { starter: io.sdkVersion, blueprint },
|
|
532
|
+
text: `✓ Published the ${blueprint} starter for SDK ${io.sdkVersion}`
|
|
514
533
|
};
|
|
515
534
|
}
|
|
516
535
|
return await followPublish(io, name, result);
|
|
@@ -519,6 +538,22 @@ export async function publish(io, root, isStarter, message) {
|
|
|
519
538
|
return failure(error);
|
|
520
539
|
}
|
|
521
540
|
}
|
|
541
|
+
export async function addLiveMedia(io, root, form, again) {
|
|
542
|
+
try {
|
|
543
|
+
sayHost(io);
|
|
544
|
+
const { name } = readProjectFile(root);
|
|
545
|
+
const result = await signedIn(io, again, () => callJson(`/api/apps/${name}/media`, mediaRow, {
|
|
546
|
+
method: 'POST',
|
|
547
|
+
body: form
|
|
548
|
+
}));
|
|
549
|
+
if (isOutcome(result))
|
|
550
|
+
return result;
|
|
551
|
+
return { code: 0, data: { name, media: result }, text: '' };
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
return failure(error);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
522
557
|
export async function versions(io, root) {
|
|
523
558
|
try {
|
|
524
559
|
const { name } = readProjectFile(root);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StarterContent } from '../publishWire.ts';
|
|
2
|
+
import type { PhotoFacts } from '../server/media.ts';
|
|
3
|
+
import type { Seed, Tables } from '../table.ts';
|
|
4
|
+
export declare const STARTER_ROWS = "starter/rows.ts";
|
|
5
|
+
export declare const STARTER_PHOTOS = "starter/photos";
|
|
6
|
+
export type StarterPhotoFile = PhotoFacts & {
|
|
7
|
+
id: string;
|
|
8
|
+
alt: string;
|
|
9
|
+
path: string;
|
|
10
|
+
};
|
|
11
|
+
export type LoadedStarter = {
|
|
12
|
+
photos: readonly StarterPhotoFile[];
|
|
13
|
+
rows: Seed<Tables>;
|
|
14
|
+
};
|
|
15
|
+
export declare function readStarter(root: string, tables: Tables): Promise<LoadedStarter | undefined>;
|
|
16
|
+
export declare function withStarter(seed: Seed<Tables>, starter: LoadedStarter | undefined): Seed<Tables>;
|
|
17
|
+
export declare function starterContentOf(tables: Tables, starter: LoadedStarter): StarterContent;
|