@johnhenry/letterpress 0.0.0

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.
@@ -0,0 +1,173 @@
1
+ // Import the necessary functions
2
+ import { createResponse, createRequest } from "../index.mjs";
3
+ // Utility function to create a simple readable stream
4
+ const createReadableStream = (data) => {
5
+ return new ReadableStream({
6
+ start(controller) {
7
+ controller.enqueue(new TextEncoder().encode(data));
8
+ controller.close();
9
+ },
10
+ });
11
+ };
12
+
13
+ // Demo 1: Simple GET request
14
+ async function demoSimpleGetRequest() {
15
+ console.log("Demo 1: Simple GET request");
16
+ const request = await createRequest()`
17
+ GET /api/users HTTP/1.1
18
+ Accept: application/json
19
+ `;
20
+ console.log("Method:", request.method);
21
+ console.log("URL:", request.url);
22
+ console.log("Headers:", Object.fromEntries(request.headers));
23
+ console.log("\n");
24
+ }
25
+
26
+ // Demo 2: POST request with JSON body
27
+ async function demoPostRequestWithJson() {
28
+ console.log("Demo 2: POST request with JSON body");
29
+ const userData = { name: "John Doe", email: "john@example.com" };
30
+ const request = await createRequest({ baseUrl: "https://api.example.com" })`
31
+ POST /users HTTP/1.1
32
+ Content-Type: application/json
33
+
34
+ ${JSON.stringify(userData)}
35
+ `;
36
+ console.log("Method:", request.method);
37
+ console.log("URL:", request.url);
38
+ console.log("Headers:", Object.fromEntries(request.headers));
39
+ console.log("Body:", await request.json());
40
+ console.log("\n");
41
+ }
42
+
43
+ // Demo 3: PUT request with URLSearchParams
44
+ async function demoPutRequestWithUrlSearchParams() {
45
+ console.log("Demo 3: PUT request with URLSearchParams");
46
+ const params = new URLSearchParams({ key1: "value1", key2: "value2" });
47
+ const request = await createRequest()`
48
+ PUT https://api.example.com/update HTTP/1.1
49
+ User-Agent: TestAgent/1.0
50
+
51
+ ${params}
52
+ `;
53
+ console.log("Method:", request.method);
54
+ console.log("URL:", request.url);
55
+ console.log("Headers:", Object.fromEntries(request.headers));
56
+ console.log("Body:", await request.text());
57
+ console.log("\n");
58
+ }
59
+
60
+ // Demo 4: POST request with FormData
61
+ async function demoPostRequestWithFormData() {
62
+ console.log("Demo 4: POST request with FormData");
63
+ const formData = new FormData();
64
+ formData.append("username", "testuser");
65
+ formData.append("password", "testpass");
66
+ const request = await createRequest({ baseUrl: "https://auth.example.com" })`
67
+ POST /login HTTP/1.1
68
+ User-Agent: TestAgent/1.0
69
+
70
+ ${formData}
71
+ `;
72
+ console.log("Method:", request.method);
73
+ console.log("URL:", request.url);
74
+ console.log("Headers:", Object.fromEntries(request.headers));
75
+ console.log("Body is FormData:", request.body instanceof FormData);
76
+ console.log("\n");
77
+ }
78
+
79
+ // Demo 5: Basic HTML response
80
+ async function demoBasicHtmlResponse() {
81
+ console.log("Demo 5: Basic HTML response");
82
+ const response = await createResponse`HTTP/1.1 200 OK
83
+ Content-Type: text/html
84
+
85
+ <html><body>Hello, World!</body></html>
86
+ `;
87
+ console.log("Status:", response.status);
88
+ console.log("Headers:", Object.fromEntries(response.headers));
89
+ console.log("Body:", await response.text());
90
+ console.log("\n");
91
+ }
92
+
93
+ // Demo 6: JSON response
94
+ async function demoJsonResponse() {
95
+ console.log("Demo 6: JSON response");
96
+ const jsonBody = JSON.stringify({ message: "Success", code: 200 });
97
+ const response = await createResponse`HTTP/1.1 200 OK
98
+ Content-Type: application/json
99
+
100
+ ${jsonBody}
101
+ `;
102
+ console.log("Status:", response.status);
103
+ console.log("Headers:", Object.fromEntries(response.headers));
104
+ console.log("Body:", await response.json());
105
+ console.log("\n");
106
+ }
107
+
108
+ // Demo 7: Response with custom status code
109
+ async function demoCustomStatusResponse() {
110
+ console.log("Demo 7: Response with custom status code");
111
+ const response = await createResponse`HTTP/1.1 404 Not Found
112
+ Content-Type: text/plain
113
+
114
+ Resource not found
115
+ `;
116
+ console.log("Status:", response.status);
117
+ console.log("Status Text:", response.statusText);
118
+ console.log("Headers:", Object.fromEntries(response.headers));
119
+ console.log("Body:", await response.text());
120
+ console.log("\n");
121
+ }
122
+
123
+ // Demo 8: Response with ReadableStream body
124
+ async function demoReadableStreamResponse() {
125
+ console.log("Demo 8: Response with ReadableStream body");
126
+ const streamData = "This is streaming response data";
127
+ const stream = createReadableStream(streamData);
128
+ const response = await createResponse`HTTP/1.1 200 OK
129
+ Content-Type: application/octet-stream
130
+
131
+ ${stream}
132
+ `;
133
+ console.log("Status:", response.status);
134
+ console.log("Headers:", Object.fromEntries(response.headers));
135
+ console.log("Body:", await response.text());
136
+ console.log("\n");
137
+ }
138
+
139
+ const demoResponse = async () => {
140
+ const response = await createResponse`HTTP/1.1 200 OK
141
+ Content-Type: text/html
142
+
143
+ <!DOCTYPE html>
144
+ <html>
145
+ <head>
146
+ <title>Letterpress Demo</title>
147
+ </head>
148
+ <body>
149
+ <h1>Welcome to Letterpress!</h1>
150
+ <p>The current time is: </p>
151
+ <p>Your user agent is: </p>
152
+ </body>
153
+ </html>`;
154
+ console.log("Status:", response.status);
155
+ console.log("Headers:", Object.fromEntries(response.headers));
156
+ console.log("Body:", await response.text());
157
+ console.log("\n");
158
+ };
159
+
160
+ // Run all demos
161
+ async function runAllDemos() {
162
+ await demoSimpleGetRequest();
163
+ await demoPostRequestWithJson();
164
+ await demoPutRequestWithUrlSearchParams();
165
+ await demoPostRequestWithFormData();
166
+ await demoBasicHtmlResponse();
167
+ await demoJsonResponse();
168
+ await demoCustomStatusResponse();
169
+ await demoReadableStreamResponse();
170
+ await demoResponse();
171
+ }
172
+
173
+ runAllDemos().catch(console.error);
@@ -0,0 +1,128 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Home</title>
5
+ <link rel="stylesheet" type="text/css" href="./style.css" />
6
+ </head>
7
+ <body>
8
+ <main>
9
+ <ui-card>
10
+ <template shadowrootmode="closed">
11
+ <style>
12
+ :host {
13
+ display: block;
14
+ contain: content;
15
+ box-sizing: border-box;
16
+ box-shadow: var(--shadow-raised);
17
+ border: var(--stroke-thicknessMinus1) var(--stroke-style)
18
+ var(--color-layerBorder);
19
+ border-radius: var(--border-radius);
20
+ background: var(--background, transparent);
21
+ color: var(--foreground, var(--color-onLayerBase));
22
+ }
23
+ </style>
24
+ <slot></slot>
25
+ </template>
26
+ ui-card
27
+ </ui-card>
28
+ <ui-card-1>ui-card-1</ui-card-1>
29
+ <ui-card-2>ui-card-2</ui-card-2>
30
+ <ui-card-3>ui-card-3</ui-card-3>
31
+ </main>
32
+ <script>
33
+ const render = (tagname = null, attributes = {}, ...children) => {
34
+ children = children.join("");
35
+ return tagname === null
36
+ ? children
37
+ : `<${tagname} ${Object.entries(attributes)
38
+ .map(([key, value]) => `${key}="${value}"`)
39
+ .join(" ")}>
40
+ ${children}
41
+ </${tagname}>`;
42
+ };
43
+ const renderFragment = (...elements) => elements.join("");
44
+ const Define = (
45
+ tagname = "ui-card",
46
+ body,
47
+ { shadowRootMode = "open" } = {}
48
+ ) => {
49
+ const range = document.createRange();
50
+ const fragment = range.createContextualFragment(body);
51
+ customElements.define(
52
+ tagname,
53
+ class extends HTMLElement {
54
+ constructor() {
55
+ super();
56
+ this.attachShadow({ mode: shadowRootMode });
57
+ this.shadowRoot.appendChild(fragment);
58
+ // this.shadowRoot.appendChild(fragment.content.cloneNode(true));
59
+ }
60
+ }
61
+ );
62
+ };
63
+ const DefineTag = (tagname, options) => {
64
+ return (strings, ...substitutions) => {
65
+ const body = strings.reduce((acc, string, i) => {
66
+ return acc + string + (substitutions[i] || "");
67
+ }, "");
68
+ Define(tagname, body, options);
69
+ };
70
+ };
71
+ Define(
72
+ "ui-card-1",
73
+ `<style>
74
+ :host {
75
+ display: block;
76
+ contain: content;
77
+ box-sizing: border-box;
78
+ box-shadow: var(--shadow-raised);
79
+ border: var(--stroke-thicknessMinus1) var(--stroke-style)
80
+ var(--color-layerBorder);
81
+ border-radius: var(--border-radius);
82
+ background: var(--background, transparent);
83
+ color: var(--foreground, var(--color-onLayerBase));
84
+ }
85
+ </style>
86
+ <slot></slot>`
87
+ );
88
+ Define(
89
+ "ui-card-2",
90
+ render(
91
+ null,
92
+ null,
93
+ render(
94
+ "style",
95
+ {},
96
+ `
97
+ :host {
98
+ display: block;
99
+ contain: content;
100
+ box-sizing: border-box;
101
+ box-shadow: var(--shadow-raised);
102
+ border: var(--stroke-thicknessMinus1) var(--stroke-style)
103
+ var(--color-layerBorder);
104
+ border-radius: var(--border-radius);
105
+ background: var(--background, transparent);
106
+ color: var(--foreground, var(--color-onLayerBase));
107
+ }`
108
+ ),
109
+ render("slot", {})
110
+ )
111
+ );
112
+ DefineTag("ui-card-3")`<style>
113
+ :host {
114
+ display: block;
115
+ contain: content;
116
+ box-sizing: border-box;
117
+ box-shadow: var(--shadow-raised);
118
+ border: var(--stroke-thicknessMinus1) var(--stroke-style)
119
+ var(--color-layerBorder);
120
+ border-radius: var(--border-radius);
121
+ background: var(--background, transparent);
122
+ color: var(--foreground, var(--color-onLayerBase));
123
+ }
124
+ </style>
125
+ <slot></slot>`;
126
+ </script>
127
+ </body>
128
+ </html>
@@ -0,0 +1,30 @@
1
+ const HTML = ({ registry = {} } = {}) => {
2
+ return (strings, ...substitutions) => {};
3
+ };
4
+
5
+ const createRegistry = (tags = []) => ({
6
+ toString() {
7
+ return tags
8
+ .map(
9
+ ([name, tag]) =>
10
+ `<script>customElements.define('${name}',${tag})</script>`
11
+ )
12
+ .join("");
13
+ },
14
+ instantiate() {
15
+ tags.forEach(([name, tag]) => {
16
+ customElements.define(name, tag);
17
+ });
18
+ },
19
+ });
20
+
21
+ const registry = createRegistry([["ui-card", class extends HTMLElement {}]]);
22
+
23
+ HTML({ registry })`<html>
24
+ <head>
25
+ ${registry.toString()}
26
+ </head>
27
+ <body>
28
+ <ui-card></ui-card>
29
+ </body>
30
+ </html>`;
@@ -0,0 +1,53 @@
1
+ import serve from "@johnhenry/leserve";
2
+ import { createRoute, createRouter } from "../index.mjs";
3
+
4
+ // Server with single html route
5
+ const htmlRoute = createRoute({ streaming: true })`<!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <title>Letterpress Demo</title>
9
+ </head>
10
+ <body>
11
+ <h1>Welcome to Letterpress!</h1>
12
+ <p>The current time is: ${() => new Date().toISOString()}</p>
13
+ <p>Your user agent is: ${(request) => request.headers.get("User-Agent")}</p>
14
+ </body>
15
+ </html>`;
16
+ serve({ port: 8080 }, htmlRoute);
17
+ // Server with single non-html route
18
+ const customRoute = createRoute({ streaming: true })`HTTP/1.1 200 OK
19
+ Content-Type: text/markdown
20
+
21
+ ---
22
+ access time: ${() => new Date().toISOString()}
23
+ user agent: ${(request) => request.headers.get("User-Agent")}
24
+ ---
25
+
26
+ # Custom Response
27
+ This custom response has it's content type set to 'text/markdown'.
28
+ `;
29
+ serve({ port: 8081 }, customRoute);
30
+
31
+ // Server with multiple routes using router
32
+ const router = createRouter();
33
+ router.endpoint`GET /`(htmlRoute);
34
+ router.endpoint`GET /about`(customRoute);
35
+ router.endpoint`GET /user/:id?``<!DOCTYPE html>
36
+ <html>
37
+ <body>
38
+ <h1>User Profile</h1>
39
+ <p>User ID: ${(_, { params }) => (params.id ? params.id : "???")}</p>
40
+ </body>
41
+ </html>`;
42
+ router.endpoint`GET /debug/:id?`(async (request, { params }) => {
43
+ const obj = {
44
+ method: request.method,
45
+ url: request.url,
46
+ headers: Object.fromEntries(request.headers.entries()),
47
+ params,
48
+ };
49
+ return new Response(JSON.stringify(obj, null, 2), {
50
+ headers: { "Content-Type": "application/json" },
51
+ });
52
+ });
53
+ serve({ port: 8082 }, router);
package/demo/style.css ADDED
@@ -0,0 +1,49 @@
1
+ :not(:defined) > template[shadowrootmode] ~ * {
2
+ display: none;
3
+ }
4
+
5
+ :root {
6
+ --spacing-base: 0.75rem;
7
+ --spacing-baseMinus2: calc(0.5 * var(--spacing-base));
8
+ --gap: var(--spacing-base);
9
+ --gap-small: var(--spacing-baseMinus2);
10
+
11
+ --stroke-style: solid;
12
+ --stroke-thicknessBase: 0.15rem;
13
+ --stroke-thicknessMinus1: calc(0.5 * var(--stroke-thicknessBase));
14
+
15
+ --typography-sizeBase: 1rem;
16
+ --typography-lineHeightBase: 1.3rem;
17
+ --typography-sizePlus1: 1.2rem;
18
+ --typography-lineHeightPlus1: 1.6rem;
19
+ --typography-weightPlus1: 600;
20
+
21
+ --border-radius: 0.375rem;
22
+
23
+ --shadow-raised: 0 0.25rem 0.5rem var(--color-shadow);
24
+
25
+ --color-accent: #008ed8;
26
+ --color-onAccent: #f4f4f4;
27
+ --color-onLayerBase: #2c2c2c;
28
+ --color-shadow: #00000033;
29
+ --color-layerBorder: #d8d8d8;
30
+ }
31
+
32
+ body {
33
+ color: var(--color-onLayerBase);
34
+ }
35
+
36
+ h1 {
37
+ padding-inline-start: var(--gap);
38
+ }
39
+
40
+ .container {
41
+ display: flex;
42
+ gap: var(--gap);
43
+ }
44
+
45
+ @media (width <= 768px) {
46
+ .container {
47
+ flex-direction: column;
48
+ }
49
+ }
package/fs-router.mjs ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file fs-router.mjs
3
+ * @description Implements a filesystem-based router for handling HTTP requests.
4
+ * This router maps URL paths to directory structures and matches HTTP methods to
5
+ * corresponding JavaScript modules (.mjs files).
6
+ */
7
+
8
+ import fs from "fs/promises";
9
+ import path from "path";
10
+ import { pathToFileURL } from "url";
11
+ import dynamicRun from "./utility/dynamic-run.mjs";
12
+ const ALL = "all";
13
+ import theresWaldo from "theres-waldo";
14
+ const { dir } = theresWaldo(import.meta.url);
15
+
16
+ /**
17
+ * @typedef {import('./types').Route} Route
18
+ */
19
+
20
+ /**
21
+ * Creates a filesystem-based router
22
+ * @param {string} baseDir - The base directory for routing
23
+ * @returns {Route} A Route function
24
+ *
25
+ * @description
26
+ * This function creates a router that maps URL paths to a directory structure.
27
+ * It looks for files named [http method].mjs in the corresponding directories
28
+ * and executes the default exported function from these files.
29
+ *
30
+ * Directory Structure:
31
+ * The router expects a directory structure that mirrors the URL path structure.
32
+ * For example, for a URL path /user/profile, the directory structure would be:
33
+ *
34
+ * /baseDir
35
+ * /user
36
+ * /profile
37
+ * get.mjs
38
+ * post.mjs
39
+ * ...
40
+ *
41
+ * Path Parameters:
42
+ * Path parameters are denoted by directories starting with '#'.
43
+ * For example, /user/#id would match a URL like /user/123, and the 'id' parameter
44
+ * would be available in the request object passed to the handler function.
45
+ *
46
+ * Handler Functions:
47
+ * Each [http method].mjs file should export a default function of type Route.
48
+ * This function will receive the request object, augmented with a 'params' property
49
+ * containing any path parameters.
50
+ *
51
+ * @example
52
+ * // Usage:
53
+ * import { createFSRouter } from './fs-router.mjs';
54
+ * import path from 'path';
55
+ *
56
+ * const baseDir = path.join(process.cwd(), 'routes');
57
+ * const router = createFSRouter(baseDir);
58
+ *
59
+ * // Then use this router with your server implementation
60
+ * server.on('request', async (req, res) => {
61
+ * const result = await router(req);
62
+ * if (result) {
63
+ * // Handle the result
64
+ * } else {
65
+ * // Handle 404 Not Found
66
+ * }
67
+ * });
68
+ */
69
+ export function createFSRouter(
70
+ baseDir,
71
+ defaultHandler = () => new Response("not found", { status: 404 })
72
+ ) {
73
+ return async function fsRouter(req) {
74
+ const { pathname } = new URL(req.url, `http://${req.headers.host}`);
75
+ const method = req.method.toLowerCase();
76
+
77
+ // Split the pathname into segments
78
+ const segments = pathname.split("/").filter(Boolean);
79
+
80
+ // Try to find a matching file
81
+ let currentPath = baseDir;
82
+ const params = {};
83
+
84
+ for (const segment of segments) {
85
+ const dir = await fs.readdir(currentPath, { withFileTypes: true });
86
+ const match = dir.find(
87
+ (dirent) =>
88
+ dirent.isDirectory() &&
89
+ (dirent.name === segment || dirent.name.startsWith("#"))
90
+ );
91
+
92
+ if (!match) {
93
+ return null; // No matching path found
94
+ }
95
+
96
+ if (match.name.startsWith("#")) {
97
+ params[match.name.slice(1)] = segment;
98
+ }
99
+
100
+ currentPath = path.join(currentPath, match.name);
101
+ }
102
+
103
+ // Look for a matching method file
104
+ let methodFile = path.join(currentPath, `${method}.mjs`);
105
+
106
+ let html;
107
+ try {
108
+ // Check if the file exists before attempting to import
109
+ try {
110
+ await fs.access(methodFile);
111
+ } catch {
112
+ try {
113
+ methodFile = path.join(currentPath, `index.html`);
114
+ await fs.access(methodFile);
115
+ html = true;
116
+ } catch {
117
+ methodFile = path.join(currentPath, `${ALL}.mjs`);
118
+ await fs.access(methodFile);
119
+ }
120
+ }
121
+
122
+ // Convert the file path to a file URL
123
+ const fileUrl = pathToFileURL(methodFile).href;
124
+
125
+ let module;
126
+ if (html) {
127
+ const file = await fs
128
+ .readFile(methodFile, { encoding: "utf-8" })
129
+ .catch((e) => {
130
+ console.error(e);
131
+ return "";
132
+ });
133
+ const data = `import {createRoute} from "${path.join(
134
+ dir,
135
+ "./index.mjs"
136
+ )}";
137
+ export default createRoute()\`${file}\``;
138
+ // Write the synthesized temp module next to the matched route's
139
+ // index.html (currentPath), not into letterpress's own package
140
+ // directory (dir). Writing into the package dir means every
141
+ // index.html request requires the installed package directory
142
+ // (e.g. node_modules/@johnhenry/letterpress) to be writable, which
143
+ // commonly isn't true in production (read-only installs/
144
+ // containers), and has nothing to do with the route being served.
145
+ module = await dynamicRun(data, currentPath);
146
+ } else {
147
+ module = await import(fileUrl);
148
+ }
149
+ const handler = module.default;
150
+ if (typeof handler !== "function") {
151
+ throw new Error(`${methodFile} does not export a default function`);
152
+ }
153
+ return handler(req, { params });
154
+ } catch (error) {
155
+ if (error.code === "ENOENT" || error.code === "ERR_MODULE_NOT_FOUND") {
156
+ return defaultHandler(req, { params });
157
+ }
158
+ throw error; // Re-throw other errors
159
+ }
160
+ };
161
+ }
package/index.mjs ADDED
@@ -0,0 +1,8 @@
1
+ export { createRequest } from "./create-request.mjs";
2
+ export { createRouter } from "./create-router.mjs";
3
+ export { createRoute } from "./create-route.mjs";
4
+ export { createResponse } from "./create-response.mjs";
5
+ export { createFSRouter } from "./fs-router.mjs";
6
+ export * from "./utility/index.mjs";
7
+ export { deconstruct } from "./utility/deconstruct.mjs";
8
+ export { cook } from "./utility/cook.mjs";
package/license ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 John Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/logo.jpeg ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@johnhenry/letterpress",
3
+ "version": "0.0.0",
4
+ "description": "A tagged-template-string HTTP routing library for defining and matching request handlers with plain JavaScript syntax.",
5
+ "main": "index.mjs",
6
+ "types": "types.d.ts",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "provenance": true
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/johnhenry/letterpress.git"
14
+ },
15
+ "directories": {
16
+ "test": "test"
17
+ },
18
+ "scripts": {
19
+ "test": "node --test test/index.mjs",
20
+ "demo:server": "node demo/server.mjs",
21
+ "demo:client": "node demo/client.mjs"
22
+ },
23
+ "keywords": [
24
+ "router",
25
+ "routing",
26
+ "http",
27
+ "tagged-template",
28
+ "template-literal",
29
+ "request",
30
+ "response"
31
+ ],
32
+ "author": "John Henry",
33
+ "license": "MIT",
34
+ "bugs": {
35
+ "url": "https://github.com/johnhenry/letterpress/issues"
36
+ },
37
+ "homepage": "https://opensource.johnhenry.me/letterpress/",
38
+ "engines": {
39
+ "node": ">=26.0.0"
40
+ },
41
+ "devDependencies": {
42
+ "@johnhenry/leserve": "^0.0.0"
43
+ },
44
+ "dependencies": {
45
+ "theres-waldo": "^0.0.0"
46
+ }
47
+ }