@eighty4/dank 0.0.1-3 → 0.0.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/README.md CHANGED
@@ -0,0 +1,65 @@
1
+ # Build DANK webpages
2
+
3
+ ### Get developing right away:
4
+
5
+ ```shell
6
+ bun create dank --out-dir www
7
+
8
+ npm create dank -- --out-dir www
9
+
10
+ pnpm create dank --out-dir www
11
+ ```
12
+
13
+ ### DANK has some perks:
14
+
15
+ - Webpage-first development for multi-page websites
16
+ - TypeScript supported with `<script src="./dank.ts">`
17
+ - Code splitting via `esbuild` bundler across all webpages
18
+ - Hashes added to all bundled assets for efficient cache utilization
19
+ - `dank serve` updates CSS in real-time (hot-reloading)
20
+ - `dank serve` launches development processes and merges their stdio
21
+ - `dank serve --preview` builds the website and serves the output from `dist`
22
+ - `dank build --production` optimizes with `esbuild` minifying and tree-shaking
23
+ - DANK's codebase is so tiny you can read it all in 20 minutes
24
+
25
+ ### DANK isn't for every use case!
26
+
27
+ [Vite](https://vite.dev) is the right move for building a Single-Page Application.
28
+
29
+ Dynamic content with Static-Site Generation or Server-Side Rendering should use
30
+ [Astro](https://astro.build), [Next.js](https://nextjs.org) or [SvelteKit](https://svelte.dev).
31
+
32
+ #### DANK is an ideal choice for multi-page websites deployed to a CDN that integrate with serverless components and APIs.
33
+
34
+ ## `dank.config.ts` examples
35
+
36
+ Webpages and their URLs are configured explicitly to keep your URLs
37
+ and workspace organized independently:
38
+
39
+ ```typescript
40
+ import { defineConfig } from '@eighty4/dank'
41
+
42
+ export default defineConfig({
43
+ pages: {
44
+ '/': './home.html',
45
+ },
46
+ })
47
+ ```
48
+
49
+ Streamline development with `dank serve` launching APIs and databases when starting your website's dev server:
50
+
51
+ ```typescript
52
+ import { defineConfig } from '@eighty4/dank'
53
+
54
+ export default defineConfig({
55
+ pages: {
56
+ '/': './home.html',
57
+ },
58
+ services: [
59
+ {
60
+ command: 'node --watch --env-file-if-exists=.env.dev server.ts',
61
+ cwd: './api',
62
+ },
63
+ ],
64
+ })
65
+ ```
package/client/esbuild.js CHANGED
@@ -44,25 +44,37 @@ new EventSource('http://127.0.0.1:2999/esbuild').addEventListener('change', (e)
44
44
  }
45
45
  }
46
46
  });
