@terrymooreii/sia 2.1.5 → 2.1.7

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.
@@ -0,0 +1,581 @@
1
+ # Template Reference
2
+
3
+ Sia uses [Nunjucks](https://mozilla.github.io/nunjucks/) as its templating engine. This guide covers all available variables and custom filters.
4
+
5
+ ## Table of Contents
6
+
7
+ - [Global Variables](#global-variables)
8
+ - [Page Context Variables](#page-context-variables)
9
+ - [Pagination Variables](#pagination-variables)
10
+ - [Tag Page Variables](#tag-page-variables)
11
+ - [Listing Page Variables](#listing-page-variables)
12
+ - [Custom Filters](#custom-filters)
13
+ - [Nunjucks Basics](#nunjucks-basics)
14
+
15
+ ---
16
+
17
+ ## Global Variables
18
+
19
+ These variables are available in all templates.
20
+
21
+ ### `site`
22
+
23
+ Site configuration from `_config.yml`:
24
+
25
+ | Property | Type | Description |
26
+ |----------|------|-------------|
27
+ | `site.title` | string | Site title |
28
+ | `site.description` | string | Site description |
29
+ | `site.url` | string | Full site URL (e.g., `https://example.com`) |
30
+ | `site.author` | string | Default author name |
31
+ | `site.basePath` | string | URL path prefix (extracted from `site.url`) |
32
+
33
+ **Example:**
34
+
35
+ ```nunjucks
36
+ <title>{{ site.title }}</title>
37
+ <meta name="description" content="{{ site.description }}">
38
+ <link rel="canonical" href="{{ site.url }}{{ page.url }}">
39
+ ```
40
+
41
+ ### `config`
42
+
43
+ The full configuration object including all settings:
44
+
45
+ | Property | Type | Description |
46
+ |----------|------|-------------|
47
+ | `config.theme` | object | Theme configuration object |
48
+ | `config.theme.name` | string | Current theme name |
49
+ | `config.input` | string | Input directory name |
50
+ | `config.output` | string | Output directory name |
51
+ | `config.collections` | object | Collection configurations |
52
+ | `config.pagination.size` | number | Items per page |
53
+ | `config.server.port` | number | Dev server port |
54
+ | `config.server.showDrafts` | boolean | Show drafts in dev mode |
55
+
56
+ ### `collections`
57
+
58
+ Object containing all content collections:
59
+
60
+ | Property | Type | Description |
61
+ |----------|------|-------------|
62
+ | `collections.posts` | array | All blog posts |
63
+ | `collections.pages` | array | All static pages |
64
+ | `collections.notes` | array | All notes |
65
+
66
+ **Example:**
67
+
68
+ ```nunjucks
69
+ {% for post in collections.posts | limit(5) %}
70
+ <article>
71
+ <h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
72
+ <time>{{ post.date | date('long') }}</time>
73
+ </article>
74
+ {% endfor %}
75
+ ```
76
+
77
+ ### `tags`
78
+
79
+ Object containing tag data, keyed by normalized tag name:
80
+
81
+ ```nunjucks
82
+ {% for tagName, tagData in tags %}
83
+ <a href="/tags/{{ tagData.slug }}/">
84
+ {{ tagData.name }} ({{ tagData.count }})
85
+ </a>
86
+ {% endfor %}
87
+ ```
88
+
89
+ Each tag object contains:
90
+
91
+ | Property | Type | Description |
92
+ |----------|------|-------------|
93
+ | `name` | string | Original tag name |
94
+ | `slug` | string | URL-friendly slug |
95
+ | `items` | array | Content items with this tag |
96
+ | `count` | number | Number of items |
97
+
98
+ ### `allTags`
99
+
100
+ Array of all tags sorted by count (most used first):
101
+
102
+ ```nunjucks
103
+ <ul class="tag-cloud">
104
+ {% for tag in allTags %}
105
+ <li><a href="/tags/{{ tag.slug }}/">{{ tag.name }}</a></li>
106
+ {% endfor %}
107
+ </ul>
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Page Context Variables
113
+
114
+ These variables are available when rendering individual content items (posts, pages, notes).
115
+
116
+ ### `page`
117
+
118
+ The current content item being rendered:
119
+
120
+ | Property | Type | Description |
121
+ |----------|------|-------------|
122
+ | `page.title` | string | Content title |
123
+ | `page.date` | Date | Publication date |
124
+ | `page.tags` | array | Array of tag strings |
125
+ | `page.content` | string | Rendered HTML content |
126
+ | `page.excerpt` | string | Plain text excerpt |
127
+ | `page.excerptHtml` | string | HTML-rendered excerpt |
128
+ | `page.slug` | string | URL slug |
129
+ | `page.url` | string | Full URL path (with basePath) |
130
+ | `page.draft` | boolean | Draft status |
131
+ | `page.layout` | string | Layout template name |
132
+ | `page.collection` | string | Collection name (`posts`, `pages`, `notes`) |
133
+ | `page.image` | string | Featured image path |
134
+ | `page.author` | string | Author (overrides site author) |
135
+ | `page.description` | string | Meta description |
136
+ | `page.rawContent` | string | Original markdown content |
137
+ | `page.filePath` | string | Source file path |
138
+ | `page.outputPath` | string | Output file path |
139
+
140
+ **Example:**
141
+
142
+ ```nunjucks
143
+ <article>
144
+ <h1>{{ page.title }}</h1>
145
+ <time datetime="{{ page.date | date('iso') }}">
146
+ {{ page.date | date('long') }}
147
+ </time>
148
+
149
+ {% if page.tags and page.tags.length %}
150
+ <div class="tags">
151
+ {% for tag in page.tags %}
152
+ <a href="/tags/{{ tag | slug }}/">{{ tag }}</a>
153
+ {% endfor %}
154
+ </div>
155
+ {% endif %}
156
+
157
+ <div class="content">
158
+ {{ content | safe }}
159
+ </div>
160
+ </article>
161
+ ```
162
+
163
+ ### `content`
164
+
165
+ The rendered HTML content of the current page. Always use with the `safe` filter:
166
+
167
+ ```nunjucks
168
+ <div class="prose">
169
+ {{ content | safe }}
170
+ </div>
171
+ ```
172
+
173
+ ### `title`
174
+
175
+ The page title, passed separately for convenience:
176
+
177
+ ```nunjucks
178
+ <title>{% if title %}{{ title }} | {% endif %}{{ site.title }}</title>
179
+ ```
180
+
181
+ ---
182
+
183
+ ## Pagination Variables
184
+
185
+ Available on paginated listing pages (blog, notes, tags).
186
+
187
+ ### `pagination`
188
+
189
+ | Property | Type | Description |
190
+ |----------|------|-------------|
191
+ | `pagination.pageNumber` | number | Current page (1-indexed) |
192
+ | `pagination.totalPages` | number | Total number of pages |
193
+ | `pagination.totalItems` | number | Total items across all pages |
194
+ | `pagination.isFirst` | boolean | Is this the first page? |
195
+ | `pagination.isLast` | boolean | Is this the last page? |
196
+ | `pagination.previousPage` | number/null | Previous page number |
197
+ | `pagination.nextPage` | number/null | Next page number |
198
+ | `pagination.previousUrl` | string/null | URL to previous page |
199
+ | `pagination.nextUrl` | string/null | URL to next page |
200
+ | `pagination.url` | string | Current page URL |
201
+ | `pagination.items` | array | Items on current page |
202
+ | `pagination.startIndex` | number | Start index of items |
203
+ | `pagination.endIndex` | number | End index of items |
204
+
205
+ **Example:**
206
+
207
+ ```nunjucks
208
+ {% if pagination and pagination.totalPages > 1 %}
209
+ <nav class="pagination">
210
+ <span>Page {{ pagination.pageNumber }} of {{ pagination.totalPages }}</span>
211
+
212
+ {% if pagination.previousUrl %}
213
+ <a href="{{ pagination.previousUrl }}">← Newer</a>
214
+ {% endif %}
215
+
216
+ {% if pagination.nextUrl %}
217
+ <a href="{{ pagination.nextUrl }}">Older →</a>
218
+ {% endif %}
219
+ </nav>
220
+ {% endif %}
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Tag Page Variables
226
+
227
+ Available on individual tag pages (`/tags/:slug/`).
228
+
229
+ ### `tag`
230
+
231
+ The current tag being displayed:
232
+
233
+ | Property | Type | Description |
234
+ |----------|------|-------------|
235
+ | `tag.name` | string | Original tag name |
236
+ | `tag.slug` | string | URL-friendly slug |
237
+ | `tag.items` | array | All items with this tag |
238
+ | `tag.count` | number | Number of items |
239
+
240
+ **Example:**
241
+
242
+ ```nunjucks
243
+ <h1>Tagged: {{ tag.name }}</h1>
244
+ <p>{{ tag.count }} item{% if tag.count != 1 %}s{% endif %}</p>
245
+
246
+ {% for post in posts %}
247
+ <article>
248
+ <a href="{{ post.url }}">{{ post.title }}</a>
249
+ </article>
250
+ {% endfor %}
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Listing Page Variables
256
+
257
+ ### Blog Listing (`/blog/`)
258
+
259
+ | Variable | Type | Description |
260
+ |----------|------|-------------|
261
+ | `posts` | array | Posts for current page |
262
+ | `pagination` | object | Pagination data |
263
+
264
+ ### Notes Listing (`/notes/`)
265
+
266
+ | Variable | Type | Description |
267
+ |----------|------|-------------|
268
+ | `notes` | array | Notes for current page |
269
+ | `pagination` | object | Pagination data |
270
+
271
+ ### Tags Listing (`/tags/`)
272
+
273
+ | Variable | Type | Description |
274
+ |----------|------|-------------|
275
+ | `allTags` | array | All tags sorted by count |
276
+ | `tags` | object | Tag data keyed by slug |
277
+
278
+ ---
279
+
280
+ ## Custom Filters
281
+
282
+ Sia provides these custom Nunjucks filters:
283
+
284
+ ### `date(format)`
285
+
286
+ Format a date. Supports a special `'now'` keyword to get the current date.
287
+
288
+ **Formats:**
289
+
290
+ | Format | Output Example |
291
+ |--------|----------------|
292
+ | `'short'` | Dec 17, 2024 |
293
+ | `'long'` | December 17, 2024 |
294
+ | `'iso'` | 2024-12-17 |
295
+ | `'rss'` | Tue, 17 Dec 2024 00:00:00 GMT |
296
+ | `'year'` | 2024 |
297
+ | `'month'` | December 2024 |
298
+ | `'time'` | 3:45 PM |
299
+ | `'full'` | Tuesday, December 17, 2024 |
300
+ | `'full_time'` | Tue, Dec 17, 2024, 3:45 PM |
301
+
302
+ **Examples:**
303
+
304
+ ```nunjucks
305
+ {{ page.date | date('long') }}
306
+ <!-- December 17, 2024 -->
307
+
308
+ {{ page.date | date('iso') }}
309
+ <!-- 2024-12-17 -->
310
+
311
+ <time datetime="{{ page.date | date('iso') }}">
312
+ {{ page.date | date('full') }}
313
+ </time>
314
+
315
+ <!-- Current year -->
316
+ © {{ 'now' | date('year') }}
317
+ ```
318
+
319
+ ### `slug`
320
+
321
+ Generate a URL-friendly slug from a string:
322
+
323
+ ```nunjucks
324
+ {{ "Hello World!" | slug }}
325
+ <!-- hello-world -->
326
+
327
+ <a href="/tags/{{ tag | slug }}/">{{ tag }}</a>
328
+ ```
329
+
330
+ ### `excerpt(length)`
331
+
332
+ Extract an excerpt from HTML content. Default length is 200 characters.
333
+
334
+ ```nunjucks
335
+ {{ post.content | excerpt }}
336
+ <!-- First 200 characters... -->
337
+
338
+ {{ post.content | excerpt(100) }}
339
+ <!-- First 100 characters... -->
340
+ ```
341
+
342
+ ### `limit(count)`
343
+
344
+ Limit an array to the first N items:
345
+
346
+ ```nunjucks
347
+ {% for post in collections.posts | limit(5) %}
348
+ <!-- First 5 posts -->
349
+ {% endfor %}
350
+ ```
351
+
352
+ ### `skip(count)`
353
+
354
+ Skip the first N items in an array:
355
+
356
+ ```nunjucks
357
+ {% for post in collections.posts | skip(3) %}
358
+ <!-- All posts except first 3 -->
359
+ {% endfor %}
360
+
361
+ {% for post in collections.posts | skip(5) | limit(5) %}
362
+ <!-- Posts 6-10 -->
363
+ {% endfor %}
364
+ ```
365
+
366
+ ### `wordCount`
367
+
368
+ Get the word count of content:
369
+
370
+ ```nunjucks
371
+ {{ post.content | wordCount }} words
372
+ <!-- 1234 words -->
373
+ ```
374
+
375
+ ### `readingTime(wordsPerMinute)`
376
+
377
+ Estimate reading time. Default is 200 words per minute.
378
+
379
+ ```nunjucks
380
+ {{ post.content | readingTime }}
381
+ <!-- 5 min read -->
382
+
383
+ {{ post.content | readingTime(250) }}
384
+ <!-- 4 min read -->
385
+ ```
386
+
387
+ ### `groupBy(key)`
388
+
389
+ Group an array of objects by a property:
390
+
391
+ ```nunjucks
392
+ {% set postsByYear = collections.posts | groupBy('year') %}
393
+ {% for year, posts in postsByYear %}
394
+ <h2>{{ year }}</h2>
395
+ {% for post in posts %}
396
+ <a href="{{ post.url }}">{{ post.title }}</a>
397
+ {% endfor %}
398
+ {% endfor %}
399
+ ```
400
+
401
+ ### `sortBy(key, order)`
402
+
403
+ Sort an array by a property. Order can be `'asc'` (default) or `'desc'`.
404
+
405
+ ```nunjucks
406
+ {% for post in collections.posts | sortBy('title', 'asc') %}
407
+ <!-- Posts sorted alphabetically by title -->
408
+ {% endfor %}
409
+
410
+ {% for post in collections.posts | sortBy('date', 'desc') %}
411
+ <!-- Posts sorted newest first -->
412
+ {% endfor %}
413
+ ```
414
+
415
+ ### `where(key, value)`
416
+
417
+ Filter items where a property equals a value:
418
+
419
+ ```nunjucks
420
+ {% for post in collections.posts | where('draft', false) %}
421
+ <!-- Only non-draft posts -->
422
+ {% endfor %}
423
+
424
+ {% for post in collections.posts | where('author', 'John') %}
425
+ <!-- Only posts by John -->
426
+ {% endfor %}
427
+ ```
428
+
429
+ ### `withTag(tag)`
430
+
431
+ Filter items that have a specific tag:
432
+
433
+ ```nunjucks
434
+ {% for post in collections.posts | withTag('javascript') %}
435
+ <!-- Only posts tagged with 'javascript' -->
436
+ {% endfor %}
437
+ ```
438
+
439
+ ### `json(spaces)`
440
+
441
+ Convert an object to JSON string. Useful for debugging.
442
+
443
+ ```nunjucks
444
+ <script>
445
+ const data = {{ page | json | safe }};
446
+ </script>
447
+
448
+ <!-- Pretty printed with 2 spaces -->
449
+ <pre>{{ page | json(2) }}</pre>
450
+ ```
451
+
452
+ ### `url`
453
+
454
+ Prepend the site's basePath to a URL. Essential for sites hosted in subdirectories.
455
+
456
+ ```nunjucks
457
+ <a href="{{ '/' | url }}">Home</a>
458
+ <link rel="stylesheet" href="{{ '/styles/main.css' | url }}">
459
+ <a href="{{ '/blog/' | url }}">Blog</a>
460
+ ```
461
+
462
+ If your site is at `https://example.com/blog/`, the `url` filter ensures paths work correctly.
463
+
464
+ ---
465
+
466
+ ## Nunjucks Basics
467
+
468
+ ### Variables
469
+
470
+ ```nunjucks
471
+ {{ variable }}
472
+ {{ object.property }}
473
+ {{ array[0] }}
474
+ ```
475
+
476
+ ### Conditionals
477
+
478
+ ```nunjucks
479
+ {% if condition %}
480
+ <!-- content -->
481
+ {% elif otherCondition %}
482
+ <!-- content -->
483
+ {% else %}
484
+ <!-- content -->
485
+ {% endif %}
486
+ ```
487
+
488
+ ### Loops
489
+
490
+ ```nunjucks
491
+ {% for item in items %}
492
+ {{ item }}
493
+ {{ loop.index }} <!-- 1-indexed -->
494
+ {{ loop.index0 }} <!-- 0-indexed -->
495
+ {{ loop.first }} <!-- true on first iteration -->
496
+ {{ loop.last }} <!-- true on last iteration -->
497
+ {{ loop.length }} <!-- total items -->
498
+ {% else %}
499
+ <!-- if items is empty -->
500
+ {% endfor %}
501
+ ```
502
+
503
+ ### Template Inheritance
504
+
505
+ **Base template (`base.njk`):**
506
+
507
+ ```nunjucks
508
+ <!DOCTYPE html>
509
+ <html>
510
+ <head>
511
+ {% block head %}{% endblock %}
512
+ </head>
513
+ <body>
514
+ {% block content %}{% endblock %}
515
+ </body>
516
+ </html>
517
+ ```
518
+
519
+ **Child template:**
520
+
521
+ ```nunjucks
522
+ {% extends "base.njk" %}
523
+
524
+ {% block head %}
525
+ <title>{{ title }}</title>
526
+ {% endblock %}
527
+
528
+ {% block content %}
529
+ <h1>{{ page.title }}</h1>
530
+ {{ content | safe }}
531
+ {% endblock %}
532
+ ```
533
+
534
+ ### Includes
535
+
536
+ ```nunjucks
537
+ {% include "header.njk" %}
538
+ {% include "footer.njk" %}
539
+ ```
540
+
541
+ ### Comments
542
+
543
+ ```nunjucks
544
+ {# This is a comment that won't appear in output #}
545
+ ```
546
+
547
+ ### Safe Output
548
+
549
+ Use `safe` to output HTML without escaping:
550
+
551
+ ```nunjucks
552
+ {{ content | safe }}
553
+ ```
554
+
555
+ ### Setting Variables
556
+
557
+ ```nunjucks
558
+ {% set myVar = "value" %}
559
+ {% set myArray = [1, 2, 3] %}
560
+ {% set myObj = { key: "value" } %}
561
+ ```
562
+
563
+ ### Macros
564
+
565
+ Create reusable template functions:
566
+
567
+ ```nunjucks
568
+ {% macro postCard(post) %}
569
+ <article class="post-card">
570
+ <h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
571
+ <time>{{ post.date | date('short') }}</time>
572
+ </article>
573
+ {% endmacro %}
574
+
575
+ <!-- Use the macro -->
576
+ {% for post in posts %}
577
+ {{ postCard(post) }}
578
+ {% endfor %}
579
+ ```
580
+
581
+ For more Nunjucks features, see the [official documentation](https://mozilla.github.io/nunjucks/templating.html).
package/lib/assets.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  rmdirSync
11
11
  } from 'fs';
12
12
  import { join, dirname, relative, extname } from 'path';
13
+ import { resolveTheme, getBuiltInThemesDir } from './theme-resolver.js';
13
14
 
14
15
  // Supported asset extensions
15
16
  const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', '.ico', '.avif'];
@@ -126,8 +127,11 @@ function hasCssFiles(dir) {
126
127
 
127
128
  /**
128
129
  * Copy default styles to output
130
+ *
131
+ * @param {object} config - Site configuration
132
+ * @param {object} [resolvedTheme] - Pre-resolved theme info from resolveTheme()
129
133
  */
130
- export function copyDefaultStyles(config, themesDir) {
134
+ export function copyDefaultStyles(config, resolvedTheme = null) {
131
135
  const outputStylesDir = join(config.outputDir, 'styles');
132
136
 
133
137
  // Check if user has custom styles (must actually have CSS files)
@@ -140,20 +144,23 @@ export function copyDefaultStyles(config, themesDir) {
140
144
  return copied;
141
145
  }
142
146
 
143
- // Copy styles from the selected theme
144
- const themeName = config.theme || 'main';
145
- const themeStylesDir = join(themesDir, themeName, 'styles');
147
+ // Resolve theme if not already resolved
148
+ const themeName = config.theme?.name || 'main';
149
+ const theme = resolvedTheme || resolveTheme(themeName, config.rootDir);
150
+ const themeStylesDir = join(theme.themeDir, 'styles');
146
151
 
147
152
  if (existsSync(themeStylesDir)) {
148
153
  const copied = copyAssets(themeStylesDir, outputStylesDir);
149
- console.log(`🎨 Using "${themeName}" theme`);
154
+ if (!theme.isExternal) {
155
+ console.log(`🎨 Using "${theme.themeName}" theme`);
156
+ }
150
157
  return copied;
151
158
  }
152
159
 
153
- // Fallback to main theme if selected theme not found
154
- const fallbackStylesDir = join(themesDir, 'main', 'styles');
160
+ // Fallback to main theme if theme styles not found
161
+ const fallbackStylesDir = join(getBuiltInThemesDir(), 'main', 'styles');
155
162
  if (existsSync(fallbackStylesDir)) {
156
- console.log(`⚠️ Theme "${themeName}" not found, using "main" theme`);
163
+ console.log(`⚠️ Theme "${themeName}" styles not found, using "main" theme styles`);
157
164
  const copied = copyAssets(fallbackStylesDir, outputStylesDir);
158
165
  return copied;
159
166
  }
package/lib/build.js CHANGED
@@ -5,10 +5,10 @@ import { loadConfig } from './config.js';
5
5
  import { buildSiteData, paginate, getPaginationUrls } from './collections.js';
6
6
  import { createTemplateEngine, renderTemplate } from './templates.js';
7
7
  import { copyImages, copyDefaultStyles, copyStaticAssets, writeFile, ensureDir } from './assets.js';
8
+ import { resolveTheme } from './theme-resolver.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
10
11
  const __dirname = dirname(__filename);
11
- const themesDir = join(__dirname, '..', 'themes');
12
12
 
13
13
  /**
14
14
  * Clean the output directory
@@ -215,11 +215,15 @@ export async function build(options = {}) {
215
215
  cleanOutput(config);
216
216
  }
217
217
 
218
+ // Resolve theme once for both templates and assets
219
+ const themeName = config.theme?.name || 'main';
220
+ const resolvedTheme = resolveTheme(themeName, config.rootDir);
221
+
218
222
  // Build site data (collections, tags, etc.)
219
223
  const siteData = buildSiteData(config);
220
224
 
221
- // Create template engine
222
- const env = createTemplateEngine(config);
225
+ // Create template engine with resolved theme
226
+ const env = createTemplateEngine(config, resolvedTheme);
223
227
 
224
228
  // Render all content items
225
229
  let itemCount = 0;
@@ -244,7 +248,7 @@ export async function build(options = {}) {
244
248
 
245
249
  // Copy assets
246
250
  copyImages(config);
247
- copyDefaultStyles(config, themesDir);
251
+ copyDefaultStyles(config, resolvedTheme);
248
252
  copyStaticAssets(config);
249
253
 
250
254
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
package/lib/config.js CHANGED
@@ -10,7 +10,9 @@ const defaultConfig = {
10
10
  url: 'http://localhost:3000',
11
11
  author: 'Anonymous'
12
12
  },
13
- theme: 'main', // Theme to use: 'main' or 'minimal'
13
+ theme: {
14
+ name: 'main' // Theme to use: 'main', 'minimal', 'developer', or 'magazine'
15
+ },
14
16
  input: 'src',
15
17
  output: 'dist',
16
18
  layouts: '_layouts',