spooder 5.0.1 → 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 +226 -73
  2. package/package.json +1 -1
  3. package/src/api.ts +53 -20
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>): 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
  });
@@ -1341,6 +1343,172 @@ server.bootstrap({
1341
1343
  });
1342
1344
  ```
1343
1345
 
1346
+ #### Bootstrap Options
1347
+
1348
+ The `BootstrapOptions` object accepts the following properties:
1349
+
1350
+ ##### `base?: string | BunFile`
1351
+ Optional base template that wraps all route content. The base template should include `{{content}}` where the route content will be inserted.
1352
+
1353
+ ```ts
1354
+ // Base template: base.html
1355
+ <html>
1356
+ <head><title>{{title}}</title></head>
1357
+ <body>{{content}}</body>
1358
+ </html>
1359
+
1360
+ // Usage
1361
+ server.bootstrap({
1362
+ base: Bun.file('./templates/base.html'),
1363
+ routes: {
1364
+ '/': {
1365
+ content: '<h1>Welcome</h1>',
1366
+ subs: { title: 'Home' }
1367
+ }
1368
+ }
1369
+ });
1370
+ ```
1371
+
1372
+ ##### `routes: Record<string, BootstrapRoute>`
1373
+ **Required.** Defines the routes and their content. Each route can have:
1374
+ - `content`: The page content (string or BunFile)
1375
+ - `subs?`: Template substitutions specific to this route
1376
+
1377
+ ```ts
1378
+ routes: {
1379
+ '/about': {
1380
+ content: Bun.file('./pages/about.html'),
1381
+ subs: {
1382
+ title: 'About Us',
1383
+ description: 'Learn more about our company'
1384
+ }
1385
+ }
1386
+ }
1387
+ ```
1388
+
1389
+ ##### `cache?: CacheOptions | ReturnType<typeof cache_http>`
1390
+ Optional HTTP caching configuration. Can be:
1391
+ - A `CacheOptions` object (creates new cache instance)
1392
+ - An existing cache instance from `cache_http()`
1393
+ - Omitted to disable caching
1394
+
1395
+ ```ts
1396
+ cache: {
1397
+ ttl: 5 * 60 * 1000, // 5 minutes
1398
+ max_size: 10 * 1024 * 1024, // 10 MB
1399
+ use_etags: true,
1400
+ use_canary_reporting: true
1401
+ }
1402
+ ```
1403
+
1404
+ ##### `cache_bust?: boolean`
1405
+ When enabled, automatically generates git hash-based cache busting for static assets using dynamic template resolvers.
1406
+
1407
+ ```ts
1408
+ cache_bust: true
1409
+ ```
1410
+
1411
+ With `cache_bust` enabled, you can use the `{{asset=filename}}` template function to generate cache-busted URLs:
1412
+
1413
+ **Usage:**
1414
+ ```ts
1415
+ cache_bust: true
1416
+ // Creates dynamic asset resolver: {{asset=static/css/style.css}} -> "static/css/style.css?v=a1b2c3d"
1417
+ ```
1418
+
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}}">
1424
+ ```
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
+
1428
+ ##### `error?: object`
1429
+ Optional error page configuration:
1430
+ - `error_page`: Template for error pages (string or BunFile)
1431
+ - `use_canary_reporting?`: Whether to report errors via canary
1432
+
1433
+ Error templates receive `{{error_code}}` and `{{error_text}}` substitutions.
1434
+
1435
+ ```ts
1436
+ error: {
1437
+ error_page: Bun.file('./templates/error.html'),
1438
+ use_canary_reporting: true
1439
+ }
1440
+ ```
1441
+
1442
+ ##### `static?: object`
1443
+ Optional static file serving configuration:
1444
+ - `route`: URL path prefix for static files
1445
+ - `directory`: Local directory containing static files
1446
+ - `sub_ext?`: Array of file extensions that should have template substitution applied
1447
+
1448
+ ```ts
1449
+ static: {
1450
+ route: '/assets',
1451
+ directory: './public',
1452
+ sub_ext: ['.css', '.js'] // These files get template processing
1453
+ }
1454
+ ```
1455
+
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}};`
1475
+
1476
+ ##### `global_subs?: Record<string, BootstrapSub>`
1477
+ Optional global template substitutions available to all routes, error pages, and static files with `sub_ext`.
1478
+
1479
+ ```ts
1480
+ global_subs: {
1481
+ site_name: 'My Website',
1482
+ version: '1.0.0',
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
+ }
1500
+ }
1501
+ ```
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
+
1505
+ #### Template Processing Order
1506
+
1507
+ 1. Route content is loaded
1508
+ 2. If `base` is defined, content is wrapped using `{{content}}` substitution
1509
+ 3. Route-specific `subs` and `global_subs` are applied
1510
+ 4. Hash substitutions (if enabled) are applied
1511
+
1344
1512
  <a id="api-error-handling"></a>
1345
1513
  ## API > Error Handling
1346
1514
 
@@ -1692,29 +1860,60 @@ await parse_template(template, replacements, true);
1692
1860
  </html>
1693
1861
  ```
1694
1862
 
1695
- `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.
1696
1866
 
1697
1867
  ```ts
