@eighty4/dank 0.0.3 → 0.0.4-1

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