spooder 5.0.2 → 5.1.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.
Files changed (3) hide show
  1. package/README.md +190 -103
  2. package/package.json +1 -1
  3. package/src/api.ts +106 -37
package/README.md CHANGED
@@ -96,6 +96,8 @@ The `CLI` component of `spooder` is a global command-line tool for running serve
96
96
  - [API > Workers](#api-workers)
97
97
  - [API > Caching](#api-caching)
98
98
  - [API > Templating](#api-templating)
99
+ - [API > Cache Busting](#api-cache-busting)
100
+ - [API > Git](#api-git)
99
101
  - [API > Database](#api-database)
100
102
  - [API > Database > Schema](#api-database-schema)
101
103
  - [API > Database > Interface](#api-database-interface)
@@ -525,9 +527,18 @@ pipe.once(event: string, callback: (data: object) => void | Promise<void>): void
525
527
  pipe.off(event: string): void;
526
528
 
527
529
  // templates
528
- parse_template(template: string, replacements: Record<string, string>, drop_missing?: boolean): Promise<string>;
529
- generate_hash_subs(length?: number, prefix?: string, hashes?: Record<string, string>, format?: string): Promise<Record<string, string>>;
530
- get_git_hashes(length: number): Promise<Record<string, string>>;
530
+ Replacements = Record<string, string | Array<string> | object | object[]> | ReplacerFn | AsyncReplaceFn;
531
+ parse_template(template: string, replacements: Replacements, drop_missing?: boolean): Promise<string>;
532
+
533
+ // cache busting
534
+ cache_bust(string: path, format: string): string
535
+ cache_bust_set_hash_length(length: number): void;
536
+ cache_bust_set_format(format: string): void;
537
+ cache_bust_get_hash_table(): Record<string, string>;
538
+
539
+ // git
540
+ git_get_hashes(length: number): Promise<Record<string, string>>;
541
+ git_get_hashes_sync(length: number): Record<string, string>
531
542
 
532
543
  // database interface
533
544
  db_sqlite(filename: string, options: number|object): db_sqlite;
@@ -1197,7 +1208,7 @@ server.websocket('/path/to/websocket', {
1197
1208
  For simpler projects, the scaffolding can often look the same, potentially something similar to below.
1198
1209
 
1199
1210
  ```ts
1200
- import { http_serve, cache_http, generate_hash_subs, parse_template, http_apply_range } from 'spooder';
1211
+ import { http_serve, cache_http, parse_template, http_apply_range, git_get_hashes } from 'spooder';
1201
1212
  import path from 'node:path';
1202
1213
 
1203
1214
  const server = http_serve(80);
@@ -1209,7 +1220,7 @@ const cache = cache_http({
1209
1220
  });
1210
1221
 
1211
1222
  const base_file = await Bun.file('./html/base_template.html').text();
1212
- const hash_table = await generate_hash_subs();
1223
+ const git_hash_table = await git_get_hashes();
1213
1224
 
1214
1225
  async function default_handler(status_code: number): Promise<Response> {
1215
1226
  const error_text = HTTP_STATUS_CODE[status_code] as string;
@@ -1239,10 +1250,12 @@ server.dir('/static', './static', async (file_path, file, stat, request) => {
1239
1250
  if (stat.isDirectory())
1240
1251
  return HTTP_STATUS_CODE.Unauthorized_401;
1241
1252
 
1242
- // cache busting
1253
+ // serve css/js files directly
1243
1254
  const ext = path.extname(file_path);
1244
1255
  if (ext === '.css' || ext === '.js') {
1245
- const content = await parse_template(await file.text(), hash_table, true);
1256
+ const content = await parse_template(await file.text(), {
1257
+ cache_bust: (file) => `${file}?v=${git_hash_table[file]}`
1258
+ }, true);
1246
1259
 
1247
1260
  return new Response(content, {
1248
1261
  headers: {
@@ -1261,8 +1274,8 @@ function add_route(route: string, file: string, title: string) {
1261
1274
  const template = await parse_template(base_file, {
1262
1275
  title: title,
1263
1276
  content: file_content,
1264
- ...hash_table
1265
- }, false);
1277
+ asset: (file) => git_hash_table[file]
1278
+ }, true);
1266
1279
 
1267
1280
  return template;
1268
1281
  });
@@ -1300,7 +1313,11 @@ server.bootstrap({
1300
1313
  error_page: Bun.file('./html/error.html')
1301
1314
  },
1302
1315
 
1303
- hash_subs: true,
1316
+ cache_bust: { // true or options
1317
+ format: '$file#$hash', // default: $file?v=$hash
1318
+ hash_length: 20, // default: 7
1319
+ prefix: 'bust' // default: cache_bust
1320
+ },
1304
1321
 
1305
1322
  static: {
1306
1323
  directory: './static',
@@ -1399,37 +1416,30 @@ cache: {
1399
1416
  }
1400
1417
  ```
1401
1418
 
1402
- ##### `hash_subs?: boolean | object`
1403
- When enabled, automatically generates git hash-based substitutions. Can be a boolean for defaults or an object for custom options.
1419
+ ##### `cache_bust?: CacheBustOptions | boolean`
1420
+ Enables the use of the [`cache_bust()`](#api-cache-busting) API inside templates using the ``{{cache_bust=file}}`` directive.
1404
1421
 
1405
- **Boolean usage (uses defaults):**
1406
- ```ts
1407
- hash_subs: true // Equivalent to { length: 7, prefix: 'hash=' }
1422
+ ```html
1423
+ <link href="{{cache_bust=static/css/style.css}}">
1424
+ <script src="{{cache_bust=static/js/app.js}}"></script>
1425
+ <img src="{{cache_bust=static/images/logo.png}}">
1408
1426
  ```
1409
1427
 
1410
- **Object usage (custom options):**
1411
- ```ts
1412
- hash_subs: {
1413
- length: 7, // Hash length (default: 7)
1414
- prefix: 'asset=', // Substitution prefix (default: 'hash=')
1415
- format: '$file?v=$hash', // Custom format (default: just hash)
1416
- hashes: { ... } // Pre-generated hash map from get_git_hashes (optional)
1417
- }
1418
- ```
1428
+ Since this uses the [`cache_bust()`](#api-cache-busting) API internally, it is effected by the `cache_bust_set_hash_length` and `cache_bust_set_format` global functions.
1419
1429
 
1420
- **Examples:**
1421
- ```ts
1422
- // Default hash substitutions
1423
- hash_subs: true
1424
- // Creates: {{hash=static/css/style.css}} -> "a1b2c3d"
1425
- // Usage: <link href="/css/style.css?v={{hash=static/css/style.css}}">
1430
+ Setting `cache_bust` to `true` assumes the normal defaults, however this can be customized by providing an options object.
1426
1431
 
1427
- // Asset-style substitutions (reduces verbosity)
1428
- hash_subs: { prefix: 'asset=', format: '$file?v=$hash' }
1429
- // Creates: {{asset=static/css/style.css}} -> "static/css/style.css?v=a1b2c3d"
1430
- // Usage: <link href="{{asset=static/css/style.css}}">
1432
+ ```ts
1433
+ cache_bust: { // true or options
1434
+ format: '$file#$hash', // default: $file?v=$hash
1435
+ hash_length: 20, // default: 7
1436
+ prefix: 'bust' // default: cache_bust
1437
+ },
1431
1438
  ```
1432
1439
 
1440
+ > ![IMPORTANT]
1441
+ > `format` and `hash_length` internally call `cache_bust_set_format` and `cache_bust_set_hash_length` respectively, so these values will effect `cache_bust()` globally.
1442
+
1433
1443
  ##### `error?: object`
1434
1444
  Optional error page configuration:
1435
1445
  - `error_page`: Template for error pages (string or BunFile)
@@ -1458,7 +1468,25 @@ static: {
1458
1468
  }
1459
1469
  ```
1460
1470
 
1461
- Files with extensions in `sub_ext` will have template substitutions applied before serving.
1471
+ Files with extensions in `sub_ext` will have template substitutions applied before serving. This includes support for functions to generate dynamic content:
1472
+
1473
+ ```ts
1474
+ // Dynamic CSS with function-based substitutions
1475
+ static: {
1476
+ route: '/assets',
1477
+ directory: './public',
1478
+ sub_ext: ['.css']
1479
+ },
1480
+
1481
+ global_subs: {
1482
+ theme_color: () => {
1483
+ const hour = new Date().getHours();
1484
+ return hour < 6 || hour > 18 ? '#2d3748' : '#4a5568';
1485
+ }
1486
+ }
1487
+ ```
1488
+
1489
+ This allows CSS files to use dynamic substitutions: `color: {{theme_color}};`
1462
1490
 
1463
1491
  ##### `global_subs?: Record<string, BootstrapSub>`
1464
1492
  Optional global template substitutions available to all routes, error pages, and static files with `sub_ext`.
@@ -1467,10 +1495,28 @@ Optional global template substitutions available to all routes, error pages, and
1467
1495
  global_subs: {
1468
1496
  site_name: 'My Website',
1469
1497
  version: '1.0.0',
1470
- api_url: 'https://api.example.com'
1498
+ api_url: 'https://api.example.com',
1499
+
1500
+ // Function-based substitutions for dynamic content
1501
+ current_year: () => new Date().getFullYear().toString(),
1502
+
1503
+ build_time: async () => {
1504
+ // Example: fetch build timestamp from git
1505
+ const process = Bun.spawn(['git', 'log', '-1', '--format=%ct']);
1506
+ const output = await Bun.readableStreamToText(process.stdout);
1507
+ return new Date(parseInt(output.trim()) * 1000).toISOString();
1508
+ },
1509
+
1510
+ user_count: async () => {
1511
+ // Example: dynamic user count from database
1512
+ const count = await db.count('SELECT COUNT(*) as count FROM users');
1513
+ return count.toLocaleString();
1514
+ }
1471
1515
  }
1472
1516
  ```
1473
1517
 
1518
+ Functions in `global_subs` and route-specific `subs` are called during template processing, allowing for dynamic content generation. Both synchronous and asynchronous functions are supported.
1519
+
1474
1520
  #### Template Processing Order
1475
1521
 
1476
1522
  1. Route content is loaded
@@ -1829,29 +1875,60 @@ await parse_template(template, replacements, true);
1829
1875
  </html>
1830
1876
  ```
1831
1877
 
1832
- `parse_template` supports passing a function instead of a replacement object. This function will be called for each placeholder and the return value will be used as the replacement. This function can be a Promise/async function.
1878
+ #### Custom Replacer Function
1879
+
1880
+ `parse_template` supports passing a function instead of a replacement object. This function will be called for each placeholder and the return value will be used as the replacement. Both synchronous and asynchronous functions are supported.
1833
1881
 
1834
1882
  ```ts
1835
- const replacer = (placeholder: string) => {
1836
- return placeholder.toUpperCase();
1883
+ const replacer = (key: string) => {
1884
+ switch (key) {
1885
+ case 'timestamp': return Date.now().toString();
1886
+ case 'random': return Math.random().toString(36).substring(7);
1887
+ case 'greeting': return 'Hello, World!';
1888
+ default: return undefined;
1889
+ }
1837
1890
  };
1838
1891
 
1839
- await parse_template('Hello {{world}}', replacer);
1892
+ await parse_template('Generated at {{timestamp}}: {{greeting}} (ID: {{random}})', replacer);
1893
+ // Result: "Generated at 1635789123456: Hello, World! (ID: x7k2p9m)"
1840
1894
  ```
1841
1895
 
1842
- ```html
1843
- <html>
1844
- <head>
1845
- <title>TITLE</title>
1846
- </head>
1847
- <body>
1848
- <h1>TITLE</h1>
1849
- <p>CONTENT</p>
1850
- <p>IGNORED</p>
1851
- </body>
1852
- </html>
1896
+ Custom replacer functions are supported on a per-key basis, mixing with static string replacement.
1897
+
1898
+ ```ts
1899
+ await parse_template('Hello {{foo}}, it is {{now}}', {
1900
+ foo: 'world',
1901
+ now: () => Date.now()
1902
+ });
1903
+ ```
1904
+
1905
+ #### Key/Value Based Substitutions
1906
+
1907
+ `parse_template` supports key/value based substitutions using the `{{key=value}}` syntax. When a function replacer is provided for the key, the value is passed as a parameter to the function.
1908
+
1909
+ ```ts
1910
+ await parse_template('Color: {{hex=blue}}', {
1911
+ hex: (color) => {
1912
+ const colors = { blue: '#0000ff', red: '#ff0000', green: '#00ff00' };
1913
+ return colors[color] || color;
1914
+ }
1915
+ });
1916
+ // Result: "Color: #0000ff"
1853
1917
  ```
1854
1918
 
1919
+ Global replacer functions also support the value parameter:
1920
+
1921
+ ```ts
1922
+ await parse_template('Transform: {{upper=hello}} and {{lower=WORLD}}', (key, value) => {
1923
+ if (key === 'upper' && value) return value.toUpperCase();
1924
+ if (key === 'lower' && value) return value.toLowerCase();
1925
+ return 'unknown';
1926
+ });
1927
+ // Result: "Transform: HELLO and world"
1928
+ ```
1929
+
1930
+ #### Conditional Rendering
1931
+
1855
1932
  `parse_template` supports conditional rendering with the following syntax.
1856
1933
 
1857
1934
  ```html
@@ -1900,6 +1977,34 @@ const html = await parse_template(template, replacements);
1900
1977
  </ul>
1901
1978
  ```
1902
1979
 
1980
+ #### Simple Array Iteration
1981
+
1982
+ For simple arrays containing strings, you can iterate directly over the array items:
1983
+
1984
+ ```ts
1985
+ const template = `
1986
+ <ul>
1987
+ <t-for items="fruits" as="fruit">
1988
+ <li>{{fruit}}</li>
1989
+ </t-for>
1990
+ </ul>
1991
+ `;
1992
+
1993
+ const replacements = {
1994
+ fruits: ['apple', 'banana', 'orange']
1995
+ };
1996
+
1997
+ const html = await parse_template(template, replacements);
1998
+ ```
1999
+
2000
+ ```html
2001
+ <ul>
2002
+ <li>apple</li>
2003
+ <li>banana</li>
2004
+ <li>orange</li>
2005
+ </ul>
2006
+ ```
2007
+
1903
2008
  #### Dot Notation Property Access
1904
2009
 
1905
2010
  You can access nested object properties using dot notation:
@@ -1940,91 +2045,73 @@ await parse_template(..., {
1940
2045
  </t-for>
1941
2046
  ```
1942
2047
 
1943
- ### 🔧 `generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>, format?: string): Promise<Record<string, string>>`
2048
+ <a id="api-cache-busting"></a>
2049
+ ## API > Cache Busting
1944
2050
 
1945
- Generate a replacement table for mapping file paths to hashes in templates. This is useful for cache-busting static assets.
2051
+ ### 🔧 ``cache_bust(string: path, format: string): string``
1946
2052
 
1947
- > [!IMPORTANT]
1948
- > Internally `generate_hash_subs()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
2053
+ Appends a hash-suffix to the provided string, formatted by default as a query parameter, for cache-busting purposes.
1949
2054
 
1950
2055
  ```ts
1951
- let hash_sub_table = {};
1952
-
1953
- generate_hash_subs().then(subs => hash_sub_table = subs).catch(caution);
1954
-
1955
- server.route('/test', (req, url) => {
1956
- return parse_template('Hello world {{hash=docs/project-logo.png}}', hash_sub_table);
1957
- });
2056
+ cache_bust('static/my_image.png'); // > static/my_image.png?v=123fea
1958
2057
  ```
1959
2058
 
1960
- ```html
1961
- Hello world 754d9ea
1962
- ```
2059
+ > ![NOTE]
2060
+ > Internally `cache_bust()` uses `git_get_hashes()` to hash paths, requiring the input `path` to be a valid git path. If the path cannot be resolved in git, an empty hash is substituted.
1963
2061
 
1964
- > [!IMPORTANT]
1965
- > Specify paths as they appear in git, relative to the repository root and with forward slashes (no leading slash).
2062
+ ### 🔧 ``cache_bust_set_format(format: string): void``
1966
2063
 
1967
- By default hashes are truncated to `7` characters (a short hash), a custom length can be provided instead.
2064
+ The default format for used for `cache_bust()` is `$file?v=$hash`, this can be customized per-call with the `format` parameter, or globally using `cache_bust_set_format()`
1968
2065
 
1969
2066
  ```ts
1970
- generate_hash_subs(40).then(...);
1971
- // d65c52a41a75db43e184d2268c6ea9f9741de63e
2067
+ cache_bust('dogs.txt'); // > dogs.txt?v=fff
2068
+ cache_bust('dogs.txt', '$file?hash=$hash'); // > dogs.txt?hash=fff
2069
+
2070
+ cache_bust_set_format('$file#$hash');
2071
+ cache_bust('dogs.txt'); // > dogs#fff
1972
2072
  ```
1973
2073
 
1974
- > [!NOTE]
1975
- > SHA-1 hashes are `40` characters. Git is transitioning to SHA-256, which are `64` characters. Short hashes of `7` are generally sufficient for cache-busting.
2074
+ ### 🔧 ``cache_bust_set_hash_length(length: number): void``
1976
2075
 
1977
- Use a different prefix other than `hash=` by passing it as the first parameter.
2076
+ The default hash-length used by `cache_bust()` is 7. This can be changed with `cache_bust_set_hash_length()`.
1978
2077
 
1979
- ```ts
1980
- generate_hash_subs(7, '$#').then(subs => hash_sub_table = subs).catch(caution);
2078
+ > ![NOTE]
2079
+ > Hashes are cached once at the specified length, therefore `cache_bust_set_hash_length()` must be called before calling `cache_bust()` and has no effect calling it after.
1981
2080
 
1982
- server.route('/test', (req, url) => {
1983
- return parse_template('Hello world {{$#docs/project-logo.png}}', hash_sub_table);
1984
- });
2081
+ ```ts
2082
+ cache_bust_set_hash_length(10);
2083
+ cache_bust('dogs.txt'); // > dogs.txt?v=ffffffffff
1985
2084
  ```
1986
2085
 
1987
- #### Custom Format Parameter
1988
-
1989
- The optional `format` parameter allows you to customize how the substitution values are formatted. Use `$file` and `$hash` placeholders within the format string:
2086
+ ### 🔧 ``cache_bust_get_hash_table(): Record<string, string>``
1990
2087
 
1991
- ```ts
1992
- // Asset-style substitutions - reduces verbosity
1993
- const asset_subs = await generate_hash_subs(7, 'asset=', undefined, '$file?v=$hash');
2088
+ This function returns the internal hash table used by `cache_bust()`. This is exposed to userland in the event that you which to use the hashes for other purposes, avoiding the need to call and store `git_get_hashes()` twice.
1994
2089
 
1995
- // Usage in templates:
1996
- // <link rel="stylesheet" href="{{asset=static/css/shared.css}}">
1997
- // Resolves to: static/css/shared.css?v=a1b2c3d
1998
-
1999
- // Custom format example:
2000
- await generate_hash_subs(7, 'url=', undefined, 'https://assets.example.com/$file?hash=$hash');
2001
- // Result: { 'url=app.js': 'https://assets.example.com/app.js?hash=a1b2c3d' }
2002
- ```
2090
+ <a id="api-templating"></a>
2091
+ ## API > Git
2003
2092
 
2004
- When `format` is omitted, the default behavior returns just the hash value (backward compatible).
2093
+ ### 🔧 ``git_get_hashes(length: number): Promise<Record<string, string>>``
2005
2094
 
2006
- ### 🔧 ``get_git_hashes(length: number): Promise<Record<string, string>>``
2095
+ ### 🔧 ``git_get_hashes_sync(length: number): Record<string, string>``
2007
2096
 
2008
- Internally, `generate_hash_subs()` uses `get_git_hashes()` to retrieve the hash table from git. This function is exposed for convenience.
2097
+ Retrieve git hashes for all files in the repository. This is useful for implementing cache-busting functionality or creating file integrity checks.
2009
2098
 
2010
2099
  > [!IMPORTANT]
2011
- > Internally `get_git_hashes()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
2100
+ > Internally `git_get_hashes()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
2012
2101
 
2013
2102
  ```ts
2014
- const hashes = await get_git_hashes(7);
2103
+ const hashes = await git_get_hashes(7);
2015
2104
  // { 'docs/project-logo.png': '754d9ea' }
2016
2105
  ```
2017
2106
 
2018
- If you're using `generate_hash_subs()` and `get_git_hashes()` at the same time, it is more efficient to pass the result of `get_git_hashes()` directly to `generate_hash_subs()` to prevent redundant calls to git.
2107
+ You can specify the hash length (default is 7 characters for short hashes):
2019
2108
 
2020
2109
  ```ts
2021
- const hashes = await get_git_hashes(7);
2022
- const subs = await generate_hash_subs(7, undefined, hashes);
2023
-
2024
- // hashes[0] -> { 'docs/project-logo.png': '754d9ea' }
2025
- // subs[0] -> { 'hash=docs/project-logo.png': '754d9ea' }
2110
+ const full_hashes = await git_get_hashes(40);
2111
+ // { 'docs/project-logo.png': 'd65c52a41a75db43e184d2268c6ea9f9741de63e' }
2026
2112
  ```
2027
2113
 
2114
+
2028
2115
  <a id="api-database"></a>
2029
2116
  <a id="api-database-interface"></a>
2030
2117
  ## API > Database
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "spooder",
3
3
  "type": "module",
4
- "version": "5.0.2",
4
+ "version": "5.1.0",
5
5
  "module": "./src/api.ts",
6
6
  "bin": {
7
7
  "spooder": "./src/cli.ts"
package/src/api.ts CHANGED
@@ -397,9 +397,14 @@ export async function safe(target_fn: Callable) {
397
397
  // endregion
398
398
 
399
399
  // region templates
400
- type ReplacerFn = (key: string) => string | Array<string> | undefined;
401
- type AsyncReplaceFn = (key: string) => Promise<string | Array<string> | undefined>;
402
- type Replacements = Record<string, string | Array<string> | object | object[]> | ReplacerFn | AsyncReplaceFn;
400
+ type ReplacerFn = (key: string, value?: string) => string | Array<string> | undefined;
401
+ type AsyncReplaceFn = (key: string, value?: string) => Promise<string | Array<string> | undefined>;
402
+ type ReplacementValueFn = () => string | Array<string> | undefined;
403
+ type AsyncReplacementValueFn = () => Promise<string | Array<string> | undefined>;
404
+ type ReplacementValueWithKeyFn = (value?: string) => string | Array<string> | undefined;
405
+ type AsyncReplacementValueWithKeyFn = (value?: string) => Promise<string | Array<string> | undefined>;
406
+ type ReplacementValue = string | Array<string> | object | object[] | ReplacementValueFn | AsyncReplacementValueFn | ReplacementValueWithKeyFn | AsyncReplacementValueWithKeyFn;
407
+ type Replacements = Record<string, ReplacementValue> | ReplacerFn | AsyncReplaceFn;
403
408
 
404
409
  function get_nested_property(obj: any, path: string): any {
405
410
  const keys = path.split('.');
@@ -435,11 +440,14 @@ export async function parse_template(template: string, replacements: Replacement
435
440
 
436
441
  if (typeof replacements === 'function') {
437
442
  scoped_replacements = async (key: string) => {
438
- if (key === alias_name) return loop_entry;
443
+ if (key === alias_name)
444
+ return loop_entry;
445
+
439
446
  if (key.startsWith(alias_name + '.')) {
440
447
  const prop_path = key.substring(alias_name.length + 1);
441
448
  return get_nested_property(loop_entry, prop_path);
442
449
  }
450
+
443
451
  return await replacements(key);
444
452
  };
445
453
  } else {
@@ -467,6 +475,7 @@ export async function parse_template(template: string, replacements: Replacement
467
475
 
468
476
  if (!drop_missing && !condition_value)
469
477
  return match;
478
+
470
479
  if (condition_value)
471
480
  return await parse_template(if_content, replacements, drop_missing);
472
481
 
@@ -478,15 +487,31 @@ export async function parse_template(template: string, replacements: Replacement
478
487
  result = await replace_async(result, var_regex, async (match, var_name) => {
479
488
  // Trim whitespace from variable name
480
489
  var_name = var_name.trim();
490
+
491
+ // Check for key=value syntax
492
+ let key = var_name;
493
+ let value: string | undefined = undefined;
494
+ const equals_index = var_name.indexOf('=');
495
+
496
+ if (equals_index !== -1) {
497
+ key = var_name.substring(0, equals_index);
498
+ value = var_name.substring(equals_index + 1);
499
+ }
500
+
481
501
  let replacement;
482
502
 
483
503
  if (is_replacer_fn) {
484
- replacement = await replacements(var_name);
504
+ replacement = await replacements(key, value);
485
505
  } else {
486
506
  // First try direct key lookup (handles hash keys with dots like "hash=.gitignore")
487
507
  replacement = replacements[var_name];
488
508
 
489
- // If direct lookup fails and variable contains dots, try nested property access
509
+ // If direct lookup fails and we have key=value syntax, try key lookup
510
+ if (replacement === undefined && value !== undefined) {
511
+ replacement = replacements[key];
512
+ }
513
+
514
+ // If still undefined and variable contains dots, try nested property access
490
515
  if (replacement === undefined && var_name.includes('.')) {
491
516
  const dot_index = var_name.indexOf('.');
492
517
  const base_key = var_name.substring(0, dot_index);
@@ -499,6 +524,13 @@ export async function parse_template(template: string, replacements: Replacement
499
524
  }
500
525
  }
501
526
 
527
+ if (replacement !== undefined && typeof replacement === 'function') {
528
+ if (value !== undefined && replacement.length > 0)
529
+ replacement = await replacement(value);
530
+ else
531
+ replacement = await replacement();
532
+ }
533
+
502
534
  if (replacement !== undefined)
503
535
  return replacement;
504
536
 
@@ -524,8 +556,36 @@ async function replace_async(str: string, regex: RegExp, replacer_fn: (match: st
524
556
 
525
557
  return result;
526
558
  }
559
+ // endregion
560
+
561
+ // region cache busting
562
+ let cache_bust_map: Record<string, string> | null = null;
563
+ let cache_bust_global_length = 7;
564
+ let cache_bust_global_format = '$file?v=$hash';
565
+
566
+ export function cache_bust_get_hash_table(): Record<string, string> {
567
+ if (cache_bust_map === null)
568
+ cache_bust_map = git_get_hashes_sync(cache_bust_global_length);
569
+
570
+ return cache_bust_map;
571
+ }
572
+
573
+ export function cache_bust(path: string, format = cache_bust_global_format): string {
574
+ const hash = cache_bust_get_hash_table()[path] || '';
575
+ return format.replace('$file', path).replace('$hash', hash);
576
+ }
577
+
578
+ export function cache_bust_set_hash_length(length: number): void {
579
+ cache_bust_global_length = length;
580
+ }
581
+
582
+ export function cache_bust_set_format(format: string): void {
583
+ cache_bust_global_format = format;
584
+ }
585
+ // endregion
527
586
 
528
- export async function get_git_hashes(length = 7): Promise<Record<string, string>> {
587
+ // region git
588
+ export async function git_get_hashes(length = 7): Promise<Record<string, string>> {
529
589
  const cmd = ['git', 'ls-tree', '-r', 'HEAD'];
530
590
  const process = Bun.spawn(cmd, {
531
591
  stdout: 'pipe',
@@ -535,7 +595,7 @@ export async function get_git_hashes(length = 7): Promise<Record<string, string>
535
595
  await process.exited;
536
596
 
537
597
  if (process.exitCode as number > 0)
538
- throw new Error('get_git_hashes() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
598
+ throw new Error('git_get_hashes() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
539
599
 
540
600
  const stdout = await Bun.readableStreamToText(process.stdout as ReadableStream);
541
601
  const hash_map: Record<string, string> = {};
@@ -549,22 +609,24 @@ export async function get_git_hashes(length = 7): Promise<Record<string, string>
549
609
  return hash_map;
550
610
  }
551
611
 
552
- export async function generate_hash_subs(length = 7, prefix = 'hash=', hashes?: Record<string, string>, format?: string): Promise<Record<string, string>> {
612
+ export function git_get_hashes_sync(length = 7): Record<string, string> {
613
+ const cmd = ['git', 'ls-tree', '-r', 'HEAD'];
614
+ const process = Bun.spawnSync(cmd, {
615
+ stdout: 'pipe',
616
+ stderr: 'pipe'
617
+ });
618
+
619
+ if (process.exitCode > 0)
620
+ throw new Error('git_get_hashes_sync() failed, `' + cmd.join(' ') + '` exited with non-zero exit code.');
621
+
622
+ const stdout = process.stdout.toString();
553
623
  const hash_map: Record<string, string> = {};
554
624
 
555
- if (!hashes)
556
- hashes = await get_git_hashes(length);
625
+ const regex = /([^\s]+)\s([^\s]+)\s([^\s]+)\t(.+)/g;
626
+ let match: RegExpExecArray | null;
557
627
 
558
- for (const [file, hash] of Object.entries(hashes)) {
559
- if (format !== undefined) {
560
- const formatted_value = format
561
- .replace(/\$file/g, file)
562
- .replace(/\$hash/g, hash);
563
- hash_map[prefix + file] = formatted_value;
564
- } else {
565
- hash_map[prefix + file] = hash;
566
- }
567
- }
628
+ while (match = regex.exec(stdout))
629
+ hash_map[match[4]] = match[3].substring(0, length);
568
630
 
569
631
  return hash_map;
570
632
  }
@@ -984,23 +1046,26 @@ type WebsocketHandlers = {
984
1046
  drain?: (ws: WebSocket) => void
985
1047
  };
986
1048
 
987
- type BootstrapSub = string | string[];
1049
+ type BootstrapSub = ReplacementValue;
988
1050
 
989
1051
  type BootstrapRoute = {
990
1052
  content: string | BunFile;
991
1053
  subs?: Record<string, BootstrapSub>;
992
1054
  };
993
1055
 
1056
+ type BootstrapCacheBust = {
1057
+ prefix?: string;
1058
+ hash_length?: number;
1059
+ format?: string;
1060
+ };
1061
+
994
1062
  type BootstrapOptions = {
995
1063
  base?: string | BunFile;
996
1064
  routes: Record<string, BootstrapRoute>;
997
1065
  cache?: ReturnType<typeof cache_http> | CacheOptions;
998
- hash_subs?: boolean | {
999
- length?: number;
1000
- prefix?: string;
1001
- format?: string;
1002
- hashes?: Record<string, string>;
1003
- };
1066
+
1067
+ cache_bust?: boolean | BootstrapCacheBust;
1068
+
1004
1069
  error?: {
1005
1070
  use_canary_reporting?: boolean;
1006
1071
  error_page: string | BunFile;
@@ -1420,18 +1485,22 @@ export function http_serve(port: number, hostname?: string) {
1420
1485
 
1421
1486
  /* Bootstrap a static web server */
1422
1487
  bootstrap: async function(options: BootstrapOptions) {
1423
- let hash_sub_table = {};
1488
+ let cache_bust_subs: Record<string, ReplacementValue> = {};
1424
1489
 
1425
- if (options.hash_subs) {
1426
- if (options.hash_subs === true) {
1427
- hash_sub_table = await generate_hash_subs();
1428
- } else {
1429
- const { length, prefix, format, hashes } = options.hash_subs;
1430
- hash_sub_table = await generate_hash_subs(length, prefix, hashes, format);
1431
- }
1490
+ const cache_bust_opts = options.cache_bust;
1491
+ if (typeof cache_bust_opts === 'object' && cache_bust_opts !== null) {
1492
+ if (typeof cache_bust_opts.hash_length === 'number')
1493
+ cache_bust_set_hash_length(cache_bust_opts.hash_length);
1494
+
1495
+ if (typeof cache_bust_opts.format === 'string')
1496
+ cache_bust_set_format(cache_bust_opts.format);
1497
+
1498
+ cache_bust_subs[cache_bust_opts.prefix ?? 'cache_bust'] = cache_bust;
1499
+ } else if (cache_bust_opts === true) {
1500
+ cache_bust_subs = { cache_bust };
1432
1501
  }
1433
1502
 
1434
- const global_sub_table = sub_table_merge(hash_sub_table, options.global_subs);
1503
+ const global_sub_table = sub_table_merge(cache_bust_subs, options.global_subs);
1435
1504
 
1436
1505
  let cache = options.cache;
1437
1506
  if (cache !== undefined && !is_cache_http(cache))