@funcstache/stache-stream 0.2.2 → 0.2.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 (109) hide show
  1. package/.eslintrc.json +30 -0
  2. package/.swcrc +29 -0
  3. package/DEV.md +84 -0
  4. package/README.md +145 -0
  5. package/TASKS.md +13 -0
  6. package/TODO.md +28 -0
  7. package/docs/.nojekyll +1 -0
  8. package/docs/assets/hierarchy.js +1 -0
  9. package/docs/assets/highlight.css +120 -0
  10. package/docs/assets/icons.js +18 -0
  11. package/docs/assets/icons.svg +1 -0
  12. package/docs/assets/main.js +60 -0
  13. package/docs/assets/navigation.js +1 -0
  14. package/docs/assets/search.js +1 -0
  15. package/docs/assets/style.css +1633 -0
  16. package/docs/classes/StacheTransformStream.html +13 -0
  17. package/docs/hierarchy.html +1 -0
  18. package/docs/index.html +73 -0
  19. package/docs/interfaces/Context.html +3 -0
  20. package/docs/interfaces/ContextProvider.html +10 -0
  21. package/docs/interfaces/PartialTagContextLambda.html +11 -0
  22. package/docs/interfaces/PartialTagContextLambdaResult.html +7 -0
  23. package/docs/interfaces/SectionTagCallback.html +12 -0
  24. package/docs/interfaces/SectionTagContextRecord.html +4 -0
  25. package/docs/interfaces/Tag.html +45 -0
  26. package/docs/interfaces/VariableTagContextLambda.html +4 -0
  27. package/docs/interfaces/VariableTagContextRecord.html +3 -0
  28. package/docs/media/StacheStream.ts +79 -0
  29. package/docs/modules.html +1 -0
  30. package/docs/types/ContextTypes.html +3 -0
  31. package/docs/types/JsonType.html +2 -0
  32. package/docs/types/PartialTagContext.html +4 -0
  33. package/docs/types/SectionTagContext.html +4 -0
  34. package/docs/types/TemplateName.html +9 -0
  35. package/docs/types/VariableTagContext.html +4 -0
  36. package/docs/types/VariableTagContextPrimitive.html +3 -0
  37. package/docs-assets/images/context-dotted-found.png +0 -0
  38. package/docs-assets/images/context-dotted-not-found.png +0 -0
  39. package/docs-assets/images/context-not-found.png +0 -0
  40. package/package.json +3 -6
  41. package/project.json +26 -0
  42. package/src/global.d.ts +10 -0
  43. package/src/index.ts +67 -0
  44. package/src/lib/parse/Parse.spec.ts +50 -0
  45. package/src/lib/parse/Parse.ts +92 -0
  46. package/src/lib/parse/README.md +62 -0
  47. package/src/lib/plan_base_v2.md +33 -0
  48. package/src/lib/plan_comment.md +53 -0
  49. package/src/lib/plan_implicit-iterator.md +213 -0
  50. package/src/lib/plan_inverted-sections.md +160 -0
  51. package/src/lib/plan_partials.md +237 -0
  52. package/src/lib/plan_sections.md +167 -0
  53. package/src/lib/plan_stache-stream.md +110 -0
  54. package/src/lib/plan_whitespace.md +98 -0
  55. package/src/lib/queue/Queue.spec.ts +275 -0
  56. package/src/lib/queue/Queue.ts +253 -0
  57. package/src/lib/queue/README.md +110 -0
  58. package/src/lib/stache-stream/README.md +45 -0
  59. package/src/lib/stache-stream/StacheStream.spec.ts +107 -0
  60. package/src/lib/stache-stream/StacheStream.ts +79 -0
  61. package/src/lib/tag/README.md +95 -0
  62. package/src/lib/tag/Tag.spec.ts +212 -0
  63. package/src/lib/tag/Tag.ts +295 -0
  64. package/src/lib/template/README.md +102 -0
  65. package/src/lib/template/Template-comment.spec.ts +76 -0
  66. package/src/lib/template/Template-inverted-section.spec.ts +85 -0
  67. package/src/lib/template/Template-partials.spec.ts +125 -0
  68. package/src/lib/template/Template-section.spec.ts +142 -0
  69. package/src/lib/template/Template.spec.ts +178 -0
  70. package/src/lib/template/Template.ts +614 -0
  71. package/src/lib/test/streams.ts +36 -0
  72. package/src/lib/tokenize/README.md +97 -0
  73. package/src/lib/tokenize/Tokenize.spec.ts +364 -0
  74. package/src/lib/tokenize/Tokenize.ts +374 -0
  75. package/src/lib/{types.d.ts → types.ts} +73 -25
  76. package/tsconfig.json +21 -0
  77. package/tsconfig.lib.json +16 -0
  78. package/tsconfig.spec.json +21 -0
  79. package/typedoc.mjs +15 -0
  80. package/vite.config.ts +27 -0
  81. package/vitest.setup.ts +6 -0
  82. package/src/global.d.js +0 -8
  83. package/src/global.d.js.map +0 -1
  84. package/src/index.d.ts +0 -7
  85. package/src/index.js +0 -24
  86. package/src/index.js.map +0 -1
  87. package/src/lib/parse/Parse.d.ts +0 -14
  88. package/src/lib/parse/Parse.js +0 -79
  89. package/src/lib/parse/Parse.js.map +0 -1
  90. package/src/lib/queue/Queue.d.ts +0 -32
  91. package/src/lib/queue/Queue.js +0 -181
  92. package/src/lib/queue/Queue.js.map +0 -1
  93. package/src/lib/stache-stream/StacheStream.d.ts +0 -22
  94. package/src/lib/stache-stream/StacheStream.js +0 -71
  95. package/src/lib/stache-stream/StacheStream.js.map +0 -1
  96. package/src/lib/tag/Tag.d.ts +0 -33
  97. package/src/lib/tag/Tag.js +0 -231
  98. package/src/lib/tag/Tag.js.map +0 -1
  99. package/src/lib/template/Template.d.ts +0 -18
  100. package/src/lib/template/Template.js +0 -428
  101. package/src/lib/template/Template.js.map +0 -1
  102. package/src/lib/test/streams.d.ts +0 -2
  103. package/src/lib/test/streams.js +0 -39
  104. package/src/lib/test/streams.js.map +0 -1
  105. package/src/lib/tokenize/Tokenize.d.ts +0 -22
  106. package/src/lib/tokenize/Tokenize.js +0 -268
  107. package/src/lib/tokenize/Tokenize.js.map +0 -1
  108. package/src/lib/types.js +0 -33
  109. package/src/lib/types.js.map +0 -1
