@wervt/nuxt 0.2.15 → 0.2.16
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/dist/module.js +11 -1
- package/dist/runtime/app/useShape.d.ts +19 -11
- package/dist/runtime/app/useShape.js +3 -8
- package/dist/runtime/email/components.d.ts +71 -10
- package/dist/runtime/email/components.js +445 -98
- package/dist/runtime/email/render.js +4 -0
- package/dist/runtime/server/boot.d.ts +1 -1
- package/dist/runtime/server/index.d.ts +3 -2
- package/dist/runtime/server/shape.d.ts +12 -5
- package/dist/runtime/server/shape.js +3 -1
- package/dist/runtime/shared/shape.d.ts +18 -0
- package/dist/runtime/shared/shape.js +16 -0
- package/dist/shapes.js +67 -0
- package/package.json +1 -1
package/dist/module.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveAppName, setupDev } from "./dev.js";
|
|
2
|
-
import {
|
|
2
|
+
import { findShapeRoutes, shapeRoutesTypes } from "./shapes.js";
|
|
3
|
+
import { addImports, addPlugin, addServerHandler, addServerPlugin, addServerTemplate, addTypeTemplate, createResolver, defineNuxtModule, getNuxtVersion, hasNuxtModule, installModule, updateTemplates, useLogger } from "nuxt/kit";
|
|
3
4
|
import { existsSync } from "node:fs";
|
|
4
5
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
6
|
import { builtinModules, createRequire } from "node:module";
|
|
@@ -298,6 +299,15 @@ var module_default = defineNuxtModule({
|
|
|
298
299
|
mode: "client"
|
|
299
300
|
});
|
|
300
301
|
}
|
|
302
|
+
const shapesDir = join(nuxt.options.serverDir, "api/shapes");
|
|
303
|
+
const shapeTypes = addTypeTemplate({
|
|
304
|
+
filename: "types/wervt-shapes.d.ts",
|
|
305
|
+
getContents: async () => shapeRoutesTypes(await findShapeRoutes(shapesDir), join(nuxt.options.buildDir, "types"), resolver.resolve("./runtime/shared/shape"))
|
|
306
|
+
});
|
|
307
|
+
if (nuxt.options.dev) nuxt.hook("builder:watch", async (event, path) => {
|
|
308
|
+
const file = relative(shapesDir, resolve(nuxt.options.srcDir, path));
|
|
309
|
+
if ((event === "add" || event === "unlink") && !file.startsWith("..")) await updateTemplates({ filter: (template) => template.filename === shapeTypes.filename });
|
|
310
|
+
});
|
|
301
311
|
nuxt.options.build.transpile.push(resolver.resolve("./runtime"));
|
|
302
312
|
addImports([
|
|
303
313
|
{
|
|
@@ -1,26 +1,34 @@
|
|
|
1
1
|
import { Row } from "@electric-sql/client";
|
|
2
|
+
import { Ref, ShallowRef } from "vue";
|
|
2
3
|
//#region src/runtime/app/useShape.d.ts
|
|
4
|
+
declare global {
|
|
5
|
+
/**
|
|
6
|
+
* Shape routes and their rows, generated from `server/api/shapes/` (`.nuxt/types/wervt-shapes.d.ts`).
|
|
7
|
+
*/
|
|
8
|
+
interface WervtShapeRoutes {}
|
|
9
|
+
}
|
|
3
10
|
export interface UseShapeOptions {
|
|
4
11
|
/** Payload key. Defaults to the path. */
|
|
5
12
|
key?: string;
|
|
6
13
|
/** Keep the shape live on the client. Defaults to true. */
|
|
7
14
|
live?: boolean;
|
|
8
15
|
}
|
|
16
|
+
type ShapeResult<T> = Promise<ShapeRefs<T>> & ShapeRefs<T>;
|
|
17
|
+
interface ShapeRefs<T> {
|
|
18
|
+
data: ShallowRef<T[]>;
|
|
19
|
+
error: Ref<unknown>;
|
|
20
|
+
isLive: Ref<boolean>;
|
|
21
|
+
}
|
|
9
22
|
/**
|
|
10
23
|
* Live rows of an Electric shape served by one of the app's shape routes
|
|
11
24
|
* (see `proxyShape`). The snapshot is loaded during SSR and sent in the payload;
|
|
12
25
|
* the client resumes the stream from the snapshot's offset, so rows are not
|
|
13
|
-
* fetched twice.
|
|
26
|
+
* fetched twice. Timestamps arrive as Dates, like Drizzle reads them.
|
|
27
|
+
*
|
|
28
|
+
* The row type comes from the route when it passes a Drizzle table to `proxyShape`.
|
|
14
29
|
*
|
|
15
|
-
* @example const { data: todos } = await useShape
|
|
30
|
+
* @example const { data: todos } = await useShape('/api/shapes/todos')
|
|
16
31
|
*/
|
|
17
|
-
export declare function useShape<
|
|
18
|
-
|
|
19
|
-
error: import("vue").Ref<unknown, unknown>;
|
|
20
|
-
isLive: import("vue").Ref<boolean, boolean>;
|
|
21
|
-
}> & {
|
|
22
|
-
data: import("vue").ShallowRef<T[], T[]>;
|
|
23
|
-
error: import("vue").Ref<unknown, unknown>;
|
|
24
|
-
isLive: import("vue").Ref<boolean, boolean>;
|
|
25
|
-
};
|
|
32
|
+
export declare function useShape<P extends keyof WervtShapeRoutes>(path: P, options?: UseShapeOptions): ShapeResult<WervtShapeRoutes[P]>;
|
|
33
|
+
export declare function useShape<T extends Row<unknown>>(path: string, options?: UseShapeOptions): ShapeResult<T>;
|
|
26
34
|
//#endregion
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
+
import { shapeParser } from "../shared/shape.js";
|
|
1
2
|
import { ShapeStream, isChangeMessage, isControlMessage } from "@electric-sql/client";
|
|
2
3
|
import { getCurrentScope, onScopeDispose, ref, shallowRef } from "vue";
|
|
3
4
|
import { onNuxtReady, useAsyncData, useRequestEvent, useRequestFetch } from "#app";
|
|
4
5
|
//#region src/runtime/app/useShape.ts
|
|
5
|
-
/**
|
|
6
|
-
* Live rows of an Electric shape served by one of the app's shape routes
|
|
7
|
-
* (see `proxyShape`). The snapshot is loaded during SSR and sent in the payload;
|
|
8
|
-
* the client resumes the stream from the snapshot's offset, so rows are not
|
|
9
|
-
* fetched twice.
|
|
10
|
-
*
|
|
11
|
-
* @example const { data: todos } = await useShape<Todo>('/api/shapes/todos')
|
|
12
|
-
*/
|
|
13
6
|
function useShape(path, options = {}) {
|
|
14
7
|
const fetchClient = import.meta.server ? inProcessFetch() : void 0;
|
|
15
8
|
const asyncData = useAsyncData(options.key ?? `wervt-shape:${path}`, () => loadSnapshot(path, fetchClient));
|
|
@@ -40,6 +33,7 @@ function useShape(path, options = {}) {
|
|
|
40
33
|
url: absolute(path),
|
|
41
34
|
offset: snapshot?.offset,
|
|
42
35
|
handle: snapshot?.handle,
|
|
36
|
+
parser: shapeParser,
|
|
43
37
|
signal: controller.signal,
|
|
44
38
|
onError: (e) => {
|
|
45
39
|
error.value = e;
|
|
@@ -64,6 +58,7 @@ async function loadSnapshot(path, fetchClient) {
|
|
|
64
58
|
url: absolute(path),
|
|
65
59
|
subscribe: false,
|
|
66
60
|
fetchClient,
|
|
61
|
+
parser: shapeParser,
|
|
67
62
|
onError: (e) => fail(e)
|
|
68
63
|
});
|
|
69
64
|
await new Promise((resolve, reject) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InjectionKey } from "vue";
|
|
1
|
+
import { InjectionKey, PropType } from "vue";
|
|
2
2
|
//#region src/runtime/email/components.d.ts
|
|
3
3
|
/** The template's subject, handed to renderEmail() while rendering. */
|
|
4
4
|
export declare const SUBJECT_KEY: InjectionKey<(subject: string) => void>;
|
|
@@ -6,19 +6,31 @@ export declare const emailTheme: {
|
|
|
6
6
|
page: string;
|
|
7
7
|
card: string;
|
|
8
8
|
border: string;
|
|
9
|
+
rule: string;
|
|
9
10
|
text: string;
|
|
10
11
|
highlighted: string;
|
|
11
12
|
muted: string;
|
|
12
13
|
dimmed: string;
|
|
14
|
+
accent: string;
|
|
15
|
+
accentSoft: string;
|
|
16
|
+
accentBorder: string;
|
|
17
|
+
band: string;
|
|
18
|
+
bandText: string;
|
|
19
|
+
bandDimmed: string;
|
|
13
20
|
button: string;
|
|
14
21
|
buttonText: string;
|
|
15
22
|
codeBackground: string;
|
|
16
23
|
radius: string;
|
|
17
24
|
font: string;
|
|
18
25
|
mono: string;
|
|
26
|
+
/** Where the logo images live. PNG, because Gmail and Outlook don't show SVG. */
|
|
27
|
+
assets: string;
|
|
28
|
+
site: string;
|
|
29
|
+
docs: string;
|
|
19
30
|
};
|
|
20
31
|
/**
|
|
21
|
-
* The whole email: document, preheader,
|
|
32
|
+
* The whole email: document, preheader, header with the logo, body and footer. Put it at the
|
|
33
|
+
* root of every template.
|
|
22
34
|
*
|
|
23
35
|
* ```vue
|
|
24
36
|
* <WervtEmail subject="Your code" app="kontor"> … </WervtEmail>
|
|
@@ -30,7 +42,7 @@ export declare const WervtEmail: import("vue").DefineComponent<import("vue").Ext
|
|
|
30
42
|
type: StringConstructor;
|
|
31
43
|
required: true;
|
|
32
44
|
};
|
|
33
|
-
/**
|
|
45
|
+
/** The app that sends the mail, shown in the header and the footer. */
|
|
34
46
|
app: {
|
|
35
47
|
type: StringConstructor;
|
|
36
48
|
default: string;
|
|
@@ -40,11 +52,16 @@ export declare const WervtEmail: import("vue").DefineComponent<import("vue").Ext
|
|
|
40
52
|
type: StringConstructor;
|
|
41
53
|
default: string;
|
|
42
54
|
};
|
|
43
|
-
/** Replaces the footer
|
|
55
|
+
/** Replaces the footer's "Sent by <app> via wervt" line. */
|
|
44
56
|
footer: {
|
|
45
57
|
type: StringConstructor;
|
|
46
58
|
default: string;
|
|
47
59
|
};
|
|
60
|
+
/** Why the recipient got this mail, shown small in the footer. */
|
|
61
|
+
reason: {
|
|
62
|
+
type: StringConstructor;
|
|
63
|
+
default: string;
|
|
64
|
+
};
|
|
48
65
|
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
49
66
|
[key: string]: any;
|
|
50
67
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
@@ -53,7 +70,7 @@ export declare const WervtEmail: import("vue").DefineComponent<import("vue").Ext
|
|
|
53
70
|
type: StringConstructor;
|
|
54
71
|
required: true;
|
|
55
72
|
};
|
|
56
|
-
/**
|
|
73
|
+
/** The app that sends the mail, shown in the header and the footer. */
|
|
57
74
|
app: {
|
|
58
75
|
type: StringConstructor;
|
|
59
76
|
default: string;
|
|
@@ -63,19 +80,39 @@ export declare const WervtEmail: import("vue").DefineComponent<import("vue").Ext
|
|
|
63
80
|
type: StringConstructor;
|
|
64
81
|
default: string;
|
|
65
82
|
};
|
|
66
|
-
/** Replaces the footer
|
|
83
|
+
/** Replaces the footer's "Sent by <app> via wervt" line. */
|
|
67
84
|
footer: {
|
|
68
85
|
type: StringConstructor;
|
|
69
86
|
default: string;
|
|
70
87
|
};
|
|
88
|
+
/** Why the recipient got this mail, shown small in the footer. */
|
|
89
|
+
reason: {
|
|
90
|
+
type: StringConstructor;
|
|
91
|
+
default: string;
|
|
92
|
+
};
|
|
71
93
|
}>> & Readonly<{}>, {
|
|
72
94
|
app: string;
|
|
73
95
|
preview: string;
|
|
74
96
|
footer: string;
|
|
97
|
+
reason: string;
|
|
75
98
|
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
76
|
-
export declare const EmailHeading: import("vue").DefineComponent<
|
|
99
|
+
export declare const EmailHeading: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
100
|
+
/** A short label above the heading, in the landing page's mono eyebrow style. */
|
|
101
|
+
eyebrow: {
|
|
102
|
+
type: StringConstructor;
|
|
103
|
+
default: string;
|
|
104
|
+
};
|
|
105
|
+
}>, () => (import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
77
106
|
[key: string]: any;
|
|
78
|
-
}
|
|
107
|
+
}> | null)[], {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
108
|
+
/** A short label above the heading, in the landing page's mono eyebrow style. */
|
|
109
|
+
eyebrow: {
|
|
110
|
+
type: StringConstructor;
|
|
111
|
+
default: string;
|
|
112
|
+
};
|
|
113
|
+
}>> & Readonly<{}>, {
|
|
114
|
+
eyebrow: string;
|
|
115
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
79
116
|
export declare const EmailText: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
80
117
|
[key: string]: any;
|
|
81
118
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
@@ -102,6 +139,11 @@ export declare const EmailButton: import("vue").DefineComponent<import("vue").Ex
|
|
|
102
139
|
type: StringConstructor;
|
|
103
140
|
required: true;
|
|
104
141
|
};
|
|
142
|
+
/** `primary` is the landing page's blue; `neutral` the dashboard's black. */
|
|
143
|
+
color: {
|
|
144
|
+
type: PropType<'primary' | 'neutral'>;
|
|
145
|
+
default: string;
|
|
146
|
+
};
|
|
105
147
|
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
106
148
|
[key: string]: any;
|
|
107
149
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
@@ -109,13 +151,25 @@ export declare const EmailButton: import("vue").DefineComponent<import("vue").Ex
|
|
|
109
151
|
type: StringConstructor;
|
|
110
152
|
required: true;
|
|
111
153
|
};
|
|
112
|
-
|
|
154
|
+
/** `primary` is the landing page's blue; `neutral` the dashboard's black. */
|
|
155
|
+
color: {
|
|
156
|
+
type: PropType<'primary' | 'neutral'>;
|
|
157
|
+
default: string;
|
|
158
|
+
};
|
|
159
|
+
}>> & Readonly<{}>, {
|
|
160
|
+
color: "neutral" | "primary";
|
|
161
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
113
162
|
/** A one-time code, large and spaced so it reads (and copies) easily. */
|
|
114
163
|
export declare const EmailCode: import("vue").DefineComponent<import("vue").ExtractPropTypes<{
|
|
115
164
|
code: {
|
|
116
165
|
type: StringConstructor;
|
|
117
166
|
required: true;
|
|
118
167
|
};
|
|
168
|
+
/** A small line under the code, e.g. when it expires. */
|
|
169
|
+
caption: {
|
|
170
|
+
type: StringConstructor;
|
|
171
|
+
default: string;
|
|
172
|
+
};
|
|
119
173
|
}>, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
120
174
|
[key: string]: any;
|
|
121
175
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<import("vue").ExtractPropTypes<{
|
|
@@ -123,7 +177,14 @@ export declare const EmailCode: import("vue").DefineComponent<import("vue").Extr
|
|
|
123
177
|
type: StringConstructor;
|
|
124
178
|
required: true;
|
|
125
179
|
};
|
|
126
|
-
|
|
180
|
+
/** A small line under the code, e.g. when it expires. */
|
|
181
|
+
caption: {
|
|
182
|
+
type: StringConstructor;
|
|
183
|
+
default: string;
|
|
184
|
+
};
|
|
185
|
+
}>> & Readonly<{}>, {
|
|
186
|
+
caption: string;
|
|
187
|
+
}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
127
188
|
export declare const EmailDivider: import("vue").DefineComponent<{}, () => import("vue").VNode<import("vue").RendererNode, import("vue").RendererElement, {
|
|
128
189
|
[key: string]: any;
|
|
129
190
|
}>, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
@@ -5,21 +5,58 @@ const SUBJECT_KEY = Symbol("wervt-email-subject");
|
|
|
5
5
|
const emailTheme = {
|
|
6
6
|
page: "#fafafa",
|
|
7
7
|
card: "#ffffff",
|
|
8
|
-
border: "#
|
|
9
|
-
|
|
8
|
+
border: "#e5e5e5",
|
|
9
|
+
rule: "#d4d4d4",
|
|
10
|
+
text: "#262626",
|
|
10
11
|
highlighted: "#0a0a0a",
|
|
11
12
|
muted: "#666666",
|
|
12
13
|
dimmed: "#8f8f8f",
|
|
13
|
-
|
|
14
|
+
accent: "#2f5bff",
|
|
15
|
+
accentSoft: "#eef2ff",
|
|
16
|
+
accentBorder: "#d6defe",
|
|
17
|
+
band: "#0a0b0f",
|
|
18
|
+
bandText: "#d4d7de",
|
|
19
|
+
bandDimmed: "#6b7080",
|
|
20
|
+
button: "#2f5bff",
|
|
14
21
|
buttonText: "#ffffff",
|
|
15
|
-
codeBackground: "#
|
|
16
|
-
radius: "
|
|
22
|
+
codeBackground: "#f6f8ff",
|
|
23
|
+
radius: "8px",
|
|
17
24
|
font: "Poppins, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
|
|
18
|
-
mono: "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"
|
|
25
|
+
mono: "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
|
|
26
|
+
/** Where the logo images live. PNG, because Gmail and Outlook don't show SVG. */
|
|
27
|
+
assets: "https://wervt.app/email",
|
|
28
|
+
site: "https://wervt.app",
|
|
29
|
+
docs: "https://wervt.app/getting-started/introduction"
|
|
19
30
|
};
|
|
20
31
|
const t = emailTheme;
|
|
32
|
+
const darkStyles = `
|
|
33
|
+
:root { color-scheme: light dark; supported-color-schemes: light dark; }
|
|
34
|
+
@media (prefers-color-scheme: dark) {
|
|
35
|
+
.wv-page { background: #0a0a0a !important; }
|
|
36
|
+
.wv-card { background: #111113 !important; }
|
|
37
|
+
.wv-foot { background: #0a0a0a !important; }
|
|
38
|
+
.wv-frame { border-color: #262626 !important; }
|
|
39
|
+
.wv-line { background: #262626 !important; }
|
|
40
|
+
.wv-mark { background: #6b6b6b !important; }
|
|
41
|
+
.wv-sep { color: #404040 !important; }
|
|
42
|
+
.wv-text { color: #e5e5e5 !important; }
|
|
43
|
+
.wv-strong { color: #fafafa !important; }
|
|
44
|
+
.wv-muted { color: #a1a1a1 !important; }
|
|
45
|
+
.wv-accent { color: #7c9bff !important; }
|
|
46
|
+
.wv-code { background: #151a2e !important; border-color: #26304f !important; color: #fafafa !important; }
|
|
47
|
+
}
|
|
48
|
+
[data-ogsc] .wv-text { color: #e5e5e5 !important; }
|
|
49
|
+
[data-ogsc] .wv-strong { color: #fafafa !important; }
|
|
50
|
+
[data-ogsc] .wv-muted { color: #a1a1a1 !important; }
|
|
51
|
+
@media only screen and (max-width: 480px) {
|
|
52
|
+
.wv-pad { padding-left: 24px !important; padding-right: 24px !important; }
|
|
53
|
+
.wv-hero { font-size: 22px !important; line-height: 30px !important; }
|
|
54
|
+
.wv-codetext { font-size: 28px !important; letter-spacing: 6px !important; }
|
|
55
|
+
}
|
|
56
|
+
`;
|
|
21
57
|
/**
|
|
22
|
-
* The whole email: document, preheader,
|
|
58
|
+
* The whole email: document, preheader, header with the logo, body and footer. Put it at the
|
|
59
|
+
* root of every template.
|
|
23
60
|
*
|
|
24
61
|
* ```vue
|
|
25
62
|
* <WervtEmail subject="Your code" app="kontor"> … </WervtEmail>
|
|
@@ -33,7 +70,7 @@ const WervtEmail = defineComponent({
|
|
|
33
70
|
type: String,
|
|
34
71
|
required: true
|
|
35
72
|
},
|
|
36
|
-
/**
|
|
73
|
+
/** The app that sends the mail, shown in the header and the footer. */
|
|
37
74
|
app: {
|
|
38
75
|
type: String,
|
|
39
76
|
default: "wervt"
|
|
@@ -43,10 +80,15 @@ const WervtEmail = defineComponent({
|
|
|
43
80
|
type: String,
|
|
44
81
|
default: ""
|
|
45
82
|
},
|
|
46
|
-
/** Replaces the footer
|
|
83
|
+
/** Replaces the footer's "Sent by <app> via wervt" line. */
|
|
47
84
|
footer: {
|
|
48
85
|
type: String,
|
|
49
86
|
default: ""
|
|
87
|
+
},
|
|
88
|
+
/** Why the recipient got this mail, shown small in the footer. */
|
|
89
|
+
reason: {
|
|
90
|
+
type: String,
|
|
91
|
+
default: ""
|
|
50
92
|
}
|
|
51
93
|
},
|
|
52
94
|
setup(props, { slots }) {
|
|
@@ -59,22 +101,26 @@ const WervtEmail = defineComponent({
|
|
|
59
101
|
}),
|
|
60
102
|
h("meta", {
|
|
61
103
|
name: "color-scheme",
|
|
62
|
-
content: "light"
|
|
104
|
+
content: "light dark"
|
|
63
105
|
}),
|
|
64
106
|
h("meta", {
|
|
65
107
|
name: "supported-color-schemes",
|
|
66
|
-
content: "light"
|
|
108
|
+
content: "light dark"
|
|
67
109
|
}),
|
|
68
110
|
h("title", props.subject),
|
|
69
111
|
h("link", {
|
|
70
112
|
rel: "stylesheet",
|
|
71
|
-
href: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap"
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
113
|
+
href: "https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap"
|
|
114
|
+
}),
|
|
115
|
+
h("style", { innerHTML: darkStyles })
|
|
116
|
+
]), h("body", {
|
|
117
|
+
class: "wv-page",
|
|
118
|
+
style: {
|
|
119
|
+
margin: 0,
|
|
120
|
+
padding: 0,
|
|
121
|
+
background: t.page
|
|
122
|
+
}
|
|
123
|
+
}, [props.preview ? h("div", {
|
|
78
124
|
class: "wervt-preheader",
|
|
79
125
|
style: {
|
|
80
126
|
display: "none",
|
|
@@ -88,75 +134,333 @@ const WervtEmail = defineComponent({
|
|
|
88
134
|
}
|
|
89
135
|
}, props.preview) : null, table({
|
|
90
136
|
width: "100%",
|
|
137
|
+
class: "wv-page",
|
|
91
138
|
style: { background: t.page }
|
|
92
139
|
}, [h("tr", [h("td", {
|
|
93
140
|
align: "center",
|
|
94
|
-
style: { padding: "
|
|
141
|
+
style: { padding: "0 12px" }
|
|
95
142
|
}, table({
|
|
96
143
|
width: "100%",
|
|
97
|
-
style: { maxWidth: "
|
|
144
|
+
style: { maxWidth: "600px" }
|
|
98
145
|
}, [
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
146
|
+
stub(),
|
|
147
|
+
rule(true, none, {
|
|
148
|
+
color: t.band,
|
|
149
|
+
image: `url('${t.assets}/header.png') right top / 600px 100px no-repeat`
|
|
150
|
+
}),
|
|
151
|
+
framed(header(props.app), { padding: 0 }),
|
|
152
|
+
framed(accentLine(), { padding: 0 }),
|
|
153
|
+
framed(slots.default?.(), {
|
|
154
|
+
class: "wv-card wv-pad",
|
|
107
155
|
background: t.card,
|
|
108
|
-
|
|
109
|
-
borderRadius: t.radius,
|
|
110
|
-
padding: "32px",
|
|
156
|
+
padding: "36px 40px 20px",
|
|
111
157
|
fontFamily: t.font,
|
|
112
158
|
color: t.text
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
159
|
+
}),
|
|
160
|
+
rule(true, {
|
|
161
|
+
color: t.card,
|
|
162
|
+
class: "wv-card"
|
|
163
|
+
}, {
|
|
164
|
+
color: t.page,
|
|
165
|
+
class: "wv-foot"
|
|
166
|
+
}),
|
|
167
|
+
framed(footer(props), {
|
|
168
|
+
class: "wv-foot wv-pad",
|
|
169
|
+
background: t.page,
|
|
170
|
+
padding: "24px 40px 28px"
|
|
171
|
+
}),
|
|
172
|
+
rule(false, {
|
|
173
|
+
color: t.page,
|
|
174
|
+
class: "wv-foot"
|
|
175
|
+
}, none),
|
|
176
|
+
stub()
|
|
121
177
|
]))])])])]);
|
|
122
178
|
}
|
|
123
179
|
});
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
180
|
+
/** The dark band on top: the lockup over the dot grid, the sending app on the right. */
|
|
181
|
+
function header(app) {
|
|
182
|
+
return table({
|
|
183
|
+
width: "100%",
|
|
184
|
+
bgcolor: t.band,
|
|
185
|
+
background: `${t.assets}/header.png`,
|
|
186
|
+
style: { background: `${t.band} url('${t.assets}/header.png') right -4px / 600px 100px no-repeat` }
|
|
187
|
+
}, [h("tr", [h("td", {
|
|
188
|
+
class: "wv-pad wervt-skip-text",
|
|
189
|
+
style: {
|
|
190
|
+
padding: "30px 40px",
|
|
191
|
+
verticalAlign: "middle"
|
|
192
|
+
}
|
|
193
|
+
}, h("a", {
|
|
194
|
+
href: t.site,
|
|
195
|
+
style: {
|
|
196
|
+
textDecoration: "none",
|
|
197
|
+
color: "#fafafa"
|
|
198
|
+
}
|
|
199
|
+
}, h("img", {
|
|
200
|
+
src: `${t.assets}/lockup-on-dark.png`,
|
|
201
|
+
width: 116,
|
|
202
|
+
height: 32,
|
|
203
|
+
alt: "wervt",
|
|
204
|
+
style: {
|
|
205
|
+
display: "block",
|
|
206
|
+
width: "116px",
|
|
207
|
+
height: "32px",
|
|
208
|
+
border: 0,
|
|
209
|
+
outline: "none",
|
|
129
210
|
fontFamily: t.font,
|
|
130
|
-
fontSize: "
|
|
131
|
-
lineHeight: "
|
|
211
|
+
fontSize: "24px",
|
|
212
|
+
lineHeight: "32px",
|
|
132
213
|
fontWeight: 600,
|
|
133
|
-
|
|
134
|
-
|
|
214
|
+
letterSpacing: "-0.5px",
|
|
215
|
+
color: "#fafafa"
|
|
216
|
+
}
|
|
217
|
+
}))), app === "wervt" ? null : h("td", {
|
|
218
|
+
class: "wv-pad wervt-skip-text",
|
|
219
|
+
align: "right",
|
|
220
|
+
style: {
|
|
221
|
+
padding: "30px 40px",
|
|
222
|
+
verticalAlign: "middle"
|
|
223
|
+
}
|
|
224
|
+
}, table({ align: "right" }, [h("tr", [h("td", { style: {
|
|
225
|
+
border: "1px solid #2a2e38",
|
|
226
|
+
borderRadius: "999px",
|
|
227
|
+
background: "#12141b",
|
|
228
|
+
padding: "5px 12px",
|
|
229
|
+
fontFamily: t.mono,
|
|
230
|
+
fontSize: "12px",
|
|
231
|
+
lineHeight: "16px",
|
|
232
|
+
color: t.bandText,
|
|
233
|
+
whiteSpace: "nowrap"
|
|
234
|
+
} }, [h("span", { style: { color: "#5885ff" } }, "●"), `\u00A0\u00A0${app}`])])]))])]);
|
|
235
|
+
}
|
|
236
|
+
/** The landing page's headline gradient, as a hairline under the header. */
|
|
237
|
+
function accentLine() {
|
|
238
|
+
return table({ width: "100%" }, [h("tr", [h("td", {
|
|
239
|
+
height: 2,
|
|
240
|
+
bgcolor: t.accent,
|
|
241
|
+
style: {
|
|
242
|
+
height: "2px",
|
|
243
|
+
fontSize: 0,
|
|
244
|
+
lineHeight: 0,
|
|
245
|
+
background: `${t.accent} linear-gradient(90deg, #60a5fa, ${t.accent} 45%, #a78bfa)`
|
|
246
|
+
}
|
|
247
|
+
})])]);
|
|
248
|
+
}
|
|
249
|
+
function footer(props) {
|
|
250
|
+
const sentBy = props.footer || (props.app === "wervt" ? "Sent by wervt" : `Sent by ${props.app} via wervt`);
|
|
251
|
+
const small = {
|
|
252
|
+
fontFamily: t.font,
|
|
253
|
+
fontSize: "12px",
|
|
254
|
+
lineHeight: "18px"
|
|
255
|
+
};
|
|
256
|
+
const link = {
|
|
257
|
+
color: t.muted,
|
|
258
|
+
textDecoration: "none"
|
|
259
|
+
};
|
|
260
|
+
return table({ width: "100%" }, [h("tr", [h("td", {
|
|
261
|
+
width: 40,
|
|
262
|
+
style: {
|
|
263
|
+
width: "40px",
|
|
264
|
+
verticalAlign: "top",
|
|
265
|
+
paddingTop: "2px"
|
|
266
|
+
}
|
|
267
|
+
}, h("img", {
|
|
268
|
+
src: `${t.assets}/mark-tile.png`,
|
|
269
|
+
width: 28,
|
|
270
|
+
height: 28,
|
|
271
|
+
alt: "",
|
|
272
|
+
style: {
|
|
273
|
+
display: "block",
|
|
274
|
+
width: "28px",
|
|
275
|
+
height: "28px",
|
|
276
|
+
border: 0,
|
|
277
|
+
borderRadius: "7px"
|
|
278
|
+
}
|
|
279
|
+
})), h("td", { style: { verticalAlign: "top" } }, [
|
|
280
|
+
h("p", {
|
|
281
|
+
class: "wv-strong",
|
|
282
|
+
style: {
|
|
283
|
+
...small,
|
|
284
|
+
margin: 0,
|
|
285
|
+
fontSize: "13px",
|
|
286
|
+
fontWeight: 500,
|
|
287
|
+
color: t.highlighted
|
|
288
|
+
}
|
|
289
|
+
}, sentBy),
|
|
290
|
+
props.reason ? h("p", {
|
|
291
|
+
class: "wv-muted",
|
|
292
|
+
style: {
|
|
293
|
+
...small,
|
|
294
|
+
margin: "4px 0 0",
|
|
295
|
+
color: t.muted
|
|
296
|
+
}
|
|
297
|
+
}, props.reason) : null,
|
|
298
|
+
h("p", {
|
|
299
|
+
class: "wv-muted",
|
|
300
|
+
style: {
|
|
301
|
+
...small,
|
|
302
|
+
margin: "10px 0 0",
|
|
303
|
+
fontFamily: t.mono,
|
|
304
|
+
fontSize: "11px",
|
|
305
|
+
color: t.dimmed
|
|
306
|
+
}
|
|
307
|
+
}, [
|
|
308
|
+
h("a", {
|
|
309
|
+
href: t.site,
|
|
310
|
+
class: "wv-muted",
|
|
311
|
+
style: link
|
|
312
|
+
}, "wervt.app"),
|
|
313
|
+
separator(),
|
|
314
|
+
h("a", {
|
|
315
|
+
href: t.docs,
|
|
316
|
+
class: "wv-muted",
|
|
317
|
+
style: link
|
|
318
|
+
}, "Docs")
|
|
319
|
+
])
|
|
320
|
+
])])]);
|
|
321
|
+
}
|
|
322
|
+
function separator() {
|
|
323
|
+
return h("span", {
|
|
324
|
+
class: "wv-sep",
|
|
325
|
+
style: { color: t.rule }
|
|
326
|
+
}, "\xA0\xA0/\xA0\xA0");
|
|
327
|
+
}
|
|
328
|
+
/** A row inside the frame: the hairlines run down both sides, like the landing page's sections. */
|
|
329
|
+
function framed(children, style) {
|
|
330
|
+
const { class: className, ...rest } = style;
|
|
331
|
+
return h("tr", [h("td", { style: { padding: "0 4px" } }, table({ width: "100%" }, [h("tr", [h("td", {
|
|
332
|
+
class: ["wv-frame", className],
|
|
333
|
+
style: {
|
|
334
|
+
borderLeft: `1px solid ${t.border}`,
|
|
335
|
+
borderRight: `1px solid ${t.border}`,
|
|
336
|
+
...rest
|
|
337
|
+
}
|
|
338
|
+
}, children)])]))]);
|
|
339
|
+
}
|
|
340
|
+
/** The frame's sides fading out above and below, so it reads as part of a larger sheet. */
|
|
341
|
+
function stub() {
|
|
342
|
+
return framed(h("div", { style: {
|
|
343
|
+
height: "28px",
|
|
344
|
+
lineHeight: "28px",
|
|
345
|
+
fontSize: 0
|
|
346
|
+
} }, "\xA0"), { padding: 0 });
|
|
347
|
+
}
|
|
348
|
+
const none = { color: "transparent" };
|
|
349
|
+
/**
|
|
350
|
+
* A horizontal rule across the frame, drawn as a 9px-high grid so the "+" marks can sit where it
|
|
351
|
+
* crosses the sides. `above` and `below` paint the frame's inside around the line, so the
|
|
352
|
+
* sections it separates start right at the line instead of 4px off.
|
|
353
|
+
*/
|
|
354
|
+
function rule(crosshairs, above, below) {
|
|
355
|
+
const mark = {
|
|
356
|
+
color: t.dimmed,
|
|
357
|
+
class: "wv-mark"
|
|
358
|
+
};
|
|
359
|
+
const line = {
|
|
360
|
+
color: t.border,
|
|
361
|
+
class: "wv-line"
|
|
362
|
+
};
|
|
363
|
+
const side = crosshairs ? mark : line;
|
|
364
|
+
const cell = (fill, width, height, colspan) => h("td", {
|
|
365
|
+
width,
|
|
366
|
+
height,
|
|
367
|
+
colspan,
|
|
368
|
+
bgcolor: fill === none ? void 0 : fill.color,
|
|
369
|
+
class: fill.class,
|
|
370
|
+
style: {
|
|
371
|
+
width: width === void 0 ? void 0 : `${width}px`,
|
|
372
|
+
height: `${height}px`,
|
|
373
|
+
fontSize: 0,
|
|
374
|
+
lineHeight: 0,
|
|
375
|
+
background: fill.image ? `${fill.color} ${fill.image}` : fill.color
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
const row = (fill, height) => h("tr", [
|
|
379
|
+
cell(none, 4, height),
|
|
380
|
+
cell(side, 1, height),
|
|
381
|
+
cell(fill, void 0, height, 3),
|
|
382
|
+
cell(side, 1, height),
|
|
383
|
+
cell(none, 4, height)
|
|
384
|
+
]);
|
|
385
|
+
const arm = crosshairs ? mark : line;
|
|
386
|
+
const crossing = h("tr", [
|
|
387
|
+
cell(crosshairs ? mark : none, 4, 1),
|
|
388
|
+
cell(side, 1, 1),
|
|
389
|
+
cell(arm, 4, 1),
|
|
390
|
+
cell(line, void 0, 1),
|
|
391
|
+
cell(arm, 4, 1),
|
|
392
|
+
cell(side, 1, 1),
|
|
393
|
+
cell(crosshairs ? mark : none, 4, 1)
|
|
394
|
+
]);
|
|
395
|
+
return h("tr", [h("td", table({ width: "100%" }, [
|
|
396
|
+
row(above, 4),
|
|
397
|
+
crossing,
|
|
398
|
+
row(below, 4)
|
|
399
|
+
]))]);
|
|
400
|
+
}
|
|
401
|
+
const EmailHeading = defineComponent({
|
|
402
|
+
name: "EmailHeading",
|
|
403
|
+
props: {
|
|
404
|
+
/** A short label above the heading, in the landing page's mono eyebrow style. */
|
|
405
|
+
eyebrow: {
|
|
406
|
+
type: String,
|
|
407
|
+
default: ""
|
|
408
|
+
} },
|
|
409
|
+
setup(props, { slots }) {
|
|
410
|
+
return () => [props.eyebrow ? h("p", {
|
|
411
|
+
class: "wv-accent",
|
|
412
|
+
style: {
|
|
413
|
+
margin: "0 0 10px",
|
|
414
|
+
fontFamily: t.mono,
|
|
415
|
+
fontSize: "12px",
|
|
416
|
+
lineHeight: "16px",
|
|
417
|
+
letterSpacing: "0.5px",
|
|
418
|
+
textTransform: "uppercase",
|
|
419
|
+
color: t.accent
|
|
420
|
+
}
|
|
421
|
+
}, props.eyebrow) : null, h("h1", {
|
|
422
|
+
class: "wv-strong wv-hero",
|
|
423
|
+
style: {
|
|
424
|
+
margin: "0 0 16px",
|
|
425
|
+
fontFamily: t.font,
|
|
426
|
+
fontSize: "26px",
|
|
427
|
+
lineHeight: "34px",
|
|
428
|
+
fontWeight: 600,
|
|
429
|
+
letterSpacing: "-0.6px",
|
|
430
|
+
color: t.highlighted
|
|
431
|
+
}
|
|
432
|
+
}, slots.default?.())];
|
|
135
433
|
}
|
|
136
434
|
});
|
|
137
435
|
const EmailText = defineComponent({
|
|
138
436
|
name: "EmailText",
|
|
139
437
|
setup(_, { slots }) {
|
|
140
|
-
return () => h("p", {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
438
|
+
return () => h("p", {
|
|
439
|
+
class: "wv-text",
|
|
440
|
+
style: {
|
|
441
|
+
margin: "0 0 16px",
|
|
442
|
+
fontFamily: t.font,
|
|
443
|
+
fontSize: "15px",
|
|
444
|
+
lineHeight: "24px",
|
|
445
|
+
color: t.text
|
|
446
|
+
}
|
|
447
|
+
}, slots.default?.());
|
|
147
448
|
}
|
|
148
449
|
});
|
|
149
450
|
/** Secondary text: hints, "ignore this if …". */
|
|
150
451
|
const EmailMuted = defineComponent({
|
|
151
452
|
name: "EmailMuted",
|
|
152
453
|
setup(_, { slots }) {
|
|
153
|
-
return () => h("p", {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
454
|
+
return () => h("p", {
|
|
455
|
+
class: "wv-muted",
|
|
456
|
+
style: {
|
|
457
|
+
margin: "0 0 16px",
|
|
458
|
+
fontFamily: t.font,
|
|
459
|
+
fontSize: "13px",
|
|
460
|
+
lineHeight: "20px",
|
|
461
|
+
color: t.muted
|
|
462
|
+
}
|
|
463
|
+
}, slots.default?.());
|
|
160
464
|
}
|
|
161
465
|
});
|
|
162
466
|
const EmailLink = defineComponent({
|
|
@@ -168,8 +472,9 @@ const EmailLink = defineComponent({
|
|
|
168
472
|
setup(props, { slots }) {
|
|
169
473
|
return () => h("a", {
|
|
170
474
|
href: props.href,
|
|
475
|
+
class: "wv-accent",
|
|
171
476
|
style: {
|
|
172
|
-
color: t.
|
|
477
|
+
color: t.accent,
|
|
173
478
|
textDecoration: "underline"
|
|
174
479
|
}
|
|
175
480
|
}, slots.default?.() ?? props.href);
|
|
@@ -178,64 +483,106 @@ const EmailLink = defineComponent({
|
|
|
178
483
|
/** A call to action. Bulletproof: a table cell carries the background, so Outlook shows it too. */
|
|
179
484
|
const EmailButton = defineComponent({
|
|
180
485
|
name: "EmailButton",
|
|
181
|
-
props: {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
486
|
+
props: {
|
|
487
|
+
href: {
|
|
488
|
+
type: String,
|
|
489
|
+
required: true
|
|
490
|
+
},
|
|
491
|
+
/** `primary` is the landing page's blue; `neutral` the dashboard's black. */
|
|
492
|
+
color: {
|
|
493
|
+
type: String,
|
|
494
|
+
default: "primary"
|
|
495
|
+
}
|
|
496
|
+
},
|
|
185
497
|
setup(props, { slots }) {
|
|
186
|
-
return () =>
|
|
187
|
-
background: t.button
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
498
|
+
return () => {
|
|
499
|
+
const background = props.color === "neutral" ? "#171717" : t.button;
|
|
500
|
+
return table({ style: { margin: "8px 0 24px" } }, [h("tr", [h("td", {
|
|
501
|
+
bgcolor: background,
|
|
502
|
+
style: {
|
|
503
|
+
background,
|
|
504
|
+
borderRadius: t.radius
|
|
505
|
+
}
|
|
506
|
+
}, h("a", {
|
|
507
|
+
href: props.href,
|
|
508
|
+
style: {
|
|
509
|
+
display: "inline-block",
|
|
510
|
+
padding: "12px 22px",
|
|
511
|
+
border: `1px solid ${background}`,
|
|
512
|
+
fontFamily: t.font,
|
|
513
|
+
fontSize: "14px",
|
|
514
|
+
fontWeight: 500,
|
|
515
|
+
lineHeight: "20px",
|
|
516
|
+
color: t.buttonText,
|
|
517
|
+
textDecoration: "none",
|
|
518
|
+
borderRadius: t.radius
|
|
519
|
+
}
|
|
520
|
+
}, [slots.default?.(), h("span", { class: "wervt-skip-text" }, "\xA0\xA0→")]))])]);
|
|
521
|
+
};
|
|
203
522
|
}
|
|
204
523
|
});
|
|
205
524
|
/** A one-time code, large and spaced so it reads (and copies) easily. */
|
|
206
525
|
const EmailCode = defineComponent({
|
|
207
526
|
name: "EmailCode",
|
|
208
|
-
props: {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
527
|
+
props: {
|
|
528
|
+
code: {
|
|
529
|
+
type: String,
|
|
530
|
+
required: true
|
|
531
|
+
},
|
|
532
|
+
/** A small line under the code, e.g. when it expires. */
|
|
533
|
+
caption: {
|
|
534
|
+
type: String,
|
|
535
|
+
default: ""
|
|
536
|
+
}
|
|
537
|
+
},
|
|
212
538
|
setup(props) {
|
|
213
539
|
return () => table({
|
|
214
540
|
width: "100%",
|
|
215
541
|
style: { margin: "8px 0 24px" }
|
|
216
542
|
}, [h("tr", [h("td", {
|
|
217
543
|
align: "center",
|
|
544
|
+
bgcolor: t.codeBackground,
|
|
545
|
+
class: "wv-code",
|
|
218
546
|
style: {
|
|
219
547
|
background: t.codeBackground,
|
|
220
|
-
|
|
221
|
-
|
|
548
|
+
border: `1px solid ${t.accentBorder}`,
|
|
549
|
+
borderRadius: "10px",
|
|
550
|
+
padding: props.caption ? "22px 16px 16px" : "22px 16px"
|
|
551
|
+
}
|
|
552
|
+
}, [h("div", {
|
|
553
|
+
class: "wv-strong wv-codetext",
|
|
554
|
+
style: {
|
|
222
555
|
fontFamily: t.mono,
|
|
223
|
-
fontSize: "
|
|
556
|
+
fontSize: "36px",
|
|
557
|
+
lineHeight: "44px",
|
|
224
558
|
fontWeight: 600,
|
|
225
|
-
letterSpacing: "
|
|
559
|
+
letterSpacing: "10px",
|
|
560
|
+
paddingLeft: "10px",
|
|
226
561
|
color: t.highlighted
|
|
227
562
|
}
|
|
228
|
-
}, props.code)
|
|
563
|
+
}, props.code), props.caption ? h("div", {
|
|
564
|
+
class: "wv-muted",
|
|
565
|
+
style: {
|
|
566
|
+
marginTop: "8px",
|
|
567
|
+
fontFamily: t.mono,
|
|
568
|
+
fontSize: "12px",
|
|
569
|
+
lineHeight: "16px",
|
|
570
|
+
color: t.muted
|
|
571
|
+
}
|
|
572
|
+
}, props.caption) : null])])]);
|
|
229
573
|
}
|
|
230
574
|
});
|
|
231
575
|
const EmailDivider = defineComponent({
|
|
232
576
|
name: "EmailDivider",
|
|
233
577
|
setup() {
|
|
234
|
-
return () => h("hr", {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
578
|
+
return () => h("hr", {
|
|
579
|
+
class: "wv-frame",
|
|
580
|
+
style: {
|
|
581
|
+
border: "none",
|
|
582
|
+
borderTop: `1px solid ${t.border}`,
|
|
583
|
+
margin: "24px 0"
|
|
584
|
+
}
|
|
585
|
+
});
|
|
239
586
|
}
|
|
240
587
|
});
|
|
241
588
|
function table(attrs, children) {
|
|
@@ -4,9 +4,10 @@ import { useDb } from "./db.js";
|
|
|
4
4
|
import { createEvlogError, log, useLogger } from "./logger.js";
|
|
5
5
|
import { SendMailOptions, sendMail } from "./mail.js";
|
|
6
6
|
import { McpTokenContext } from "./mcp-auth.js";
|
|
7
|
-
import {
|
|
7
|
+
import { ShapeResponse } from "../shared/shape.js";
|
|
8
|
+
import { ShapeOptions, ShapeRow, proxyShape } from "./shape.js";
|
|
8
9
|
import { Task, TaskEvent, TaskResult, defineTask } from "./tasks.js";
|
|
9
10
|
import { SafeResult, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, vh } from "./validate.js";
|
|
10
11
|
import { Tool, defineTool } from "./tools.js";
|
|
11
12
|
import { useWervtUser } from "./user.js";
|
|
12
|
-
export { type EmailProps, type McpTokenContext, type RenderedEmail, type SafeResult, type SendMailOptions, type ShapeOptions, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, renderEmail, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
13
|
+
export { type EmailProps, type McpTokenContext, type RenderedEmail, type SafeResult, type SendMailOptions, type ShapeOptions, type ShapeResponse, type ShapeRow, type Task, type TaskEvent, type TaskResult, type Tool, type WervtUser, createEvlogError, defineTask, defineTool, log, proxyShape, renderEmail, sendMail, useDb, useLogger, useSafeValidatedBody, useSafeValidatedParams, useSafeValidatedQuery, useValidatedBody, useValidatedParams, useValidatedQuery, useWervtUser, vh };
|
|
@@ -1,19 +1,26 @@
|
|
|
1
|
+
import { ShapeResponse } from "../shared/shape.js";
|
|
1
2
|
import { RequestEvent } from "nuxt/server";
|
|
3
|
+
import { InferSelectModel, Table } from "drizzle-orm";
|
|
2
4
|
//#region src/runtime/server/shape.d.ts
|
|
3
|
-
export interface ShapeOptions {
|
|
4
|
-
/**
|
|
5
|
-
|
|
5
|
+
export interface ShapeOptions<T extends Table | string = Table | string> {
|
|
6
|
+
/**
|
|
7
|
+
* The Drizzle table (`todos` from `server/db/schema`), or its name without schema. With the
|
|
8
|
+
* table, `useShape` knows the row type from the route.
|
|
9
|
+
*/
|
|
10
|
+
table: T;
|
|
6
11
|
/** SQL where clause. Use `$1`, `$2`, … placeholders with `params` for values. */
|
|
7
12
|
where?: string;
|
|
8
13
|
/** Values for the `$n` placeholders in `where`. */
|
|
9
14
|
params?: (string | number | boolean)[];
|
|
10
|
-
columns?: string[];
|
|
15
|
+
columns?: (T extends Table ? keyof InferSelectModel<T> & string : string)[];
|
|
11
16
|
}
|
|
17
|
+
/** The rows a shape of `T` streams: the table's select model, or untyped for a table name. */
|
|
18
|
+
export type ShapeRow<T extends Table | string> = T extends Table ? InferSelectModel<T> : Record<string, unknown>;
|
|
12
19
|
/**
|
|
13
20
|
* Serves an Electric shape from a route. Do auth checks before calling it.
|
|
14
21
|
*
|
|
15
22
|
* In production the request goes through the wervt runtime gateway, which pins the
|
|
16
23
|
* table to this app's schema. In dev it goes to Electric directly (ELECTRIC_URL).
|
|
17
24
|
*/
|
|
18
|
-
export declare function proxyShape(event: RequestEvent, { table, where, params, columns }: ShapeOptions): Promise<
|
|
25
|
+
export declare function proxyShape<T extends Table | string>(event: RequestEvent, { table: tableOrName, where, params, columns }: ShapeOptions<T>): Promise<ShapeResponse<ShapeRow<T>>>;
|
|
19
26
|
//#endregion
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { getTableName, isTable } from "drizzle-orm";
|
|
1
2
|
//#region src/runtime/server/shape.ts
|
|
2
3
|
const CLIENT_PARAMS = [
|
|
3
4
|
"offset",
|
|
@@ -14,7 +15,8 @@ const CLIENT_PARAMS = [
|
|
|
14
15
|
* In production the request goes through the wervt runtime gateway, which pins the
|
|
15
16
|
* table to this app's schema. In dev it goes to Electric directly (ELECTRIC_URL).
|
|
16
17
|
*/
|
|
17
|
-
async function proxyShape(event, { table, where, params, columns }) {
|
|
18
|
+
async function proxyShape(event, { table: tableOrName, where, params, columns }) {
|
|
19
|
+
const table = isTable(tableOrName) ? getTableName(tableOrName) : tableOrName;
|
|
18
20
|
const runtime = process.env.WERVT_RUNTIME_URL;
|
|
19
21
|
const url = runtime ? new URL("/_wervt/electric/v1/shape", runtime) : new URL("/v1/shape", process.env.ELECTRIC_URL ?? "http://localhost:3000");
|
|
20
22
|
const query = new URL(event.req.url).searchParams;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//#region src/runtime/shared/shape.d.ts
|
|
2
|
+
declare const shapeRow: unique symbol;
|
|
3
|
+
export type ShapeResponse<Row> = Response & {
|
|
4
|
+
readonly [shapeRow]?: Row;
|
|
5
|
+
};
|
|
6
|
+
/** The row type of a shape route's handler, `never` if it doesn't serve a typed shape. */
|
|
7
|
+
export type ShapeRowOf<Handler> = Handler extends ((...args: never[]) => infer Result) ? Awaited<Result> extends ShapeResponse<infer Row> ? Row : never : never;
|
|
8
|
+
/**
|
|
9
|
+
* Postgres text of a `timestamp`/`timestamptz` as a Date, like Drizzle's default `mode: 'date'`
|
|
10
|
+
* reads it. Drizzle stores `timestamp` without time zone as UTC, so it's read as UTC too.
|
|
11
|
+
*/
|
|
12
|
+
export declare function parseTimestamp(value: string): Date;
|
|
13
|
+
/** Electric parsers for the types Drizzle reads differently from Electric's defaults. */
|
|
14
|
+
export declare const shapeParser: {
|
|
15
|
+
timestamp: typeof parseTimestamp;
|
|
16
|
+
timestamptz: typeof parseTimestamp;
|
|
17
|
+
};
|
|
18
|
+
//#endregion
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/runtime/shared/shape.ts
|
|
2
|
+
/**
|
|
3
|
+
* Postgres text of a `timestamp`/`timestamptz` as a Date, like Drizzle's default `mode: 'date'`
|
|
4
|
+
* reads it. Drizzle stores `timestamp` without time zone as UTC, so it's read as UTC too.
|
|
5
|
+
*/
|
|
6
|
+
function parseTimestamp(value) {
|
|
7
|
+
const iso = value.replace(" ", "T").replace(/([+-]\d{2})$/, "$1:00");
|
|
8
|
+
return new Date(/(Z|[+-]\d{2}:\d{2})$/.test(iso) ? iso : `${iso}Z`);
|
|
9
|
+
}
|
|
10
|
+
/** Electric parsers for the types Drizzle reads differently from Electric's defaults. */
|
|
11
|
+
const shapeParser = {
|
|
12
|
+
timestamp: parseTimestamp,
|
|
13
|
+
timestamptz: parseTimestamp
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
export { parseTimestamp, shapeParser };
|
package/dist/shapes.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { extname, join, relative, sep } from "node:path";
|
|
3
|
+
//#region src/shapes.ts
|
|
4
|
+
const ROUTE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
5
|
+
".ts",
|
|
6
|
+
".mts",
|
|
7
|
+
".js",
|
|
8
|
+
".mjs"
|
|
9
|
+
]);
|
|
10
|
+
/** The GET routes among `files` (relative to `server/api/shapes`), as Nitro would serve them. */
|
|
11
|
+
function shapeRoutes(files, base = "/api/shapes") {
|
|
12
|
+
const routes = [];
|
|
13
|
+
for (const file of files.toSorted((a, b) => a.localeCompare(b))) {
|
|
14
|
+
const ext = extname(file);
|
|
15
|
+
if (!ROUTE_EXTENSIONS.has(ext) || file.endsWith(`.d${ext}`)) continue;
|
|
16
|
+
const parts = file.slice(0, -ext.length).split(sep).join("/").split("/");
|
|
17
|
+
const last = parts.pop();
|
|
18
|
+
const method = last.match(/\.(get|post|put|patch|delete|head|options)$/)?.[1];
|
|
19
|
+
if (method && method !== "get") continue;
|
|
20
|
+
const name = method ? last.slice(0, -method.length - 1) : last;
|
|
21
|
+
if (name !== "index") parts.push(name);
|
|
22
|
+
let dynamic = false;
|
|
23
|
+
const segments = parts.map((segment) => {
|
|
24
|
+
if (!/^\[.+\]$/.test(segment)) return segment;
|
|
25
|
+
dynamic = true;
|
|
26
|
+
return "${string}";
|
|
27
|
+
});
|
|
28
|
+
routes.push({
|
|
29
|
+
route: [base, ...segments].join("/"),
|
|
30
|
+
dynamic,
|
|
31
|
+
file
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return routes;
|
|
35
|
+
}
|
|
36
|
+
async function findShapeRoutes(dir) {
|
|
37
|
+
return shapeRoutes(await readdir(dir, { recursive: true }).catch(() => [])).map((entry) => ({
|
|
38
|
+
...entry,
|
|
39
|
+
file: join(dir, entry.file)
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
/** The `.d.ts` declaring `WervtShapeRoutes`; paths are made relative to `typesDir`. */
|
|
43
|
+
function shapeRoutesTypes(routes, typesDir, shapeTypes) {
|
|
44
|
+
const importPath = (file) => {
|
|
45
|
+
const path = relative(typesDir, file).split(sep).join("/");
|
|
46
|
+
const ext = extname(path);
|
|
47
|
+
const withoutExt = ext ? path.slice(0, -ext.length) : path;
|
|
48
|
+
return withoutExt.startsWith(".") ? withoutExt : `./${withoutExt}`;
|
|
49
|
+
};
|
|
50
|
+
const entries = routes.map(({ route, dynamic, file }) => {
|
|
51
|
+
return ` ${dynamic ? `[path: \`${route}\`]` : JSON.stringify(route)}: ShapeRowOf<typeof import(${JSON.stringify(importPath(file))}).default>`;
|
|
52
|
+
});
|
|
53
|
+
return [
|
|
54
|
+
`import type { ShapeRowOf } from ${JSON.stringify(importPath(shapeTypes))}`,
|
|
55
|
+
"",
|
|
56
|
+
"declare global {",
|
|
57
|
+
" interface WervtShapeRoutes {",
|
|
58
|
+
...entries,
|
|
59
|
+
" }",
|
|
60
|
+
"}",
|
|
61
|
+
"",
|
|
62
|
+
"export {}",
|
|
63
|
+
""
|
|
64
|
+
].join("\n");
|
|
65
|
+
}
|
|
66
|
+
//#endregion
|
|
67
|
+
export { findShapeRoutes, shapeRoutes, shapeRoutesTypes };
|