@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/client/esbuild.js CHANGED
@@ -1,6 +1,9 @@
1
- new EventSource('http://127.0.0.1:2999/esbuild').addEventListener('change', (e) => {
2
- const change = JSON.parse(e.data);
3
- const cssUpdates = change.updated.filter(p => p.endsWith('.css'));
1
+ new EventSource('http://127.0.0.1:3995/esbuild').addEventListener('change', (e) => {
2
+ const { updated } = JSON.parse(e.data);
3
+ const changes = new Set();
4
+ for (const c of updated)
5
+ changes.add(c);
6
+ const cssUpdates = Array.from(changes).filter(p => p.endsWith('.css'));
4
7
  if (cssUpdates.length) {
5
8
  console.log('esbuild css updates', cssUpdates);
6
9
  const cssLinks = {};
@@ -27,8 +30,8 @@ new EventSource('http://127.0.0.1:2999/esbuild').addEventListener('change', (e)
27
30
  addCssUpdateIndicator();
28
31
  }
29
32
  }
30
- if (cssUpdates.length < change.updated.length) {
31
- const jsUpdates = change.updated.filter(p => !p.endsWith('.css'));
33
+ if (cssUpdates.length < changes.size) {
34
+ const jsUpdates = Array.from(changes).filter(p => !p.endsWith('.css'));
32
35
  const jsScripts = new Set();
33
36
  for (const elem of document.getElementsByTagName('script')) {
34
37
  if (elem.src.length) {
package/lib/build.ts CHANGED
@@ -1,26 +1,22 @@
1
- import { mkdir, rm } from 'node:fs/promises'
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
+ import { createBuildTag } from './build_tag.ts'
3
4
  import type { DankConfig } from './dank.ts'
4
- import { isProductionBuild, willMinify } from './flags.ts'
5
- import { copyAssets } from './public.ts'
6
- import { createBuildTag } from './tag.ts'
7
- import { writeBuildManifest, writeMetafile } from './manifest.ts'
8
5
  import { type DefineDankGlobal, createGlobalDefinitions } from './define.ts'
6
+ import { esbuildWebpages, esbuildWorkers, type EntryPoint } from './esbuild.ts'
7
+ import { resolveBuildFlags, type DankBuild } from './flags.ts'
9
8
  import { HtmlEntrypoint } from './html.ts'
10
- import { esbuildWebpages } from './esbuild.ts'
11
-
12
- export type DankBuild = {
13
- dir: string
14
- files: Set<string>
15
- }
9
+ import { type WebsiteManifest, WebsiteRegistry } from './metadata.ts'
10
+ import { copyAssets } from './public.ts'
16
11
 
17
- export async function buildWebsite(c: DankConfig): Promise<DankBuild> {
18
- const buildDir = 'build'
19
- const distDir = join(buildDir, 'dist')
20
- const buildTag = await createBuildTag()
12
+ export async function buildWebsite(
13
+ c: DankConfig,
14
+ build: DankBuild = resolveBuildFlags(),
15
+ ): Promise<WebsiteManifest> {
16
+ const buildTag = await createBuildTag(build)
21
17
  console.log(
22
- willMinify()
23
- ? isProductionBuild()
18
+ build.minify
19
+ ? build.production
24
20
  ? 'minified production'
25
21
  : 'minified'
26
22
  : 'unminified',
@@ -28,71 +24,131 @@ export async function buildWebsite(c: DankConfig): Promise<DankBuild> {
28
24
  buildTag,
29
25
  'building in ./build/dist',
30
26
  )
31
- await rm(buildDir, { recursive: true, force: true })
32
- await mkdir(distDir, { recursive: true })
33
- await mkdir(join(buildDir, 'metafiles'), { recursive: true })
34
- const staticAssets = await copyAssets(distDir)
35
- const buildUrls: Array<string> = []
36
- buildUrls.push(
37
- ...(await buildWebpages(distDir, createGlobalDefinitions(), c.pages)),
38
- )
39
- if (staticAssets) {
40
- buildUrls.push(...staticAssets)
41
- }
42
- const result = new Set(buildUrls)
43
- await writeBuildManifest(buildTag, result)
44
- return {
45
- dir: distDir,
46
- files: result,
27
+ await rm(build.dirs.buildRoot, { recursive: true, force: true })
28
+ await mkdir(build.dirs.buildDist, { recursive: true })
29
+ for (const subdir of Object.keys(c.pages).filter(url => url !== '/')) {
30
+ await mkdir(join(build.dirs.buildDist, subdir), { recursive: true })
47
31
  }
32
+ await mkdir(join(build.dirs.buildRoot, 'metafiles'), { recursive: true })
33
+ const registry = new WebsiteRegistry(build)
34
+ registry.pageUrls = Object.keys(c.pages)
35
+ registry.copiedAssets = await copyAssets(build)
36
+ await buildWebpages(c, registry, build, createGlobalDefinitions(build))
37
+ return await registry.writeManifest(buildTag)
48
38
  }
49
39
 
40
+ // builds all webpage entrypoints in one esbuild.build context
41
+ // to support code splitting
42
+ // returns all built assets URLs and webpage URLs from DankConfig.pages
50
43
  async function buildWebpages(
51
- distDir: string,
44
+ c: DankConfig,
45
+ registry: WebsiteRegistry,
46
+ build: DankBuild,
52
47
  define: DefineDankGlobal,
53
- pages: Record<string, string>,
54
- ): Promise<Array<string>> {
55
- const entryPointUrls: Set<string> = new Set()
56
- const entryPoints: Array<{ in: string; out: string }> = []
57
- const htmlEntrypoints: Array<HtmlEntrypoint> = await Promise.all(
58
- Object.entries(pages).map(async ([urlPath, fsPath]) => {
59
- const html = await HtmlEntrypoint.readFrom(
60
- urlPath,
61
- join('pages', fsPath),
62
- )
63
- await html.injectPartials()
64
- if (urlPath !== '/') {
65
- await mkdir(join(distDir, urlPath), { recursive: true })
48
+ ) {
49
+ // create HtmlEntrypoint for each webpage and collect awaitable esbuild entrypoints
50
+ const loadingEntryPoints: Array<Promise<Array<EntryPoint>>> = []
51
+ const htmlEntrypoints: Array<HtmlEntrypoint> = []
52
+ for (const [urlPath, mapping] of Object.entries(c.pages)) {
53
+ const fsPath = typeof mapping === 'string' ? mapping : mapping.webpage
54
+ const html = new HtmlEntrypoint(build, urlPath, fsPath)
55
+ loadingEntryPoints.push(new Promise(res => html.on('entrypoints', res)))
56
+ htmlEntrypoints.push(html)
57
+ }
58
+
59
+ // collect esbuild entrypoints from every HtmlEntrypoint
60
+ const uniqueEntryPoints: Set<string> = new Set()
61
+ const buildEntryPoints: Array<EntryPoint> = []
62
+ for (const pageEntryPoints of await Promise.all(loadingEntryPoints)) {
63
+ for (const entryPoint of pageEntryPoints) {
64
+ if (!uniqueEntryPoints.has(entryPoint.in)) {
65
+ buildEntryPoints.push(entryPoint)
66
66
  }
67
- html.collectScripts()
68
- .filter(scriptImport => !entryPointUrls.has(scriptImport.in))
69
- .forEach(scriptImport => {
70
- entryPointUrls.add(scriptImport.in)
71
- entryPoints.push({
72
- in: scriptImport.in,
73
- out: scriptImport.out,
74
- })
75
- })
76
- return html
67
+ }
68
+ }
69
+
70
+ await esbuildWebpages(build, registry, define, buildEntryPoints, c.esbuild)
71
+
72
+ // todo recursively build workers on building workers that create workers
73
+ const workerEntryPoints = registry.workerEntryPoints()
74
+ if (workerEntryPoints?.length) {
75
+ await esbuildWorkers(
76
+ build,
77
+ registry,
78
+ define,
79
+ workerEntryPoints,
80
+ c.esbuild,
81
+ )
82
+ }
83
+ await rewriteWorkerUrls(build, registry)
84
+
85
+ // write out html output with rewritten hrefs
86
+ await Promise.all(
87
+ htmlEntrypoints.map(async html => {
88
+ await writeFile(
89
+ join(build.dirs.buildDist, html.url, 'index.html'),
90
+ html.output(registry),
91
+ )
77
92
  }),
78
93
  )
79
- const metafile = await esbuildWebpages(define, entryPoints, distDir)
80
- await writeMetafile(`pages.json`, metafile)
81
- // todo these hrefs would have \ path separators on windows
82
- const buildUrls = [...Object.keys(pages)]
83
- const mapInToOutHrefs: Record<string, string> = {}
84
- for (const [outputFile, { entryPoint }] of Object.entries(
85
- metafile.outputs,
86
- )) {
87
- const outputUrl = outputFile.replace(/^build\/dist/, '')
88
- buildUrls.push(outputUrl)
89
- mapInToOutHrefs[entryPoint!] = outputUrl
94
+ }
95
+
96
+ export async function rewriteWorkerUrls(
97
+ build: DankBuild,
98
+ registry: WebsiteRegistry,
99
+ ) {
100
+ const workers = registry.workers()
101
+ if (!workers) {
102
+ return
103
+ }
104
+ const dependentBundlePaths = workers.map(w =>
105
+ registry.mappedHref(w.dependentEntryPoint),
106
+ )
107
+ const bundleOutputs: Record<string, string> = {}
108
+
109
+ // collect all js file contents concurrently
110
+ const readingFiles = Promise.all(
111
+ dependentBundlePaths.map(async p => {
112
+ bundleOutputs[p] = await readFile(
113
+ join(build.dirs.projectRootAbs, build.dirs.buildDist, p),
114
+ 'utf8',
115
+ )
116
+ }),
117
+ )
118
+
119
+ // build regex replacements during file reads
120
+ const rewriteChains: Record<string, Array<(s: string) => string>> = {}
121
+ for (const p of dependentBundlePaths) rewriteChains[p] = []
122
+ for (const w of workers) {
123
+ rewriteChains[registry.mappedHref(w.dependentEntryPoint)].push(s =>
124
+ s.replace(
125
+ createWorkerRegex(w.workerUrlPlaceholder),
126
+ `new Worker('${registry.mappedHref(w.workerEntryPoint)}')`,
127
+ ),
128
+ )
90
129
  }
130
+
131
+ // wait for file reads
132
+ await readingFiles
133
+
134
+ // run rewrite regex chain and write back to dist
91
135
  await Promise.all(
92
- htmlEntrypoints.map(async html => {
93
- html.rewriteHrefs(mapInToOutHrefs)
94
- await html.writeTo(distDir)
136
+ Object.entries(bundleOutputs).map(async ([p, content]) => {
137
+ let result = content
138
+ for (const rewriteFn of rewriteChains[p]) {
139
+ result = rewriteFn(result)
140
+ }
141
+ await writeFile(
142
+ join(build.dirs.projectRootAbs, build.dirs.buildDist, p),
143
+ result,
144
+ )
95
145
  }),
96
146
  )
97
- return buildUrls
147
+ }
148
+
149
+ export function createWorkerRegex(workerUrl: string): RegExp {
150
+ return new RegExp(
151
+ `new(?:\\s|\\r?\\n)+Worker(?:\\s|\\r?\\n)*\\((?:\\s|\\r?\\n)*['"]${workerUrl}['"](?:\\s|\\r?\\n)*\\)`,
152
+ 'g',
153
+ )
98
154
  }
@@ -1,7 +1,7 @@
1
1
  import { exec } from 'node:child_process'
2
- import { isProductionBuild } from './flags.ts'
2
+ import type { DankBuild } from './flags.ts'
3
3
 
4
- export async function createBuildTag(): Promise<string> {
4
+ export async function createBuildTag(build: DankBuild): Promise<string> {
5
5
  const now = new Date()
6
6
  const ms =
7
7
  now.getUTCMilliseconds() +
@@ -11,7 +11,7 @@ export async function createBuildTag(): Promise<string> {
11
11
  const date = now.toISOString().substring(0, 10)
12
12
  const time = String(ms).padStart(8, '0')
13
13
  const when = `${date}-${time}`
14
- if (isProductionBuild()) {
14
+ if (build.production) {
15
15
  const gitHash = await new Promise((res, rej) =>
16
16
  exec('git rev-parse --short HEAD', (err, stdout) => {
17
17
  if (err) rej(err)
package/lib/dank.ts CHANGED
@@ -1,27 +1,121 @@
1
+ import type { Plugin as EsbuildPlugin } from 'esbuild'
2
+
1
3
  export type DankConfig = {
2
4
  // used for releases and service worker caching
3
5
  // buildTag?: (() => Promise<string> | string) | string
4
- // mapping url to fs paths of webpages to build
5
- pages: Record<`/${string}`, `${string}.html`>
6
6
 
7
+ // customize esbuild configs
8
+ esbuild?: EsbuildConfig
9
+
10
+ // mapping url to html files in the project pages dir
11
+ // page url (map key) represents html output path in build dir
12
+ // regardless of the html path in the pages dir
13
+ // cdn url rewriting can be simulated with PageMapping
14
+ pages: Record<`/${string}`, `${string}.html` | PageMapping>
15
+
16
+ // port of `dank serve` frontend dev server
17
+ // used for `dan serve --preview` if previewPort not specified
18
+ port?: number
19
+
20
+ // port used for `dank serve --preview` frontend dev server
21
+ previewPort?: number
22
+
23
+ // dev services launched during `dank serve`
7
24
  services?: Array<DevService>
8
25
  }
9
26
 
27
+ // extend an html entrypoint with url rewriting similar to cdn configurations
28
+ // after trying all webpage, bundle and asset paths, mapping patterns
29
+ // will be tested in the alphabetical order of the webpage paths
30
+ export type PageMapping = {
31
+ pattern?: RegExp
32
+ webpage: `${string}.html`
33
+ }
34
+
10
35
  export type DevService = {
11
36
  command: string
12
37
  cwd?: string
13
38
  env?: Record<string, string>
39
+ http?: {
40
+ port: number
41
+ }
14
42
  }
15
43
 
44
+ export type EsbuildConfig = {
45
+ // mapping of extensions to loaders
46
+ // if not specified, defaults to support WOFF/WOFF2 fonts
47
+ // with `{'.woff': 'file', '.woff2': 'file'}`
48
+ loaders?: Record<`.${string}`, EsbuildLoader>
49
+
50
+ // documented on https://esbuild.github.io/plugins
51
+ plugins?: Array<EsbuildPlugin>
52
+
53
+ // port used by esbuild.context() during `dank serve`
54
+ // defaults to 3995
55
+ port?: number
56
+ }
57
+
58
+ // documented on https://esbuild.github.io/content-types
59
+ export type EsbuildLoader =
60
+ | 'base64'
61
+ | 'binary'
62
+ | 'copy'
63
+ | 'dataurl'
64
+ | 'empty'
65
+ | 'file'
66
+ | 'json'
67
+ | 'text'
68
+
16
69
  export async function defineConfig(
17
70
  c: Partial<DankConfig>,
18
71
  ): Promise<DankConfig> {
72
+ if (c.port !== null && typeof c.port !== 'undefined') {
73
+ if (typeof c.port !== 'number') {
74
+ throw Error('DankConfig.port must be a number')
75
+ }
76
+ }
77
+ if (c.previewPort !== null && typeof c.previewPort !== 'undefined') {
78
+ if (typeof c.previewPort !== 'number') {
79
+ throw Error('DankConfig.previewPort must be a number')
80
+ }
81
+ }
19
82
  validatePages(c.pages)
20
83
  validateDevServices(c.services)
21
- normalizePagePaths(c.pages)
84
+ validateEsbuildConfig(c.esbuild)
85
+ normalizePagePaths(c.pages!)
22
86
  return c as DankConfig
23
87
  }
24
88
 
89
+ function validateEsbuildConfig(esbuild?: EsbuildConfig) {
90
+ if (esbuild?.loaders !== null && typeof esbuild?.loaders !== 'undefined') {
91
+ if (typeof esbuild.loaders !== 'object') {
92
+ throw Error(
93
+ 'DankConfig.esbuild.loaders must be a map of extensions to esbuild loaders',
94
+ )
95
+ } else {
96
+ for (const [ext, loader] of Object.entries(esbuild.loaders)) {
97
+ if (typeof loader !== 'string') {
98
+ throw Error(
99
+ `DankConfig.esbuild.loaders['${ext}'] must be a string of a loader name`,
100
+ )
101
+ }
102
+ }
103
+ }
104
+ }
105
+ if (esbuild?.plugins !== null && typeof esbuild?.plugins !== 'undefined') {
106
+ if (!Array.isArray(esbuild.plugins)) {
107
+ throw Error(
108
+ 'DankConfig.esbuild.plugins must be an array of esbuild plugins',
109
+ )
110
+ }
111
+ }
112
+ if (esbuild?.port !== null && typeof esbuild?.port !== 'undefined') {
113
+ if (typeof esbuild.port !== 'number') {
114
+ throw Error('DankConfig.esbuild.port must be a number')
115
+ }
116
+ }
117
+ }
118
+
25
119
  function validatePages(pages?: DankConfig['pages']) {
26
120
  if (
27
121
  pages === null ||
@@ -30,15 +124,42 @@ function validatePages(pages?: DankConfig['pages']) {
30
124
  ) {
31
125
  throw Error('DankConfig.pages is required')
32
126
  }
33
- for (const [urlPath, htmlPath] of Object.entries(pages)) {
34
- if (typeof htmlPath !== 'string' || !htmlPath.endsWith('.html')) {
35
- throw Error(
36
- `DankConfig.pages['${urlPath}'] must configure an html file`,
37
- )
127
+ for (const [urlPath, mapping] of Object.entries(pages)) {
128
+ if (typeof mapping === 'string' && mapping.endsWith('.html')) {
129
+ continue
130
+ }
131
+ if (typeof mapping === 'object') {
132
+ validatePageMapping(urlPath, mapping)
133
+ continue
38
134
  }
135
+ throw Error(
136
+ `DankConfig.pages['${urlPath}'] must configure an html file`,
137
+ )
39
138
  }
40
139
  }
41
140
 
141
+ function validatePageMapping(urlPath: string, mapping: PageMapping) {
142
+ if (
143
+ mapping.webpage === null ||
144
+ typeof mapping.webpage !== 'string' ||
145
+ !mapping.webpage.endsWith('.html')
146
+ ) {
147
+ throw Error(
148
+ `DankConfig.pages['${urlPath}'].webpage must configure an html file`,
149
+ )
150
+ }
151
+ if (mapping.pattern === null || typeof mapping.pattern === 'undefined') {
152
+ return
153
+ }
154
+ if (
155
+ typeof mapping.pattern === 'object' &&
156
+ mapping.pattern.constructor.name === 'RegExp'
157
+ ) {
158
+ return
159
+ }
160
+ throw Error(`DankConfig.pages['${urlPath}'].pattern must be a RegExp`)
161
+ }
162
+
42
163
  function validateDevServices(services: DankConfig['services']) {
43
164
  if (services === null || typeof services === 'undefined') {
44
165
  return
@@ -76,11 +197,26 @@ function validateDevServices(services: DankConfig['services']) {
76
197
  }
77
198
  }
78
199
  }
200
+ if (s.http !== null && typeof s.http !== 'undefined') {
201
+ if (typeof s.http.port !== 'number') {
202
+ throw Error(
203
+ `DankConfig.services[${i}].http.port must be a number`,
204
+ )
205
+ }
206
+ }
79
207
  }
80
208
  }
81
209
 
82
- function normalizePagePaths(pages: any) {
83
- for (const urlPath of Object.keys(pages)) {
84
- pages[urlPath] = pages[urlPath].replace(/^\.\//, '')
210
+ function normalizePagePaths(pages: DankConfig['pages']) {
211
+ for (const [pageUrl, mapping] of Object.entries(pages)) {
212
+ if (typeof mapping === 'string') {
213
+ pages[pageUrl as `/${string}`] = normalizePagePath(mapping)
214
+ } else {
215
+ mapping.webpage = normalizePagePath(mapping.webpage)
216
+ }
85
217
  }
86
218
  }
219
+
220
+ function normalizePagePath(p: `${string}.html`): `${string}.html` {
221
+ return p.replace(/^\.\//, '') as `${string}.html`
222
+ }
package/lib/define.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { isProductionBuild } from './flags.ts'
1
+ import type { DankBuild } from './flags.ts'
2
2
 
3
3
  export type DankGlobal = {
4
4
  IS_DEV: boolean
@@ -9,10 +9,9 @@ type DefineDankGlobalKey = 'dank.IS_DEV' | 'dank.IS_PROD'
9
9
 
10
10
  export type DefineDankGlobal = Record<DefineDankGlobalKey, string>
11
11
 
12
- export function createGlobalDefinitions(): DefineDankGlobal {
13
- const isProduction = isProductionBuild()
12
+ export function createGlobalDefinitions(build: DankBuild): DefineDankGlobal {
14
13
  return {
15
- 'dank.IS_DEV': JSON.stringify(!isProduction),
16
- 'dank.IS_PROD': JSON.stringify(isProduction),
14
+ 'dank.IS_DEV': JSON.stringify(!build.production),
15
+ 'dank.IS_PROD': JSON.stringify(build.production),
17
16
  }
18
17
  }