miki-template 2.0.1 → 2.2.2

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 (71) hide show
  1. package/.github/workflows/ci.yml +13 -37
  2. package/.github/workflows/docs.yml +105 -0
  3. package/.github/workflows/npm-publish-github-packages.yml +36 -0
  4. package/README.md +69 -14
  5. package/assets/logo.png +0 -0
  6. package/benchmarks/ejs-results.json +4 -4
  7. package/benchmarks/handlebars-results.json +6 -6
  8. package/benchmarks/miki-results.json +4 -4
  9. package/benchmarks/pug-results.json +4 -4
  10. package/benchmarks/stress.mjs +1 -1
  11. package/docs/api/async-render.md +85 -0
  12. package/docs/api/cache.md +87 -0
  13. package/docs/api/compile.md +128 -0
  14. package/docs/api/context-processors.md +77 -0
  15. package/docs/api/filters.md +217 -0
  16. package/docs/api/finder.md +94 -0
  17. package/docs/api/helpers.md +53 -0
  18. package/docs/api/i18n.md +157 -0
  19. package/docs/api/index.md +54 -0
  20. package/docs/api/libraries.md +207 -0
  21. package/docs/api/render-partial.md +81 -0
  22. package/docs/api/render.md +92 -0
  23. package/docs/api/security.md +145 -0
  24. package/docs/api/setup-express.md +76 -0
  25. package/docs/api/tags.md +134 -0
  26. package/docs/assets/banner.png +0 -0
  27. package/docs/assets/logo.png +0 -0
  28. package/docs/guide/advanced-usage.md +397 -0
  29. package/docs/guide/async-rendering.md +308 -0
  30. package/docs/guide/context-processors.md +257 -0
  31. package/docs/guide/custom-filters.md +311 -0
  32. package/docs/guide/custom-tags.md +271 -0
  33. package/docs/guide/filters.md +642 -0
  34. package/docs/guide/getting-started.md +102 -0
  35. package/docs/guide/installation.md +95 -0
  36. package/docs/guide/partial-templates.md +367 -0
  37. package/docs/guide/quick-start.md +222 -0
  38. package/docs/guide/security.md +345 -0
  39. package/docs/guide/tags.md +783 -0
  40. package/docs/guide/template-discovery.md +170 -0
  41. package/docs/guide/template-inheritance.md +273 -0
  42. package/docs/guide/what-is-miki-template.md +28 -0
  43. package/docs/guide/why-miki-template.md +75 -0
  44. package/docs/index.md +104 -0
  45. package/docs/integrations/elysia.md +78 -0
  46. package/docs/integrations/express.md +219 -0
  47. package/docs/integrations/fastify.md +77 -0
  48. package/docs/integrations/hono.md +78 -0
  49. package/docs/integrations/index.md +68 -0
  50. package/docs/integrations/koa.md +88 -0
  51. package/docs/integrations/nestjs.md +78 -0
  52. package/docs/integrations/tsed.md +81 -0
  53. package/docs/javascripts/extra.js +174 -0
  54. package/docs/performance.md +37 -0
  55. package/docs/stylesheets/extra.css +819 -0
  56. package/mkdocs.yml +217 -0
  57. package/overrides/main.html +26 -0
  58. package/overrides/partials/footer.html +9 -0
  59. package/package.json +4 -2
  60. package/requirements-docs.txt +1 -0
  61. package/docs/README.md +0 -18
  62. package/docs/advanced_usage.md +0 -71
  63. package/docs/api.md +0 -122
  64. package/docs/filters.md +0 -708
  65. package/docs/installation.md +0 -106
  66. package/docs/integrations.md +0 -214
  67. package/docs/overview.md +0 -79
  68. package/docs/partialdef.md +0 -70
  69. package/docs/security.md +0 -27
  70. package/docs/tags.md +0 -673
  71. package/docs/usage.md +0 -646
