@trypattern/sdk 0.1.9 → 0.1.11
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 +119 -6
- package/dist/cli/publishing.js +2 -0
- package/dist/components/AppFrame.d.ts +4 -1
- package/dist/components/AppFrame.js +25 -7
- 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/ListRow.d.ts +3 -1
- package/dist/components/ListRow.js +18 -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/ScrollArea.d.ts +2 -1
- package/dist/components/ScrollArea.js +7 -2
- package/dist/components/Text.d.ts +1 -1
- package/dist/components/Text.js +10 -4
- package/dist/components/TextField.d.ts +5 -1
- package/dist/components/TextField.js +47 -9
- 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/publishWire.d.ts +2 -0
- package/dist/publishWire.js +21 -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 +5 -3
- package/dist/server/createServer.js +32 -2
- 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/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/session.js +1 -1
- package/dist/server/tables.d.ts +3 -0
- package/dist/server/tables.js +23 -8
- package/dist/start.js +30 -2
- 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
|
@@ -2,12 +2,15 @@ import { Hono } from 'hono';
|
|
|
2
2
|
import { csrf } from 'hono/csrf';
|
|
3
3
|
import { adminEmails } from "../access.js";
|
|
4
4
|
import { appTitle } from "../appTitle.js";
|
|
5
|
+
import { media } from "../media.js";
|
|
5
6
|
import { auth, devSignIn } from "./auth.js";
|
|
6
7
|
import { serveDatabase } from "./database.js";
|
|
7
8
|
import { attachPatternViewer, bearerOf } from "./patternViewer.js";
|
|
8
9
|
import { forgetSession, issuePass, needsRecheck, readPass, rememberUser } from "./session.js";
|
|
9
10
|
import { renewSession } from "./sessions.js";
|
|
10
11
|
import { readTitle, writeTitle } from "./settings.js";
|
|
12
|
+
import { serveMedia, uploadMedia } from "./media.js";
|
|
13
|
+
import { matchPage, renderPage, robots, sitemap, wantsHtml } from "./pages.js";
|
|
11
14
|
import { serveTables } from "./tables.js";
|
|
12
15
|
import { findUser } from "./users.js";
|
|
13
16
|
const PUBLIC_PATHS = ['/api/health', '/api/auth/'];
|
|
@@ -47,8 +50,9 @@ async function attachViewer(c, next) {
|
|
|
47
50
|
});
|
|
48
51
|
return next();
|
|
49
52
|
}
|
|
50
|
-
export function createServer({ tables, admins, routes }) {
|
|
53
|
+
export function createServer({ tables, admins, routes, pages }) {
|
|
51
54
|
const authRoutes = import.meta.env.DEV ? auth.route('/', devSignIn) : auth;
|
|
55
|
+
const hasMedia = Object.values(tables).some((table) => table === media);
|
|
52
56
|
const server = new Hono()
|
|
53
57
|
.basePath('/api')
|
|
54
58
|
.use(csrf())
|
|
@@ -83,6 +87,32 @@ export function createServer({ tables, admins, routes }) {
|
|
|
83
87
|
})
|
|
84
88
|
.route('/', serveDatabase(tables))
|
|
85
89
|
.route('/tables', serveTables(tables));
|
|
90
|
+
if (hasMedia)
|
|
91
|
+
server.post('/media', uploadMedia);
|
|
86
92
|
const own = routes;
|
|
87
|
-
|
|
93
|
+
const root = new Hono();
|
|
94
|
+
if (hasMedia)
|
|
95
|
+
root.get('/media/*', serveMedia);
|
|
96
|
+
if (pages !== undefined) {
|
|
97
|
+
root.get('/sitemap.xml', (c) => sitemap(c, pages));
|
|
98
|
+
root.get('/robots.txt', robots);
|
|
99
|
+
}
|
|
100
|
+
return root
|
|
101
|
+
.route('/', own === undefined ? server : server.route('/', own))
|
|
102
|
+
.notFound((c) => {
|
|
103
|
+
if (c.req.path === '/api' || c.req.path.startsWith('/api/')) {
|
|
104
|
+
return c.json({ error: `No route for ${c.req.path}` }, 404);
|
|
105
|
+
}
|
|
106
|
+
const page = pages === undefined || !wantsHtml(c)
|
|
107
|
+
? undefined
|
|
108
|
+
: matchPage(pages, c.req.path);
|
|
109
|
+
if (page !== undefined && pages !== undefined) {
|
|
110
|
+
return renderPage(c, pages, page.route, page.params);
|
|
111
|
+
}
|
|
112
|
+
const assets = c.env.ASSETS;
|
|
113
|
+
if (assets === undefined) {
|
|
114
|
+
return c.json({ error: `No route for ${c.req.path}` }, 404);
|
|
115
|
+
}
|
|
116
|
+
return assets.fetch(c.req.raw);
|
|
117
|
+
});
|
|
88
118
|
}
|
package/dist/server/env.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type { D1Database } from '@cloudflare/workers-types';
|
|
1
|
+
import type { D1Database, Fetcher, R2Bucket } from '@cloudflare/workers-types';
|
|
2
2
|
import type { Viewer } from '../access.ts';
|
|
3
3
|
export type { Viewer };
|
|
4
4
|
export type ServerBindings = {
|
|
5
5
|
DB: D1Database;
|
|
6
6
|
SESSION_SECRET: string;
|
|
7
7
|
AUTH_SECRET?: string;
|
|
8
|
+
MEDIA?: R2Bucket;
|
|
9
|
+
ASSETS?: Fetcher;
|
|
8
10
|
};
|
|
9
11
|
export type ServerEnv<Bindings extends ServerBindings = ServerBindings> = {
|
|
10
12
|
Bindings: Bindings;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type ImageFormat = {
|
|
2
|
+
type: 'image/jpeg' | 'image/png' | 'image/webp' | 'image/gif' | 'image/avif';
|
|
3
|
+
extension: 'jpg' | 'png' | 'webp' | 'gif' | 'avif';
|
|
4
|
+
};
|
|
5
|
+
export type ImageHeader = ImageFormat & {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function isHeic(bytes: Uint8Array): boolean;
|
|
10
|
+
export declare function readImageHeader(bytes: Uint8Array): ImageHeader | undefined;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const SOF_MARKERS = new Set([
|
|
2
|
+
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf
|
|
3
|
+
]);
|
|
4
|
+
const ORIENTATION_TAG = 0x0112;
|
|
5
|
+
const ROTATED_ORIENTATIONS = new Set([5, 6, 7, 8]);
|
|
6
|
+
const HEIC_BRANDS = new Set([
|
|
7
|
+
'heic',
|
|
8
|
+
'heix',
|
|
9
|
+
'hevc',
|
|
10
|
+
'hevx',
|
|
11
|
+
'heim',
|
|
12
|
+
'heis',
|
|
13
|
+
'mif1',
|
|
14
|
+
'msf1'
|
|
15
|
+
]);
|
|
16
|
+
const AVIF_BRANDS = new Set(['avif', 'avis']);
|
|
17
|
+
function ascii(bytes, start, length) {
|
|
18
|
+
return String.fromCharCode(...bytes.subarray(start, start + length));
|
|
19
|
+
}
|
|
20
|
+
function brandsOf(bytes) {
|
|
21
|
+
if (bytes.length < 16 || ascii(bytes, 4, 4) !== 'ftyp')
|
|
22
|
+
return [];
|
|
23
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
24
|
+
const end = Math.min(bytes.length, view.getUint32(0));
|
|
25
|
+
const brands = [ascii(bytes, 8, 4)];
|
|
26
|
+
for (let offset = 16; offset + 4 <= end; offset += 4) {
|
|
27
|
+
brands.push(ascii(bytes, offset, 4));
|
|
28
|
+
}
|
|
29
|
+
return brands;
|
|
30
|
+
}
|
|
31
|
+
export function isHeic(bytes) {
|
|
32
|
+
const brands = brandsOf(bytes);
|
|
33
|
+
return (!brands.some((brand) => AVIF_BRANDS.has(brand)) &&
|
|
34
|
+
brands.some((brand) => HEIC_BRANDS.has(brand)));
|
|
35
|
+
}
|
|
36
|
+
function exifOrientation(view, start, end) {
|
|
37
|
+
if (end - start < 14)
|
|
38
|
+
return 1;
|
|
39
|
+
if (view.getUint32(start) !== 0x45786966 || view.getUint16(start + 4) !== 0)
|
|
40
|
+
return 1;
|
|
41
|
+
const tiff = start + 6;
|
|
42
|
+
const little = view.getUint16(tiff) === 0x4949;
|
|
43
|
+
const ifd = tiff + view.getUint32(tiff + 4, little);
|
|
44
|
+
if (ifd + 2 > end)
|
|
45
|
+
return 1;
|
|
46
|
+
const count = view.getUint16(ifd, little);
|
|
47
|
+
for (let index = 0; index < count; index += 1) {
|
|
48
|
+
const entry = ifd + 2 + index * 12;
|
|
49
|
+
if (entry + 12 > end)
|
|
50
|
+
return 1;
|
|
51
|
+
if (view.getUint16(entry, little) === ORIENTATION_TAG) {
|
|
52
|
+
return view.getUint16(entry + 8, little);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return 1;
|
|
56
|
+
}
|
|
57
|
+
function jpegSize(view) {
|
|
58
|
+
let orientation = 1;
|
|
59
|
+
let offset = 2;
|
|
60
|
+
while (offset + 4 <= view.byteLength) {
|
|
61
|
+
if (view.getUint8(offset) !== 0xff)
|
|
62
|
+
return undefined;
|
|
63
|
+
const marker = view.getUint8(offset + 1);
|
|
64
|
+
if (marker === 0xff) {
|
|
65
|
+
offset += 1;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const length = view.getUint16(offset + 2);
|
|
69
|
+
if (marker === 0xe1) {
|
|
70
|
+
orientation = exifOrientation(view, offset + 4, Math.min(view.byteLength, offset + 2 + length));
|
|
71
|
+
}
|
|
72
|
+
if (SOF_MARKERS.has(marker) && offset + 9 <= view.byteLength) {
|
|
73
|
+
const height = view.getUint16(offset + 5);
|
|
74
|
+
const width = view.getUint16(offset + 7);
|
|
75
|
+
return ROTATED_ORIENTATIONS.has(orientation)
|
|
76
|
+
? { width: height, height: width }
|
|
77
|
+
: { width, height };
|
|
78
|
+
}
|
|
79
|
+
offset += 2 + length;
|
|
80
|
+
}
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
function webpSize(bytes, view) {
|
|
84
|
+
const chunk = ascii(bytes, 12, 4);
|
|
85
|
+
if (chunk === 'VP8X' && bytes.length >= 30) {
|
|
86
|
+
const width = 1 + (view.getUint32(24, true) & 0xffffff);
|
|
87
|
+
const height = 1 + (view.getUint32(27, true) & 0xffffff);
|
|
88
|
+
return { width, height };
|
|
89
|
+
}
|
|
90
|
+
if (chunk === 'VP8 ' && bytes.length >= 30) {
|
|
91
|
+
return {
|
|
92
|
+
width: view.getUint16(26, true) & 0x3fff,
|
|
93
|
+
height: view.getUint16(28, true) & 0x3fff
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
if (chunk === 'VP8L' && bytes.length >= 25) {
|
|
97
|
+
const bits = view.getUint32(21, true);
|
|
98
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >> 14) & 0x3fff) };
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
function avifSize(bytes, view) {
|
|
103
|
+
const limit = Math.min(bytes.length - 16, 65_536);
|
|
104
|
+
for (let offset = 4; offset < limit; offset += 1) {
|
|
105
|
+
if (ascii(bytes, offset, 4) === 'ispe') {
|
|
106
|
+
return {
|
|
107
|
+
width: view.getUint32(offset + 8),
|
|
108
|
+
height: view.getUint32(offset + 12)
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
function sizeOf(bytes) {
|
|
115
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
116
|
+
if (bytes.length >= 4 && view.getUint16(0) === 0xffd8) {
|
|
117
|
+
const size = jpegSize(view);
|
|
118
|
+
return size && { type: 'image/jpeg', extension: 'jpg', ...size };
|
|
119
|
+
}
|
|
120
|
+
if (bytes.length >= 24 && ascii(bytes, 1, 3) === 'PNG') {
|
|
121
|
+
return {
|
|
122
|
+
type: 'image/png',
|
|
123
|
+
extension: 'png',
|
|
124
|
+
width: view.getUint32(16),
|
|
125
|
+
height: view.getUint32(20)
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (bytes.length >= 10 && ascii(bytes, 0, 4) === 'GIF8') {
|
|
129
|
+
return {
|
|
130
|
+
type: 'image/gif',
|
|
131
|
+
extension: 'gif',
|
|
132
|
+
width: view.getUint16(6, true),
|
|
133
|
+
height: view.getUint16(8, true)
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (bytes.length >= 16 &&
|
|
137
|
+
ascii(bytes, 0, 4) === 'RIFF' &&
|
|
138
|
+
ascii(bytes, 8, 4) === 'WEBP') {
|
|
139
|
+
const size = webpSize(bytes, view);
|
|
140
|
+
return size && { type: 'image/webp', extension: 'webp', ...size };
|
|
141
|
+
}
|
|
142
|
+
if (brandsOf(bytes).some((brand) => AVIF_BRANDS.has(brand))) {
|
|
143
|
+
const size = avifSize(bytes, view);
|
|
144
|
+
return size && { type: 'image/avif', extension: 'avif', ...size };
|
|
145
|
+
}
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
export function readImageHeader(bytes) {
|
|
149
|
+
try {
|
|
150
|
+
const found = sizeOf(bytes);
|
|
151
|
+
if (found === undefined || found.width < 1 || found.height < 1) {
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
return found;
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -10,4 +10,9 @@ export { readTitle, seedTitleSql, settings, writeTitle } from './settings.ts';
|
|
|
10
10
|
export { exchangeReply, exchangeRequest, sendCodeReply, sendCodeRequest, verifiedIdentity, verifyCodeReply, verifyCodeRequest } from './signInWire.ts';
|
|
11
11
|
export type { VerifiedIdentity } from './signInWire.ts';
|
|
12
12
|
export { findUser, users } from './users.ts';
|
|
13
|
-
export
|
|
13
|
+
export { MEDIA_LIMIT_BYTES } from '../media.ts';
|
|
14
|
+
export { localBusiness, product } from './jsonLd.ts';
|
|
15
|
+
export type { JsonLd, LocalBusinessFacts, OpeningHours, PostalAddress, ProductFacts, Weekday } from './jsonLd.ts';
|
|
16
|
+
export { definePages } from './pages.ts';
|
|
17
|
+
export type { PageDefinition, PageHead, PageHero, PageParams, PageRequest, Pages } from './pages.ts';
|
|
18
|
+
export type { D1Database, Fetcher, R2Bucket } from '@cloudflare/workers-types';
|
package/dist/server/index.js
CHANGED
|
@@ -7,3 +7,6 @@ export { deleteSession, sessions } from "./sessions.js";
|
|
|
7
7
|
export { readTitle, seedTitleSql, settings, writeTitle } from "./settings.js";
|
|
8
8
|
export { exchangeReply, exchangeRequest, sendCodeReply, sendCodeRequest, verifiedIdentity, verifyCodeReply, verifyCodeRequest } from "./signInWire.js";
|
|
9
9
|
export { findUser, users } from "./users.js";
|
|
10
|
+
export { MEDIA_LIMIT_BYTES } from "../media.js";
|
|
11
|
+
export { localBusiness, product } from "./jsonLd.js";
|
|
12
|
+
export { definePages } from "./pages.js";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type JsonLd = {
|
|
2
|
+
readonly '@context': 'https://schema.org';
|
|
3
|
+
readonly '@type': string;
|
|
4
|
+
} & Readonly<Record<string, unknown>>;
|
|
5
|
+
export type Weekday = 'Monday' | 'Tuesday' | 'Wednesday' | 'Thursday' | 'Friday' | 'Saturday' | 'Sunday';
|
|
6
|
+
export type OpeningHours = {
|
|
7
|
+
days: Weekday | readonly Weekday[];
|
|
8
|
+
opens: string;
|
|
9
|
+
closes: string;
|
|
10
|
+
};
|
|
11
|
+
export type PostalAddress = {
|
|
12
|
+
street?: string;
|
|
13
|
+
locality?: string;
|
|
14
|
+
region?: string;
|
|
15
|
+
postalCode?: string;
|
|
16
|
+
country?: string;
|
|
17
|
+
};
|
|
18
|
+
export type LocalBusinessFacts = {
|
|
19
|
+
name: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
type?: string;
|
|
22
|
+
image?: string | readonly string[];
|
|
23
|
+
description?: string;
|
|
24
|
+
telephone?: string;
|
|
25
|
+
email?: string;
|
|
26
|
+
address?: PostalAddress;
|
|
27
|
+
hours?: readonly OpeningHours[];
|
|
28
|
+
priceRange?: string;
|
|
29
|
+
sameAs?: readonly string[];
|
|
30
|
+
};
|
|
31
|
+
export type ProductFacts = {
|
|
32
|
+
name: string;
|
|
33
|
+
url?: string;
|
|
34
|
+
image?: string | readonly string[];
|
|
35
|
+
description?: string;
|
|
36
|
+
sku?: string;
|
|
37
|
+
brand?: string;
|
|
38
|
+
offer?: {
|
|
39
|
+
price: number;
|
|
40
|
+
currency: string;
|
|
41
|
+
availability?: 'InStock' | 'OutOfStock' | 'PreOrder' | 'BackOrder';
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
export declare function localBusiness(facts: LocalBusinessFacts): JsonLd;
|
|
45
|
+
export declare function product(facts: ProductFacts): JsonLd;
|
|
@@ -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,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 {};
|