@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.
Files changed (88) hide show
  1. package/README.md +37 -8
  2. package/dist/app.d.ts +1 -0
  3. package/dist/app.js +4 -3
  4. package/dist/cli/app.js +120 -7
  5. package/dist/cli/publishing.js +3 -1
  6. package/dist/components/AppFrame.d.ts +5 -1
  7. package/dist/components/AppFrame.js +25 -7
  8. package/dist/components/Appear.d.ts +4 -1
  9. package/dist/components/Appear.js +82 -3
  10. package/dist/components/Card.d.ts +7 -2
  11. package/dist/components/Card.js +59 -3
  12. package/dist/components/Checkbox.d.ts +10 -0
  13. package/dist/components/Checkbox.js +59 -0
  14. package/dist/components/Choice.d.ts +15 -0
  15. package/dist/components/Choice.js +80 -0
  16. package/dist/components/ChoiceGroup.d.ts +11 -0
  17. package/dist/components/ChoiceGroup.js +44 -0
  18. package/dist/components/Icon.d.ts +2 -1
  19. package/dist/components/Icon.js +2 -2
  20. package/dist/components/Image.d.ts +11 -0
  21. package/dist/components/Image.js +76 -0
  22. package/dist/components/Link.js +8 -3
  23. package/dist/components/ListRow.d.ts +2 -1
  24. package/dist/components/ListRow.js +7 -4
  25. package/dist/components/PlainScreen.d.ts +16 -0
  26. package/dist/components/PlainScreen.js +80 -0
  27. package/dist/components/PlainSidebar.d.ts +15 -0
  28. package/dist/components/PlainSidebar.js +179 -0
  29. package/dist/components/Screen.d.ts +3 -1
  30. package/dist/components/Screen.js +5 -1
  31. package/dist/components/Sidebar.js +2 -1
  32. package/dist/components/TextField.d.ts +3 -1
  33. package/dist/components/TextField.js +25 -4
  34. package/dist/components/choiceContext.d.ts +4 -0
  35. package/dist/components/choiceContext.js +7 -0
  36. package/dist/components/frame.d.ts +2 -0
  37. package/dist/components/icons.d.ts +3 -0
  38. package/dist/components/icons.js +4 -1
  39. package/dist/components/interaction.d.ts +1 -1
  40. package/dist/entityStore.d.ts +1 -0
  41. package/dist/entityStore.js +1 -1
  42. package/dist/index.d.ts +2 -0
  43. package/dist/index.js +1 -0
  44. package/dist/media.d.ts +36 -0
  45. package/dist/media.js +74 -0
  46. package/dist/patternAccess.d.ts +21 -0
  47. package/dist/patternAccess.js +110 -0
  48. package/dist/publishWire.d.ts +318 -0
  49. package/dist/publishWire.js +128 -1
  50. package/dist/react.d.ts +5 -0
  51. package/dist/react.js +4 -0
  52. package/dist/rules/checkApiRouting.js +1 -1
  53. package/dist/rules/lintPlugin.js +13 -4
  54. package/dist/rules/messages.d.ts +1 -0
  55. package/dist/rules/messages.js +1 -0
  56. package/dist/schema.d.ts +2 -0
  57. package/dist/schema.js +1 -0
  58. package/dist/server/createServer.d.ts +92 -6
  59. package/dist/server/createServer.js +38 -2
  60. package/dist/server/database.d.ts +74 -0
  61. package/dist/server/database.js +93 -0
  62. package/dist/server/env.d.ts +3 -1
  63. package/dist/server/imageHeader.d.ts +10 -0
  64. package/dist/server/imageHeader.js +159 -0
  65. package/dist/server/index.d.ts +6 -1
  66. package/dist/server/index.js +3 -0
  67. package/dist/server/jsonLd.d.ts +45 -0
  68. package/dist/server/jsonLd.js +88 -0
  69. package/dist/server/keyset.d.ts +13 -0
  70. package/dist/server/keyset.js +52 -0
  71. package/dist/server/media.d.ts +16 -0
  72. package/dist/server/media.js +135 -0
  73. package/dist/server/pages.d.ts +72 -0
  74. package/dist/server/pages.js +301 -0
  75. package/dist/server/patternKeys.d.ts +1 -0
  76. package/dist/server/patternKeys.js +3 -0
  77. package/dist/server/patternViewer.d.ts +6 -0
  78. package/dist/server/patternViewer.js +27 -0
  79. package/dist/server/services.js +1 -1
  80. package/dist/server/tables.d.ts +19 -3
  81. package/dist/server/tables.js +38 -14
  82. package/dist/serviceClients.js +3 -5
  83. package/dist/start.js +26 -1
  84. package/dist/table.d.ts +1 -0
  85. package/dist/table.js +8 -1
  86. package/dist/tables.d.ts +2 -1
  87. package/dist/tables.js +10 -1
  88. 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('&', '&amp;')
90
+ .replaceAll('<', '&lt;')
91
+ .replaceAll('>', '&gt;')
92
+ .replaceAll('"', '&quot;')
93
+ .replaceAll("'", '&#39;');
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>>;