@hyperspan/framework 1.0.0-alpha.1 → 1.0.0-alpha.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperspan/framework",
3
- "version": "1.0.0-alpha.1",
3
+ "version": "1.0.0-alpha.3",
4
4
  "description": "Hyperspan Web Framework",
5
5
  "main": "src/server.ts",
6
6
  "types": "src/server.ts",
@@ -44,6 +44,10 @@
44
44
  "./plugins": {
45
45
  "types": "./src/plugins.ts",
46
46
  "default": "./src/plugins.ts"
47
+ },
48
+ "./actions": {
49
+ "types": "./src/actions.ts",
50
+ "default": "./src/actions.ts"
47
51
  }
48
52
  },
49
53
  "author": "Vance Lucas <vance@vancelucas.com>",
@@ -0,0 +1,206 @@
1
+ import { test, expect, describe } from 'bun:test';
2
+ import { formDataToJSON, createAction } from './actions';
3
+ import { html, render, type HSHtml } from '@hyperspan/html';
4
+ import { createContext } from './server';
5
+ import type { Hyperspan as HS } from './types';
6
+ import * as z from 'zod/v4';
7
+
8
+ describe('formDataToJSON', () => {
9
+ test('formDataToJSON returns empty object for empty FormData', () => {
10
+ const formData = new FormData();
11
+ const result = formDataToJSON(formData);
12
+
13
+ expect(result).toEqual({});
14
+ });
15
+
16
+ test('formDataToJSON handles simple FormData object', () => {
17
+ const formData = new FormData();
18
+ formData.append('name', 'John Doe');
19
+ formData.append('email', 'john@example.com');
20
+ formData.append('age', '30');
21
+
22
+ const result = formDataToJSON(formData);
23
+
24
+ expect(result).toEqual({
25
+ name: 'John Doe',
26
+ email: 'john@example.com',
27
+ age: '30',
28
+ });
29
+ });
30
+
31
+ test('formDataToJSON handles complex FormData with nested fields', () => {
32
+ const formData = new FormData();
33
+ formData.append('user[firstName]', 'John');
34
+ formData.append('user[lastName]', 'Doe');
35
+ formData.append('user[email]', 'john@example.com');
36
+ formData.append('user[address][street]', '123 Main St');
37
+ formData.append('user[address][city]', 'New York');
38
+ formData.append('user[address][zip]', '10001');
39
+
40
+ const result = formDataToJSON(formData);
41
+
42
+ expect(result).toEqual({
43
+ user: {
44
+ firstName: 'John',
45
+ lastName: 'Doe',
46
+ email: 'john@example.com',
47
+ address: {
48
+ street: '123 Main St',
49
+ city: 'New York',
50
+ zip: '10001',
51
+ },
52
+ },
53
+ } as any);
54
+ });
55
+
56
+ test('formDataToJSON handles FormData with array of values', () => {
57
+ const formData = new FormData();
58
+ formData.append('tags', 'javascript');
59
+ formData.append('tags', 'typescript');
60
+ formData.append('tags', 'nodejs');
61
+ formData.append('colors[]', 'red');
62
+ formData.append('colors[]', 'green');
63
+ formData.append('colors[]', 'blue');
64
+
65
+ const result = formDataToJSON(formData);
66
+
67
+ expect(result).toEqual({
68
+ tags: ['javascript', 'typescript', 'nodejs'],
69
+ colors: ['red', 'green', 'blue'],
70
+ });
71
+ });
72
+ });
73
+
74
+ describe('createAction', () => {
75
+ test('creates an action with a simple form and no schema', async () => {
76
+ const action = createAction({
77
+ form: (c: HS.Context) => {
78
+ return html`
79
+ <form>
80
+ <input type="text" name="name" />
81
+ <button type="submit">Submit</button>
82
+ </form>
83
+ `;
84
+ },
85
+ });
86
+
87
+ expect(action).toBeDefined();
88
+ expect(action._kind).toBe('hsAction');
89
+ expect(action._route).toContain('/__actions/');
90
+
91
+ // Test render method
92
+ const request = new Request('http://localhost:3000/');
93
+ const context = createContext(request);
94
+ const rendered = render(action.render(context) as HSHtml);
95
+
96
+ expect(rendered).not.toBeNull();
97
+ const htmlString = rendered;
98
+ expect(htmlString).toContain('<hs-action');
99
+ expect(htmlString).toContain('name="name"');
100
+ });
101
+
102
+ test('creates an action with a Zod schema matching form inputs', async () => {
103
+ const schema = z.object({
104
+ name: z.string().min(1, 'Name is required'),
105
+ email: z.email('Invalid email address'),
106
+ });
107
+
108
+ const action = createAction({
109
+ schema,
110
+ form: (c: HS.Context, { data }) => {
111
+ return html`
112
+ <form>
113
+ <input type="text" name="name" value="${data?.name || ''}" />
114
+ <input type="email" name="email" value="${data?.email || ''}" />
115
+ <button type="submit">Submit</button>
116
+ </form>
117
+ `;
118
+ },
119
+ }).post(async (c: HS.Context, { data }) => {
120
+ return c.res.html(`
121
+ <p>Hello, ${data?.name}!</p>
122
+ <p>Your email is ${data?.email}.</p>
123
+ `);
124
+ });
125
+
126
+ expect(action).toBeDefined();
127
+ expect(action._kind).toBe('hsAction');
128
+ expect(action._route).toContain('/__actions/');
129
+
130
+ // Test render method
131
+ const request = new Request('http://localhost:3000/');
132
+ const context = createContext(request);
133
+ const rendered = action.render(context);
134
+
135
+ expect(rendered).not.toBeNull();
136
+ const htmlString = render(rendered as unknown as HSHtml);
137
+ expect(htmlString).toContain('name="name"');
138
+ expect(htmlString).toContain('name="email"');
139
+
140
+ // Test fetch method with POST request to trigger validation
141
+ const formData = new FormData();
142
+ formData.append('name', 'John Doe');
143
+ formData.append('email', 'john@example.com');
144
+
145
+ const postRequest = new Request(`http://localhost:3000${action._route}`, {
146
+ method: 'POST',
147
+ body: formData,
148
+ });
149
+
150
+ const response = await action.fetch(postRequest);
151
+ expect(response).toBeInstanceOf(Response);
152
+ expect(response.status).toBe(200);
153
+
154
+ const responseText = await response.text();
155
+ expect(responseText).toContain('Hello, John Doe!');
156
+ expect(responseText).toContain('Your email is john@example.com.');
157
+ });
158
+
159
+ test('re-renders form with error when schema validation fails', async () => {
160
+ const schema = z.object({
161
+ name: z.string().min(1, 'Name is required'),
162
+ email: z.email('Invalid email address'),
163
+ });
164
+
165
+ const action = createAction({
166
+ schema,
167
+ form: (c: HS.Context, { data, error }) => {
168
+ return html`
169
+ <form>
170
+ <input type="text" name="name" value="${data?.name || ''}" />
171
+ ${error ? html`<div class="error">Validation failed</div>` : ''}
172
+ <input type="email" name="email" value="${data?.email || ''}" />
173
+ <button type="submit">Submit</button>
174
+ </form>
175
+ `;
176
+ },
177
+ }).post(async (c: HS.Context, { data }) => {
178
+ return c.res.html(`
179
+ <p>Hello, ${data?.name}!</p>
180
+ <p>Your email is ${data?.email}.</p>
181
+ `);
182
+ });
183
+
184
+ // Test fetch method with invalid data (missing name, invalid email)
185
+ const formData = new FormData();
186
+ formData.append('email', 'not-an-email');
187
+
188
+ const postRequest = new Request(`http://localhost:3000${action._route}`, {
189
+ method: 'POST',
190
+ body: formData,
191
+ });
192
+
193
+ const response = await action.fetch(postRequest);
194
+ expect(response).toBeInstanceOf(Response);
195
+ expect(response.status).toBe(400);
196
+
197
+ const responseText = await response.text();
198
+ // Should re-render the form, not the post handler output
199
+ expect(responseText).toContain('name="name"');
200
+ expect(responseText).toContain('name="email"');
201
+ expect(responseText).toContain('Validation failed');
202
+ // Should NOT contain the success message from post handler
203
+ expect(responseText).not.toContain('Hello,');
204
+ });
205
+ });
206
+
package/src/actions.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { html, HSHtml } from '@hyperspan/html';
2
+ import { createRoute, parsePath, returnHTMLResponse } from './server';
3
+ import * as z from 'zod/v4';
4
+ import type { Hyperspan as HS } from './types';
5
+ import { assetHash } from './utils';
6
+
7
+ /**
8
+ * Actions = Form + route handler
9
+ * Automatically handles and parses form data
10
+ *
11
+ * HOW THIS WORKS:
12
+ * ---
13
+ * 1. Renders in any template as initial form markup with action.render()
14
+ * 2. Binds form onSubmit function to custom client JS handling via <hs-action> web component
15
+ * 3. Submits form with JavaScript fetch() + FormData as normal POST form submission
16
+ * 4. All validation and save logic is run on the server
17
+ * 5. Replaces form content in place with HTML response content from server via the Idiomorph library
18
+ * 6. Handles any Exception thrown on server as error displayed back to user on the page
19
+ */;
20
+ type HSActionResponse = HSHtml | void | null | Promise<HSHtml | void | null> | Response | Promise<Response>;
21
+ export type HSFormHandler<T extends z.ZodTypeAny> = (
22
+ c: HS.Context,
23
+ { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
24
+ ) => HSActionResponse;
25
+ export interface HSAction<T extends z.ZodTypeAny> {
26
+ _kind: 'hsAction';
27
+ _name: string;
28
+ _path(): string;
29
+ _form: null | HSFormHandler<T>;
30
+ form(form: HSFormHandler<T>): HSAction<T>;
31
+ post(
32
+ handler: (
33
+ c: HS.Context,
34
+ { data }: { data?: Partial<z.infer<T>> }
35
+ ) => HSActionResponse
36
+ ): HSAction<T>;
37
+ error(
38
+ handler: (
39
+ c: HS.Context,
40
+ { data, error }: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
41
+ ) => HSActionResponse
42
+ ): HSAction<T>;
43
+ render(
44
+ c: HS.Context,
45
+ props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }
46
+ ): HSActionResponse;
47
+ middleware: (middleware: Array<HS.MiddlewareFunction>) => HSAction<T>;
48
+ fetch(request: Request): Response | Promise<Response>;
49
+ }
50
+
51
+ export function createAction<T extends z.ZodTypeAny>(params: { name: string; schema?: T }) {
52
+ const { name, schema } = params;
53
+
54
+ let _handler: Parameters<HSAction<T>['post']>[0] | null = null;
55
+ let _errorHandler: Parameters<HSAction<T>['error']>[0] | null = null;
56
+
57
+ const route = createRoute()
58
+ .get((c: HS.Context) => api.render(c))
59
+ .post(async (c: HS.Context) => {
60
+ // Parse form data
61
+ const formData = await c.req.raw.formData();
62
+ const jsonData = formDataToJSON(formData) as Partial<z.infer<T>>;
63
+ const schemaData = schema ? schema.safeParse(jsonData) : null;
64
+ const data = schemaData?.success ? (schemaData.data as Partial<z.infer<T>>) : jsonData;
65
+ let error: z.ZodError | Error | null = null;
66
+
67
+ try {
68
+ if (schema && schemaData?.error) {
69
+ throw schemaData.error;
70
+ }
71
+
72
+ if (!_handler) {
73
+ throw new Error('Action POST handler not set! Every action must have a POST handler.');
74
+ }
75
+
76
+ const response = await _handler(c, { data });
77
+
78
+ if (response instanceof Response) {
79
+ // Replace redirects with special header because fetch() automatically follows redirects
80
+ // and we want to redirect the user to the actual full page instead
81
+ if ([301, 302, 307, 308].includes(response.status)) {
82
+ response.headers.set('X-Redirect-Location', response.headers.get('Location') || '/');
83
+ response.headers.delete('Location');
84
+ }
85
+ }
86
+
87
+ return response;
88
+ } catch (e) {
89
+ error = e as Error | z.ZodError;
90
+ }
91
+
92
+ if (error && _errorHandler) {
93
+ const errorHandler = _errorHandler; // Required for TypeScript to infer the correct type after narrowing
94
+ return await returnHTMLResponse(c, () => errorHandler(c, { data, error }), {
95
+ status: 400,
96
+ });
97
+ }
98
+
99
+ return await returnHTMLResponse(c, () => api.render(c, { data, error }), { status: 400 });
100
+ });
101
+
102
+ const api: HSAction<T> = {
103
+ _kind: 'hsAction',
104
+ _name: name,
105
+ _path() {
106
+ return `/__actions/${assetHash(name)}`;
107
+ },
108
+ _form: null,
109
+ /**
110
+ * Form to render
111
+ * This will be wrapped in a <hs-action> web component and submitted via fetch()
112
+ */
113
+ form(form: HSFormHandler<T>) {
114
+ api._form = form;
115
+ return api;
116
+ },
117
+ /**
118
+ * Process form data
119
+ *
120
+ * Returns result from form processing if successful
121
+ * Re-renders form with data and error information otherwise
122
+ */
123
+ post(handler) {
124
+ _handler = handler;
125
+ return api;
126
+ },
127
+ /**
128
+ * Cusotm error handler if you want to display something other than the default
129
+ */
130
+ error(handler) {
131
+ _errorHandler = handler;
132
+ return api;
133
+ },
134
+ /**
135
+ * Add middleware specific to this route
136
+ */
137
+ middleware(middleware) {
138
+ route.middleware(middleware);
139
+ return api;
140
+ },
141
+ /**
142
+ * Get form renderer method
143
+ */
144
+ render(c: HS.Context, props?: { data?: Partial<z.infer<T>>; error?: z.ZodError | Error }) {
145
+ const formContent = api._form ? api._form(c, props || {}) : null;
146
+ return formContent ? html`<hs-action url="${this._path()}">${formContent}</hs-action>` : null;
147
+ },
148
+ /**
149
+ * Run action route handler
150
+ */
151
+ fetch(request: Request) {
152
+ return route.fetch(request);
153
+ },
154
+ };
155
+
156
+ return api;
157
+ }
158
+
159
+ /**
160
+ * Return JSON data structure for a given FormData object
161
+ * Accounts for array fields (e.g. name="options[]" or <select multiple>)
162
+ *
163
+ * @link https://stackoverflow.com/a/75406413
164
+ */
165
+ export function formDataToJSON(formData: FormData): Record<string, string | string[]> {
166
+ let object = {};
167
+
168
+ /**
169
+ * Parses FormData key xxx`[x][x][x]` fields into array
170
+ */
171
+ const parseKey = (key: string) => {
172
+ const subKeyIdx = key.indexOf('[');
173
+
174
+ if (subKeyIdx !== -1) {
175
+ const keys = [key.substring(0, subKeyIdx)];
176
+ key = key.substring(subKeyIdx);
177
+
178
+ for (const match of key.matchAll(/\[(?<key>.*?)]/gm)) {
179
+ if (match.groups) {
180
+ keys.push(match.groups.key);
181
+ }
182
+ }
183
+ return keys;
184
+ } else {
185
+ return [key];
186
+ }
187
+ };
188
+
189
+ /**
190
+ * Recursively iterates over keys and assigns key/values to object
191
+ */
192
+ const assign = (keys: string[], value: FormDataEntryValue, object: any): void => {
193
+ const key = keys.shift();
194
+
195
+ // When last key in the iterations
196
+ if (key === '' || key === undefined) {
197
+ return object.push(value);
198
+ }
199
+
200
+ if (Reflect.has(object, key)) {
201
+ // If key has been found, but final pass - convert the value to array
202
+ if (keys.length === 0) {
203
+ if (!Array.isArray(object[key])) {
204
+ object[key] = [object[key], value];
205
+ return;
206
+ }
207
+ }
208
+ // Recurse again with found object
209
+ return assign(keys, value, object[key]);
210
+ }
211
+
212
+ // Create empty object for key, if next key is '' do array instead, otherwise set value
213
+ if (keys.length >= 1) {
214
+ object[key] = keys[0] === '' ? [] : {};
215
+ return assign(keys, value, object[key]);
216
+ } else {
217
+ object[key] = value;
218
+ }
219
+ };
220
+
221
+ for (const pair of formData.entries()) {
222
+ assign(parseKey(pair[0]), pair[1], object);
223
+ }
224
+
225
+ return object;
226
+ }
package/src/client/js.ts CHANGED
@@ -1,7 +1,6 @@
1
1
  import { html } from '@hyperspan/html';
