@trypattern/sdk 0.1.8 → 0.1.10
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 +37 -8
- package/dist/app.d.ts +1 -0
- package/dist/app.js +4 -3
- package/dist/cli/app.js +120 -7
- package/dist/cli/publishing.js +3 -1
- package/dist/components/AppFrame.d.ts +5 -1
- package/dist/components/AppFrame.js +25 -7
- package/dist/components/Appear.d.ts +4 -1
- package/dist/components/Appear.js +82 -3
- package/dist/components/Card.d.ts +7 -2
- package/dist/components/Card.js +59 -3
- package/dist/components/Checkbox.d.ts +10 -0
- package/dist/components/Checkbox.js +59 -0
- package/dist/components/Choice.d.ts +15 -0
- package/dist/components/Choice.js +80 -0
- package/dist/components/ChoiceGroup.d.ts +11 -0
- package/dist/components/ChoiceGroup.js +44 -0
- package/dist/components/Icon.d.ts +2 -1
- package/dist/components/Icon.js +2 -2
- package/dist/components/Image.d.ts +11 -0
- package/dist/components/Image.js +76 -0
- package/dist/components/Link.js +8 -3
- package/dist/components/ListRow.d.ts +2 -1
- package/dist/components/ListRow.js +7 -4
- package/dist/components/PlainScreen.d.ts +16 -0
- package/dist/components/PlainScreen.js +80 -0
- package/dist/components/PlainSidebar.d.ts +15 -0
- package/dist/components/PlainSidebar.js +179 -0
- package/dist/components/Screen.d.ts +3 -1
- package/dist/components/Screen.js +5 -1
- package/dist/components/Sidebar.js +2 -1
- package/dist/components/TextField.d.ts +3 -1
- package/dist/components/TextField.js +25 -4
- package/dist/components/choiceContext.d.ts +4 -0
- package/dist/components/choiceContext.js +7 -0
- package/dist/components/frame.d.ts +2 -0
- package/dist/components/icons.d.ts +3 -0
- package/dist/components/icons.js +4 -1
- package/dist/components/interaction.d.ts +1 -1
- package/dist/entityStore.d.ts +1 -0
- package/dist/entityStore.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/media.d.ts +36 -0
- package/dist/media.js +74 -0
- package/dist/patternAccess.d.ts +21 -0
- package/dist/patternAccess.js +110 -0
- package/dist/publishWire.d.ts +318 -0
- package/dist/publishWire.js +128 -1
- package/dist/react.d.ts +5 -0
- package/dist/react.js +4 -0
- package/dist/rules/checkApiRouting.js +1 -1
- package/dist/rules/lintPlugin.js +13 -4
- package/dist/rules/messages.d.ts +1 -0
- package/dist/rules/messages.js +1 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +1 -0
- package/dist/server/createServer.d.ts +92 -6
- package/dist/server/createServer.js +38 -2
- package/dist/server/database.d.ts +74 -0
- package/dist/server/database.js +93 -0
- package/dist/server/env.d.ts +3 -1
- package/dist/server/imageHeader.d.ts +10 -0
- package/dist/server/imageHeader.js +159 -0
- package/dist/server/index.d.ts +6 -1
- package/dist/server/index.js +3 -0
- package/dist/server/jsonLd.d.ts +45 -0
- package/dist/server/jsonLd.js +88 -0
- package/dist/server/keyset.d.ts +13 -0
- package/dist/server/keyset.js +52 -0
- package/dist/server/media.d.ts +16 -0
- package/dist/server/media.js +135 -0
- package/dist/server/pages.d.ts +72 -0
- package/dist/server/pages.js +301 -0
- package/dist/server/patternKeys.d.ts +1 -0
- package/dist/server/patternKeys.js +3 -0
- package/dist/server/patternViewer.d.ts +6 -0
- package/dist/server/patternViewer.js +27 -0
- package/dist/server/services.js +1 -1
- package/dist/server/tables.d.ts +19 -3
- package/dist/server/tables.js +38 -14
- package/dist/serviceClients.js +3 -5
- package/dist/start.js +26 -1
- package/dist/table.d.ts +1 -0
- package/dist/table.js +8 -1
- package/dist/tables.d.ts +2 -1
- package/dist/tables.js +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
2
|
+
const CURRENCY = /^[A-Z]{3}$/;
|
|
3
|
+
function present(fields) {
|
|
4
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined &&
|
|
5
|
+
value !== '' &&
|
|
6
|
+
!(Array.isArray(value) && value.length === 0)));
|
|
7
|
+
}
|
|
8
|
+
function postalAddress(address) {
|
|
9
|
+
if (address === undefined)
|
|
10
|
+
return undefined;
|
|
11
|
+
return {
|
|
12
|
+
'@type': 'PostalAddress',
|
|
13
|
+
...present({
|
|
14
|
+
streetAddress: address.street,
|
|
15
|
+
addressLocality: address.locality,
|
|
16
|
+
addressRegion: address.region,
|
|
17
|
+
postalCode: address.postalCode,
|
|
18
|
+
addressCountry: address.country
|
|
19
|
+
})
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function openingHours(hours) {
|
|
23
|
+
return hours?.map(({ days, opens, closes }) => {
|
|
24
|
+
if (!TIME.test(opens) || !TIME.test(closes)) {
|
|
25
|
+
throw new Error(`Opening hours are 24-hour HH:MM times, like 09:00 and 17:30 (got ${opens} to ${closes})`);
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
'@type': 'OpeningHoursSpecification',
|
|
29
|
+
dayOfWeek: typeof days === 'string' ? days : [...days],
|
|
30
|
+
opens,
|
|
31
|
+
closes
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function localBusiness(facts) {
|
|
36
|
+
return {
|
|
37
|
+
'@context': 'https://schema.org',
|
|
38
|
+
'@type': facts.type ?? 'LocalBusiness',
|
|
39
|
+
...present({
|
|
40
|
+
name: facts.name,
|
|
41
|
+
url: facts.url,
|
|
42
|
+
image: facts.image,
|
|
43
|
+
description: facts.description,
|
|
44
|
+
telephone: facts.telephone,
|
|
45
|
+
email: facts.email,
|
|
46
|
+
address: postalAddress(facts.address),
|
|
47
|
+
openingHoursSpecification: openingHours(facts.hours),
|
|
48
|
+
priceRange: facts.priceRange,
|
|
49
|
+
sameAs: facts.sameAs
|
|
50
|
+
})
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function product(facts) {
|
|
54
|
+
const { offer } = facts;
|
|
55
|
+
if (offer !== undefined) {
|
|
56
|
+
if (!Number.isFinite(offer.price) || offer.price < 0) {
|
|
57
|
+
throw new Error(`A price is a number of at least 0 (got ${offer.price})`);
|
|
58
|
+
}
|
|
59
|
+
if (!CURRENCY.test(offer.currency)) {
|
|
60
|
+
throw new Error(`A currency is a three-letter ISO 4217 code, like CAD or USD (got ${offer.currency})`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
'@context': 'https://schema.org',
|
|
65
|
+
'@type': 'Product',
|
|
66
|
+
...present({
|
|
67
|
+
name: facts.name,
|
|
68
|
+
url: facts.url,
|
|
69
|
+
image: facts.image,
|
|
70
|
+
description: facts.description,
|
|
71
|
+
sku: facts.sku,
|
|
72
|
+
brand: facts.brand === undefined
|
|
73
|
+
? undefined
|
|
74
|
+
: { '@type': 'Brand', name: facts.brand },
|
|
75
|
+
offers: offer === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: present({
|
|
78
|
+
'@type': 'Offer',
|
|
79
|
+
price: offer.price.toFixed(2),
|
|
80
|
+
priceCurrency: offer.currency,
|
|
81
|
+
availability: offer.availability === undefined
|
|
82
|
+
? undefined
|
|
83
|
+
: `https://schema.org/${offer.availability}`,
|
|
84
|
+
url: facts.url
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SQLiteColumn } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import type { Context } from 'hono';
|
|
3
|
+
import type { ServerEnv } from './env.ts';
|
|
4
|
+
export declare const PAGE_PROBLEM = "limit must be 1 to 1000, after a cursor";
|
|
5
|
+
export type Page = {
|
|
6
|
+
readonly limit: number;
|
|
7
|
+
readonly after: readonly [string, string] | undefined;
|
|
8
|
+
};
|
|
9
|
+
export declare function pageOf(c: Context<ServerEnv>): Page | undefined | 'invalid';
|
|
10
|
+
export declare function afterCursor(createdAt: SQLiteColumn, id: SQLiteColumn, page: Page | undefined): import("drizzle-orm").SQL<unknown> | undefined;
|
|
11
|
+
export declare function pageReply(c: Context<ServerEnv>, rows: readonly Readonly<Record<string, unknown>>[], page: Page | undefined): Response & import("hono").TypedResponse<readonly {
|
|
12
|
+
readonly [x: string]: import("hono/utils/types").JSONValue;
|
|
13
|
+
}[], import("hono/utils/http-status").ContentfulStatusCode, "json">;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { and, eq, gt, or } from 'drizzle-orm';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { NEXT_PAGE_HEADER } from "../patternAccess.js";
|
|
4
|
+
const PAGE_LIMIT = 1000;
|
|
5
|
+
export const PAGE_PROBLEM = 'limit must be 1 to 1000, after a cursor';
|
|
6
|
+
const pageQuery = z.object({
|
|
7
|
+
limit: z.coerce.number().int().min(1).max(PAGE_LIMIT).optional(),
|
|
8
|
+
after: z.string().min(1).optional()
|
|
9
|
+
});
|
|
10
|
+
const cursor = z.tuple([z.string(), z.string()]);
|
|
11
|
+
function cursorOf(row) {
|
|
12
|
+
return btoa(JSON.stringify([row['createdAt'], row['id']]))
|
|
13
|
+
.replaceAll('+', '-')
|
|
14
|
+
.replaceAll('/', '_')
|
|
15
|
+
.replaceAll('=', '');
|
|
16
|
+
}
|
|
17
|
+
function cursorParts(text) {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = cursor.safeParse(JSON.parse(atob(text.replaceAll('-', '+').replaceAll('_', '/'))));
|
|
20
|
+
return parsed.success ? parsed.data : undefined;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export function pageOf(c) {
|
|
27
|
+
const parsed = pageQuery.safeParse(c.req.query());
|
|
28
|
+
if (!parsed.success)
|
|
29
|
+
return 'invalid';
|
|
30
|
+
const { limit, after } = parsed.data;
|
|
31
|
+
if (limit === undefined)
|
|
32
|
+
return after === undefined ? undefined : 'invalid';
|
|
33
|
+
if (after === undefined)
|
|
34
|
+
return { limit, after: undefined };
|
|
35
|
+
const parts = cursorParts(after);
|
|
36
|
+
return parts === undefined ? 'invalid' : { limit, after: parts };
|
|
37
|
+
}
|
|
38
|
+
export function afterCursor(createdAt, id, page) {
|
|
39
|
+
if (page?.after === undefined)
|
|
40
|
+
return undefined;
|
|
41
|
+
const [at, lastId] = page.after;
|
|
42
|
+
return or(gt(createdAt, at), and(eq(createdAt, at), gt(id, lastId)));
|
|
43
|
+
}
|
|
44
|
+
export function pageReply(c, rows, page) {
|
|
45
|
+
if (page === undefined || rows.length <= page.limit)
|
|
46
|
+
return c.json(rows);
|
|
47
|
+
const shown = rows.slice(0, page.limit);
|
|
48
|
+
const last = shown.at(-1);
|
|
49
|
+
if (last !== undefined)
|
|
50
|
+
c.header(NEXT_PAGE_HEADER, cursorOf(last));
|
|
51
|
+
return c.json(shown);
|
|
52
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { ServerEnv } from './env.ts';
|
|
3
|
+
export declare function uploadMedia(c: Context<ServerEnv>): Promise<(Response & import("hono").TypedResponse<{
|
|
4
|
+
error: string;
|
|
5
|
+
}, 401, "json">) | (Response & import("hono").TypedResponse<{
|
|
6
|
+
error: string;
|
|
7
|
+
}, 403, "json">) | (Response & import("hono").TypedResponse<{
|
|
8
|
+
error: string;
|
|
9
|
+
}, 503, "json">) | (Response & import("hono").TypedResponse<{
|
|
10
|
+
error: string;
|
|
11
|
+
}, 413, "json">) | (Response & import("hono").TypedResponse<{
|
|
12
|
+
error: string;
|
|
13
|
+
}, 400, "json">) | (Response & import("hono").TypedResponse<{
|
|
14
|
+
error: string;
|
|
15
|
+
}, 415, "json">) | (Response & import("hono").TypedResponse<{}, 201, "json">)>;
|
|
16
|
+
export declare function serveMedia(c: Context<ServerEnv>): Promise<Response>;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { drizzle } from 'drizzle-orm/d1';
|
|
2
|
+
import { MEDIA_LIMIT_BYTES, media, mediaPath } from "../media.js";
|
|
3
|
+
import { isHeic, readImageHeader } from "./imageHeader.js";
|
|
4
|
+
import { sqlTable } from "./tables.js";
|
|
5
|
+
const FORM_OVERHEAD_BYTES = 64 * 1024;
|
|
6
|
+
const ALT_LIMIT = 500;
|
|
7
|
+
const KEY = /^images\/[0-9a-f]{16}\.(?:jpg|png|webp|gif|avif)$/;
|
|
8
|
+
const IMMUTABLE = 'public, max-age=31536000, immutable';
|
|
9
|
+
const PLACEHOLDER = {
|
|
10
|
+
width: 24,
|
|
11
|
+
quality: 50,
|
|
12
|
+
format: 'jpeg',
|
|
13
|
+
fit: 'scale-down'
|
|
14
|
+
};
|
|
15
|
+
const PLACEHOLDER_LIMIT_BYTES = 4096;
|
|
16
|
+
const NO_BUCKET = 'This app has no MEDIA bucket. Add "r2_buckets": [{ "binding": "MEDIA", "bucket_name": "media" }] to wrangler.jsonc; publishing creates the real one.';
|
|
17
|
+
const NO_ALT = 'Describe this photo for someone who can’t see it: alt is required.';
|
|
18
|
+
const TOO_LARGE = 'Photos can be up to 10 MB.';
|
|
19
|
+
const HEIC = 'This is an iPhone HEIC photo, which browsers can’t show. Send it as a JPEG instead.';
|
|
20
|
+
const UNKNOWN_TYPE = 'Photos must be JPEG, PNG, WebP, GIF or AVIF.';
|
|
21
|
+
const target = sqlTable(media);
|
|
22
|
+
function hex(bytes) {
|
|
23
|
+
return [...new Uint8Array(bytes)]
|
|
24
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
25
|
+
.join('');
|
|
26
|
+
}
|
|
27
|
+
function base64(bytes) {
|
|
28
|
+
let text = '';
|
|
29
|
+
const view = new Uint8Array(bytes);
|
|
30
|
+
for (let index = 0; index < view.length; index += 0x8000) {
|
|
31
|
+
text += String.fromCharCode(...view.subarray(index, index + 0x8000));
|
|
32
|
+
}
|
|
33
|
+
return btoa(text);
|
|
34
|
+
}
|
|
35
|
+
async function placeholderOf(c, key) {
|
|
36
|
+
if (import.meta.env.DEV)
|
|
37
|
+
return null;
|
|
38
|
+
const init = { method: 'GET', cf: { image: PLACEHOLDER } };
|
|
39
|
+
try {
|
|
40
|
+
const response = await fetch(new URL(mediaPath(key), c.req.url).href, init);
|
|
41
|
+
const type = response.headers.get('content-type') ?? '';
|
|
42
|
+
const length = Number(response.headers.get('content-length') ?? 0);
|
|
43
|
+
if (!response.ok ||
|
|
44
|
+
!type.startsWith('image/jpeg') ||
|
|
45
|
+
length > PLACEHOLDER_LIMIT_BYTES) {
|
|
46
|
+
await response.body?.cancel();
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const bytes = await response.arrayBuffer();
|
|
50
|
+
return bytes.byteLength > PLACEHOLDER_LIMIT_BYTES
|
|
51
|
+
? null
|
|
52
|
+
: `data:image/jpeg;base64,${base64(bytes)}`;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function uploadMedia(c) {
|
|
59
|
+
const viewer = c.get('viewer');
|
|
60
|
+
if (viewer === null)
|
|
61
|
+
return c.json({ error: 'Signed out' }, 401);
|
|
62
|
+
if (!viewer.isAdmin)
|
|
63
|
+
return c.json({ error: 'forbidden' }, 403);
|
|
64
|
+
const bucket = c.env.MEDIA;
|
|
65
|
+
if (bucket === undefined)
|
|
66
|
+
return c.json({ error: NO_BUCKET }, 503);
|
|
67
|
+
const declared = Number(c.req.header('content-length') ?? 0);
|
|
68
|
+
if (declared > MEDIA_LIMIT_BYTES + FORM_OVERHEAD_BYTES) {
|
|
69
|
+
return c.json({ error: TOO_LARGE }, 413);
|
|
70
|
+
}
|
|
71
|
+
const form = await c.req.formData().catch(() => undefined);
|
|
72
|
+
const file = form?.get('file');
|
|
73
|
+
const alt = form?.get('alt');
|
|
74
|
+
if (!(file instanceof File)) {
|
|
75
|
+
return c.json({ error: 'Send the photo as the form field "file".' }, 400);
|
|
76
|
+
}
|
|
77
|
+
if (typeof alt !== 'string' || alt.trim() === '') {
|
|
78
|
+
return c.json({ error: NO_ALT }, 400);
|
|
79
|
+
}
|
|
80
|
+
if (alt.trim().length > ALT_LIMIT) {
|
|
81
|
+
return c.json({ error: `Keep alt to ${ALT_LIMIT} characters.` }, 400);
|
|
82
|
+
}
|
|
83
|
+
if (file.size > MEDIA_LIMIT_BYTES)
|
|
84
|
+
return c.json({ error: TOO_LARGE }, 413);
|
|
85
|
+
const buffer = await file.arrayBuffer();
|
|
86
|
+
const bytes = new Uint8Array(buffer);
|
|
87
|
+
if (isHeic(bytes))
|
|
88
|
+
return c.json({ error: HEIC }, 415);
|
|
89
|
+
const header = readImageHeader(bytes);
|
|
90
|
+
if (header === undefined)
|
|
91
|
+
return c.json({ error: UNKNOWN_TYPE }, 415);
|
|
92
|
+
const digest = await crypto.subtle.digest('SHA-256', buffer);
|
|
93
|
+
const key = `images/${hex(digest).slice(0, 16)}.${header.extension}`;
|
|
94
|
+
await bucket.put(key, buffer, {
|
|
95
|
+
httpMetadata: { contentType: header.type, cacheControl: IMMUTABLE }
|
|
96
|
+
});
|
|
97
|
+
const row = await drizzle(c.env.DB)
|
|
98
|
+
.insert(target)
|
|
99
|
+
.values({
|
|
100
|
+
id: crypto.randomUUID(),
|
|
101
|
+
key,
|
|
102
|
+
width: header.width,
|
|
103
|
+
height: header.height,
|
|
104
|
+
placeholder: await placeholderOf(c, key),
|
|
105
|
+
alt: alt.trim(),
|
|
106
|
+
type: header.type,
|
|
107
|
+
bytes: bytes.byteLength
|
|
108
|
+
})
|
|
109
|
+
.returning()
|
|
110
|
+
.get();
|
|
111
|
+
return c.json(row, 201);
|
|
112
|
+
}
|
|
113
|
+
function unquoted(etag) {
|
|
114
|
+
return etag.replace(/^W\//, '').replaceAll('"', '').trim();
|
|
115
|
+
}
|
|
116
|
+
export async function serveMedia(c) {
|
|
117
|
+
const key = decodeURIComponent(c.req.path.slice('/media/'.length));
|
|
118
|
+
const bucket = c.env.MEDIA;
|
|
119
|
+
if (!KEY.test(key) || bucket === undefined) {
|
|
120
|
+
return c.text('No such photo', 404);
|
|
121
|
+
}
|
|
122
|
+
const seen = c.req.header('if-none-match');
|
|
123
|
+
const object = await bucket.get(key, seen === undefined ? {} : { onlyIf: { etagDoesNotMatch: unquoted(seen) } });
|
|
124
|
+
if (object === null)
|
|
125
|
+
return c.text('No such photo', 404);
|
|
126
|
+
const headers = new Headers({
|
|
127
|
+
'cache-control': IMMUTABLE,
|
|
128
|
+
'content-type': object.httpMetadata?.contentType ?? 'application/octet-stream',
|
|
129
|
+
etag: object.httpEtag,
|
|
130
|
+
'x-content-type-options': 'nosniff'
|
|
131
|
+
});
|
|
132
|
+
if (!('body' in object))
|
|
133
|
+
return new Response(null, { status: 304, headers });
|
|
134
|
+
return new Response(object.body, { headers });
|
|
135
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Context } from 'hono';
|
|
2
|
+
import type { MediaImage } from '../media.ts';
|
|
3
|
+
import type { Row, Tables } from '../table.ts';
|
|
4
|
+
import type { ServerEnv } from './env.ts';
|
|
5
|
+
import type { JsonLd } from './jsonLd.ts';
|
|
6
|
+
type ReadableName<Group extends Tables> = {
|
|
7
|
+
[Key in keyof Group & string]: Group[Key]['access']['read'] extends 'anyone' ? Key : never;
|
|
8
|
+
}[keyof Group & string];
|
|
9
|
+
type Answer<Value> = Value | Promise<Value>;
|
|
10
|
+
export type PageParams = Readonly<Record<string, string>>;
|
|
11
|
+
export type PageRequest<Group extends Tables> = {
|
|
12
|
+
url: URL;
|
|
13
|
+
params: PageParams;
|
|
14
|
+
rows: <Name extends ReadableName<Group>>(table: Name) => Promise<readonly Row<Group[Name]>[]>;
|
|
15
|
+
row: <Name extends ReadableName<Group>>(table: Name, id: string) => Promise<Row<Group[Name]> | undefined>;
|
|
16
|
+
photoUrl: (image: MediaImage) => string;
|
|
17
|
+
};
|
|
18
|
+
export type PageHero = {
|
|
19
|
+
image: MediaImage;
|
|
20
|
+
sizes: string;
|
|
21
|
+
aspect?: number;
|
|
22
|
+
};
|
|
23
|
+
export type PageHead<Group extends Tables> = {
|
|
24
|
+
title: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
image?: MediaImage | null;
|
|
27
|
+
hero?: PageHero | null;
|
|
28
|
+
jsonLd?: JsonLd | readonly JsonLd[];
|
|
29
|
+
data?: {
|
|
30
|
+
[Name in ReadableName<Group>]?: readonly Row<Group[Name]>[];
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
type HeadOf<Group extends Tables> = (request: PageRequest<Group>) => Answer<PageHead<Group> | null>;
|
|
34
|
+
export type PageDefinition<Group extends Tables> = HeadOf<Group> | {
|
|
35
|
+
head: HeadOf<Group>;
|
|
36
|
+
paths: (request: PageRequest<Group>) => Answer<readonly PageParams[]>;
|
|
37
|
+
};
|
|
38
|
+
type AnyHead = {
|
|
39
|
+
title: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
image?: MediaImage | null;
|
|
42
|
+
hero?: PageHero | null;
|
|
43
|
+
jsonLd?: JsonLd | readonly JsonLd[];
|
|
44
|
+
data?: Readonly<Record<string, readonly unknown[] | undefined>>;
|
|
45
|
+
};
|
|
46
|
+
type AnyRequest = {
|
|
47
|
+
url: URL;
|
|
48
|
+
params: PageParams;
|
|
49
|
+
rows: (table: string) => Promise<readonly unknown[]>;
|
|
50
|
+
row: (table: string, id: string) => Promise<unknown>;
|
|
51
|
+
photoUrl: (image: MediaImage) => string;
|
|
52
|
+
};
|
|
53
|
+
type PageRoute = {
|
|
54
|
+
pattern: string;
|
|
55
|
+
segments: readonly string[];
|
|
56
|
+
head: (request: AnyRequest) => Answer<AnyHead | null>;
|
|
57
|
+
paths?: (request: AnyRequest) => Answer<readonly PageParams[]>;
|
|
58
|
+
};
|
|
59
|
+
export type Pages = {
|
|
60
|
+
readonly tables: Tables;
|
|
61
|
+
readonly routes: readonly PageRoute[];
|
|
62
|
+
};
|
|
63
|
+
export declare function definePages<Group extends Tables>(tables: Group, pages: Readonly<Record<string, PageDefinition<Group>>>): Pages;
|
|
64
|
+
export declare function matchPage(pages: Pages, path: string): {
|
|
65
|
+
route: PageRoute;
|
|
66
|
+
params: PageParams;
|
|
67
|
+
} | undefined;
|
|
68
|
+
export declare function wantsHtml(c: Context<ServerEnv>): boolean;
|
|
69
|
+
export declare function renderPage(c: Context<ServerEnv>, pages: Pages, route: PageRoute, params: PageParams): Promise<Response>;
|
|
70
|
+
export declare function sitemap(c: Context<ServerEnv>, pages: Pages): Promise<Response>;
|
|
71
|
+
export declare function robots(c: Context<ServerEnv>): Response;
|
|
72
|
+
export {};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { imageSources, mediaPath, transformsOn } from "../media.js";
|
|
2
|
+
import { readTitle } from "./settings.js";
|
|
3
|
+
import { readRow, readRows } from "./tables.js";
|
|
4
|
+
const SEGMENT = /^(:[A-Za-z_]\w*|[\w.~-]+)$/;
|
|
5
|
+
const OG_WIDTH = 1200;
|
|
6
|
+
const OG_HEIGHT = 630;
|
|
7
|
+
const SITEMAP_LIMIT = 1000;
|
|
8
|
+
const DATA_ID = 'pattern-data';
|
|
9
|
+
function segmentsOf(pattern) {
|
|
10
|
+
if (!pattern.startsWith('/')) {
|
|
11
|
+
throw new Error(`A page path starts with /: "${pattern}"`);
|
|
12
|
+
}
|
|
13
|
+
const segments = pattern.split('/').filter((segment) => segment !== '');
|
|
14
|
+
const wrong = segments.find((segment) => !SEGMENT.test(segment));
|
|
15
|
+
if (wrong !== undefined) {
|
|
16
|
+
throw new Error(`"${pattern}" is not a page path: use words and :params, like /items/:itemId`);
|
|
17
|
+
}
|
|
18
|
+
return segments;
|
|
19
|
+
}
|
|
20
|
+
export function definePages(tables, pages) {
|
|
21
|
+
return {
|
|
22
|
+
tables,
|
|
23
|
+
routes: Object.entries(pages).map(([pattern, definition]) => {
|
|
24
|
+
const page = definition;
|
|
25
|
+
return {
|
|
26
|
+
pattern,
|
|
27
|
+
segments: segmentsOf(pattern),
|
|
28
|
+
...(typeof page === 'function' ? { head: page } : page)
|
|
29
|
+
};
|
|
30
|
+
})
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function matchPage(pages, path) {
|
|
34
|
+
const parts = path
|
|
35
|
+
.split('/')
|
|
36
|
+
.filter((part) => part !== '')
|
|
37
|
+
.map((part) => {
|
|
38
|
+
try {
|
|
39
|
+
return decodeURIComponent(part);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return part;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
for (const route of pages.routes) {
|
|
46
|
+
if (route.segments.length !== parts.length)
|
|
47
|
+
continue;
|
|
48
|
+
const params = {};
|
|
49
|
+
const matches = route.segments.every((segment, index) => {
|
|
50
|
+
const part = parts[index] ?? '';
|
|
51
|
+
if (segment.startsWith(':')) {
|
|
52
|
+
params[segment.slice(1)] = part;
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return segment === part;
|
|
56
|
+
});
|
|
57
|
+
if (matches)
|
|
58
|
+
return { route, params };
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function readableTable(pages, name) {
|
|
63
|
+
const table = pages.tables[name];
|
|
64
|
+
if (table === undefined)
|
|
65
|
+
throw new Error(`There is no table "${name}"`);
|
|
66
|
+
if (table.access.read !== 'anyone') {
|
|
67
|
+
throw new Error(`Pages are public, so they read only tables anyone may read; "${name}" has read: '${table.access.read}'`);
|
|
68
|
+
}
|
|
69
|
+
return table;
|
|
70
|
+
}
|
|
71
|
+
function requestFor(c, pages, params) {
|
|
72
|
+
const url = new URL(c.req.url);
|
|
73
|
+
const read = new Map();
|
|
74
|
+
return {
|
|
75
|
+
url,
|
|
76
|
+
params,
|
|
77
|
+
rows: (name) => {
|
|
78
|
+
const table = readableTable(pages, name);
|
|
79
|
+
const found = read.get(name) ?? readRows(c.env.DB, table);
|
|
80
|
+
read.set(name, found);
|
|
81
|
+
return found;
|
|
82
|
+
},
|
|
83
|
+
row: (name, id) => readRow(c.env.DB, readableTable(pages, name), id),
|
|
84
|
+
photoUrl: (image) => `${url.origin}${mediaPath(image.key)}`
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
function escapeHtml(text) {
|
|
88
|
+
return text
|
|
89
|
+
.replaceAll('&', '&')
|
|
90
|
+
.replaceAll('<', '<')
|
|
91
|
+
.replaceAll('>', '>')
|
|
92
|
+
.replaceAll('"', '"')
|
|
93
|
+
.replaceAll("'", ''');
|
|
94
|
+
}
|
|
95
|
+
function scriptJson(value) {
|
|
96
|
+
return JSON.stringify(value)
|
|
97
|
+
.replaceAll('<', '\\u003c')
|
|
98
|
+
.replaceAll('
', '\\u2028')
|
|
99
|
+
.replaceAll('
', '\\u2029');
|
|
100
|
+
}
|
|
101
|
+
function meta(attribute, key, content) {
|
|
102
|
+
return `<meta ${attribute}="${key}" content="${escapeHtml(content)}">`;
|
|
103
|
+
}
|
|
104
|
+
function openGraphImage(url, image) {
|
|
105
|
+
if (!transformsOn(url.hostname)) {
|
|
106
|
+
return {
|
|
107
|
+
src: `${url.origin}${mediaPath(image.key)}`,
|
|
108
|
+
width: image.width,
|
|
109
|
+
height: image.height
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const options = `width=${OG_WIDTH},height=${OG_HEIGHT},quality=82,format=jpeg,fit=cover`;
|
|
113
|
+
return {
|
|
114
|
+
src: `${url.origin}/cdn-cgi/image/${options}${mediaPath(image.key)}`,
|
|
115
|
+
width: OG_WIDTH,
|
|
116
|
+
height: OG_HEIGHT
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function heroPreload(url, hero) {
|
|
120
|
+
const sources = imageSources(hero.image, {
|
|
121
|
+
aspect: hero.aspect,
|
|
122
|
+
transforms: transformsOn(url.hostname)
|
|
123
|
+
});
|
|
124
|
+
const attributes = [
|
|
125
|
+
'rel="preload"',
|
|
126
|
+
'as="image"',
|
|
127
|
+
'fetchpriority="high"',
|
|
128
|
+
`href="${escapeHtml(sources.src)}"`,
|
|
129
|
+
...(sources.srcSet === undefined
|
|
130
|
+
? []
|
|
131
|
+
: [
|
|
132
|
+
`imagesrcset="${escapeHtml(sources.srcSet)}"`,
|
|
133
|
+
`imagesizes="${escapeHtml(hero.sizes)}"`
|
|
134
|
+
])
|
|
135
|
+
];
|
|
136
|
+
return `<link ${attributes.join(' ')}>`;
|
|
137
|
+
}
|
|
138
|
+
function dataOf(pages, head) {
|
|
139
|
+
return Object.fromEntries(Object.entries(head.data ?? {}).flatMap(([name, rows]) => {
|
|
140
|
+
if (rows === undefined)
|
|
141
|
+
return [];
|
|
142
|
+
readableTable(pages, name);
|
|
143
|
+
return [[name, rows]];
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
function headTags(pages, url, siteName, head) {
|
|
147
|
+
const canonical = `${url.origin}${url.pathname}`;
|
|
148
|
+
const image = head.image ?? head.hero?.image;
|
|
149
|
+
const og = image === undefined || image === null
|
|
150
|
+
? undefined
|
|
151
|
+
: openGraphImage(url, image);
|
|
152
|
+
const jsonLd = head.jsonLd === undefined
|
|
153
|
+
? []
|
|
154
|
+
: Array.isArray(head.jsonLd)
|
|
155
|
+
? head.jsonLd
|
|
156
|
+
: [head.jsonLd];
|
|
157
|
+
return [
|
|
158
|
+
...(head.description === undefined
|
|
159
|
+
? []
|
|
160
|
+
: [
|
|
161
|
+
meta('name', 'description', head.description),
|
|
162
|
+
meta('property', 'og:description', head.description)
|
|
163
|
+
]),
|
|
164
|
+
`<link rel="canonical" href="${escapeHtml(canonical)}">`,
|
|
165
|
+
meta('property', 'og:type', 'website'),
|
|
166
|
+
meta('property', 'og:title', head.title),
|
|
167
|
+
meta('property', 'og:url', canonical),
|
|
168
|
+
...(siteName === '' ? [] : [meta('property', 'og:site_name', siteName)]),
|
|
169
|
+
...(og === undefined || image === undefined || image === null
|
|
170
|
+
? [meta('name', 'twitter:card', 'summary')]
|
|
171
|
+
: [
|
|
172
|
+
meta('property', 'og:image', og.src),
|
|
173
|
+
meta('property', 'og:image:width', String(og.width)),
|
|
174
|
+
meta('property', 'og:image:height', String(og.height)),
|
|
175
|
+
meta('property', 'og:image:alt', image.alt),
|
|
176
|
+
meta('name', 'twitter:card', 'summary_large_image')
|
|
177
|
+
]),
|
|
178
|
+
...(head.hero === undefined || head.hero === null
|
|
179
|
+
? []
|
|
180
|
+
: [heroPreload(url, head.hero)]),
|
|
181
|
+
...jsonLd.map((item) => `<script type="application/ld+json">${scriptJson(item)}</script>`),
|
|
182
|
+
`<script type="application/json" id="${DATA_ID}">${scriptJson(dataOf(pages, head))}</script>`
|
|
183
|
+
].join('\n ');
|
|
184
|
+
}
|
|
185
|
+
const TITLE = /<title>[\s\S]*?<\/title>/;
|
|
186
|
+
const DESCRIPTION = /\s*<meta\s+name="description"[^>]*>/gi;
|
|
187
|
+
function rewrite(shell, title, tags) {
|
|
188
|
+
const titleTag = `<title>${escapeHtml(title)}</title>`;
|
|
189
|
+
const titled = TITLE.test(shell)
|
|
190
|
+
? shell.replace(TITLE, titleTag)
|
|
191
|
+
: shell.replace('</head>', ` ${titleTag}\n </head>`);
|
|
192
|
+
return titled
|
|
193
|
+
.replace(DESCRIPTION, '')
|
|
194
|
+
.replace('</head>', ` ${tags}\n </head>`);
|
|
195
|
+
}
|
|
196
|
+
async function shellOf(c) {
|
|
197
|
+
const assets = c.env.ASSETS;
|
|
198
|
+
if (assets === undefined)
|
|
199
|
+
return undefined;
|
|
200
|
+
const response = await assets.fetch(new URL('/', c.req.url).href);
|
|
201
|
+
return response.ok ? response.text() : undefined;
|
|
202
|
+
}
|
|
203
|
+
const HTML = { 'content-type': 'text/html; charset=utf-8' };
|
|
204
|
+
export function wantsHtml(c) {
|
|
205
|
+
return (c.req.method === 'GET' &&
|
|
206
|
+
(c.req.header('sec-fetch-dest') === 'document' ||
|
|
207
|
+
(c.req.header('accept') ?? '').includes('text/html')));
|
|
208
|
+
}
|
|
209
|
+
export async function renderPage(c, pages, route, params) {
|
|
210
|
+
const shell = await shellOf(c);
|
|
211
|
+
if (shell === undefined) {
|
|
212
|
+
return c.text('Pages need the built index.html: add "binding": "ASSETS" under "assets" in wrangler.jsonc', 500);
|
|
213
|
+
}
|
|
214
|
+
const request = requestFor(c, pages, params);
|
|
215
|
+
let head;
|
|
216
|
+
let tags = '';
|
|
217
|
+
try {
|
|
218
|
+
head = await route.head(request);
|
|
219
|
+
if (head !== null) {
|
|
220
|
+
tags = headTags(pages, request.url, await readTitle(c.env.DB), head);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
if (import.meta.env.DEV)
|
|
225
|
+
throw error;
|
|
226
|
+
console.error(`Page ${route.pattern} failed:`, error);
|
|
227
|
+
return new Response(shell, { headers: HTML });
|
|
228
|
+
}
|
|
229
|
+
if (head === null) {
|
|
230
|
+
const missing = rewrite(shell, 'Not found', `${meta('name', 'robots', 'noindex')}\n <script type="application/json" id="${DATA_ID}">{}</script>`);
|
|
231
|
+
return new Response(missing, { status: 404, headers: HTML });
|
|
232
|
+
}
|
|
233
|
+
return new Response(rewrite(shell, head.title, tags), { headers: HTML });
|
|
234
|
+
}
|
|
235
|
+
function fill(route, params) {
|
|
236
|
+
const parts = route.segments.map((segment) => segment.startsWith(':') ? params[segment.slice(1)] : segment);
|
|
237
|
+
if (parts.some((part) => part === undefined || part === ''))
|
|
238
|
+
return undefined;
|
|
239
|
+
return `/${parts.map((part) => encodeURIComponent(part ?? '')).join('/')}`;
|
|
240
|
+
}
|
|
241
|
+
async function sitemapEntries(c, pages) {
|
|
242
|
+
const entries = [];
|
|
243
|
+
for (const route of pages.routes) {
|
|
244
|
+
const isStatic = route.segments.every((segment) => !segment.startsWith(':'));
|
|
245
|
+
const listed = isStatic
|
|
246
|
+
? [{}]
|
|
247
|
+
: route.paths === undefined
|
|
248
|
+
? []
|
|
249
|
+
: await route.paths(requestFor(c, pages, {}));
|
|
250
|
+
for (const params of listed) {
|
|
251
|
+
const path = fill(route, params);
|
|
252
|
+
if (path !== undefined)
|
|
253
|
+
entries.push({ path, route, params });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return entries.slice(0, SITEMAP_LIMIT);
|
|
257
|
+
}
|
|
258
|
+
export async function sitemap(c, pages) {
|
|
259
|
+
const origin = new URL(c.req.url).origin;
|
|
260
|
+
const urls = [];
|
|
261
|
+
for (const { path, route, params } of await sitemapEntries(c, pages)) {
|
|
262
|
+
const request = requestFor(c, pages, params);
|
|
263
|
+
const head = await (async () => route.head(request))().catch(() => null);
|
|
264
|
+
if (head === null)
|
|
265
|
+
continue;
|
|
266
|
+
const image = head.image ?? head.hero?.image;
|
|
267
|
+
urls.push([
|
|
268
|
+
' <url>',
|
|
269
|
+
` <loc>${escapeHtml(`${origin}${path}`)}</loc>`,
|
|
270
|
+
...(image === undefined || image === null
|
|
271
|
+
? []
|
|
272
|
+
: [
|
|
273
|
+
' <image:image>',
|
|
274
|
+
` <image:loc>${escapeHtml(request.photoUrl(image))}</image:loc>`,
|
|
275
|
+
` <image:caption>${escapeHtml(image.alt)}</image:caption>`,
|
|
276
|
+
' </image:image>'
|
|
277
|
+
]),
|
|
278
|
+
' </url>'
|
|
279
|
+
].join('\n'));
|
|
280
|
+
}
|
|
281
|
+
const xml = [
|
|
282
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
283
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">',
|
|
284
|
+
...urls,
|
|
285
|
+
'</urlset>',
|
|
286
|
+
''
|
|
287
|
+
].join('\n');
|
|
288
|
+
return new Response(xml, {
|
|
289
|
+
headers: { 'content-type': 'application/xml; charset=utf-8' }
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
export function robots(c) {
|
|
293
|
+
const origin = new URL(c.req.url).origin;
|
|
294
|
+
return new Response([
|
|
295
|
+
'User-agent: *',
|
|
296
|
+
'Allow: /',
|
|
297
|
+
'Disallow: /api/',
|
|
298
|
+
`Sitemap: ${origin}/sitemap.xml`,
|
|
299
|
+
''
|
|
300
|
+
].join('\n'), { headers: { 'content-type': 'text/plain; charset=utf-8' } });
|
|
301
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const PATTERN_KEYS: Readonly<Record<string, string>>;
|