spooder 5.0.2 → 5.0.3

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 +114 -117
  2. package/package.json +1 -1
  3. package/src/api.ts +51 -41
package/README.md CHANGED
@@ -525,8 +525,8 @@ pipe.once(event: string, callback: (data: object) => void | Promise<void>): void
525
525
  pipe.off(event: string): void;
526
526
 
527
527
  // 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>>;
528
+ Replacements = Record<string, string | Array<string> | object | object[]> | ReplacerFn | AsyncReplaceFn;
529
+ parse_template(template: string, replacements: Replacements, drop_missing?: boolean): Promise<string>;
530
530
  get_git_hashes(length: number): Promise<Record<string, string>>;
531
531
 
532
532
  // database interface
@@ -1197,7 +1197,7 @@ server.websocket('/path/to/websocket', {
1197
1197
  For simpler projects, the scaffolding can often look the same, potentially something similar to below.
1198
1198
 
1199
1199
  ```ts
1200
- import { http_serve, cache_http, generate_hash_subs, parse_template, http_apply_range } from 'spooder';
1200
+ import { http_serve, cache_http, parse_template, http_apply_range, get_git_hashes } from 'spooder';
1201
1201
  import path from 'node:path';
1202
1202
 
1203
1203
  const server = http_serve(80);
@@ -1209,7 +1209,7 @@ const cache = cache_http({
1209
1209
  });
1210
1210
 
1211
1211
  const base_file = await Bun.file('./html/base_template.html').text();
1212
- const hash_table = await generate_hash_subs();
1212
+ const git_hash_table = await get_git_hashes();
1213
1213
 
1214
1214
  async function default_handler(status_code: number): Promise<Response> {
1215
1215
  const error_text = HTTP_STATUS_CODE[status_code] as string;
@@ -1239,10 +1239,12 @@ server.dir('/static', './static', async (file_path, file, stat, request) => {
1239
1239
  if (stat.isDirectory())
1240
1240
  return HTTP_STATUS_CODE.Unauthorized_401;
1241
1241
 
1242
- // cache busting
1242
+ // serve css/js files directly
1243
1243
  const ext = path.extname(file_path);
1244
1244
  if (ext === '.css' || ext === '.js') {
1245
- const content = await parse_template(await file.text(), hash_table, true);
1245
+ const content = await parse_template(await file.text(), {
1246
+ asset: (file) => git_hash_table[file]
1247
+ }, true);
1246
1248
 
1247
1249
  return new Response(content, {
1248
1250
  headers: {
@@ -1261,8 +1263,8 @@ function add_route(route: string, file: string, title: string) {
1261
1263
  const template = await parse_template(base_file, {
1262
1264
  title: title,
1263
1265
  content: file_content,
1264
- ...hash_table
1265
- }, false);
1266
+ asset: (file) => git_hash_table[file]
1267
+ }, true);
1266
1268
 
1267
1269
  return template;
1268
1270
  });
@@ -1300,7 +1302,7 @@ server.bootstrap({
1300
1302
  error_page: Bun.file('./html/error.html')
1301
1303
  },
1302
1304
 
1303
- hash_subs: true,
1305
+ cache_bust: true,
1304
1306
 
1305
1307
  static: {
1306
1308
  directory: './static',
@@ -1399,37 +1401,30 @@ cache: {
1399
1401
  }
1400
1402
  ```
1401
1403
 
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.
1404
+ ##### `cache_bust?: boolean`
1405
+ When enabled, automatically generates git hash-based cache busting for static assets using dynamic template resolvers.
1404
1406
 
1405
- **Boolean usage (uses defaults):**
1406
1407
  ```ts
1407
- hash_subs: true // Equivalent to { length: 7, prefix: 'hash=' }
1408
+ cache_bust: true
1408
1409
  ```
1409
1410
 
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
- ```
1411
+ With `cache_bust` enabled, you can use the `{{asset=filename}}` template function to generate cache-busted URLs:
1419
1412
 
1420
- **Examples:**
1413
+ **Usage:**
1421
1414
  ```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}}">
1415
+ cache_bust: true
1416
+ // Creates dynamic asset resolver: {{asset=static/css/style.css}} -> "static/css/style.css?v=a1b2c3d"
1417
+ ```
1426
1418
 
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}}">
1419
+ **Template usage:**
1420
+ ```html
1421
+ <link href="{{asset=static/css/style.css}}">
1422
+ <script src="{{asset=static/js/app.js}}"></script>
1423
+ <img src="{{asset=static/images/logo.png}}">
1431
1424
  ```
1432
1425
 
1426
+ The `asset` function automatically appends the git hash as a query parameter for cache busting, or returns the original filename if no hash is available.
1427
+
1433
1428
  ##### `error?: object`
1434
1429
  Optional error page configuration:
1435
1430
  - `error_page`: Template for error pages (string or BunFile)
@@ -1458,7 +1453,25 @@ static: {
1458
1453
  }
1459
1454
  ```
1460
1455
 