47
- function addCssUpdateIndicator() {
47
+ export function addCssUpdateIndicator() {
48
48
  const indicator = createUpdateIndicator('green', '9999');
49
- indicator.style.transition = 'opacity ease-in-out .38s';
50
49
  indicator.style.opacity = '0';
51
- indicator.ontransitionend = () => {
52
- if (indicator.style.opacity === '1') {
53
- indicator.style.opacity = '0';
54
- }
55
- else {
56
- indicator.remove();
57
- indicator.onload = null;
58
- indicator.ontransitionend = null;
59
- }
60
- };
50
+ indicator.animate([
51
+ { opacity: 0 },
52
+ { opacity: 1 },
53
+ { opacity: 1 },
54
+ { opacity: 1 },
55
+ { opacity: 0.75 },
56
+ { opacity: 0.5 },
57
+ { opacity: 0.25 },
58
+ { opacity: 0 },
59
+ ], {
60
+ duration: 400,
61
+ iterations: 1,
62
+ direction: 'normal',
63
+ easing: 'linear',
64
+ });
61
65
  document.body.appendChild(indicator);
62
- setTimeout(() => (indicator.style.opacity = '1'), 0);
66
+ Promise.all(indicator.getAnimations().map(a => a.finished)).then(() => indicator.remove());
63
67
  }
64
68
  function addJsReloadIndicator() {
65
- document.body.appendChild(createUpdateIndicator('orange', '9000'));
69
+ const indicator = createUpdateIndicator('orange', '9000');
70
+ indicator.style.opacity = '0';
71
+ indicator.animate([{ opacity: 0 }, { opacity: 1 }], {
72
+ duration: 400,
73
+ iterations: 1,
74
+ direction: 'normal',
75
+ easing: 'ease-in',
76
+ });
77
+ document.body.appendChild(indicator);
66
78
  }
67
79
  function createUpdateIndicator(color, zIndex) {
68
80
  const indicator = document.createElement('div');
@@ -74,4 +86,3 @@ function createUpdateIndicator(color, zIndex) {
74
86
  indicator.style.boxSizing = 'border-box';
75
87
  return indicator;
76
88
  }
77
- export {};
package/lib/build.ts CHANGED
@@ -42,7 +42,7 @@ export async function buildWebsite(c: DankConfig): Promise<DankBuild> {
42
42
  const result = new Set(buildUrls)
43
43
  await writeBuildManifest(buildTag, result)
44
44
  return {
45
- dir: buildDir,
45
+ dir: distDir,
46
46
  files: result,
47
47
  }
48
48
  }
package/lib/config.ts CHANGED
@@ -4,7 +4,8 @@ import type { DankConfig } from './dank.ts'
4
4
  const CFG_P = './dank.config.ts'
5
5
 
6
6
  export async function loadConfig(path: string = CFG_P): Promise<DankConfig> {
7
- const module = await import(resolveConfigPath(path))
7
+ const modulePath = `${resolveConfigPath(path)}?${Date.now()}`
8
+ const module = await import(modulePath)
8
9
  return await module.default
9
10
  }
10
11
 
package/lib/dank.ts CHANGED
@@ -2,7 +2,7 @@ export type DankConfig = {
2
2
  // used for releases and service worker caching
3
3
  // buildTag?: (() => Promise<string> | string) | string
4
4
  // mapping url to fs paths of webpages to build
5
- pages: Record<`/${string}`, `./${string}.html`>
5
+ pages: Record<`/${string}`, `${string}.html`>
6
6
 
7
7
  services?: Array<DevService>
8
8
  }
@@ -18,6 +18,7 @@ export async function defineConfig(
18
18
  ): Promise<DankConfig> {
19
19
  validatePages(c.pages)
20
20
  validateDevServices(c.services)
21
+ normalizePagePaths(c.pages)
21
22
  return c as DankConfig
22
23
  }
23
24
 
@@ -77,3 +78,9 @@ function validateDevServices(services: DankConfig['services']) {
77
78
  }
78
79
  }
79
80
  }
81
+
82
+ function normalizePagePaths(pages: any) {
83
+ for (const urlPath of Object.keys(pages)) {
84
+ pages[urlPath] = pages[urlPath].replace(/^\.\//, '')
85
+ }
86
+ }
package/lib/esbuild.ts CHANGED
@@ -21,6 +21,11 @@ const jsBuildOptions: BuildOptions & { metafile: true; write: true } = {
21
21
  const webpageBuildOptions: BuildOptions & { metafile: true; write: true } = {
22
22
  assetNames: 'assets/[name]-[hash]',
23
23
  format: 'esm',
24
+ loader: {
25
+ '.tff': 'file',
26
+ '.woff': 'file',
27
+ '.woff2': 'file',
28
+ },
24
29
  ...jsBuildOptions,
25
30
  }
26
31
 
@@ -35,6 +40,8 @@ export async function esbuildDevContext(
35
40
  entryPoints: removeEntryPointOutExt(entryPoints),
36
41
  outdir,
37
42
  ...webpageBuildOptions,
43
+ metafile: false,
44
+ write: false,
38
45
  })
39
46
  }
40
47
 
package/lib/html.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises'
2
2
  import { dirname, join, relative } from 'node:path'