1698
- const replacer = (placeholder: string) => {
1699
- 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
+ }
1700
1875
  };
1701
1876
 
1702
- 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)"
1703
1879
  ```
1704
1880
 
1705
- ```html
1706
- <html>
1707
- <head>
1708
- <title>TITLE</title>
1709
- </head>
1710
- <body>
1711
- <h1>TITLE</h1>
1712
- <p>CONTENT</p>
1713
- <p>IGNORED</p>
1714
- </body>
1715
- </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"
1716
1913
  ```
1717
1914
 
1915
+ #### Conditional Rendering
1916
+
1718
1917
  `parse_template` supports conditional rendering with the following syntax.
1719
1918
 
1720
1919
  ```html
@@ -1803,53 +2002,10 @@ await parse_template(..., {
1803
2002
  </t-for>
1804
2003
  ```
1805
2004
 
1806
- ### 🔧 `generate_hash_subs(length: number, prefix: string, hashes?: Record<string, string>): Promise<Record<string, string>>`
1807
-
1808
- Generate a replacement table for mapping file paths to hashes in templates. This is useful for cache-busting static assets.
1809
-
1810
- > [!IMPORTANT]
1811
- > Internally `generate_hash_subs()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
1812
-
1813
- ```ts
1814
- let hash_sub_table = {};
1815
-
1816
- generate_hash_subs().then(subs => hash_sub_table = subs).catch(caution);
1817
-
1818
- server.route('/test', (req, url) => {
1819
- return parse_template('Hello world {{hash=docs/project-logo.png}}', hash_sub_table);
1820
- });
1821
- ```
1822
-
1823
- ```html
1824
- Hello world 754d9ea
1825
- ```
1826
-
1827
- > [!IMPORTANT]
1828
- > Specify paths as they appear in git, relative to the repository root and with forward slashes (no leading slash).
1829
-
1830
- By default hashes are truncated to `7` characters (a short hash), a custom length can be provided instead.
1831
-
1832
- ```ts
1833
- generate_hash_subs(40).then(...);
1834
- // d65c52a41a75db43e184d2268c6ea9f9741de63e
1835
- ```
1836
-
1837
- > [!NOTE]
1838
- > 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.
1839
-
1840
- Use a different prefix other than `hash=` by passing it as the first parameter.
1841
-
1842
- ```ts
1843
- generate_hash_subs(7, '$#').then(subs => hash_sub_table = subs).catch(caution);
1844
-
1845
- server.route('/test', (req, url) => {
1846
- return parse_template('Hello world {{$#docs/project-logo.png}}', hash_sub_table);
1847
- });
1848
- ```
1849
2005
 
1850
2006
  ### 🔧 ``get_git_hashes(length: number): Promise<Record<string, string>>``
1851
2007
 
1852
- 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.
1853
2009
 
1854
2010
  > [!IMPORTANT]
1855
2011
  > Internally `get_git_hashes()` uses `git ls-tree -r HEAD`, so the working directory must be a git repository.
@@ -1859,14 +2015,11 @@ const hashes = await get_git_hashes(7);
1859
2015
  // { 'docs/project-logo.png': '754d9ea' }
1860
2016
  ```
1861
2017
 
1862
- 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):
1863
2019
 
1864
2020
  ```ts
1865
- const hashes = await get_git_hashes(7);
1866
- const subs = await generate_hash_subs(7, undefined, hashes);
1867
-
1868
- // hashes[0] -> { 'docs/project-logo.png': '754d9ea' }
1869
- // subs[0] -> { 'hash=docs/project-logo.png': '754d9ea' }
2021
+ const full_hashes = await get_git_hashes(40);
2022
+ // { 'docs/project-logo.png': 'd65c52a41a75db43e184d2268c6ea9f9741de63e' }
1870
2023
  ```
1871
2024
 
1872
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.1",
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,17 +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>): 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
- hash_map[prefix + file] = hash;
560
-
561
- return hash_map;
562
- }
563
584
  // endregion
564
585
 
565
586
  // region serving
@@ -976,7 +997,7 @@ type WebsocketHandlers = {
976
997
  drain?: (ws: WebSocket) => void
977
998
  };
978
999
 
979
- type BootstrapSub = string | string[];
1000
+ type BootstrapSub = ReplacementValue;
980
1001
 
981
1002
  type BootstrapRoute = {
982
1003
  content: string | BunFile;
@@ -1407,8 +1428,20 @@ export function http_serve(port: number, hostname?: string) {
1407
1428
 
1408
1429
  /* Bootstrap a static web server */
1409
1430
  bootstrap: async function(options: BootstrapOptions) {
1410
- const hash_sub_table = options.cache_bust ? await generate_hash_subs() : {};
1411
- const global_sub_table = sub_table_merge(hash_sub_table, options.global_subs);
1431
+ let git_hash_table: Record<string, string> = {};
1432
+ let cache_bust_subs = {};
1433
+
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
+ };
1442
+ }
1443
+
1444
+ const global_sub_table = sub_table_merge(cache_bust_subs, options.global_subs);
1412
1445
 
1413
1446
  let cache = options.cache;
1414
1447
  if (cache !== undefined && !is_cache_http(cache))