@eighty4/dank 0.0.3 → 0.0.4-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.
package/lib/html.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
2
- import { dirname, join, relative } from 'node:path'
1
+ import EventEmitter from 'node:events'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { dirname, join, relative, resolve } from 'node:path'
3
4
  import { extname } from 'node:path/posix'
4
5
  import {
5
6
  defaultTreeAdapter,
@@ -8,164 +9,312 @@ import {
8
9
  parseFragment,
9
10
  serialize,
10
11
  } from 'parse5'
12
+ import type { EntryPoint } from './esbuild.ts'
13
+ import type { DankBuild } from './flags.ts'
11
14
 
12
15
  type CommentNode = DefaultTreeAdapterTypes.CommentNode
13
16
  type Document = DefaultTreeAdapterTypes.Document
17
+ type DocumentFragment = DefaultTreeAdapterTypes.DocumentFragment
14
18
  type Element = DefaultTreeAdapterTypes.Element
15
19
  type ParentNode = DefaultTreeAdapterTypes.ParentNode
16
20
 
17
- export type ImportedScript = {
21
+ type CollectedImports = {
22
+ partials: Array<PartialReference>
23
+ scripts: Array<ImportedScript>
24
+ }
25
+
26
+ type PartialReference = {
27
+ commentNode: CommentNode
28
+ fsPath: string
29
+ }
30
+
31
+ type PartialContent = PartialReference & {
32
+ fragment: DocumentFragment
33
+ imports: CollectedImports
34
+ // todo recursive partials?
35
+ // partials: Array<PartialContent>
36
+ }
37
+
38
+ type ImportedScript = {
18
39
  type: 'script' | 'style'
19
40
  href: string
20
41
  elem: Element
21
- in: string
22
- out: string
42
+ entrypoint: EntryPoint
23
43
  }
24
44
 
25
- // unenforced but necessary sequence:
26
- // injectPartials
27
- // collectScripts
28
- // rewriteHrefs
29
- // writeTo
30
- export class HtmlEntrypoint {
31
- static async readFrom(
32
- urlPath: string,
33
- fsPath: string,
34
- ): Promise<HtmlEntrypoint> {
35
- let html: string
36
- try {
37
- html = await readFile(fsPath, 'utf-8')
38
- } catch (e) {
39
- console.log(`\u001b[31merror:\u001b[0m`, fsPath, 'does not exist')
40
- process.exit(1)
41
- }
42
- return new HtmlEntrypoint(urlPath, html, fsPath)
43
- }
45
+ export type HtmlDecoration = {
46
+ type: 'script'
47
+ js: string
48
+ }
44
49
 
45
- #document: Document
50
+ // implicitly impl'd by WebsiteRegistry
51
+ export type HtmlHrefs = {
52
+ mappedHref(lookup: string): string
53
+ }
54
+
55
+ export type HtmlEntrypointEvents = {
56
+ // Dispatched from fs watch to notify HtmlEntrypoint of changes to HtmlEntrypoint.#fsPath
57
+ // Optional parameter `partial` notifies the page when a partial of the page has changed
58
+ change: [partial?: string]
59
+ // Dispatched from HtmlEntrypoint to notify `dank serve` of changes to esbuild entrypoints
60
+ // Parameter `entrypoints` is the esbuild mappings of the input and output paths
61
+ entrypoints: [entrypoints: Array<EntryPoint>]
62
+ // Dispatched from HtmlEntrypoint to notify when new HtmlEntrypoint.#document output is ready for write
63
+ // Parameter `html` is the updated html content of the page ready to be output to the build dir
64
+ output: [html: string]
65
+ // Dispatched from HtmlEntrypoint to notify `dank serve` of a partial dependency for an HtmlEntrypoint
66
+ // Seemingly a duplicate of event `partials` but it keeps relevant state in sync during async io
67
+ // Parameter `partial` is the fs path to the partial
68
+ partial: [partial: string]
69
+ // Dispatched from HtmlEntrypoint to notify `dank serve` of completely resolved imported partials
70
+ // Parameter `partials` are the fs paths to the partials
71
+ partials: [partials: Array<string>]
72
+ }
73
+
74
+ export class HtmlEntrypoint extends EventEmitter<HtmlEntrypointEvents> {
75
+ #build: DankBuild
76
+ #decorations?: Array<HtmlDecoration>
77
+ #document: Document = defaultTreeAdapter.createDocument()
78
+ // todo cache entrypoints set for quicker diffing
79
+ // #entrypoints: Set<string> = new Set()
46
80
  #fsPath: string
47
- #partials: Array<CommentNode> = []
81
+ #partials: Array<PartialContent> = []
48
82
  #scripts: Array<ImportedScript> = []
83
+ #update: Object = Object()
49
84
  #url: string
50
85
 
51
- constructor(url: string, html: string, fsPath: string) {
86
+ constructor(
87
+ build: DankBuild,
88
+ url: string,
89
+ fsPath: string,
90
+ decorations?: Array<HtmlDecoration>,
91
+ ) {
92
+ super({ captureRejections: true })
93
+ this.#build = build
94
+ this.#decorations = decorations
52
95
  this.#url = url
53
- this.#document = parse(html)
54
96
  this.#fsPath = fsPath
97
+ this.on('change', this.#onChange)
98
+ this.emit('change')
55
99
  }
56
100
 
57
- async injectPartials() {
58
- this.#collectPartials(this.#document)
59
- await this.#injectPartials()
101
+ get fsPath(): string {
102
+ return this.#fsPath
60
103
  }
61
104
 
62
- collectScripts(): Array<ImportedScript> {
63
- this.#collectScripts(this.#document)
64
- return this.#scripts
105
+ get url(): string {
106
+ return this.#url
65
107
  }
66
108
 
67
- // rewrites hrefs to content hashed urls
68
- // call without hrefs to rewrite tsx? ext to js
69
- rewriteHrefs(hrefs?: Record<string, string>) {
70
- for (const importScript of this.#scripts) {
71
- const rewriteTo = hrefs ? hrefs[importScript.in] : null
72
- if (importScript.type === 'script') {
73
- if (
74
- importScript.in.endsWith('.tsx') ||
75
- importScript.in.endsWith('.ts')
76
- ) {
77
- importScript.elem.attrs.find(
78
- attr => attr.name === 'src',
79
- )!.value = rewriteTo || `/${importScript.out}`
109
+ async #html(): Promise<string> {
110
+ try {
111
+ return await readFile(
112
+ join(this.#build.dirs.pages, this.#fsPath),
113
+ 'utf8',
114
+ )
115
+ } catch (e) {
116
+ // todo error handling
117
+ errorExit(this.#fsPath + ' does not exist')
118
+ }
119
+ }
120
+
121
+ // todo if partial changes, hot swap content in page
122
+ #onChange = async (_partial?: string) => {
123
+ const update = (this.#update = Object())
124
+ const html = await this.#html()
125
+ const document = parse(html)
126
+ const imports: CollectedImports = {
127
+ partials: [],
128
+ scripts: [],
129
+ }
130
+ this.#collectImports(document, imports)
131
+ const partials = await this.#resolvePartialContent(imports.partials)
132
+ if (update !== this.#update) {
133
+ // another update has started so aborting this one
134
+ return
135
+ }
136
+ this.#addDecorations(document)
137
+ this.#update = update
138
+ this.#document = document
139
+ this.#partials = partials
140
+ this.#scripts = imports.scripts
141
+ const entrypoints = mergeEntrypoints(
142
+ imports,
143
+ ...partials.map(p => p.imports),
144
+ )
145
+ // this.#entrypoints = new Set(entrypoints.map(entrypoint => entrypoint.in))
146
+ this.emit('entrypoints', entrypoints)
147
+ this.emit(
148
+ 'partials',
149
+ this.#partials.map(p => p.fsPath),
150
+ )
151
+ if (this.listenerCount('output')) {
152
+ this.emit('output', this.output())
153
+ }
154
+ }
155
+
156
+ // Emits `partial` on detecting a partial reference for `dank serve` file watches
157
+ // to respond to dependent changes
158
+ // todo safeguard recursive partials that cause circular imports
159
+ async #resolvePartialContent(
160
+ partials: Array<PartialReference>,
161
+ ): Promise<Array<PartialContent>> {
162
+ return await Promise.all(
163
+ partials.map(async p => {
164
+ this.emit('partial', p.fsPath)
165
+ const html = await readFile(
166
+ join(this.#build.dirs.pages, p.fsPath),
167
+ 'utf8',
168
+ )
169
+ const fragment = parseFragment(html)
170
+ const imports: CollectedImports = {
171
+ partials: [],
172
+ scripts: [],
80
173
  }
81
- } else if (importScript.type === 'style') {
82
- importScript.elem.attrs.find(
83
- attr => attr.name === 'href',
84
- )!.value = rewriteTo || `/${importScript.out}`
174
+ this.#collectImports(fragment, imports, node => {
175
+ this.#rewritePartialRelativePaths(node, p.fsPath)
176
+ })
177
+ if (imports.partials.length) {
178
+ // todo recursive partials?
179
+ // await this.#resolvePartialContent(imports.partials)
180
+ errorExit(
181
+ `partial ${p.fsPath} cannot recursively import partials`,
182
+ )
183
+ }
184
+ const content: PartialContent = {
185
+ ...p,
186
+ fragment,
187
+ imports,
188
+ }
189
+ return content
190
+ }),
191
+ )
192
+ }
193
+
194
+ // rewrite hrefs in a partial to be relative to the html entrypoint instead of the partial
195
+ #rewritePartialRelativePaths(elem: Element, partialPath: string) {
196
+ let rewritePath: 'src' | 'href' | null = null
197
+ if (elem.nodeName === 'script') {
198
+ rewritePath = 'src'
199
+ } else if (
200
+ elem.nodeName === 'link' &&
201
+ hasAttr(elem, 'rel', 'stylesheet')
202
+ ) {
203
+ rewritePath = 'href'
204
+ }
205
+ if (rewritePath !== null) {
206
+ const attr = getAttr(elem, rewritePath)
207
+ if (attr) {
208
+ attr.value = join(
209
+ relative(dirname(this.#fsPath), dirname(partialPath)),
210
+ attr.value,
211
+ )
85
212
  }
86
213
  }
87
214
  }
88
215
 
89
- appendScript(clientJS: string) {
90
- const scriptNode = parseFragment(
91
- `<script type="module">${clientJS}</script>`,
92
- ).childNodes[0]
93
- const htmlNode = this.#document.childNodes.find(
94
- node => node.nodeName === 'html',
95
- ) as ParentNode
96
- const headNode = htmlNode.childNodes.find(
97
- node => node.nodeName === 'head',
98
- ) as ParentNode | undefined
99
- defaultTreeAdapter.appendChild(headNode || htmlNode, scriptNode)
216
+ #addDecorations(document: Document) {
217
+ if (!this.#decorations?.length) {
218
+ return
219
+ }
220
+ for (const decoration of this.#decorations) {
221
+ switch (decoration.type) {
222
+ case 'script':
223
+ const scriptNode = parseFragment(
224
+ `<script type="module">${decoration.js}</script>`,
225
+ ).childNodes[0]
226
+ const htmlNode = document.childNodes.find(
227
+ node => node.nodeName === 'html',
228
+ ) as ParentNode
229
+ const headNode = htmlNode.childNodes.find(
230
+ node => node.nodeName === 'head',
231
+ ) as ParentNode | undefined
232
+ defaultTreeAdapter.appendChild(
233
+ headNode || htmlNode,
234
+ scriptNode,
235
+ )
236
+ break
237
+ }
238
+ }
100
239
  }
101
240
 
102
- async writeTo(buildDir: string): Promise<void> {
103
- await writeFile(
104
- join(buildDir, this.#url, 'index.html'),
105
- serialize(this.#document),
106
- )
241
+ output(hrefs?: HtmlHrefs): string {
242
+ this.#injectPartials()
243
+ this.#rewriteHrefs(hrefs)
244
+ return serialize(this.#document)
245
+ }
246
+
247
+ // rewrites hrefs to content hashed urls
248
+ // call without hrefs to rewrite tsx? ext to js
249
+ #rewriteHrefs(hrefs?: HtmlHrefs) {
250
+ rewriteHrefs(this.#scripts, hrefs)
251
+ for (const partial of this.#partials) {
252
+ rewriteHrefs(partial.imports.scripts, hrefs)
253
+ }
107
254
  }
108
255
 
109
256
  async #injectPartials() {
110
- for (const commentNode of this.#partials) {
111
- const pp = commentNode.data
112
- .match(/\{\{(?<pp>.+)\}\}/)!
113
- .groups!.pp.trim()
114
- const fragment = parseFragment(await readFile(pp, 'utf-8'))
257
+ for (const { commentNode, fragment } of this.#partials) {
258
+ if (!this.#build.production) {
259
+ defaultTreeAdapter.insertBefore(
260
+ commentNode.parentNode!,
261
+ defaultTreeAdapter.createCommentNode(commentNode.data),
262
+ commentNode,
263
+ )
264
+ }
115
265
  for (const node of fragment.childNodes) {
116
- if (node.nodeName === 'script') {
117
- this.#rewritePathFromPartial(pp, node, 'src')
118
- } else if (
119
- node.nodeName === 'link' &&
120
- hasAttr(node, 'rel', 'stylesheet')
121
- ) {
122
- this.#rewritePathFromPartial(pp, node, 'href')
123
- }
124
266
  defaultTreeAdapter.insertBefore(
125
267
  commentNode.parentNode!,
126
268
  node,
127
269
  commentNode,
128
270
  )
129
271
  }
130
- defaultTreeAdapter.detachNode(commentNode)
272
+ if (this.#build.production) {
273
+ defaultTreeAdapter.detachNode(commentNode)
274
+ }
131
275
  }
132
276
  }
133
277
 
134
- // rewrite a ts or css href relative to an html partial to be relative to the html entrypoint
135
- #rewritePathFromPartial(
136
- pp: string,
137
- elem: Element,
138
- attrName: 'href' | 'src',
278
+ #collectImports(
279
+ node: ParentNode,
280
+ collection: CollectedImports,
281
+ forEach?: (elem: Element) => void,
139
282
  ) {
140
- const attr = getAttr(elem, attrName)
141
- if (attr) {
142
- attr.value = join(
143
- relative(dirname(this.#fsPath), dirname(pp)),
144
- attr.value,
145
- )
146
- }
147
- }
148
-
149
- #collectPartials(node: ParentNode) {
150
283
  for (const childNode of node.childNodes) {
284
+ if (forEach && 'tagName' in childNode) {
285
+ forEach(childNode)
286
+ }
151
287
  if (childNode.nodeName === '#comment' && 'data' in childNode) {
152
- if (/\{\{.+\}\}/.test(childNode.data)) {
153
- this.#partials.push(childNode)
288
+ const partialMatch = childNode.data.match(/\{\{(?<pp>.+)\}\}/)
289
+ if (partialMatch) {
290
+ const partialSpecifier = partialMatch.groups!.pp.trim()
291
+ if (partialSpecifier.startsWith('/')) {
292
+ errorExit(
293
+ `partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be an absolute path`,
294
+ )
295
+ }
296
+ const partialPath = join(
297
+ dirname(this.#fsPath),
298
+ partialSpecifier,
299
+ )
300
+ if (!isPagesSubpathInPagesDir(this.#build, partialPath)) {
301
+ errorExit(
302
+ `partial ${partialSpecifier} in webpage ${this.#fsPath} cannot be outside of the pages directory`,
303
+ )
304
+ }
305
+ collection.partials.push({
306
+ fsPath: partialPath,
307
+ commentNode: childNode,
308
+ })
154
309
  }
155
- } else if ('childNodes' in childNode) {
156
- this.#collectPartials(childNode)
157
- }
158
- }
159
- }
160
-
161
- #collectScripts(node: ParentNode) {
162
- for (const childNode of node.childNodes) {
163
- if (childNode.nodeName === 'script') {
310
+ } else if (childNode.nodeName === 'script') {
164
311
  const srcAttr = childNode.attrs.find(
165
312
  attr => attr.name === 'src',
166
313
  )
167
314
  if (srcAttr) {
168
- this.#addScript('script', srcAttr.value, childNode)
315
+ collection.scripts.push(
316
+ this.#parseImport('script', srcAttr.value, childNode),
317
+ )
169
318
  }
170
319
  } else if (
171
320
  childNode.nodeName === 'link' &&
@@ -173,33 +322,57 @@ export class HtmlEntrypoint {
173
322
  ) {
174
323
  const hrefAttr = getAttr(childNode, 'href')
175
324
  if (hrefAttr) {
176
- this.#addScript('style', hrefAttr.value, childNode)
325
+ collection.scripts.push(
326
+ this.#parseImport('style', hrefAttr.value, childNode),
327
+ )
177
328
  }
178
329
  } else if ('childNodes' in childNode) {
179
- this.#collectScripts(childNode)
330
+ this.#collectImports(childNode, collection)
180
331
  }
181
332
  }
182
333
  }
183
334
 
184
- #addScript(type: ImportedScript['type'], href: string, elem: Element) {
185
- const inPath = join(dirname(this.#fsPath), href)
186
- let outPath = inPath.replace(/^pages\//, '')
335
+ #parseImport(
336
+ type: ImportedScript['type'],
337
+ href: string,
338
+ elem: Element,
339
+ ): ImportedScript {
340
+ const inPath = join(this.#build.dirs.pages, dirname(this.#fsPath), href)
341
+ if (!isPathInPagesDir(this.#build, inPath)) {
342
+ errorExit(
343
+ `href ${href} in webpage ${this.#fsPath} cannot reference sources outside of the pages directory`,
344
+ )
345
+ }
346
+ let outPath = join(dirname(this.#fsPath), href)
187
347
  if (type === 'script' && !outPath.endsWith('.js')) {
188
348
  outPath = outPath.replace(
189
349
  new RegExp(extname(outPath).substring(1) + '$'),
190
350
  'js',
191
351
  )
192
352
  }
193
- this.#scripts.push({
353
+ return {
194
354
  type,
195
355
  href,
196
356
  elem,
197
- in: inPath,
198
- out: outPath,
199
- })
357
+ entrypoint: {
358
+ in: inPath,
359
+ out: outPath,
360
+ },
361
+ }
200
362
  }
201
363
  }
202
364
 
365
+ // check if relative dir is a subpath of pages dir when joined with pages dir
366
+ // used if the joined pages dir path is only used for the pages dir check
367
+ function isPagesSubpathInPagesDir(build: DankBuild, subpath: string): boolean {
368
+ return isPathInPagesDir(build, join(build.dirs.pages, subpath))
369
+ }
370
+
371
+ // check if subpath joined with pages dir is a subpath of pages dir
372
+ function isPathInPagesDir(build: DankBuild, p: string): boolean {
373
+ return resolve(p).startsWith(build.dirs.pagesResolved)
374
+ }
375
+
203
376
  function getAttr(elem: Element, name: string) {
204
377
  return elem.attrs.find(attr => attr.name === name)
205
378
  }
@@ -207,3 +380,38 @@ function getAttr(elem: Element, name: string) {
207
380
  function hasAttr(elem: Element, name: string, value: string): boolean {
208
381
  return elem.attrs.some(attr => attr.name === name && attr.value === value)
209
382
  }
383
+
384
+ function mergeEntrypoints(
385
+ ...imports: Array<CollectedImports>
386
+ ): Array<EntryPoint> {
387
+ const entrypoints: Array<EntryPoint> = []
388
+ for (const { scripts } of imports) {
389
+ for (const script of scripts) {
390
+ entrypoints.push(script.entrypoint)
391
+ }
392
+ }
393
+ return entrypoints
394
+ }
395
+
396
+ function rewriteHrefs(scripts: Array<ImportedScript>, hrefs?: HtmlHrefs) {
397
+ for (const { elem, entrypoint, type } of scripts) {
398
+ const rewriteTo = hrefs ? hrefs.mappedHref(entrypoint.in) : null
399
+ if (type === 'script') {
400
+ if (
401
+ entrypoint.in.endsWith('.tsx') ||
402
+ entrypoint.in.endsWith('.ts')
403
+ ) {
404
+ elem.attrs.find(attr => attr.name === 'src')!.value =
405
+ rewriteTo || `/${entrypoint.out}`
406
+ }
407
+ } else if (type === 'style') {
408
+ elem.attrs.find(attr => attr.name === 'href')!.value =
409
+ rewriteTo || `/${entrypoint.out}`
410
+ }
411
+ }
412
+ }
413
+
414
+ function errorExit(msg: string): never {
415
+ console.log(`\u001b[31merror:\u001b[0m`, msg)
416
+ process.exit(1)
417
+ }