2
2
  import type { Hyperspan as HS } from '../types';
3
3
 
4
-
5
4
  export const JS_PUBLIC_PATH = '/_hs/js';
6
5
  export const JS_ISLAND_PUBLIC_PATH = '/_hs/js/islands';
7
6
  export const JS_IMPORT_MAP = new Map<string, string>();
package/src/plugins.ts CHANGED
@@ -65,7 +65,7 @@ export function clientJSPlugin(): HS.Plugin {
65
65
 
66
66
  // Export a special object that can be used to render the client JS as a script tag
67
67
  const moduleCode = `// hyperspan:processed
68
- import { functionToString } from '@hyperspan/framework/assets';
68
+ import { functionToString } from '@hyperspan/framework/client/js';
69
69
 
70
70
  // Original file contents
71
71
  ${contents}
@@ -3,7 +3,7 @@ import { createRoute, createServer } from './server';
3
3
  import type { Hyperspan as HS } from './types';
4
4
 
5
5
  test('route fetch() returns a Response', async () => {
6
- const route = createRoute().get((context) => {
6
+ const route = createRoute().get((context: HS.Context) => {
7
7
  return context.res.html('<h1>Hello World</h1>');
8
8
  });
9
9
 
@@ -16,24 +16,25 @@ test('route fetch() returns a Response', async () => {
16
16
  });
17
17
 
18
18
  test('server with two routes can return Response from one', async () => {
19
- const server = createServer({
19
+ const server = await createServer({
20
20
  appDir: './app',
21
- staticFileRoot: './public',
21
+ publicDir: './public',
22
+ plugins: [],
22
23
  });
23
24
 
24
25
  // Add two routes to the server
25
- server.get('/users', (context) => {
26
+ server.get('/users', (context: HS.Context) => {
26
27
  return context.res.html('<h1>Users Page</h1>');
27
28
  });
28
29
 
29
- server.get('/posts', (context) => {
30
+ server.get('/posts', (context: HS.Context) => {
30
31
  return context.res.html('<h1>Posts Page</h1>');
31
32
  });
32
33
 
33
34
 
34
35
  // Test that we can get a Response from one of the routes
35
36
  const request = new Request('http://localhost:3000/users');
36
- const testRoute = server._routes.find((route) => route._path === '/users');
37
+ const testRoute = server._routes.find((route: HS.Route) => route._path() === '/users');
37
38
  const response = await testRoute!.fetch(request);
38
39
 
39
40
  expect(response).toBeInstanceOf(Response);
@@ -42,22 +43,23 @@ test('server with two routes can return Response from one', async () => {
42
43
  });
43
44
 
44
45
  test('server returns a route with a POST request', async () => {
45
- const server = createServer({
46
+ const server = await createServer({
46
47
  appDir: './app',
47
- staticFileRoot: './public',
48
+ publicDir: './public',
49
+ plugins: [],
48
50
  });
49
51
 
50
52
  // Add two routes to the server
51
- server.get('/users', (context) => {
53
+ server.get('/users', (context: HS.Context) => {
52
54
  return context.res.html('<h1>GET /users</h1>');
53
55
  });
54
56
 
55
- server.post('/users', (context) => {
57
+ server.post('/users', (context: HS.Context) => {
56
58
  return context.res.html('<h1>POST /users</h1>');
57
59
  });
58
60
 
59
- const route = server._routes.find((route) => route._path === '/users' && route._methods().includes('POST')) as HS.Route;
60
- const request = new Request('http://localhost:3000/', { method: 'POST' });
61
+ const route = server._routes.find((route: HS.Route) => route._path() === '/users' && route._methods().includes('POST')) as HS.Route;
62
+ const request = new Request('http://localhost:3000/users', { method: 'POST' });
61
63
  const response = await route.fetch(request);
62
64
 
63
65
  expect(response).toBeInstanceOf(Response);
@@ -66,18 +68,19 @@ test('server returns a route with a POST request', async () => {
66
68
  });
67
69
 
68
70
  test('returns 405 when route path matches but HTTP method does not', async () => {
69
- const server = createServer({
71
+ const server = await createServer({
70
72
  appDir: './app',
71
- staticFileRoot: './public',
73
+ publicDir: './public',
74
+ plugins: [],
72
75
  });
73
76
 
74
77
  // Route registered for GET only
75
- server.get('/users', (context) => {
78
+ server.get('/users', (context: HS.Context) => {
76
79
  return context.res.html('<h1>Users Page</h1>');
77
80
  });
78
81
 
79
82
  // Attempt to POST to /users, which should return 405
80
- const route = server._routes.find((route) => route._path === '/users')!;
83
+ const route = server._routes.find((route: HS.Route) => route._path() === '/users')!;
81
84
  const request = new Request('http://localhost:3000/users', { method: 'POST' });
82
85
  const response = await route.fetch(request);
83
86
 
package/src/server.ts CHANGED
@@ -2,10 +2,10 @@ import { HSHtml, html, isHSHtml, renderStream, renderAsync, render } from '@hype
2
2
  import { executeMiddleware } from './middleware';
3
3
  import type { Hyperspan as HS } from './types';
4
4
  import { clientJSPlugin } from './plugins';
5
+ import { CSS_ROUTE_MAP } from './client/css';
5
6
  export type { HS as Hyperspan };
6
7
 
7
8
  export const IS_PROD = process.env.NODE_ENV === 'production';
8
- const CWD = process.cwd();
9
9
 
10
10
  export class HTTPException extends Error {
11
11
  constructor(public status: number, message?: string) {
@@ -38,9 +38,11 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
38
38
  const params: HS.RouteParamsParser<path> = req?.params || {};
39
39
 
40
40
  return {
41
+ vars: {},
41
42
  route: {
42
43
  path,
43
44
  params: params,
45
+ cssImports: route ? route._config.cssImports ?? [] : [],
44
46
  },
45
47
  req: {
46
48
  raw: req,
@@ -51,6 +53,7 @@ export function createContext(req: Request, route?: HS.Route): HS.Context {
51
53
  body: req.body,
52
54
  },
53
55
  res: {
56
+ headers: new Headers(),
54
57
  raw: new Response(),
55
58
  html: (html: string, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(html, { ...options, headers: { 'Content-Type': 'text/html; charset=UTF-8', ...options?.headers } }),
56
59
  json: (json: any, options?: { status?: number; headers?: Headers | Record<string, string> }) => new Response(JSON.stringify(json), { ...options, headers: { 'Content-Type': 'application/json', ...options?.headers } }),
@@ -145,12 +148,11 @@ export function createRoute(config: HS.RouteConfig = {}): HS.Route {
145
148
  */
146
149
  async fetch(request: Request) {
147
150
  const context = createContext(request, api);
151
+ const method = context.req.method;
148
152
  const globalMiddleware = _middleware['*'] || [];
149
- const methodMiddleware = _middleware[context.req.method] || [];
153
+ const methodMiddleware = _middleware[method] || [];
150
154
 
151
155
  const methodHandler = async (context: HS.Context) => {
152
- const method = context.req.method;
153
-
154
156
  // Handle CORS preflight requests (if no OPTIONS handler is defined)
155
157
  if (method === 'OPTIONS' && !_handlers['OPTIONS']) {
156
158
  return context.res.html(
package/src/utils.ts CHANGED
@@ -1,5 +1,9 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHash, randomBytes } from "node:crypto";
2
2
 
3
3
  export function assetHash(content: string): string {
4
4
  return createHash('md5').update(content).digest('hex');
5
+ }
6
+
7
+ export function randomHash(): string {
8
+ return createHash('md5').update(randomBytes(32).toString('hex')).digest('hex');
5
9
  }
package/tsconfig.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "compileOnSave": true,
3
3
  "compilerOptions": {
4
- "rootDir": "src",
5
4
  "outDir": "dist",
6
5
  "target": "es2019",
7
6
  "lib": ["ESNext", "dom", "dom.iterable"],
@@ -25,5 +24,6 @@
25
24
  "@hyperspan/html": ["../html/src/html.ts"]
26
25
  }
27
26
  },
27
+ "references": [{ "path": "../html" }],
28
28
  "exclude": ["node_modules", "__tests__", "*.test.ts"]
29
29
  }