@eighty4/dank 0.0.4-1 → 0.0.4-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/lib_js/html.js CHANGED
@@ -1,253 +1,302 @@
1
- import EventEmitter from 'node:events';
2
- import { readFile } from 'node:fs/promises';
3
- import { dirname, join, relative } from 'node:path';
4
- import { extname } from 'node:path/posix';
5
- import { defaultTreeAdapter, parse, parseFragment, serialize, } from 'parse5';
6
- export class HtmlEntrypoint extends EventEmitter {
7
- #build;
8
- #decorations;
9
- #document = defaultTreeAdapter.createDocument();
10
- // todo cache entrypoints set for quicker diffing
11
- // #entrypoints: Set<string> = new Set()
12
- // path within pages dir omitting pages/ segment
13
- #fsPath;
14
- #partials = [];
15
- #resolver;
16
- #scripts = [];
17
- #update = Object();
18
- #url;
19
- constructor(build, resolver, url, fsPath, decorations) {
20
- super({ captureRejections: true });
21
- this.#build = build;
22
- this.#resolver = resolver;
23
- this.#decorations = decorations;
24
- this.#url = url;
25
- this.#fsPath = fsPath;
26
- this.on('change', this.#onChange);
27
- this.emit('change');
28
- }
29
- get fsPath() {
30
- return this.#fsPath;
31
- }
32
- get url() {
33
- return this.#url;
34
- }
35
- async #html() {
36
- try {
37
- return await readFile(join(this.#build.dirs.pages, this.#fsPath), 'utf8');
38
- }
39
- catch (e) {
40
- // todo error handling
41
- errorExit(this.#fsPath + ' does not exist');
42
- }
1
+ import EventEmitter from "node:events";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+ import { extname } from "node:path/posix";
5
+ import { defaultTreeAdapter, parse, parseFragment, serialize } from "parse5";
6
+ import { DankError } from "./errors.js";
7
+ class HtmlEntrypoint extends EventEmitter {
8
+ #c;
9
+ #clientJS;
10
+ #document = defaultTreeAdapter.createDocument();
11
+ #entrypoints = /* @__PURE__ */ new Set();
12
+ // path within pages dir omitting pages/ segment
13
+ #fsPath;
14
+ #partials = [];
15
+ #resolver;
16
+ #scripts = [];
17
+ #update = Object();
18
+ #url;
19
+ constructor(c, resolver, url, fsPath) {
20
+ super({ captureRejections: true });
21
+ this.#c = c;
22
+ this.#clientJS = ClientJS.initialize(c);
23
+ this.#resolver = resolver;
24
+ this.#url = url;
25
+ this.#fsPath = fsPath;
26
+ this.on("change", this.#onChange);
27
+ }
28
+ get fsPath() {
29
+ return this.#fsPath;
30
+ }
31
+ get url() {
32
+ return this.#url;
33
+ }
34
+ async process() {
35
+ await this.#onChange();
36
+ }
37
+ output(hrefs) {
38
+ this.#injectPartials();
39
+ this.#rewriteHrefs(hrefs);
40
+ return serialize(this.#document);
41
+ }
42
+ usesPartial(fsPath) {
43
+ return this.#partials.some((partial) => partial.fsPath === fsPath);
44
+ }
45
+ #onChange = async () => {
46
+ const update = this.#update = Object();
47
+ let html;
48
+ try {
49
+ html = await this.#readFromPages(this.#fsPath);
50
+ } catch (e) {
51
+ this.#error(`url \`${this.#url}\` html file \`${join(this.#c.dirs.pages, this.#fsPath)}\` does not exist`);
52
+ return;
43
53
  }
44
- // todo if partial changes, hot swap content in page
45
- #onChange = async (_partial) => {
46
- const update = (this.#update = Object());
47
- const html = await this.#html();
48
- const document = parse(html);
49
- const imports = {
50
- partials: [],
51
- scripts: [],
52
- };
53
- this.#collectImports(document, imports);
54
- const partials = await this.#resolvePartialContent(imports.partials);
55
- if (update !== this.#update) {
56
- // another update has started so aborting this one
57
- return;
58
- }
59
- this.#addDecorations(document);
60
- this.#update = update;
61
- this.#document = document;
62
- this.#partials = partials;
63
- this.#scripts = imports.scripts;
64
- const entrypoints = mergeEntrypoints(imports, ...partials.map(p => p.imports));
65
- // this.#entrypoints = new Set(entrypoints.map(entrypoint => entrypoint.in))
66
- this.emit('entrypoints', entrypoints);
67
- this.emit('partials', this.#partials.map(p => p.fsPath));
68
- if (this.listenerCount('output')) {
69
- this.emit('output', this.output());
70
- }
54
+ const document = parse(html);
55
+ const imports = {
56
+ partials: [],
57
+ scripts: []
71
58
  };
72
- // Emits `partial` on detecting a partial reference for `dank serve` file watches
73
- // to respond to dependent changes
74
- // todo safeguard recursive partials that cause circular imports
75
- async #resolvePartialContent(partials) {
76
- return await Promise.all(partials.map(async (p) => {
77
- this.emit('partial', p.fsPath);
78
- const html = await readFile(join(this.#build.dirs.pages, p.fsPath), 'utf8');
79
- const fragment = parseFragment(html);
80
- const imports = {
81
- partials: [],
82
- scripts: [],
83
- };
84
- this.#collectImports(fragment, imports, node => {
85
- this.#rewritePartialRelativePaths(node, p.fsPath);
86
- });
87
- if (imports.partials.length) {
88
- // todo recursive partials?
89
- // await this.#resolvePartialContent(imports.partials)
90
- errorExit(`partial ${p.fsPath} cannot recursively import partials`);
91
- }
92
- const content = {
93
- ...p,
94
- fragment,
95
- imports,
96
- };
97
- return content;
98
- }));
99
- }
100
- // rewrite hrefs in a partial to be relative to the html entrypoint instead of the partial
101
- #rewritePartialRelativePaths(elem, partialPath) {
102
- let rewritePath = null;
103
- if (elem.nodeName === 'script') {
104
- rewritePath = 'src';
105
- }
106
- else if (elem.nodeName === 'link' &&
107
- hasAttr(elem, 'rel', 'stylesheet')) {
108
- rewritePath = 'href';
109
- }
110
- if (rewritePath !== null) {
111
- const attr = getAttr(elem, rewritePath);
112
- if (attr) {
113
- attr.value = join(relative(dirname(this.#fsPath), dirname(partialPath)), attr.value);
114
- }
115
- }
59
+ let partials;
60
+ this.#collectImports(document, imports);
61
+ const partialResults = await this.#resolvePartialContent(imports.partials);
62
+ partials = partialResults.filter((p) => p !== false);
63
+ if (partials.length !== partialResults.length) {
64
+ this.#error(`update to \`${join(this.#c.dirs.pages, this.#fsPath)}\` did not update to \`${this.#url}\` because of unresolved partials`);
65
+ return;
116
66
  }
117
- #addDecorations(document) {
118
- if (!this.#decorations?.length) {
119
- return;
120
- }
121
- for (const decoration of this.#decorations) {
122
- switch (decoration.type) {
123
- case 'script':
124
- const scriptNode = parseFragment(`<script type="module">${decoration.js}</script>`).childNodes[0];
125
- const htmlNode = document.childNodes.find(node => node.nodeName === 'html');
126
- const headNode = htmlNode.childNodes.find(node => node.nodeName === 'head');
127
- defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode);
128
- break;
129
- }
130
- }
67
+ if (this.#clientJS !== null) {
68
+ const decoration = await this.#clientJS.retrieve(this.#c.esbuildPort);
69
+ this.#addScriptDecoration(document, decoration.js);
131
70
  }
132
- output(hrefs) {
133
- this.#injectPartials();
134
- this.#rewriteHrefs(hrefs);
135
- return serialize(this.#document);
136
- }
137
- // rewrites hrefs to content hashed urls
138
- // call without hrefs to rewrite tsx? ext to js
139
- #rewriteHrefs(hrefs) {
140
- rewriteHrefs(this.#scripts, hrefs);
141
- for (const partial of this.#partials) {
142
- rewriteHrefs(partial.imports.scripts, hrefs);
143
- }
71
+ if (update !== this.#update) {
72
+ return;
144
73
  }
145
- async #injectPartials() {
146
- for (const { commentNode, fragment } of this.#partials) {
147
- if (!this.#build.production) {
148
- defaultTreeAdapter.insertBefore(commentNode.parentNode, defaultTreeAdapter.createCommentNode(commentNode.data), commentNode);
149
- }
150
- for (const node of fragment.childNodes) {
151
- defaultTreeAdapter.insertBefore(commentNode.parentNode, node, commentNode);
152
- }
153
- if (this.#build.production) {
154
- defaultTreeAdapter.detachNode(commentNode);
155
- }
156
- }
74
+ this.#document = document;
75
+ this.#partials = partials;
76
+ this.#scripts = imports.scripts;
77
+ const entrypoints = mergeEntrypoints(imports, ...partials.map((p) => p.imports));
78
+ if (this.#haveEntrypointsChanged(entrypoints)) {
79
+ this.emit("entrypoints", entrypoints);
157
80
  }
158
- #collectImports(node, collection, forEach) {
159
- for (const childNode of node.childNodes) {
160
- if (forEach && 'tagName' in childNode) {
161
- forEach(childNode);
162
- }
163
- if (childNode.nodeName === '#comment' && 'data' in childNode) {
164
- const partialMatch = childNode.data.match(/\{\{(?<pp>.+)\}\}/);
165
- if (partialMatch) {
166
- const partialSpecifier = partialMatch.groups.pp.trim();
167
- if (partialSpecifier.startsWith('/')) {
168
- errorExit(`partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be an absolute path`);
169
- }
170
- const partialPath = join(dirname(this.#fsPath), partialSpecifier);
171
- if (!this.#resolver.isPagesSubpathInPagesDir(partialPath)) {
172
- errorExit(`partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be outside of the pages directory`);
173
- }
174
- collection.partials.push({
175
- fsPath: partialPath,
176
- commentNode: childNode,
177
- });
178
- }
179
- }
180
- else if (childNode.nodeName === 'script') {
181
- const srcAttr = childNode.attrs.find(attr => attr.name === 'src');
182
- if (srcAttr) {
183
- collection.scripts.push(this.#parseImport('script', srcAttr.value, childNode));
184
- }
185
- }
186
- else if (childNode.nodeName === 'link' &&
187
- hasAttr(childNode, 'rel', 'stylesheet')) {
188
- const hrefAttr = getAttr(childNode, 'href');
189
- if (hrefAttr) {
190
- collection.scripts.push(this.#parseImport('style', hrefAttr.value, childNode));
191
- }
192
- }
193
- else if ('childNodes' in childNode) {
194
- this.#collectImports(childNode, collection);
195
- }
196
- }
81
+ if (this.listenerCount("output")) {
82
+ this.emit("output", this.output());
83
+ }
84
+ };
85
+ #error(e) {
86
+ let message;
87
+ let error;
88
+ if (typeof e === "string") {
89
+ message = e;
90
+ error = new DankError(e);
91
+ } else {
92
+ message = e.message;
93
+ error = e;
94
+ }
95
+ if (this.listenerCount("error")) {
96
+ this.emit("error", error);
97
+ } else {
98
+ throw error;
197
99
  }
198
- #parseImport(type, href, elem) {
199
- const inPath = join(this.#build.dirs.pages, dirname(this.#fsPath), href);
200
- if (!this.#resolver.isProjectSubpathInPagesDir(inPath)) {
201
- errorExit(`href ${href} in webpage ${this.#fsPath} cannot reference sources outside of the pages directory`);
100
+ }
101
+ #haveEntrypointsChanged(entrypoints) {
102
+ const set = new Set(entrypoints.map((entrypoint) => entrypoint.in));
103
+ const changed = set.symmetricDifference(this.#entrypoints).size > 0;
104
+ this.#entrypoints = set;
105
+ return changed;
106
+ }
107
+ async #readFromPages(p) {
108
+ return await readFile(this.#resolver.absPagesPath(p), "utf8");
109
+ }
110
+ async #resolvePartialContent(partials) {
111
+ return await Promise.all(partials.map(async (p) => {
112
+ let html;
113
+ if (isAbsolute(p.specifier)) {
114
+ this.#error(`partials cannot be referenced with an absolute path like \`${p.specifier}\` in webpage \`${join(this.#c.dirs.pages, this.#fsPath)}\``);
115
+ return false;
116
+ }
117
+ if (!this.#resolver.isPagesSubpathInPagesDir(p.fsPath)) {
118
+ this.#error(`partials cannot be referenced from outside the pages dir like \`${p.specifier}\` in webpage \`${join(this.#c.dirs.pages, this.#fsPath)}\``);
119
+ return false;
120
+ }
121
+ if (extname(p.fsPath) !== ".html") {
122
+ this.#error(`partial path \`${p.fsPath}\` referenced by \`${this.#fsPath}\` is not a valid partial path`);
123
+ return false;
124
+ }
125
+ try {
126
+ html = await this.#readFromPages(p.fsPath);
127
+ } catch (e) {
128
+ this.#error(`partial \`${join("pages", p.fsPath)}\` imported by \`${join("pages", this.#fsPath)}\` does not exist`);
129
+ return false;
130
+ }
131
+ const fragment = parseFragment(html);
132
+ const imports = {
133
+ partials: [],
134
+ scripts: []
135
+ };
136
+ this.#collectImports(fragment, imports, (node) => {
137
+ this.#rewritePartialRelativePaths(node, p.fsPath);
138
+ });
139
+ if (imports.partials.length) {
140
+ this.#error(`partials cannot import another partial like \`${join(this.#c.dirs.pages, p.fsPath)}\``);
141
+ }
142
+ const content = {
143
+ ...p,
144
+ fragment,
145
+ imports
146
+ };
147
+ return content;
148
+ }));
149
+ }
150
+ // rewrite hrefs in a partial to be relative to the html entrypoint instead of the partial
151
+ #rewritePartialRelativePaths(elem, partialPath) {
152
+ let rewritePath = null;
153
+ if (elem.nodeName === "script") {
154
+ rewritePath = "src";
155
+ } else if (elem.nodeName === "link" && hasAttr(elem, "rel", "stylesheet")) {
156
+ rewritePath = "href";
157
+ }
158
+ if (rewritePath !== null) {
159
+ const attr = getAttr(elem, rewritePath);
160
+ if (attr) {
161
+ attr.value = join(relative(dirname(this.#fsPath), dirname(partialPath)), attr.value);
162
+ }
163
+ }
164
+ }
165
+ #addScriptDecoration(document, js) {
166
+ const scriptNode = parseFragment(`<script type="module">${js}</script>`).childNodes[0];
167
+ const htmlNode = document.childNodes.find((node) => node.nodeName === "html");
168
+ const headNode = htmlNode.childNodes.find((node) => node.nodeName === "head");
169
+ defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode);
170
+ }
171
+ // rewrites hrefs to content hashed urls
172
+ // call without hrefs to rewrite tsx? ext to js
173
+ #rewriteHrefs(hrefs) {
174
+ rewriteHrefs(this.#scripts, hrefs);
175
+ for (const partial of this.#partials) {
176
+ rewriteHrefs(partial.imports.scripts, hrefs);
177
+ }
178
+ }
179
+ async #injectPartials() {
180
+ for (const { commentNode, fragment } of this.#partials) {
181
+ if (!this.#c.flags.production) {
182
+ defaultTreeAdapter.insertBefore(commentNode.parentNode, defaultTreeAdapter.createCommentNode(commentNode.data), commentNode);
183
+ }
184
+ for (const node of fragment.childNodes) {
185
+ defaultTreeAdapter.insertBefore(commentNode.parentNode, node, commentNode);
186
+ }
187
+ if (this.#c.flags.production) {
188
+ defaultTreeAdapter.detachNode(commentNode);
189
+ }
190
+ }
191
+ }
192
+ #collectImports(node, collection, forEach) {
193
+ for (const childNode of node.childNodes) {
194
+ if (forEach && "tagName" in childNode) {
195
+ forEach(childNode);
196
+ }
197
+ if (childNode.nodeName === "#comment" && "data" in childNode) {
198
+ const partialMatch = childNode.data.match(/\{\{(?<pp>.+)\}\}/);
199
+ if (partialMatch) {
200
+ const specifier = partialMatch.groups.pp.trim();
201
+ collection.partials.push({
202
+ commentNode: childNode,
203
+ fsPath: join(dirname(this.#fsPath), specifier),
204
+ specifier
205
+ });
202
206
  }
203
- let outPath = join(dirname(this.#fsPath), href);
204
- if (type === 'script' && !outPath.endsWith('.js')) {
205
- outPath = outPath.replace(new RegExp(extname(outPath).substring(1) + '$'), 'js');
207
+ } else if (childNode.nodeName === "script") {
208
+ const srcAttr = childNode.attrs.find((attr) => attr.name === "src");
209
+ if (srcAttr) {
210
+ collection.scripts.push(this.#parseImport("script", srcAttr.value, childNode));
206
211
  }
207
- return {
208
- type,
209
- href,
210
- elem,
211
- entrypoint: {
212
- in: inPath,
213
- out: outPath,
214
- },
215
- };
212
+ } else if (childNode.nodeName === "link" && hasAttr(childNode, "rel", "stylesheet")) {
213
+ const hrefAttr = getAttr(childNode, "href");
214
+ if (hrefAttr) {
215
+ collection.scripts.push(this.#parseImport("style", hrefAttr.value, childNode));
216
+ }
217
+ } else if ("childNodes" in childNode) {
218
+ this.#collectImports(childNode, collection);
219
+ }
220
+ }
221
+ }
222
+ #parseImport(type, href, elem) {
223
+ const inPath = join(this.#c.dirs.pages, dirname(this.#fsPath), href);
224
+ if (!this.#resolver.isProjectSubpathInPagesDir(inPath)) {
225
+ throw new DankError(`href \`${href}\` in webpage \`${join(this.#c.dirs.pages, this.#fsPath)}\` cannot reference sources outside of the pages directory`);
226
+ }
227
+ let outPath = join(dirname(this.#fsPath), href);
228
+ if (type === "script" && !outPath.endsWith(".js")) {
229
+ outPath = outPath.replace(new RegExp(extname(outPath).substring(1) + "$"), "js");
216
230
  }
231
+ return {
232
+ type,
233
+ href,
234
+ elem,
235
+ entrypoint: {
236
+ in: inPath,
237
+ out: outPath
238
+ }
239
+ };
240
+ }
217
241
  }
218
242
  function getAttr(elem, name) {
219
- return elem.attrs.find(attr => attr.name === name);
243
+ return elem.attrs.find((attr) => attr.name === name);
220
244
  }
221
245
  function hasAttr(elem, name, value) {
222
- return elem.attrs.some(attr => attr.name === name && attr.value === value);
246
+ return elem.attrs.some((attr) => attr.name === name && attr.value === value);
223
247
  }
224
248
  function mergeEntrypoints(...imports) {
225
- const entrypoints = [];
226
- for (const { scripts } of imports) {
227
- for (const script of scripts) {
228
- entrypoints.push(script.entrypoint);
229
- }
249
+ const entrypoints = [];
250
+ for (const { scripts } of imports) {
251
+ for (const script of scripts) {
252
+ entrypoints.push(script.entrypoint);
230
253
  }
231
- return entrypoints;
254
+ }
255
+ return entrypoints;
232
256
  }
233
257
  function rewriteHrefs(scripts, hrefs) {
234
- for (const { elem, entrypoint, type } of scripts) {
235
- const rewriteTo = hrefs ? hrefs.mappedHref(entrypoint.in) : null;
236
- if (type === 'script') {
237
- if (entrypoint.in.endsWith('.tsx') ||
238
- entrypoint.in.endsWith('.ts')) {
239
- elem.attrs.find(attr => attr.name === 'src').value =
240
- rewriteTo || `/${entrypoint.out}`;
241
- }
242
- }
243
- else if (type === 'style') {
244
- elem.attrs.find(attr => attr.name === 'href').value =
245
- rewriteTo || `/${entrypoint.out}`;
246
- }
258
+ for (const { elem, entrypoint, type } of scripts) {
259
+ const rewriteTo = hrefs ? hrefs.mappedHref(entrypoint.in) : null;
260
+ if (type === "script") {
261
+ if (entrypoint.in.endsWith(".tsx") || entrypoint.in.endsWith(".ts")) {
262
+ elem.attrs.find((attr) => attr.name === "src").value = rewriteTo || `/${entrypoint.out}`;
263
+ }
264
+ } else if (type === "style") {
265
+ elem.attrs.find((attr) => attr.name === "href").value = rewriteTo || `/${entrypoint.out}`;
247
266
  }
267
+ }
248
268
  }
249
- // todo evented error handling so HtmlEntrypoint can be unit tested
250
- function errorExit(msg) {
251
- console.log(`\u001b[31merror:\u001b[0m`, msg);
252
- process.exit(1);
269
+ class ClientJS {
270
+ static #instance = null;
271
+ static initialize(c) {
272
+ if (c.mode === "build" || c.flags.preview) {
273
+ return null;
274
+ } else if (!ClientJS.#instance) {
275
+ ClientJS.#instance = new ClientJS(c.esbuildPort);
276
+ }
277
+ return ClientJS.#instance;
278
+ }
279
+ #esbuildPort;
280
+ #read;
281
+ #result;
282
+ constructor(esbuildPort) {
283
+ this.#esbuildPort = esbuildPort;
284
+ this.#read = readFile(resolve(import.meta.dirname, join("..", "client", "client.js")), "utf-8");
285
+ this.#result = this.#read.then(this.#transform);
286
+ }
287
+ async retrieve(esbuildPort) {
288
+ if (esbuildPort !== this.#esbuildPort) {
289
+ this.#result = this.#read.then(this.#transform);
290
+ }
291
+ return await this.#result;
292
+ }
293
+ #transform = (js) => {
294
+ return {
295
+ type: "script",
296
+ js: js.replace("3995", "" + this.#esbuildPort)
297
+ };
298
+ };
253
299
  }
300
+ export {
301
+ HtmlEntrypoint
302
+ };