3
+ import { extname } from 'node:path/posix'
3
4
  import {
4
5
  defaultTreeAdapter,
5
6
  type DefaultTreeAdapterTypes,
@@ -75,12 +76,12 @@ export class HtmlEntrypoint {
75
76
  ) {
76
77
  importScript.elem.attrs.find(
77
78
  attr => attr.name === 'src',
78
- )!.value = rewriteTo || `/${importScript.out}.js`
79
+ )!.value = rewriteTo || `/${importScript.out}`
79
80
  }
80
81
  } else if (importScript.type === 'style') {
81
82
  importScript.elem.attrs.find(
82
83
  attr => attr.name === 'href',
83
- )!.value = rewriteTo || `/${importScript.out}.css`
84
+ )!.value = rewriteTo || `/${importScript.out}`
84
85
  }
85
86
  }
86
87
  }
@@ -182,12 +183,19 @@ export class HtmlEntrypoint {
182
183
 
183
184
  #addScript(type: ImportedScript['type'], href: string, elem: Element) {
184
185
  const inPath = join(dirname(this.#fsPath), href)
186
+ let outPath = inPath.replace(/^pages\//, '')
187
+ if (type === 'script' && !outPath.endsWith('.js')) {
188
+ outPath = outPath.replace(
189
+ new RegExp(extname(outPath).substring(1) + '$'),
190
+ 'js',
191
+ )
192
+ }
185
193
  this.#scripts.push({
186
194
  type,
187
195
  href,
188
196
  elem,
189
197
  in: inPath,
190
- out: inPath.replace(/^pages\//, ''),
198
+ out: outPath,
191
199
  })
192
200
  }
193
201
  }
package/lib/http.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createReadStream } from 'node:fs'
2
+ import { stat } from 'node:fs/promises'
2
3
  import {
3
4
  createServer,
4
5
  type IncomingHttpHeaders,
@@ -6,7 +7,7 @@ import {
6
7
  type OutgoingHttpHeaders,
7
8
  type ServerResponse,
8
9
  } from 'node:http'
9
- import { extname, join as fsJoin } from 'node:path'
10
+ import { extname, join } from 'node:path'
10
11
  import { isProductionBuild } from './flags.ts'
11
12
 
12
13
  export type FrontendFetcher = (
@@ -44,41 +45,122 @@ export function createBuiltDistFilesFetcher(
44
45
  res.writeHead(404)
45
46
  res.end()
46
47
  } else {
47
- const mimeType = resolveMimeType(url)
48
- res.setHeader('Content-Type', mimeType)
49
- const reading = createReadStream(
50
- mimeType === 'text/html'
51
- ? fsJoin(dir, url.pathname, 'index.html')
52
- : fsJoin(dir, url.pathname),
53
- )
54
- reading.pipe(res)
55
- reading.on('error', err => {
56
- console.error(
57
- `${url.pathname} file read ${reading.path} error ${err.message}`,
58
- )
59
- res.statusCode = 500
60
- res.end()
61
- })
48
+ const p =
49
+ extname(url.pathname) === ''
50
+ ? join(dir, url.pathname, 'index.html')
51
+ : join(dir, url.pathname)
52
+ streamFile(p, res)
62
53
  }
63
54
  }
64
55
  }
65
56
 