1461
- Files with extensions in `sub_ext` will have template substitutions applied before serving.
1456
+ Files with extensions in `sub_ext` will have template substitutions applied before serving. This includes support for functions to generate dynamic content:
1457
+
1458
+ ```ts
1459
+ // Dynamic CSS with function-based substitutions
1460
+ static: {
1461
+ route: '/assets',
1462
+ directory: './public',
1463
+ sub_ext: ['.css']
1464
+ },
1465
+
1466
+ global_subs: {
1467
+ theme_color: () => {
1468
+ const hour = new Date().getHours();
1469
+ return hour < 6 || hour > 18 ? '#2d3748' : '#4a5568';
1470
+ }
1471
+ }
1472
+ ```
1473
+
1474
+ This allows CSS files to use dynamic substitutions: `color: {{theme_color}};`
1462
1475
 
1463
1476
  ##### `global_subs?: Record<string, BootstrapSub>`
1464
1477
  Optional global template substitutions available to all routes, error pages, and static files with `sub_ext`.
@@ -1467,10 +1480,28 @@ Optional global template substitutions available to all routes, error pages, and
1467
1480
  global_subs: {
1468
1481
  site_name: 'My Website',
1469
1482
  version: '1.0.0',
1470
- api_url: 'https://api.example.com'
1483
+ api_url: 'https://api.example.com',
1484
+
1485
+ // Function-based substitutions for dynamic content
1486
+ current_year: () => new Date().getFullYear().toString(),
1487
+
1488
+ build_time: async () => {
1489
+ // Example: fetch build timestamp from git
1490
+ const process = Bun.spawn(['git', 'log', '-1', '--format=%ct']);
1491
+ const output = await Bun.readableStreamToText(process.stdout);
1492
+ return new Date(parseInt(output.trim()) * 1000).toISOString();
1493
+ },
1494
+
1495
+ user_count: async () => {
1496
+ // Example: dynamic user count from database
1497
+ const count = await db.count('SELECT COUNT(*) as count FROM users');
1498
+ return count.toLocaleString();
1499
+ }
1471
1500
  }
1472
1501
  ```
1473
1502
 
1503
+ 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.
1504
+
1474
1505
  #### Template Processing Order
1475
1506
 
1476
1507
  1. Route content is loaded
@@ -1829,29 +1860,60 @@ await parse_template(template, replacements, true);
1829
1860
  </html>
1830
1861
  ```
1831
1862
 
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.
1863
+ #### Custom Replacer Function
1864
+
1865
+ `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
1866
 
1834
1867
  ```ts
1835
- const replacer = (placeholder: string) => {
1836
- return placeholder.toUpperCase();
1868
+ const replacer = (key: string) => {
1869
+ switch (key) {
1870
+ case 'timestamp': return Date.now().toString();
1871
+ case 'random': return Math.random().toString(36).substring(7);
1872
+ case 'greeting': return 'Hello, World!';
1873
+ default: return undefined;
1874
+ }
1837
1875
  };
1838
1876
 
1839
- await parse_template('Hello {{world}}', replacer);
1877
+ await parse_template('Generated at {{timestamp}}: {{greeting}} (ID: {{random}})', replacer);
1878
+ // Result: "Generated at 1635789123456: Hello, World! (ID: x7k2p9m)"
1840
1879
  ```
1841
1880
 
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>
1881
+ Custom replacer functions are supported on a per-key basis, mixing with static string replacement.
1882
+
1883
+ ```ts
1884
+ await parse_template('Hello {{foo}}, it is {{now}}', {
1885
+ foo: 'world',
1886
+ now: () => Date.now()
1887
+ });
1888
+ ```
1889
+
1890
+ #### Key/Value Based Substitutions
1891
+
1892
+ `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.
1893
+
1894
+ ```ts
1895
+ await parse_template('Color: {{hex=blue}}', {
1896
+ hex: (color) => {
1897
+ const colors = { blue: '#0000ff', red: '#ff0000', green: '#00ff00' };
1898
+ return colors[color] || color;
1899
+ }
1900
+ });
1901
+ // Result: "Color: #0000ff"
1902
+ ```
1903
+
1904
+ Global replacer functions also support the value parameter:
1905
+
1906
+ ```ts
1907
+ await parse_template('Transform: {{upper=hello}} and {{lower=WORLD}}', (key, value) => {
1908
+ if (key === 'upper' && value) return value.toUpperCase();
1909
+ if (key === 'lower' && value) return value.toLowerCase();
1910
+ return 'unknown';
1911
+ });
1912
+ // Result: "Transform: HELLO and world"
1853
1913
  ```
1854
1914
 
1915
+ #### Conditional Rendering
1916
+
1855
1917
  `parse_template` supports conditional rendering with the following syntax.
1856
1918
 
1857
1919
  ```html
@@ -1940,72 +2002,10 @@ await parse_template(..., {
1940
2002
  </t-for>
1941
2003
  ```
1942
2004
 
1943
- ### 🔧 `generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>, format?: string): Promise<Record<string, string>>`
1944
-
1945
- Generate a replacement table for mapping file paths to hashes in templates. This is useful for cache-busting static assets.
1946
-
1947
- > [!IMPORTANT]
1948
- > Internally `generate_hash_subs()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
1949
-
1950
- ```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
- });
1958
- ```
1959
-
1960
- ```html
1961
- Hello world 754d9ea
1962
- ```
1963
-
1964
- > [!IMPORTANT]
1965
- > Specify paths as they appear in git, relative to the repository root and with forward slashes (no leading slash).
1966
-
1967
- By default hashes are truncated to `7` characters (a short hash), a custom length can be provided instead.
1968
-
1969
- ```ts
1970
- generate_hash_subs(40).then(...);
1971
- // d65c52a41a75db43e184d2268c6ea9f9741de63e
1972
- ```
1973
-
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.
1976
-
1977
- Use a different prefix other than `hash=` by passing it as the first parameter.
1978
-
1979
- ```ts
1980
- generate_hash_subs(7, '$#').then(subs => hash_sub_table = subs).catch(caution);
1981
-
1982
- server.route('/test', (req, url) => {
1983
- return parse_template('Hello world {{$#docs/project-logo.png}}', hash_sub_table);
1984
- });
1985
- ```
1986
-
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:
1990
-
1991
- ```ts
1992
- // Asset-style substitutions - reduces verbosity
1993
- const asset_subs = await generate_hash_subs(7, 'asset=', undefined, '$file?v=$hash');
1994
-
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
- ```
2003
-
2004
- When `format` is omitted, the default behavior returns just the hash value (backward compatible).
2005
2005
 
2006
2006
  ### 🔧 ``get_git_hashes(length: number): Promise<Record<string, string>>``
2007
2007
 
2008
- Internally, `generate_hash_subs()` uses `get_git_hashes()` to retrieve the hash table from git. This function is exposed for convenience.
2008
+ Retrieve git hashes for all files in the repository. This is useful for implementing cache-busting functionality or creating file integrity checks.
2009
2009
 
2010
2010
  > [!IMPORTANT]
2011
2011
  > Internally `get_git_hashes()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