@@ -0,0 +1,92 @@
1
+ import { ReadableStream } from "node:stream/web";
2
+ import { EventEmitter } from "node:events";
3
+
4
+ type ParseEventMap = { inactive: [Parse] };
5
+
6
+ export interface ParseOptions {
7
+ onChar: (char: string | null) => Promise<void>;
8
+ }
9
+
10
+ export class Parse extends EventEmitter<ParseEventMap> {
11
+ #onChar: ParseOptions["onChar"];
12
+ #processPending = false;
13
+ #queue: string[] = [];
14
+ #readState: "finished" | "pending" | "reading" = "pending";
15
+
16
+ constructor(
17
+ options: ConstructorParameters<typeof EventEmitter<ParseEventMap>>[0] &
18
+ ParseOptions
19
+ ) {
20
+ super(options);
21
+ this.#onChar = options.onChar;
22
+ }
23
+
24
+ async read(readable: ReadableStream<string>) {
25
+ this.#readState = "reading";
26
+
27
+ for await (const chunk of readable) {
28
+ if (!chunk) {
29
+ break;
30
+ }
31
+
32
+ if (1 < chunk.length) {
33
+ for (const char of chunk) {
34
+ this.#push(char);
35
+ }
36
+ } else {
37
+ this.#push(chunk);
38
+ }
39
+ }
40
+
41
+ this.#readState = "finished";
42
+ this.#finalize();
43
+ }
44
+
45
+ #invokeChar(char: string | null) {
46
+ try {
47
+ this.#onChar(char);
48
+ } catch (err) {
49
+ console.error(err);
50
+ }
51
+ }
52
+
53
+ #process() {
54
+ this.#processPending = false;
55
+
56
+ while (this.#queue.length) {
57
+ const char = this.#queue.shift();
58
+ if (!char) {
59
+ continue;
60
+ }
61
+
62
+ this.#invokeChar(char);
63
+ }
64
+
65
+ this.#finalize();
66
+ }
67
+
68
+ #push(char: string) {
69
+ this.#queue.push(char);
70
+
71
+ if (this.#processPending) {
72
+ return;
73
+ }
74
+
75
+ this.#processPending = true;
76
+
77
+ setTimeout(() => this.#process(), 0);
78
+ }
79
+
80
+ #finalize() {
81
+ if (this.#readState !== "finished") {
82
+ return;
83
+ }
84
+
85
+ if (this.#queue.length) {
86
+ return;
87
+ }
88
+
89
+ this.#invokeChar(null);
90
+ this.emit("inactive", this);
91
+ }
92
+ }
@@ -0,0 +1,62 @@
1
+ # Parse
2
+
3
+ `Parse` reads a `ReadableStream<string>` character-by-character and calls a user-supplied `onChar` callback for each character. When the stream is exhausted, `onChar` is called once more with `null` to signal end-of-input, and the `inactive` event is emitted.
4
+
5
+ ## How it works
6
+
7
+ ### Reading
8
+
9
+ `read(readable)` iterates over the stream asynchronously. Each chunk is either pushed directly (single character) or split into individual characters before pushing. This ensures `onChar` is always called with exactly one character at a time regardless of chunk size.
10
+
11
+ ### Queuing and processing
12
+
13
+ Characters are not dispatched immediately. `#push` adds each character to an internal queue and, if no processing tick is already scheduled, sets a `setTimeout(..., 0)` to drain the queue on the next event loop turn. This batches any characters that arrive in the same microtask tick into a single `#process` call, keeping `onChar` invocations orderly without blocking the stream reader.
14
+
15
+ ### Finalization
16
+
17
+ `#finalize` is called after each `#process` drain and at the end of `read`. It only proceeds when both conditions are true:
18
+
19
+ 1. The stream has been fully read (`readState === "finished"`)
20
+ 2. The queue is empty
21
+
22
+ When both are true, `onChar(null)` is called and the `inactive` event is emitted. Callers wait for this event to know that all characters have been processed.
23
+
24
+ ## Usage
25
+
26
+ ```ts
27
+ import { Parse } from "./Parse";
28
+
29
+ const parse = new Parse({
30
+ onChar: async (char) => {
31
+ if (char === null) return; // end of stream
32
+ process.stdout.write(char);
33
+ },
34
+ });
35
+
36
+ parse.on("inactive", () => console.log("done"));
37
+ parse.read(myReadableStream);
38
+ ```
39
+
40
+ ## API
41
+
42
+ ### `new Parse(options)`
43
+
44
+ | Option | Type | Description |
45
+ | -------- | ----------------------------------------- | --------------------------------------------------------------- |
46
+ | `onChar` | `(char: string \| null) => Promise<void>` | Called for each character. Called with `null` at end of stream. |
47
+
48
+ Also accepts all `EventEmitter` constructor options.
49
+
50
+ ### `parse.on(eventName, listener)`
51
+
52
+ Inherited from `EventEmitter`. Registers a listener for a parse event.
53
+
54
+ ### `parse.read(readable: ReadableStream<string>): Promise<void>`
55
+
56
+ Starts reading from the stream. Resolves when the stream ends (but before `onChar(null)` is called — listen for `inactive` to know processing is complete).
57
+
58
+ ### Events
59
+
60
+ | Event | Payload | Description |
61
+ | ---------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
62
+ | `inactive` | `Parse` | Emitted after `onChar(null)` has been called and the queue is empty. The `Parse` instance is passed as the argument. |
@@ -0,0 +1,33 @@
1
+ PLAN_FILENAME = "plan_comment.md"
2
+ TOKEN_TYPE = "comment"
3
+ TOKEN_TYPE_EXAMPLE = `{{! comment }}`
4
+
5
+ The stache-stream components (libs/stache-stream/src/lib/) must handle {{TOKEN_TYPE}} tokens, e.g. {{TOKEN_TYPE_EXAMPLE}}. See: https://mustache.github.io/mustache.5.html#Comments. Create an implementation plan.
6
+
7
+ ## Tasks
8
+
9
+ Do the following tasks in order:
10
+
11
+ 1. Create a new markdown file named {{PLAN_FILENAME}} to contain the plan.
12
+ 2. Create the specification in the file.
13
+ - must be implemented according to the Mustache specification
14
+ - include changes to the components in v2 and any other components
15
+ - include unit tests
16
+ - create a list of questions that need answers to resolve ambiguities, conflicts, or missing requirements
17
+ 3. STOP and get approval for all questions to be answered before proceeding.
18
+ 4. Tests for the `Template` class should be placed into a test file named "Template-{{TOKEN_TYPE}}.spec.ts".
19
+ 5. Suggest a phased implementations that uses a TDD approach:
20
+ 1. define the text of a mustache template and context type that is expected to be supported, start with the least complex implementation that is in scope
21
+ 2. create one or more tests using template and context defined in step 1
22
+ 3. verify the test fails — confirm the test is failing by running it before proceeding
23
+ 4. implement the changes in code
24
+ 5. rerun the test; if the test fails iterate on fixing the code until the test passes
25
+ 6. once the test passes start over at step 1 with the next more complex template, continue until all templates within scope are complete, if there are more than 15 tests stop and get approval for the tests
26
+
27
+ ## Out-of-scope
28
+
29
+ - Any Mustache Template specifications that are not {{TOKEN_TYPE}} tags types.
30
+
31
+ ## Resources
32
+
33
+ [README](../../README.md)
@@ -0,0 +1,53 @@
1
+ # Plan: Comment Token Implementation
2
+
3
+ ## Spec Requirements
4
+
5
+ Per https://mustache.github.io/mustache.5.html#Comments:
6
+
7
+ - `{{! comment text }}` — everything inside is ignored; produces no output.
8
+ - Comment text may contain newlines.
9
+ - **Standalone** (per https://github.com/mustache/spec/blob/master/specs/comments.yml): when a comment tag is the only non-whitespace content on a line, the entire line is suppressed — leading whitespace and trailing newline are not written to output.
10
+ - **Non-standalone**: only the tag itself is removed; surrounding text on the same line is preserved.
11
+
12
+ ## Decisions
13
+
14
+ 1. **Multi-line comments are treated atomically.** Standalone detection is based only on what precedes `{{` on the current line. Newlines inside `{{! ... }}` do not affect `#lineHasContent` or `#pendingWhitespace`. This is naturally correct since `Tokenize` emits the entire comment as a single tag token.
15
+
16
+ 2. **Comments must be accumulated when inside a section body.** Dropping comment tokens from the accumulator would leave surrounding text tokens (`\n` before and after a standalone comment) intact, producing incorrect output. The child `Template` must receive the comment token in its reconstructed template string to apply standalone suppression. Comments are emitted immediately (not accumulated) only when `Queue` is not currently inside an accumulator.
17
+
18
+ ## Component Changes
19
+
20
+ ### `Queue.ts`
21
+
22
+ 1. Add `comment: TokenizeTagEvent` to `QueueEventMap`.
23
+ 2. Add a `case "comment":` branch in the `#processToken` switch that mirrors the `implicit`/`partial`/`variable` branch: push to `#accumulator` when inside a section, otherwise emit the `"comment"` event.
24
+
25
+ ### `Template.ts`
26
+
27
+ 1. In the `#queue` getter, register `.on("comment", this.#handleComment.bind(this))`.
28
+ 2. Add a `#handleComment` private method:
29
+ - **Standalone** (`!this.#lineHasContent`): set `#pendingWhitespace` to `""`, set `#consumeNextNewline = true`. Write nothing.
30
+ - **Non-standalone**: call `#flushPendingWhitespace()`. Write nothing.
31
+
32
+ No changes are required to `Tag.ts` (type `comment` is already assigned for the `!` sigil) or `Tokenize.ts`.
33
+
34
+ ## Test File
35
+
36
+ `libs/stache-stream/src/lib/template/Template-comment.spec.ts`
37
+
38
+ Follow the `render` helper pattern from `Template-partials.spec.ts`. Section tests require context `{ s: true }`.
39
+
40
+ ### Planned tests
41
+
42
+ | # | Template | Context | Expected output |
43
+ | --- | -------------------------------- | ------------- | ----------------------------------------------------------------- |
44
+ | 1 | `{{! comment }}` | — | `""` — basic comment produces no output |
45
+ | 2 | `before{{! comment }}after` | — | `"beforeafter"` — non-standalone comment stripped inline |
46
+ | 3 | `{{! multi\nline }}` | — | `""` — multi-line comment treated atomically |
47
+ | 4 | `{{! comment }}\n` | — | `""` — standalone: tag line fully suppressed |
48
+ | 5 | ` {{! comment }}\n` | — | `""` — standalone with leading whitespace suppressed |
49
+ | 6 | `before\n{{! comment }}\nafter` | — | `"before\nafter"` — standalone in middle of content |
50
+ | 7 | `text {{! comment }}\n` | — | `"text \n"` — non-standalone: leading text preserved, tag removed |
51
+ | 8 | `{{#s}}{{! comment }}{{/s}}` | `{ s: true }` | `""` — comment inside section body |
52
+ | 9 | `{{#s}}\n{{! comment }}\n{{/s}}` | `{ s: true }` | `""` — standalone comment inside section body |
53
+ | 10 | `{{!\n I'm a comment\n}}` | — | `""` — comment with newlines spanning multiple source lines |
@@ -0,0 +1,213 @@
1
+ # Plan: Implicit Iterator (`{{.}}`)
2
+
3
+ ## Objective
4
+
5
+ Implement rendering of `{{.}}` (the Mustache implicit iterator) in the v2 streaming template engine. Scope is limited to a `string` or `number` context value. `{{.}}` outputs the current context value directly; HTML escaping follows the same rules as `{{variable}}` (escaped by default).
6
+
7
+ ---
8
+
9
+ ## Background
10
+
11
+ Per the [Mustache spec](https://mustache.github.io/mustache.5.html#Variables), `{{.}}` means _"the value currently sitting atop the context stack"_. When the context is a primitive string or number, `{{.}}` renders it directly.
12
+
13
+ ### What the pipeline already handles
14
+
15
+ | Component | Status | Notes |
16
+ | ---------- | ------------------- | ----------------------------------------------------------------------------- |
17
+ | `Tag` | **Change required** | `{{{.}}}` and `{{&.}}` incorrectly classified as `type: "variable"` |
18
+ | `Tokenize` | No change | Emits `TokenizeTagEvent` with the type set by `Tag` |
19
+ | `Queue` | No change | Routes `"implicit"` events to registered listeners |
20
+ | `Template` | **Change required** | `#handleImplicit` is a no-op |
21
+
22
+ ---
23
+
24
+ ## Implementation
25
+
26
+ ### `Tag/Tag.ts` — fix `#extractValueInformation`
27
+
28
+ Two code paths misclassify implicit raw tags as `"variable"`:
29
+
30
+ **1. `{{{.}}}` (triple-brace):** The `rawBraces` guard returns early before the value is inspected.
31
+
32
+ ```typescript
33
+ // before
34
+ if (this.#rawBraces) {
35
+ this.#type = "variable";
36
+ this.#raw = true;
37
+ return;
38
+ }
39
+
40
+ // after
41
+ if (this.#rawBraces) {
42
+ this.#raw = true;
43
+ this.#type = this.value === "." ? "implicit" : "variable";
44
+ return;
45
+ }
46
+ ```
47
+
48
+ **2. `{{&.}}` (ampersand):** The `&` case sets `raw = true` but leaves `tagType` as `"variable"`. Add an override when the remaining value is `.`:
49
+
50
+ ```typescript
51
+ case "&": {
52
+ raw = true;
53
+ if (normalizedTagValue.slice(1) === ".") {
54
+ tagType = "implicit";
55
+ }
56
+ break;
57
+ }
58
+ ```
59
+
60
+ After both fixes, all three forms — `{{.}}`, `{{{.}}}`, `{{&.}}` — produce `type: "implicit"`. The `raw` flag distinguishes whether output should be HTML-escaped.
61
+
62
+ ---
63
+
64
+ ### `template/Template.ts` — implement `#handleImplicit`
65
+
66
+ Replace the no-op with logic that reads the current context and writes to output.
67
+
68
+ ```typescript
69
+ async #handleImplicit({ data: tag }: TokenizeTagEvent): Promise<void> {
70
+ const ctx = this.context;
71
+
72
+ if (isPrimitive(ctx)) {
73
+ this.#writeToOutput(
74
+ tag.raw ? "" + ctx : escape("" + ctx)
75
+ );
76
+ }
77
+ }
78
+ ```
79
+
80
+ - `this.context` returns `this.#contextProvider?.context || {}`.
81
+ - `isPrimitive` is already defined in the file — covers `string`, `number`, `boolean`.
82
+ - `tag.raw` is `false` for `{{.}}`, `true` for `{{{.}}}` and `{{&.}}`.
83
+ - Non-primitive context (object, array) silently skips output, consistent with `#handleVariable`.
84
+
85
+ ---
86
+
87
+ ## Tests
88
+
89
+ ### `Tag/Tag.spec.ts` — add cases for raw implicit tags
90
+
91
+ ```typescript
92
+ describe("implicit iterator", () => {
93
+ it("triple-brace {{{.}}} produces type implicit with raw true", () => {
94
+ const tag = Tag.parse("{{{.}}}");
95
+ expect(tag?.type).toBe("implicit");
96
+ expect(tag?.raw).toBe(true);
97
+ expect(tag?.key).toBe(".");
98
+ });
99
+
100
+ it("ampersand {{&.}} produces type implicit with raw true", () => {
101
+ const tag = Tag.parse("{{&.}}");
102
+ expect(tag?.type).toBe("implicit");
103
+ expect(tag?.raw).toBe(true);
104
+ expect(tag?.key).toBe(".");
105
+ });
106
+ });
107
+ ```
108
+
109
+ ### `template/Template.spec.ts` — add to the `"Template"` describe block
110
+
111
+ ```typescript
112
+ describe("implicit iterator", () => {
113
+ async function render(template: string, context: string | number): Promise<string[]> {
114
+ return new Promise<string[]>((resolve) => {
115
+ const calls: string[] = [];
116
+ new Template({
117
+ contextProvider: { context },
118
+ readable: createReadableStream(template).pipeThrough(new TextDecoderStream()),
119
+ writeToOutput: async (text) => { calls.push(text); },
120
+ })
121
+ .on("inactive", () => resolve(calls))
122
+ .read();
123
+ });
124
+ }
125
+
126
+ it("renders a string context", async () => {
127
+ expect(await render("{{.}}", "hello")).toEqual(["hello"]);
128
+ });
129
+
130
+ it("renders a number context", async () => {
131
+ expect(await render("{{.}}", 42)).toEqual(["42"]);
132
+ });
133
+
134
+ it("HTML-escapes the context value", async () => {
135
+ expect(await render("{{.}}", '& " < >')).toEqual(["&amp; &quot; &lt; &gt;"]);
136
+ });
137
+
138
+ it("renders surrounding text alongside the implicit value", async () => {
139
+ expect(await render("Hello, {{.}}!", "world")).toEqual(["Hello, ", "world", "!"]);
140
+ });
141
+
142
+ it("produces no output when context is an object", async () => {
143
+ expect(await render("{{.}}", { name: "rich" } as any)).toEqual([]);
144
+ });
145
+
146
+ it("triple-brace {{{.}}} outputs without HTML escaping", async () => {
147
+ expect(await render('{{{.}}}', '& " < >')).toEqual(['& " < >']);
148
+ });
149
+
150
+ it("ampersand {{&.}} outputs without HTML escaping", async () => {
151
+ expect(await render('{{&.}}', '& " < >')).toEqual(['& " < >']);
152
+ });
153
+ });
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Files Changed
159
+
160
+ | File | Change |
161
+ | --- | --- |
162
+ | `Tag/Tag.ts` | Fix `#extractValueInformation` — `{{{.}}}` and `{{&.}}` → `type: "implicit"` |
163
+ | `Tag/Tag.spec.ts` | Add `"implicit iterator"` cases for triple-brace and ampersand forms |
164
+ | `template/Template.ts` | Replace no-op `#handleImplicit` with primitive-context rendering logic |
165
+ | `template/Template.spec.ts` | Add `"implicit iterator"` describe block with 7 test cases |
166
+
167
+ ---
168
+
169
+ ## Open Questions
170
+
171
+ These questions should be answered before extending the implementation beyond the current scope.
172
+
173
+ 1. **`{{{.}}}` and `{{&.}}` (raw implicit iterator):** Both produce a `"variable"` event with `tag.key === "."` and `tag.raw === true`. `#handleVariable` calls `getContextValue` which will return `undefined` for a primitive context (because `isContext()` returns `false` for primitives and `getContextValue` skips the lookup). Should `#handleVariable` detect `tag.key === "."` and fall back to `this.context`, or should `Tag` be changed so that `{{{.}}}` produces an `"implicit"` event with `raw: true`?
174
+
175
+ - **Spec finding:** The Mustache interpolation spec explicitly defines both forms as valid:
176
+ - `{{{.}}}` — "Triple mustaches should interpolate without HTML escaping."
177
+ - `{{&.}}` — "Ampersand should interpolate without HTML escaping."
178
+ Both must be supported.
179
+ - **Root cause:** `Tag.#extractValueInformation` returns early when `rawBraces === true`, setting `type: "variable"` before checking whether the value is `"."`. As a result `{{{.}}}` never reaches the `"implicit"` branch.
180
+ - **Recommended fix:** Add a check to `#handleVariable` — if `tag.key === "."`, delegate to the same primitive-context logic used in `#handleImplicit` (respecting `tag.raw` for escaping). This avoids touching `Tag`, which is shared across v1 and v2, and keeps the implicit iterator logic contained within `Template`.
181
+ - **Files changed:** add `template/Template.ts` (`#handleVariable` guard for `tag.key === "."`) and corresponding tests to the `"implicit iterator"` describe block covering `{{{.}}}` and `{{&.}}` with `& " < >` as context.
182
+
183
+ - It's OK to break v1. Propose an update to `Tag.#extractValueInformation` to properly handle a "raw" iterator tag.
184
+
185
+ 2. **Implicit iterator inside sections:** When `{{#list}}{{.}}{{/list}}` is processed, `{{.}}` appears inside a section block. The section handler (not yet implemented) will need to pass each list item as the context for a child `Template`. Should the child `Template` receive the list item as its `contextProvider.context`? If so, what interface should that context provider take — the same `ContextProvider` interface with a primitive `context` value?
186
+
187
+ - **Spec finding:** The Mustache sections spec defines list items as any of: `string`, `integer`, `decimal`, nested `array`, or `object`. Examples:
188
+ - strings: `['a', 'b', 'c']` → child context is a primitive string
189
+ - integers/decimals: `[1, 2, 3.3]` → child context is a primitive number
190
+ - arrays: `[[1, 2, 3], ['a', 'b', 'c']]` → child context is an array (for nested iteration via `{{#.}}{{.}}{{/.}}`)
191
+ - objects: `[{ value: 'a' }]` → child context is an object (for key lookup)
192
+ - **`ContextProvider` compatibility:** `ContextTypes` already covers all cases in scope:
193
+ - Primitives (string, number, boolean) via `VariableTagContext → VariableTagContextPrimitive → JsonType`
194
+ - 1-D primitive arrays via `VariableTagContext → VariableTagContextPrimitive → Array<boolean | number | string>`
195
+ - Objects via `Context` or `SectionTagContextRecord`
196
+ - Nested arrays (e.g. `[[1,2,3]]`) are **not** representable by the current types — this is an out-of-scope gap to note.
197
+ - **Answer:** No interface changes are required for the string/number scope. Each child `Template` for a list item should receive `{ context: item }` as its `contextProvider`, which is valid since `ContextTypes` includes primitives. When section rendering is implemented, the `contextProvider` passed to the child `Template` should also hold a reference to the parent `Template` as a fallback (for the context-stack miss behavior described in question 3).
198
+
199
+ 3. **Context stack vs. flat context:** The current `Template` has a single `contextProvider`. The Mustache spec describes a _context stack_ where inner sections push new frames. For the implicit iterator, "current context" means the top of that stack. Does the architecture need a context stack, or is it sufficient for each nested `Template` instance (created when rendering sections) to hold its own frame and delegate to its parent for misses?
200
+
201
+ - Each Template receives a ContextProvider, this has the context for the current Template and the ability to call a parent ContextProvider (if any) to get context values that are not found in the current context. See `Template.getContextValue`.
202
+
203
+ 4. **Float formatting:** When context is a `number` with decimal places (e.g. `3.14`), `"" + 3.14` gives `"3.14"`. Is this the desired output, or should a specific number-formatting strategy be applied?
204
+
205
+ - The default ECMAScript formatting of primitives shall be applied. If clients need more precise formatting it's their responsibility to supply that value in a string.
206
+
207
+ 5. **Boolean context:** `ContextTypes` allows `boolean` via `JsonType`. Should `{{.}}` with a boolean context render `"true"` / `"false"`, or produce no output (as with object context)?
208
+
209
+ - The default ECMAScript conversion from boolean to string shall be applied.
210
+
211
+ 6. **Existing broken test:** `Template.spec.ts` line 51–72 (`"processes a nested, implicit, template"`) has a template `"aa{{#ee}}{{#.}}{{.}}{{/.}}{{/ee}}zz"` but asserts `"ab"`, `"HELLO WORLD"`, `"yz"` — values inconsistent with the template. This test will need to be corrected when section rendering is implemented.
212
+
213
+ - agreed, skip test for now
@@ -0,0 +1,160 @@
1
+ # Plan: Inverted Section Tags (`{{^s}}…{{/s}}`)
2
+
3
+ ## Objective
4
+
5
+ Implement rendering of `{{^key}}…{{/key}}` (inverted section) blocks in the v2 streaming template engine per the [Mustache spec](https://mustache.github.io/mustache.5.html#Inverted-Sections). Scope is limited to the `inverted` tag type.
6
+
7
+ ---
8
+
9
+ ## Background
10
+
11
+ `Tag` already parses `{{^key}}` as `type: "inverted"`. `Tokenize` emits these tokens correctly. However, `Queue` only accumulates and emits blocks for `section` tags — it ignores `inverted` open tags and errors on their closing `end` tags. `Template` has no `#handleInverted` method.
12
+
13
+ ### What the pipeline already handles
14
+
15
+ | Component | Status | Notes |
16
+ | ---------- | ------------------- | ----------------------------------------------------------------- |
17
+ | `Tag` | No change | `{{^key}}` → `type: "inverted"`, `{{/key}}` → `type: "end"` |
18
+ | `Tokenize` | No change | Emits tokens correctly |
19
+ | `Queue` | **Change required** | Does not accumulate `inverted` blocks; errors on their `end` tags |
20
+ | `Template` | **Change required** | No `#handleInverted` handler |
21
+
22
+ ---
23
+
24
+ ## Mustache Spec Summary
25
+
26
+ | Context value | Behavior |
27
+ | -------------------------------- | ---------------------- |
28
+ | `false` | Block rendered |
29
+ | `undefined` / key not in context | Block rendered |
30
+ | Empty string `""` | Block rendered |
31
+ | Empty list `[]` | Block rendered |
32
+ | Any truthy value | Block **not** rendered |
33
+
34
+ Inverted sections do not push the context, do not iterate, and do not support lambdas.
35
+
36
+ ---
37
+
38
+ ## Questions
39
+
40
+ The following must be answered before proceeding to implementation.
41
+
42
+ 1. **Number zero**: Should `0` (falsy in JavaScript but not listed in the Mustache spec's falsy values) cause an inverted block to render? The section implementation skips rendering for `false`, `undefined`, `""`, and `[]` but does not explicitly exclude `0`. Should inverted sections treat `0` as falsy (render) for consistency?
43
+
44
+ - yes
45
+
46
+ 2. **Null**: Should `null` (not in the current section falsy guard) cause the inverted block to render?
47
+
48
+ - yes
49
+
50
+ ---
51
+
52
+ ## Implementation
53
+
54
+ ### `queue/Queue.ts`
55
+
56
+ #### Changes
57
+
58
+ 1. Add `inverted: TokenizeAllEvent[]` to `QueueEventMap`.
59
+ 2. In `#processToken`, add `case "inverted":` alongside `case "section":` to start accumulation (same logic).
60
+ 3. In the `end` tag handler, add a branch for `start.data.type === "inverted"` that emits the `"inverted"` event (parallel to the existing `"section"` emit).
61
+
62
+ The `start.data.type === "section"` check currently guards the final emit. Extend it:
63
+
64
+ ```typescript
65
+ if (start.data.type === "section" || start.data.type === "inverted") {
66
+ await this.#emit(start.data.type, [...Array.from(this.#accumulator), token]);
67
+ this.#accumulator = undefined;
68
+ } else {
69
+ /* existing error */
70
+ }
71
+ ```
72
+
73
+ And in the open-tag switch block:
74
+
75
+ ```typescript
76
+ case "inverted":
77
+ case "section": {
78
+ if (!this.#accumulator) {
79
+ const acc: Accumulator = [token] as any;
80
+ acc.level = 0;
81
+ this.#accumulator = acc;
82
+ } else {
83
+ this.#accumulator.level++;
84
+ this.#accumulator.push(token);
85
+ }
86
+ break;
87
+ }
88
+ ```
89
+
90
+ ### `template/Template.ts`
91
+
92
+ #### Register handler
93
+
94
+ In the `#queue` getter, add:
95
+
96
+ ```typescript
97
+ .on("inverted", this.#handleInverted.bind(this))
98
+ ```
99
+
100
+ #### `#handleInverted` implementation
101
+
102
+ ```typescript
103
+ async #handleInverted(events: TokenizeAllEvent[]): Promise<void> {
104
+ await this.#flushPendingWhitespace();
105
+ const startEvent = events[0];
106
+ if (!startEvent || startEvent.type !== "tag") {
107
+ return;
108
+ }
109
+
110
+ const value = await this.getContextValue(startEvent.data);
111
+
112
+ const isFalsy =
113
+ value === undefined ||
114
+ value === false ||
115
+ value === "" ||
116
+ (Array.isArray(value) && value.length === 0);
117
+
118
+ if (!isFalsy) {
119
+ return;
120
+ }
121
+
122
+ const templateStr = events
123
+ .slice(1, -1)
124
+ .map((e) => e.data.toString())
125
+ .join("");
126
+
127
+ await this.#renderSectionContent(templateStr, this.context);
128
+ }
129
+ ```
130
+
131
+ Note: `#renderSectionContent` is reused from the sections implementation. The context passed is `this.context` (no context push — inverted sections do not change the context stack).
132
+
133
+ ---
134
+
135
+ ## Tests (TDD — phases in complexity order)
136
+
137
+ All tests go in `template/Template-inverted-section.spec.ts` with the same `render` helper pattern used in `Template-section.spec.ts`.
138
+
139
+ | # | Template | Context | Expected output |
140
+ | --- | ---------------------------- | ----------------------------- | --------------- |
141
+ | 1 | `{{^s}}text{{/s}}` | `{ s: false }` | `["text"]` |
142
+ | 2 | `{{^s}}text{{/s}}` | `{ s: true }` | `[]` |
143
+ | 3 | `{{^s}}text{{/s}}` | `{}` (key absent) | `["text"]` |
144
+ | 4 | `{{^s}}text{{/s}}` | `{ s: [] }` | `["text"]` |
145
+ | 5 | `{{^s}}text{{/s}}` | `{ s: "" }` | `["text"]` |
146
+ | 6 | `{{^s}}text{{/s}}` | `{ s: [1, 2] }` | `[]` |
147
+ | 7 | `{{^s}}text{{/s}}` | `{ s: {} }` | `[]` |
148
+ | 8 | `{{^s}}{{name}}{{/s}}` | `{ s: false, name: "Alice" }` | `["Alice"]` |
149
+ | 9 | `{{#a}}X{{/a}}{{^a}}Y{{/a}}` | `{ a: true }` | `["X"]` |
150
+ | 10 | `{{#a}}X{{/a}}{{^a}}Y{{/a}}` | `{ a: false }` | `["Y"]` |
151
+
152
+ ---
153
+
154
+ ## Files Changed
155
+
156
+ | File | Change |
157
+ | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
158
+ | `queue/Queue.ts` | Handle `inverted` open tag (accumulation); emit `"inverted"` event on close; add `inverted` to `QueueEventMap` |
159
+ | `template/Template.ts` | Register `"inverted"` listener; implement `#handleInverted` |
160
+ | `template/Template-inverted-section.spec.ts` | New file — 10 test cases |