66
- export function createLocalProxyFilesFetcher(port: number): FrontendFetcher {
67
- const proxyAddress = 'http://127.0.0.1:' + port
57
+ type DevServeOpts = {
58
+ // ref of original DankConfig['pages'] mapping
59
+ // updated incrementally instead of replacing
60
+ pages: Record<string, string>
61
+ // dir processed html files are written to
62
+ pagesDir: string
63
+ // port to esbuild dev server
64
+ proxyPort: number
65
+ // dir of public assets
66
+ publicDir: string
67
+ }
68
+
69
+ export function createDevServeFilesFetcher(
70
+ opts: DevServeOpts,
71
+ ): FrontendFetcher {
72
+ const proxyAddress = 'http://127.0.0.1:' + opts.proxyPort
68
73
  return (url: URL, _headers: Headers, res: ServerResponse) => {
69
- fetch(proxyAddress + url.pathname).then(fetchResponse => {
70
- res.writeHead(
71
- fetchResponse.status,
72
- convertHeadersFromFetch(fetchResponse.headers),
73
- )
74
- fetchResponse.bytes().then(data => res.end(data))
75
- })
74
+ if (opts.pages[url.pathname]) {
75
+ streamFile(join(opts.pagesDir, url.pathname + 'index.html'), res)
76
+ } else {
77
+ const maybePublicPath = join(opts.publicDir, url.pathname)
78
+ exists(join(opts.publicDir, url.pathname)).then(fromPublic => {
79
+ if (fromPublic) {
80
+ streamFile(maybePublicPath, res)
81
+ } else {
82
+ retryFetchWithTimeout(proxyAddress + url.pathname)
83
+ .then(fetchResponse => {
84
+ res.writeHead(
85
+ fetchResponse.status,
86
+ convertHeadersFromFetch(fetchResponse.headers),
87
+ )
88
+ fetchResponse.bytes().then(data => res.end(data))
89
+ })
90
+ .catch(e => {
91
+ if (e === 'retrytimeout') {
92
+ res.writeHead(504)
93
+ } else {
94
+ console.error(
95
+ 'unknown frontend proxy fetch error:',
96
+ e,
97
+ )
98
+ res.writeHead(502)
99
+ }
100
+ res.end()
101
+ })
102
+ }
103
+ })
104
+ }
76
105
  }
77
106
  }
78
107
 
79
- function resolveMimeType(url: URL): string {
80
- switch (extname(url.pathname)) {
81
- case '':
108
+ const PROXY_FETCH_RETRY_INTERVAL = 27
109
+ const PROXY_FETCH_RETRY_TIMEOUT = 1000
110
+
111
+ async function retryFetchWithTimeout(url: string): Promise<Response> {
112
+ let timeout = Date.now() + PROXY_FETCH_RETRY_TIMEOUT
113
+ while (true) {
114
+ try {
115
+ return await fetch(url)
116
+ } catch (e: any) {
117
+ if (isNodeFailedFetch(e) || isBunFailedFetch(e)) {
118
+ if (timeout < Date.now()) {
119
+ throw 'retrytimeout'
120
+ } else {
121
+ await new Promise(res =>
122
+ setTimeout(res, PROXY_FETCH_RETRY_INTERVAL),
123
+ )
124
+ }
125
+ } else {
126
+ throw e
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ function isBunFailedFetch(e: any): boolean {
133
+ return e.code === 'ConnectionRefused'
134
+ }
135
+
136
+ function isNodeFailedFetch(e: any): boolean {
137
+ return e.message === 'fetch failed'
138
+ }
139
+
140
+ async function exists(p: string): Promise<boolean> {
141
+ try {
142
+ const maybe = stat(p)
143
+ return (await maybe).isFile()
144
+ } catch (ignore) {
145
+ return false
146
+ }
147
+ }
148
+
149
+ function streamFile(p: string, res: ServerResponse) {
150
+ const mimeType = resolveMimeType(p)
151
+ res.setHeader('Content-Type', mimeType)
152
+ const reading = createReadStream(p)
153
+ reading.pipe(res)
154
+ reading.on('error', err => {
155
+ console.error(`file read ${reading.path} error ${err.message}`)
156
+ res.statusCode = 500
157
+ res.end()
158
+ })
159
+ }
160
+
161
+ function resolveMimeType(p: string): string {
162
+ switch (extname(p)) {
163
+ case '.html':
82
164
  return 'text/html'
83
165
  case '.js':
84
166
  return 'text/javascript'
@@ -90,8 +172,14 @@ function resolveMimeType(url: URL): string {
90
172
  return 'image/svg+xml'
91
173
  case '.png':
92
174
  return 'image/png'
175
+ case '.ttf':
176
+ return 'font/ttf'
177
+ case '.woff':
178
+ return 'font/woff'
179
+ case '.woff2':
180
+ return 'font/woff2'
93
181
  default:
94
- console.warn('? mime type for', url.pathname)
182
+ console.warn('? mime type for', p)
95
183
  if (!isProductionBuild()) process.exit(1)
96
184
  return 'application/octet-stream'
97
185
  }