@@ -0,0 +1,642 @@
1
+ # Filters
2
+
3
+ Filters transform variable output using the pipe (`|`) syntax. You can chain multiple filters left-to-right, and many accept arguments after a colon (`:`).
4
+
5
+ ## Table of Contents
6
+
7
+ - [Basic Usage](#basic-usage)
8
+ - [Filter Chaining](#filter-chaining)
9
+ - [Filter Arguments](#filter-arguments)
10
+ - [Text Filters](#text-filters)
11
+ - [HTML / Security Filters](#html--security-filters)
12
+ - [List / Array Filters](#list--array-filters)
13
+ - [Default Value Filters](#default-value-filters)
14
+ - [Date and Time Filters](#date-and-time-filters)
15
+ - [Numeric / Math Filters](#numeric--math-filters)
16
+ - [Data Formatting Filters](#data-formatting-filters)
17
+ - [Encoding Filters](#encoding-filters)
18
+ - [Time-Ago Filters](#time-ago-filters)
19
+ - [Built-in Library Filters](#built-in-library-filters)
20
+ - [Writing Custom Filters](#writing-custom-filters)
21
+
22
+ ---
23
+
24
+ ## Basic Usage
25
+
26
+ ```html
27
+ {{ name|upper }}
28
+ {{ price|floatformat:2 }}
29
+ {{ body|truncatewords:30|escape }}
30
+ ```
31
+
32
+ Each `|` applies a filter to the value on its left. Filters are evaluated left-to-right: the output of one filter becomes the input of the next.
33
+
34
+ ## Filter Chaining
35
+
36
+ Filters apply left-to-right. The output of each filter becomes the input of the next:
37
+
38
+ ```html
39
+ {{ name|lower|capfirst }}
40
+ <!-- "Miki" → "miki" → "Miki" -->
41
+
42
+ {{ bio|striptags|truncatewords:20 }}
43
+ <!-- Strip HTML tags, then truncate to 20 words -->
44
+
45
+ {{ text|escape|truncatechars:50|upper }}
46
+ ```
47
+
48
+ You can chain any number of filters:
49
+
50
+ ```html
51
+ {{ price|mult:1.2|add:2|floatformat:2|currency:"$" }}
52
+ <!-- price = 10 → 12.0 → 14.0 → "14.00" → "$14.00" -->
53
+ ```
54
+
55
+ ## Filter Arguments
56
+
57
+ Filters accept the following argument types:
58
+
59
+ | Syntax | Type | Example |
60
+ |--------|------|---------|
61
+ | Unquoted | Variable lookup | `value|filter:count` |
62
+ | Double-quoted | String literal | `value|filter:"hello"` |
63
+ | Single-quoted | String literal | `value|filter:'world'` |
64
+ | Number | Integer/float literal | `value|truncatewords:10` |
65
+ | Boolean | `true`/`false` | `value|yesno:"yes,no"` |
66
+
67
+ **Real-world example — conditional greeting with fallback:**
68
+
69
+ ```html
70
+ <h1>{{ user.name|default:"Guest"|capfirst }}</h1>
71
+ <span class="badge {% if user.is_premium|yesno:"yes,no" %}premium{% else %}free{% endif %}">
72
+ {{ user.plan|default:"Free" }}
73
+ </span>
74
+ ```
75
+
76
+ ---
77
+
78
+ ## Text Filters
79
+
80
+ | Filter | Description | Example |
81
+ |--------|-------------|---------|
82
+ | `upper` | Uppercase | `{{ "hello"|upper }}` |
83
+ | `lower` | Lowercase | `{{ "HELLO"|lower }}` |
84
+ | `title` | Title case | `{{ "miki coder"|title }}` |
85
+ | `capfirst` | Capitalize first letter | `{{ "hello"|capfirst }}` |
86
+ | `truncatewords:N` | Truncate to N words, appends ` ...` | `{{ body|truncatewords:30 }}` |
87
+ | `truncatechars:N` | Truncate to N chars, appends `...` | `{{ title|truncatechars:50 }}` |
88
+ | `truncatechars_html:N` | HTML-aware truncation to N chars, preserves tags | `{{ html|truncatechars_html:100 }}` |
89
+ | `wordcount` | Count words | `{{ body|wordcount }}` |
90
+ | `linebreaks` | Convert double newlines to `<p>` and single to `<br>` | `{{ text|linebreaks }}` |
91
+ | `linebreaksbr` | Convert all newlines to `<br>` | `{{ text|linebreaksbr }}` |
92
+ | `striptags` | Remove HTML tags | `{{ html|striptags }}` |
93
+ | `slugify` | URL-friendly slug | `{{ title|slugify }}` |
94
+ | `length_is:N` | Test if length equals N | `{{ items|length_is:0 }}` |
95
+
96
+ ### truncatechars_html
97
+
98
+ Like `truncatechars` but respects HTML tags — tags are preserved in full and only visible text counts toward the limit:
99
+
100
+ ```html
101
+ {{ "<p>Hello world</p>"|truncatechars_html:10 }}
102
+ <!-- → "<p>Hello worl...</p>" -->
103
+ ```
104
+
105
+ **Real-world blog excerpt:**
106
+
107
+ ```html
108
+ <article>
109
+ {{ post.body|truncatechars_html:200 }}
110
+ </article>
111
+ ```
112
+
113
+ ### linebreaks
114
+
115
+ Converts newlines into paragraphs (`<p>`) and standalone line breaks into `<br>`:
116
+
117
+ ```html
118
+ {{ "Line one\n\nLine two\nLine three"|linebreaks }}
119
+ <!-- Output: <p>Line one</p><p>Line two<br>Line three</p> -->
120
+ ```
121
+
122
+ ### slugify
123
+
124
+ Converts text to a URL-safe slug (lowercase, hyphens, no special characters):
125
+
126
+ ```html
127
+ <a href="/posts/{{ post.title|slugify }}">{{ post.title }}</a>
128
+ <!-- title: "Hello World: A New Beginning!" → href="/posts/hello-world-a-new-beginning" -->
129
+ ```
130
+
131
+ ### wordcount
132
+
133
+ Use with `pluralize` for dynamic labels:
134
+
135
+ ```html
136
+ <p>{{ post.body|wordcount }} {{ post.body|wordcount|pluralize:"word,words" }} read</p>
137
+ ```
138
+
139
+ ---
140
+
141
+ ## HTML / Security Filters
142
+
143
+ | Filter | Description |
144
+ |--------|-------------|
145
+ | `safe` | Mark string as safe (no escaping) |
146
+ | `escape` | Force HTML escaping, even on SafeString |
147
+
148
+ **Real-world CMS rendering:**
149
+
150
+ ```html
151
+ <!-- Body is trusted HTML from the CMS -->
152
+ <div class="content">{{ post.body_html|safe }}</div>
153
+
154
+ <!-- User comments are always escaped -->
155
+ <div class="comment">{{ comment.text|escape }}</div>
156
+ ```
157
+
158
+ ---
159
+
160
+ ## List / Array Filters
161
+
162
+ | Filter | Description | Example |
163
+ |--------|-------------|---------|
164
+ | `length` | Length of list/string | `{{ items|length }}` |
165
+ | `join:","` | Join with separator | `{{ tags|join:", " }}` |
166
+ | `slice:"start:end"` | Slice list | `{{ items|slice:"0:5" }}` |
167
+ | `dictsort:"key"` | Sort dict by key | `{{ dict|dictsort:"name" }}` |
168
+ | `dictsortreversed:"key"` | Reverse sort dict | `{{ dict|dictsortreversed:"name" }}` |
169
+ | `sort` | Sort array | `{{ items|sort }}` |
170
+ | `unique` | Deduplicate array | `{{ tags|unique }}` |
171
+ | `random` | Random item from array | `{{ items|random }}` |
172
+ | `reverse` | Reverse array or string | `{{ items|reverse }}` |
173
+ | `split:","` | Split string into array | `{{ csv|split:"," }}` |
174
+ | `replace:"old,new"` | Replace substring | `{{ text|replace:"foo,bar" }}` |
175
+
176
+ ### slice
177
+
178
+ Slices an array or string like Python (`[start:end]`). Supports negative indices:
179
+
180
+ ```html
181
+ <!-- First 3 items -->
182
+ {{ items|slice:"0:3" }}
183
+
184
+ <!-- From index 2 onwards -->
185
+ {{ items|slice:"2:" }}
186
+
187
+ <!-- Last 2 items -->
188
+ {{ items|slice:"-2:" }}
189
+ ```
190
+
191
+ ### join
192
+
193
+ **Real-world tag cloud:**
194
+
195
+ ```html
196
+ <div class="tag-cloud">
197
+ {% for tag in post.tags|join:", " %}
198
+ <span class="tag">{{ tag }}</span>
199
+ {% endfor %}
200
+ </div>
201
+ <!-- But better as: -->
202
+ <span class="tag-cloud">{{ post.tags|join:", " }}</span>
203
+ ```
204
+
205
+ ### sort + unique
206
+
207
+ **Real-world: deduplicated, sorted navigation:**
208
+
209
+ ```html
210
+ <ul>
211
+ {% for category in categories|sort %}
212
+ <li>{{ category|capfirst }}</li>
213
+ {% endfor %}
214
+ </ul>
215
+ ```
216
+
217
+ ### split
218
+
219
+ **Real-world: parsing comma-separated metadata:**
220
+
221
+ ```html
222
+ <!-- post.keywords = "nature,landscape,autumn" -->
223
+ <div class="meta">
224
+ {% for kw in post.keywords|split:"," %}
225
+ <a href="/tag/{{ kw|slugify }}">{{ kw|capfirst }}</a>
226
+ {% endfor %}
227
+ </div>
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Default Value Filters
233
+
234
+ | Filter | Description |
235
+ |--------|-------------|
236
+ | `default:"fallback"` | Use fallback for empty string, null, or undefined |
237
+ | `default_if_none:"fallback"` | Use fallback only for null/undefined |
238
+ | `firstof:v1 v2 v3` | Return first truthy value |
239
+
240
+ ### default vs default_if_none
241
+
242
+ - `default` uses the fallback for falsy values: `null`, `undefined`, `""`, `0`, `false`
243
+ - `default_if_none` only uses the fallback for `null` and `undefined`
244
+
245
+ ```html
246
+ {{ ""|default:"empty" }} → "empty"
247
+ {{ ""|default_if_none:"N/A" }} → "" (empty string is not none)
248
+ {{ 0|default:"zero" }} → "zero"
249
+ {{ 0|default_if_none:"N/A" }} → 0 (zero is not none)
250
+ ```
251
+
252
+ **Real-world user profile:**
253
+
254
+ ```html
255
+ <!-- Show "No bio yet" only when bio is truly empty -->
256
+ <p class="bio">{{ user.bio|default:"No bio yet." }}</p>
257
+
258
+ <!-- Distinguish between "never set" and "explicitly empty" -->
259
+ {% if user.display_name %}
260
+ <h2>{{ user.display_name|default_if_none:"Anonymous" }}</h2>
261
+ {% endif %}
262
+ ```
263
+
264
+ ---
265
+
266
+ ## Date and Time Filters
267
+
268
+ | Filter | Description | Example |
269
+ |--------|-------------|---------|
270
+ | `date:"Y-m-d"` | Format date (Django-style tokens) | `{{ d|date:"Y-m-d" }}` |
271
+ | `time:"H:i"` | Format time | `{{ d|time:"H:i" }}` |
272
+ | `date_format:"yyyy-MM-dd"` | Format date (date-fns tokens) | `{{ d|date_format:"yyyy-MM-dd" }}` |
273
+ | `strftime:"PPpp"` | Format date (date-fns tokens) | `{{ now|strftime:"PPpp" }}` |
274
+ | `timesince` | Time since date | `{{ created|timesince }}` |
275
+ | `timeuntil` | Time until date | `{{ start|timeuntil }}` |
276
+ | `ago` | Time since date, human-readable | `{{ created|ago }}` |
277
+ | `until` | Time until date, human-readable | `{{ start|until }}` |
278
+ | `time_diff:other_date` | Difference between two dates | `{{ start|time_diff:end }}` |
279
+
280
+ ### Date format tokens (`date` and `time` filters)
281
+
282
+ The `date` filter uses Django-style format tokens with longest-first matching:
283
+
284
+ | Token | Output |
285
+ |-------|--------|
286
+ | `Y` | 4-digit year |
287
+ | `y` | 2-digit year |
288
+ | `m` | Month number (no pad) |
289
+ | `n` | Month number (no pad) |
290
+ | `d` | Day number (no pad) |
291
+ | `j` | Day number (no pad) |
292
+ | `H` | 24-hour hour (no pad) |
293
+ | `G` | 24-hour hour (no pad) |
294
+ | `i` | Minutes |
295
+ | `s` | Seconds |
296
+ | `F` | Long month name |
297
+ | `D` | Short day name |
298
+ | `M` | Long month name |
299
+
300
+ ### date_format and strftime (date-fns tokens)
301
+
302
+ For ISO and date-fns patterns:
303
+
304
+ ```html
305
+ {{ post.published_at|date_format:"yyyy-MM-dd" }} → "2026-08-31"
306
+ {{ post.published_at|strftime:"PPpp" }} → "Aug 31, 2026 at 10:30 PM"
307
+ {{ event.date|strftime:"EEEE, MMMM do yyyy, h:mm a" }} → "Sunday, August 31st 2026, 10:30 PM"
308
+ ```
309
+
310
+ ### timesince / timeuntil
311
+
312
+ Returns a human-readable time difference:
313
+
314
+ ```html
315
+ {{ post.created|timesince }} → "2 hours"
316
+ {{ post.created|timesince:other_date }} → "3 days" (relative to other_date)
317
+ {{ event.date|timeuntil }} → "5 days"
318
+ ```
319
+
320
+ ### ago / until
321
+
322
+ More human-readable relative time strings:
323
+
324
+ ```html
325
+ <!-- "2 days ago", "just now", "3 months ago" -->
326
+ <span class="timestamp">{{ comment.created|ago }}</span>
327
+
328
+ <!-- "2 days", "3 weeks", "1 month" -->
329
+ <time datetime="{{ event.date|date:'c' }}">{{ event.date|until }}</time>
330
+ ```
331
+
332
+ **Real-world blog post metadata:**
333
+
334
+ ```html
335
+ <article class="post">
336
+ <header>
337
+ <h1>{{ post.title }}</h1>
338
+ <time class="posted-at">
339
+ Published {{ post.published_at|date:"F j, Y" }} ({{ post.published_at|ago }})
340
+ </time>
341
+ </header>
342
+ <div class="content">
343
+ {{ post.body|linebreaks }}
344
+ </div>
345
+ </article>
346
+ ```
347
+
348
+ ---
349
+
350
+ ## Numeric / Math Filters
351
+
352
+ | Filter | Description | Example |
353
+ |--------|-------------|---------|
354
+ | `add:N` | Add number | `{{ count\|add:1 }}` |
355
+ | `sub:N` | Subtract number | `{{ total\|sub:tax }}` |
356
+ | `mult:N` | Multiply number | `{{ price\|mult:1.2 }}` |
357
+ | `divisibleby:N` | Test divisibility | `{{ i\|divisibleby:2 }}` |
358
+ | `mod:N` | Modulo | `{{ i\|mod:3 }}` |
359
+ | `floatformat:N` | Format float | `{{ price\|floatformat:2 }}` |
360
+ | `square` | Square a number | `{{ n\|square }}` |
361
+ | `sqrt` | Square root | `{{ n\|sqrt }}` |
362
+ | `abs` | Absolute value | `{{ delta\|abs }}` |
363
+ | `round:N` | Round to N decimals | `{{ price\|round:2 }}` |
364
+ | `floor` | Floor | `{{ ratio\|floor }}` |
365
+ | `ceil` | Ceiling | `{{ ratio\|ceil }}` |
366
+ | `min:N` | Minimum of value and arg | `{{ temp\|min:0 }}` |
367
+ | `max:N` | Maximum of value and arg | `{{ temp\|max:100 }}` |
368
+ | `sum` | Sum array | `{{ numbers\|sum }}` |
369
+ | `average` | Average array | `{{ scores\|average }}` |
370
+
371
+ ### floatformat behavior
372
+
373
+ | arg | behavior |
374
+ |-----|----------|
375
+ | (none) | 1 decimal (`3.4`) |
376
+ | `0` | 0 decimals (`3`) |
377
+ | `1` | 1 decimal (`3.4`) |
378
+ | `2` | 2 decimals (`3.40`) |
379
+ | `-1` | all decimals, trimmed |
380
+
381
+ ### Real-world pricing example
382
+
383
+ ```html
384
+ <td class="price">
385
+ ${{ item.price|mult:item.qty|floatformat:2 }}
386
+ </td>
387
+ <!-- If not on sale, apply discount -->
388
+ {% if not item.on_sale %}
389
+ <td>{{ item.base_price|mult:0.9|floatformat:2 }}</td>
390
+ {% endif %}
391
+ ```
392
+
393
+ ---
394
+
395
+ ## Data Formatting Filters
396
+
397
+ | Filter | Description | Example |
398
+ |--------|-------------|---------|
399
+ | `currency:"$"` | Format as currency | `{{ price|currency:"$" }}` |
400
+ | `phone_number` | Format as phone number | `{{ raw|phone_number }}` |
401
+ | `email` | Format as mailto link | `{{ address|email }}` |
402
+ | `url` | Format as URL | `{{ domain|url }}` |
403
+ | `mask:"*"` | Mask string, show last 4 chars | `{{ card|mask }}` |
404
+ | `whatsapp_link:"msg"` | Generate WhatsApp link | `{{ phone|whatsapp_link }}` |
405
+ | `credit_card` | Format as credit card | `{{ raw|credit_card }}` |
406
+ | `ssn` | Format as SSN | `{{ raw|ssn }}` |
407
+ | `ip_address` | Format as IP address | `{{ raw|ip_address }}` |
408
+ | `uuid` | Generate UUID (no input needed) | `{{ x|uuid }}` |
409
+ | `filesizeformat` | Human-readable file size | `{{ bytes|filesizeformat }}` |
410
+ | `yesno:"yes,no,maybe"` | Convert bool to string | `{{ active|yesno:"Active,Inactive" }}` |
411
+ | `pluralize:"s"` | Pluralize based on count | `{{ count|pluralize }}` |
412
+ | `urlencode` | URL encode | `{{ text|urlencode }}` |
413
+ | `escapeuri` | URI encode | `{{ text|escapeuri }}` |
414
+ | `stringformat:"%s"` | sprintf-style formatting | `{{ name|stringformat:"%s" }}` |
415
+ | `cut:"text"` | Remove substring | `{{ text|cut:"foo" }}` |
416
+ | `addslashes` | Escape quotes | `{{ text|addslashes }}` |
417
+ | `removetags:"p,div"` | Remove specific tags | `{{ html|removetags:"p,div" }}` |
418
+ | `trans` | Translate via i18n | `{{ "hello"|trans }}` |
419
+ | `regroup:"attr"` | Group list by attribute | `{{ items|regroup:"category" }}` |
420
+ | `json` | JSON-encode value | `{{ obj|json }}` |
421
+ | `urlize` | Convert URLs to links | `{{ text|urlize }}` |
422
+
423
+ ### phone_number
424
+
425
+ Formats a 10-digit US phone number as `(123) 456-7890`. Handles 11-digit numbers with leading `1` as `+1 (123) 456-7890`:
426
+
427
+ ```html
428
+ {{ "1234567890"|phone_number }} → "(123) 456-7890"
429
+ {{ "11234567890"|phone_number }} → "+1 (123) 456-7890"
430
+ ```
431
+
432
+ ### credit_card
433
+
434
+ Formats a credit card number with dashes every 4 digits:
435
+
436
+ ```html
437
+ {{ "4111111111111111"|credit_card }} → "4111-1111-1111-1111"
438
+ ```
439
+
440
+ ### ssn
441
+
442
+ Formats a 9-digit Social Security Number:
443
+
444
+ ```html
445
+ {{ "123456789"|ssn }} → "123-45-6789"
446
+ ```
447
+
448
+ ### mask
449
+
450
+ Masks all but the last 4 characters. Default mask character is `*`:
451
+
452
+ ```html
453
+ <!-- Mask a phone number or credit card in display -->
454
+ <span class="masked">{{ user.phone|mask }}</span>
455
+
456
+ <!-- Custom mask character -->
457
+ {{ "1234567890"|mask:"#" }} → "######7890"
458
+ ```
459
+
460
+ ### url (with safe filter)
461
+
462
+ ```html
463
+ <a href="{{ post.share_url|url|safe }}">{{ post.share_url|url }}</a>
464
+ ```
465
+
466
+ ### email
467
+
468
+ ```html
469
+ <a href="{{ user.email|email }}">{{ user.email }}</a>
470
+ <!-- → <a href="mailto:user@example.com">user@example.com</a> -->
471
+ ```
472
+
473
+ ### whatsapp_link
474
+
475
+ Generates a WhatsApp link with an optional pre-filled message:
476
+
477
+ ```html
478
+ <a href="{{ phone|whatsapp_link:"Hello! I'd like to know more." }}" target="_blank">
479
+ Chat on WhatsApp
480
+ </a>
481
+ ```
482
+
483
+ ### json (safe for `<script>` blocks)
484
+
485
+ Safely serializes data to JSON for client-side consumption:
486
+
487
+ ```html
488
+ <script>
489
+ const initialState = {{ page_state|json|safe }};
490
+ </script>
491
+ ```
492
+
493
+ > Note: `json` output is marked safe automatically. Use `|safe` in the template only to signal intent — the engine handles it correctly either way.
494
+
495
+ ### stringformat
496
+
497
+ ```html
498
+ {{ 3.14159|stringformat:".2f" }} → "3.14"
499
+ {{ count|stringformat:"04d" }} → "0042"
500
+ {{ name|stringformat:"%s" }} → "Alice"
501
+ ```
502
+
503
+ ### cut
504
+
505
+ Removes all occurrences of a substring:
506
+
507
+ ```html
508
+ <!-- Sanitize a URL by removing unwanted query params -->
509
+ {{ request_uri|cut:"?debug=1" }}
510
+ ```
511
+
512
+ ### pluralize
513
+
514
+ **Real-world item count:**
515
+
516
+ ```html
517
+ <p>{{ cart.items|length }} item{{ cart.items|length|pluralize }} in your cart</p>
518
+ <!-- "1 item in your cart" / "3 items in your cart" -->
519
+
520
+ <!-- Custom suffixes -->
521
+ <p>{{ count|pluralize:"y,ies" }} comment{{ count|pluralize:"y,ies" }}</p>
522
+ ```
523
+
524
+ ### yesno
525
+
526
+ **Real-world status badge:**
527
+
528
+ ```html
529
+ <span class="status {{ user.is_active|yesno:"active,inactive" }}">
530
+ {{ user.is_active|yesno:"Active,Inactive" }}
531
+ </span>
532
+ ```
533
+
534
+ ---
535
+
536
+ ## Encoding Filters
537
+
538
+ | Filter | Description | Example |
539
+ |--------|-------------|---------|
540
+ | `base64_encode` | Base64 encode | `{{ text|base64_encode }}` |
541
+ | `base64_decode` | Base64 decode | `{{ text|base64_decode }}` |
542
+
543
+ ### Real-world: embedding a CSRF token in a header
544
+
545
+ ```html
546
+ <meta name="csrf-token" content="{{ csrf_token|base64_encode }}">
547
+ ```
548
+
549
+ ---
550
+
551
+ ## Time-Ago Filters
552
+
553
+ | Filter | Description | Example |
554
+ |--------|-------------|---------|
555
+ | `time_diff:other` | Human-readable time difference | `{{ start|time_diff:end }}` |
556
+ | `ago` | Time since date | `{{ created|ago }}` |
557
+ | `until` | Time until date | `{{ start|until }}` |
558
+
559
+ ### ago — human-readable "time since"
560
+
561
+ ```html
562
+ <!-- "just now", "5 minutes ago", "2 hours ago", "3 days ago", "1 year ago" -->
563
+ <span class="time-ago">{{ post.created_at|ago }}</span>
564
+ ```
565
+
566
+ ### until — human-readable "time remaining"
567
+
568
+ ```html
569
+ <!-- "2 days", "3 weeks", "1 month" -->
570
+ <span class="countdown">{{ auction.ends_at|until }}</span>
571
+ ```
572
+
573
+ ---
574
+
575
+ ## Built-in Library Filters
576
+
577
+ ### humanize
578
+
579
+ Available after `{% load humanize %}`:
580
+
581
+ | Filter | Description | Example |
582
+ |--------|-------------|---------|
583
+ | `intcomma` | Add comma separators | `{{ views|intcomma }}` → `1,234` |
584
+ | `intword` | Convert to human word | `{{ 1000000|intword }}` → `1.0 million` |
585
+ | `apnumber` | Convert 0-19 to words | `{{ 3|apnumber }}` → `three` |
586
+ | `ordinal` | Add ordinal suffix | `{{ 1|ordinal }}` → `1st` |
587
+ | `naturalday` | Convert date to relative day | `{{ date|naturalday }}` → `today` |
588
+
589
+ Usage:
590
+
591
+ ```html
592
+ {% load humanize %}
593
+ {{ post.view_count|intcomma }}
594
+ {{ comment_count|ordinal }}
595
+ ```
596
+
597
+ ### cache
598
+
599
+ The `cache` library provides a `{% cache %}` tag, not a filter. See [Tags: cache](#cache-tag) for details.
600
+
601
+ ### lorem
602
+
603
+ The `lorem` library provides a `{% lorem %}` tag and a `lorem` filter. These are auto-activated (no `{% load %}` needed).
604
+
605
+ ```html
606
+ <!-- Generate placeholder text -->
607
+ {{ 5|lorem }}
608
+ ```
609
+
610
+ ---
611
+
612
+ ## Writing Custom Filters
613
+
614
+ You can register your own filters. See the [Custom Filters guide](custom-filters.md) for details.
615
+
616
+ === "CommonJS"
617
+
618
+ ```javascript
619
+ const { registerFilter } = require('miki-template');
620
+
621
+ registerFilter('reverse', (val) => {
622
+ return String(val).split('').reverse().join('');
623
+ });
624
+ ```
625
+
626
+ === "ES Modules"
627
+
628
+ ```javascript
629
+ import { registerFilter } from 'miki-template';
630
+
631
+ registerFilter('reverse', (val) => {
632
+ return String(val).split('').reverse().join('');
633
+ });
634
+ ```
635
+
636
+ ---
637
+
638
+ ## Next Steps
639
+
640
+ - [Custom Filters](./custom-filters)
641
+ - [Tags](./tags)
642
+ - [API Reference: Filters](../api/filters)