@@ -2015,14 +2015,11 @@ const hashes = await get_git_hashes(7);
2015
2015
  // { 'docs/project-logo.png': '754d9ea' }
2016
2016
  ```
2017
2017
 
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.
2018
+ You can specify the hash length (default is 7 characters for short hashes):
2019
2019
 
2020
2020
  ```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' }
2021
+ const full_hashes = await get_git_hashes(40);
2022
+ // { 'docs/project-logo.png': 'd65c52a41a75db43e184d2268c6ea9f9741de63e' }
2026
2023
  ```
2027
2024
 
2028
2025
  <a id="api-database"></a>
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.0.3",
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
 
@@ -549,25 +581,6 @@ export async function get_git_hashes(length = 7): Promise<Record<string, string>
549
581
  return hash_map;
550
582
  }
551
583
 
552
- export async function generate_hash_subs(length = 7, prefix = 'hash=', hashes?: Record<string, string>, format?: string): Promise<Record<string, string>> {
553
- const hash_map: Record<string, string> = {};
554
-
555
- if (!hashes)
556
- hashes = await get_git_hashes(length);
557
-
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
- }
568
-
569
- return hash_map;
570
- }
571
584
  // endregion
572
585
 
573
586
  // region serving
@@ -984,7 +997,7 @@ type WebsocketHandlers = {
984
997
  drain?: (ws: WebSocket) => void
985
998
  };
986
999
 
987
- type BootstrapSub = string | string[];
1000
+ type BootstrapSub = ReplacementValue;
988
1001
 
989
1002
  type BootstrapRoute = {
990
1003
  content: string | BunFile;
@@ -995,12 +1008,7 @@ type BootstrapOptions = {
995
1008
  base?: string | BunFile;
996
1009
  routes: Record<string, BootstrapRoute>;
997
1010
  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
- };
1011
+ cache_bust?: boolean;
1004
1012
  error?: {
1005
1013
  use_canary_reporting?: boolean;
1006
1014
  error_page: string | BunFile;
@@ -1420,18 +1428,20 @@ export function http_serve(port: number, hostname?: string) {
1420
1428
 
1421
1429
  /* Bootstrap a static web server */
1422
1430
  bootstrap: async function(options: BootstrapOptions) {
1423
- let hash_sub_table = {};
1431
+ let git_hash_table: Record<string, string> = {};
1432
+ let cache_bust_subs = {};
1424
1433
 
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
- }
1434
+ if (options.cache_bust) {
1435
+ git_hash_table = await get_git_hashes();
1436
+ cache_bust_subs = {
1437
+ asset: (file: string) => {
1438
+ const hash = git_hash_table[file];
1439
+ return hash ? `${file}?v=${hash}` : file;
1440
+ }
1441
+ };
1432
1442
  }
1433
1443
 
1434
- const global_sub_table = sub_table_merge(hash_sub_table, options.global_subs);
1444
+ const global_sub_table = sub_table_merge(cache_bust_subs, options.global_subs);
1435
1445
 
1436
1446
  let cache = options.cache;
1437
1447
  if (cache !== undefined && !is_cache_http(cache))