@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,237 @@
1
+ # Plan: Partials (`{{> name}}`)
2
+
3
+ ## Scope
4
+
5
+ Implement `{{> name}}` partial tags per the [Mustache spec](https://mustache.github.io/mustache.5.html#Partials).
6
+
7
+ ---
8
+
9
+ ## Mustache Spec Summary
10
+
11
+ - `{{> name}}` includes the partial named `name` rendered with the current context.
12
+ - A partial missing from the context produces no output (spec: treated as empty string).
13
+ - Standalone partial tags (only tag on a line, possibly with leading whitespace) consume the trailing newline; the leading whitespace becomes the indent prepended to every line of the partial's content.
14
+ - Partials may be recursive.
15
+
16
+ ---
17
+
18
+ ## Resolved Decisions
19
+
20
+ 1. **Context mechanism** — `PartialTagContextLambda` is the only mechanism in v2. File loading is a client concern: the client receives the partial name via `Tag` and returns a `ReadableStream`. `TemplateName` remains a type alias for use by clients outside v2.
21
+ 2. **Missing partial** — Silently produces no output (spec compliant).
22
+ 3. **Indentation** — In scope; standalone detection and indent application are required.
23
+ 4. **Recursive partials** — No depth limit; the child `Template` approach handles it naturally.
24
+ 5. **`PartialTagContextRecord`** — Remove from `PartialTagContext`; it duplicates `Context`. See types change below.
25
+
26
+ ---
27
+
28
+ ## Changes Required
29
+
30
+ ### `types.ts`
31
+
32
+ Remove `PartialTagContextRecord` from `PartialTagContext` and delete the interface:
33
+
34
+ ```typescript
35
+ // Before
36
+ export type PartialTagContext =
37
+ | PartialTagContextLambda
38
+ | PartialTagContextRecord
39
+ | TemplateName;
40
+
41
+ export interface PartialTagContextRecord {
42
+ [key: string]: JsonType | PartialTagContextLambda | PartialTagContextRecord;
43
+ }
44
+
45
+ // After
46
+ export type PartialTagContext = PartialTagContextLambda | TemplateName;
47
+ ```
48
+
49
+ `Context` already covers object-shaped context values, so `PartialTagContextRecord` is redundant.
50
+
51
+ ---
52
+
53
+ ### `Template` (`template/Template.ts`)
54
+
55
+ #### Indentation: deferred whitespace approach
56
+
57
+ The spec's standalone/indent behavior requires knowing whether the partial tag is the only non-whitespace on its line. To support this, `Template` defers writing horizontal whitespace at the start of a line until it knows what follows. Non-whitespace text is always written immediately.
58
+
59
+ New private state:
60
+
61
+ ```typescript
62
+ #pendingWhitespace = ""; // horizontal whitespace buffered since last newline
63
+ #lineHasContent = false; // true if current line has non-whitespace already written
64
+ #consumeNextNewline = false;
65
+ ```
66
+
67
+ **`#handleText` change** — flush non-whitespace immediately; buffer only trailing horizontal whitespace on the current line:
68
+
69
+ ```typescript
70
+ async #handleText(event: TokenizeTextEvent): Promise<void> {
71
+ let text = event.data;
72
+ // After a standalone partial the trailing newline belongs to the partial tag, not the output.
73
+ if (this.#consumeNextNewline) {
74
+ if (text.startsWith("\r\n")) text = text.slice(2);
75
+ else if (text.startsWith("\n")) text = text.slice(1);
76
+ this.#consumeNextNewline = false;
77
+ if (!text) return;
78
+ }
79
+
80
+ const lastNewlineEnd = lastNewlineIndex(text);
81
+ if (lastNewlineEnd !== -1) {
82
+ // Flush everything up to and including the last newline, then reset line state.
83
+ await this.#writeToOutput(this.#pendingWhitespace + text.slice(0, lastNewlineEnd));
84
+ this.#pendingWhitespace = "";
85
+ this.#lineHasContent = false;
86
+ text = text.slice(lastNewlineEnd);
87
+ if (!text) return;
88
+ }
89
+
90
+ // text contains no newlines — process as current-line fragment.
91
+ if (isHorizontalWhitespace(text)) {
92
+ this.#pendingWhitespace += text;
93
+ } else {
94
+ await this.#writeToOutput(this.#pendingWhitespace + text);
95
+ this.#pendingWhitespace = "";
96
+ this.#lineHasContent = true;
97
+ }
98
+ }
99
+ ```
100
+
101
+ `lastNewlineIndex` returns the index just past the last `\n` (or `\r\n`).
102
+ `isHorizontalWhitespace` returns true if the string contains only spaces and tabs.
103
+
104
+ `#pendingWhitespace` therefore only ever holds `[ \t]*` — at most a few characters — keeping memory overhead minimal even for large template files.
105
+
106
+ **`#handlePartial` implementation:**
107
+
108
+ ```typescript
109
+ async #handlePartial(event: TokenizeTagEvent): Promise<void> {
110
+ const partialContext = await this.getContextValue<PartialTagContextLambda>(event.data);
111
+ if (typeof partialContext !== "function") {
112
+ await this.#flushPendingWhitespace();
113
+ return;
114
+ }
115
+
116
+ // Standalone: current line has no non-whitespace content yet
117
+ const isStandalone = !this.#lineHasContent;
118
+ const indent = isStandalone ? this.#pendingWhitespace : "";
119
+
120
+ if (isStandalone) {
121
+ this.#pendingWhitespace = "";
122
+ this.#consumeNextNewline = true;
123
+ } else {
124
+ await this.#flushPendingWhitespace();
125
+ }
126
+
127
+ const { input, context } = await partialContext(event.data);
128
+ const readable = await input();
129
+
130
+ await this.#renderPartialContent(readable, context, indent);
131
+ }
132
+ ```
133
+
134
+ **`#renderPartialContent` (new private method):**
135
+
136
+ ```typescript
137
+ async #renderPartialContent(
138
+ readable: ReadableStream,
139
+ context: ContextTypes | undefined,
140
+ indent: string
141
+ ): Promise<void> {
142
+ return new Promise<void>((resolve) => {
143
+ const childContextProvider: ContextProvider = {
144
+ context: context ?? this.context,
145
+ getContextValue: <CTX extends ContextTypes>(tag: TagType) =>
146
+ this.getContextValue<CTX>(tag),
147
+ };
148
+
149
+ const indentingWriter: WriteToOutput = indent
150
+ ? makeIndentingWriter(this.#writeToOutput, indent)
151
+ : this.#writeToOutput;
152
+
153
+ new Template({
154
+ contextProvider: childContextProvider,
155
+ readable: readable as ReadableStream<string>,
156
+ writeToOutput: indentingWriter,
157
+ })
158
+ .on("inactive", () => resolve())
159
+ .read();
160
+ });
161
+ }
162
+ ```
163
+
164
+ **`#flushPendingWhitespace` (new private method):**
165
+
166
+ ```typescript
167
+ async #flushPendingWhitespace(): Promise<void> {
168
+ if (this.#pendingWhitespace) {
169
+ await this.#writeToOutput(this.#pendingWhitespace);
170
+ this.#pendingWhitespace = "";
171
+ }
172
+ }
173
+ ```
174
+
175
+ **`#handleInactive` change** — flush any buffered whitespace before emitting:
176
+
177
+ ```typescript
178
+ async #handleInactive(): Promise<void> {
179
+ await this.#flushPendingWhitespace();
180
+ this.emit("inactive", undefined);
181
+ }
182
+ ```
183
+
184
+ All other handlers (`#handleVariable`, `#handleImplicit`, `#handleSection`) must call `#flushPendingWhitespace()` before their own logic.
185
+
186
+ **`makeIndentingWriter` (new module-level helper):**
187
+
188
+ Prepends `indent` after every newline in the output stream, and before the first write (since the standalone indent was not written):
189
+
190
+ ```typescript
191
+ function makeIndentingWriter(
192
+ writeToOutput: WriteToOutput,
193
+ indent: string
194
+ ): WriteToOutput {
195
+ let atLineStart = true;
196
+ return async (text: string) => {
197
+ const indented = (atLineStart ? indent : "") +
198
+ text.replace(/\n(?!$)/g, "\n" + indent);
199
+ atLineStart = text.endsWith("\n");
200
+ return writeToOutput(indented);
201
+ };
202
+ }
203
+ ```
204
+
205
+ ---
206
+
207
+ ### No changes required to
208
+
209
+ - `Parse` — unchanged
210
+ - `Tokenize` — unchanged
211
+ - `Queue` — already emits `"partial"` events
212
+
213
+ ---
214
+
215
+ ## Unit Test File
216
+
217
+ ```
218
+ libs/stache-stream/src/lib/v2/template/Template-partials.spec.ts
219
+ ```
220
+
221
+ ---
222
+
223
+ ## Test Phases (TDD)
224
+
225
+ | # | Template | Context | Expected output |
226
+ |---|---|---|---|
227
+ | 1 | `{{> p}}` | `{ p: lambda → "hello" }` | `["hello"]` |
228
+ | 2 | `before {{> p}} after` | `{ p: lambda → "mid" }` | `["before ", "mid", " after"]` |
229
+ | 3 | `{{> missing}}` | `{}` | `[]` |
230
+ | 4 | `{{> p}}` | `{ p: lambda → "{{name}}", context: { name: "Alice" } }` | `["Alice"]` — partial uses lambda-provided context |
231
+ | 5 | `{{> p}}` | `{ name: "Bob", p: lambda → "{{name}}" }` | `["Bob"]` — partial inherits parent context |
232
+ | 6 | `{{#items}}{{> p}}{{/items}}` | `{ items: [{ name: "A" }, { name: "B" }], p: lambda → "{{name}}" }` | `["A", "B"]` |
233
+ | 7 | `"{{> outer}}"` where outer = `"{{> inner}}"`, inner = `"hi"` | lambdas | `["hi"]` — recursive include |
234
+ | 8 | `" {{> p}}\n"` (standalone with 2-space indent) | `{ p: lambda → "a\nb" }` | `[" a\n b"]` — indent applied to each line |
235
+ | 9 | `" {{> p}}\n"` (standalone) | `{ p: lambda → "line1\nline2\n" }` | indent on every line, trailing newline preserved |
236
+ | 10 | `"text {{> p}}\n"` (non-standalone) | `{ p: lambda → "a\nb" }` | `["text ", "a\nb"]` — no indent applied |
237
+ | 11 | `{{#s}}{{> p}}{{/s}}` | `{ name: "root", s: { }, p: lambda → "{{name}}" }` | `["root"]` — partial inside section falls back through section context to root context |
@@ -0,0 +1,167 @@
1
+ # Plan: Section Tags (`{{#s}}…{{/s}}`)
2
+
3
+ ## Objective
4
+
5
+ Implement rendering of `{{#section}}…{{/section}}` blocks in the v2 streaming template engine per the [Mustache spec](https://mustache.github.io/mustache.5.html#Sections). Scope is limited to the `section` tag type; `{{^inverted}}` blocks are out of scope.
6
+
7
+ ---
8
+
9
+ ## Background
10
+
11
+ The `Queue` already accumulates all tokens between a section open-tag and its matching close-tag into a `TokenizeAllEvent[]` and fires a `"section"` event. `Template.#handleSection` is a no-op. This plan implements that handler.
12
+
13
+ ### What the pipeline already handles
14
+
15
+ | Component | Status | Notes |
16
+ | --- | --- | --- |
17
+ | `Tag` | No change | `{{#key}}` → `type: "section"`, `{{/key}}` → `type: "end"` |
18
+ | `Tokenize` | No change | Emits tokens correctly |
19
+ | `Queue` | No change | Accumulates full section block; emits `"section"` with `TokenizeAllEvent[]` |
20
+ | `Template` | **Change required** | `#handleSection` is a no-op |
21
+
22
+ ---
23
+
24
+ ## Mustache Spec Summary
25
+
26
+ Per the spec, a section renders its block zero or more times depending on the context value at its key:
27
+
28
+ | Context value | Behavior |
29
+ | --- | --- |
30
+ | `false`, empty string `""`, empty list `[]` | Block not rendered |
31
+ | `undefined` / key not in context | Block not rendered |
32
+ | Non-empty list | Block rendered once per item; each item becomes the context |
33
+ | Lambda `(content: string) => string` | Called with raw (unrendered) section content; result written to output |
34
+ | Any other truthy value (object, `true`, number) | Block rendered once; value pushed onto context stack |
35
+
36
+ ---
37
+
38
+ ## Implementation
39
+
40
+ ### `template/Template.ts` — implement `#handleSection`
41
+
42
+ #### Key design decisions
43
+
44
+ 1. **Template string reconstruction**: Inner tokens (between open and close tags) are converted back to a template string via `tokenEventToString`. Text tokens use `event.data`; tag tokens use `event.data.toString()` (which returns the original `{{…}}` form).
45
+
46
+ 2. **Child `Template` per render**: Each section render creates a child `Template` with a `ReadableStream<string>` built from the reconstructed template string and a `contextProvider` that:
47
+ - sets `context` to the item being rendered
48
+ - sets `getContextValue` to the parent template's `getContextValue`, enabling the Mustache context-stack fall-through behavior
49
+
50
+ 3. **Lambda**: Detected via `isSectionLambda` type guard (`typeof === "function" && length === 1`). Called synchronously with the raw template string; result written directly to output.
51
+
52
+ 4. **Falsy guard**: `undefined`, `false`, `""`, and `[]` (empty array) all suppress rendering. All other values render at least once.
53
+
54
+ #### New module-level helpers
55
+
56
+ ```typescript
57
+ function stringToReadableStream(str: string): ReadableStream<string> {
58
+ return new ReadableStream<string>({
59
+ start(controller) {
60
+ controller.enqueue(str);
61
+ controller.close();
62
+ },
63
+ });
64
+ }
65
+
66
+ function isSectionLambda(
67
+ value: ContextTypes
68
+ ): value is SectionTagContextSpecFunction {
69
+ return typeof value === "function";
70
+ }
71
+ ```
72
+
73
+ #### `#handleSection` implementation
74
+
75
+ ```typescript
76
+ async #handleSection(events: TokenizeAllEvent[]): Promise<void> {
77
+ const startEvent = events[0];
78
+ if (!startEvent || startEvent.type !== "tag") {
79
+ return;
80
+ }
81
+
82
+ const sectionContext = await this.getContextValue(startEvent.data);
83
+
84
+ if (
85
+ sectionContext === undefined ||
86
+ sectionContext === false ||
87
+ sectionContext === "" ||
88
+ (Array.isArray(sectionContext) && sectionContext.length === 0)
89
+ ) {
90
+ return;
91
+ }
92
+
93
+ const innerEvents = events.slice(1, -1);
94
+ const templateStr = innerEvents.map((e) => e.data.toString()).join("");
95
+
96
+ if (isSectionLambda(sectionContext)) {
97
+ await this.#writeToOutput(sectionContext(templateStr));
98
+ return;
99
+ }
100
+
101
+ const items: ContextTypes[] = Array.isArray(sectionContext)
102
+ ? sectionContext
103
+ : [sectionContext];
104
+
105
+ for (const item of items) {
106
+ await this.#renderSectionContent(templateStr, item);
107
+ }
108
+ }
109
+ ```
110
+
111
+ #### `#renderSectionContent` implementation
112
+
113
+ ```typescript
114
+ async #renderSectionContent(
115
+ templateStr: string,
116
+ context: ContextTypes
117
+ ): Promise<void> {
118
+ return new Promise<void>((resolve) => {
119
+ const childContextProvider: ContextProvider = {
120
+ context,
121
+ getContextValue: <CTX extends ContextTypes>(
122
+ tag: TagType
123
+ ): Promise<CTX | undefined> => this.getContextValue<CTX>(tag),
124
+ };
125
+
126
+ new Template({
127
+ contextProvider: childContextProvider,
128
+ readable: stringToReadableStream(templateStr),
129
+ writeToOutput: this.#writeToOutput,
130
+ })
131
+ .on("inactive", () => resolve())
132
+ .read();
133
+ });
134
+ }
135
+ ```
136
+
137
+ #### Import additions
138
+
139
+ Add `SectionTagContextSpecFunction` to the import from `"../../types"`.
140
+
141
+ ---
142
+
143
+ ## Tests (TDD — phases in complexity order)
144
+
145
+ All tests go in a `describe("sections", …)` block in `template/Template.spec.ts` with a shared `render` helper.
146
+
147
+ | # | Template | Context | Expected output |
148
+ | --- | --- | --- | --- |
149
+ | 1 | `{{#s}}text{{/s}}` | `{ s: true }` | `["text"]` |
150
+ | 2 | `{{#s}}text{{/s}}` | `{ s: false }` | `[]` |
151
+ | 3 | `{{#s}}text{{/s}}` | `{ s: [] }` | `[]` |
152
+ | 4 | `{{#s}}{{name}}{{/s}}` | `{ s: { name: "Alice" } }` | `["Alice"]` |
153
+ | 5 | `{{#s}}{{name}}{{/s}}` | `{ s: [{ name: "Alice" }, { name: "Bob" }] }` | `["Alice", "Bob"]` |
154
+ | 6 | `{{#s}}{{.}}{{/s}}` | `{ s: ["a", "b"] }` | `["a", "b"]` |
155
+ | 7 | `{{#s}}text{{/s}}` | `{ s: (c) => \`<em>${c}</em>\` }` | `["<em>text</em>"]` |
156
+ | 8 | `{{#outer}}{{#inner}}text{{/inner}}{{/outer}}` | `{ outer: { inner: true } }` | `["text"]` |
157
+ | 9 | `{{#s}}{{name}}{{/s}}` | `{ s: true, name: "Alice" }` | `["Alice"]` |
158
+ | 10 | `a {{#outer}} L {{#inner}}text{{/inner}} N {{/outer}} z` | `{ outer: { inner: true } }` | `["a ", " L ", "text", " N ", " z"]` |
159
+
160
+ ---
161
+
162
+ ## Files Changed
163
+
164
+ | File | Change |
165
+ | --- | --- |
166
+ | `template/Template.ts` | Implement `#handleSection` and `#renderSectionContent`; add `stringToReadableStream`, `isSectionLambda` helpers; import `SectionTagContextSpecFunction` |
167
+ | `template/Template.spec.ts` | Add `"sections"` describe block with 9 test cases |
@@ -0,0 +1,110 @@
1
+ # Plan: Update StacheTransformStream to Use v2 Components
2
+
3
+ ## Goal
4
+
5
+ Replace `StacheTransformStream`'s dependency on the v1 `Template` (character-push model) with the v2 `Template`
6
+ (streaming `ReadableStream<string>` model). All Mustache tag types currently supported by v2 must be covered by
7
+ tests on `StacheTransformStream`.
8
+
9
+ ---
10
+
11
+ ## Background
12
+
13
+ `StacheTransformStream` is a `TransformStream<string, string>`. Its current `#transform(chunk, controller)` method
14
+ feeds chunks one at a time into the v1 `Template.push(chunk)`. The v1 `Template` is a character-level state machine.
15
+
16
+ The v2 `Template` has a different contract:
17
+
18
+ - **Constructor** receives a `ReadableStream<string>`, a `writeToOutput` sink, and an optional `contextProvider`.
19
+ - **`read()`** starts the internal `Parse → Tokenize → Queue` pipeline.
20
+ - **`"inactive"` event** is emitted when all output has been written.
21
+
22
+ Bridging `TransformStream` callbacks to `ReadableStream` requires a "pushable" `ReadableStream` — a stream whose
23
+ controller is captured at construction time so external code can enqueue chunks and close it on demand.
24
+
25
+ ---
26
+
27
+ ## Tag Types Supported by v2 (Scope)
28
+
29
+ | Tag syntax | Type | Status in v2 |
30
+ | ------------------------ | ------------------ | ------------ |
31
+ | plain text | — | implemented |
32
+ | `{{key}}` | variable (escaped) | implemented |
33
+ | `{{{key}}}` / `{{&key}}` | variable (raw) | implemented |
34
+ | `{{.}}` | implicit iterator | implemented |
35
+ | `{{>name}}` | partial | implemented |
36
+ | `{{#key}}…{{/key}}` | section | implemented |
37
+
38
+ ---
39
+
40
+ ## Changes
41
+
42
+ ### 1. `StacheStream.ts`
43
+
44
+ Replace the class body. The new implementation:
45
+
46
+ 1. Creates a pushable `ReadableStream<string>` whose controller is captured synchronously in its `start` callback.
47
+ 2. Instantiates the v2 `Template` with that `ReadableStream`, a `writeToOutput` that calls
48
+ `controller.enqueue(text)`, and the caller-supplied `contextProvider`.
49
+ 3. Calls `template.read()` to start the pipeline.
50
+ 4. In `transform(chunk)`: enqueues `chunk` into the pushable stream.
51
+ 5. In `flush()`: closes the pushable stream and returns a `Promise` that resolves when the `"inactive"` event fires.
52
+
53
+ A private helper `createPushableStream<T>()` is introduced to keep the constructor body readable. It returns
54
+ `{ readable, push, close }` using the `ReadableStream` `start` callback to capture the controller (no `as` cast
55
+ needed; definite-assignment assertions via `!` are acceptable since `start` is called synchronously).
56
+
57
+ The v1 `#template`, `#contextProvider`, `#getTemplate`, and `#transform` private members are removed entirely.
58
+
59
+ ### 2. `StacheStream.spec.ts`
60
+
61
+ The existing two tests are replaced. With v2, text is grouped into segments rather than emitted character by
62
+ character, so the expected `chunks` arrays change. New tests cover all six tag types in scope:
63
+
64
+ | Test | Template | Context | Expected output |
65
+ | --------------------------- | ------------------------------ | ----------------------------------- | ------------------------ |
66
+ | plain text | `"fubar"` | — | `["fubar"]` |
67
+ | variable (escaped) | `"Hi {{name}}!"` | `{name: "Alice"}` | `["Hi ", "Alice", "!"]` |
68
+ | variable (raw triple-brace) | `"{{{html}}}"` | `{html: "<b>ok</b>"}` | `["<b>ok</b>"]` |
69
+ | variable (raw ampersand) | `"{{&html}}"` | `{html: "<b>ok</b>"}` | `["<b>ok</b>"]` |
70
+ | variable HTML-escapes | `"{{val}}"` | `{val: "a&b"}` | `["a&amp;b"]` |
71
+ | implicit iterator | `"{{.}}"` | `"hello"` | `["hello"]` |
72
+ | partial | `"{{>name}}"` | lambda returning template + context | partial content rendered |
73
+ | section — truthy | `"{{#show}}yes{{/show}}"` | `{show: true}` | `["yes"]` |
74
+ | section — falsy | `"{{#show}}yes{{/show}}"` | `{show: false}` | `[]` |
75
+ | section — list | `"{{#items}}{{.}} {{/items}}"` | `{items: ["a","b"]}` | `["a ", "b "]` |
76
+
77
+ Tests use the existing `createReadableStream` helper (from `./test/streams`) piped through `TextDecoderStream`
78
+ before `StacheTransformStream`, matching current caller convention.
79
+
80
+ ### 3. No other files change
81
+
82
+ The v2 `Template`, `Parse`, `Tokenize`, `Queue`, and `Tag` files are not modified. The v1 `Template` and its spec
83
+ remain in place (they are not removed by this plan).
84
+
85
+ ---
86
+
87
+ ## Decisions
88
+
89
+ 1. **Output chunk granularity** — segment-level chunks are acceptable; character-by-character output is not required.
90
+
91
+ 2. **Inverted sections and comment tags** — both have been removed from scope; no tests for these in `StacheStream.spec.ts`.
92
+
93
+ 3. **Type parameter order** — Node.js defines `TransformStream<I, O>` where `I` is the input (writable side) and
94
+ `O` is the output (readable side):
95
+ ```typescript
96
+ interface TransformStream<I = any, O = any> {
97
+ readonly readable: ReadableStream<O>;
98
+ readonly writable: WritableStream<I>;
99
+ }
100
+ ```
101
+ The current class declaration `TransformStream<O, I>` has the names reversed from this convention. The class
102
+ will be updated to use `TransformStream<I, O>` with conventional naming.
103
+
104
+ 4. **Error propagation** — Node.js convention is to call `controller.error(reason)` on the
105
+ `TransformStreamDefaultController` to signal errors to stream consumers. The updated `StacheStream.ts` will:
106
+ - Wrap `writeToOutput` in a try/catch; on error call `controller.error(err)`.
107
+ - Use a shared `Promise` for the `flush()` return that can be either resolved (on `"inactive"`) or rejected (on
108
+ pipeline error); on rejection also call `controller.error(err)`.
109
+ - The `flush()` callback returns this promise, so any pipeline error will cancel the writable side and surface
110
+ to the caller.
@@ -0,0 +1,98 @@
1
+ # Plan: Standalone Whitespace Suppression for Section and Inverted Section Tags
2
+
3
+ ## Spec Requirements
4
+
5
+ Per https://mustache.github.io/mustache.5.html#Sections and the standalone-tag rules in
6
+ https://github.com/mustache/spec/blob/master/specs/sections.yml:
7
+
8
+ - A section or inverted-section tag is **standalone** when it is the only non-whitespace content
9
+ on its line — no non-whitespace text precedes it on the current line, and the character
10
+ immediately following the tag is a newline (or end of input).
11
+ - For a standalone opening tag (`{{#s}}` / `{{^s}}`): suppress the leading whitespace on the tag's
12
+ line and consume the newline that follows the tag (i.e., that newline is not part of the body).
13
+ - For a standalone closing tag (`{{/s}}`): consume the newline that follows it in the parent output
14
+ stream.
15
+
16
+ ---
17
+
18
+ ## Decisions
19
+
20
+ 1. **Tests go in existing spec files.** Standalone-suppression tests for `{{#s}}` go in
21
+ `Template-section.spec.ts`; tests for `{{^s}}` go in `Template-inverted-section.spec.ts`.
22
+
23
+ 2. **Closing-tag body whitespace is out of scope.** Stripping horizontal whitespace that precedes
24
+ `{{/s}}` from the body string (e.g., the ` ` in `"{{#s}}\nbody\n {{/s}}\n"`) is deferred to
25
+ a future iteration.
26
+
27
+ 3. **Comment tags are not transparent for standalone detection.** The opening tag is standalone
28
+ only when its first body event is a `TextEvent` starting with `\n` or `\r\n`. If the first body
29
+ event is a tag (including a comment), the opening tag is treated as non-standalone. This is the
30
+ simpler of the two alternatives; skipping comment events to find the first "real" body event
31
+ adds complexity with no practical benefit given realistic template usage.
32
+
33
+ 4. **`\r\n` is handled wherever `\n` is.** Consistent with the existing `#handleText`
34
+ implementation.
35
+
36
+ ---
37
+
38
+ ## Component Changes
39
+
40
+ ### `Template.ts`
41
+
42
+ Replace the unconditional `await this.#flushPendingWhitespace()` at the top of `#handleSection`
43
+ and `#handleInverted` with standalone detection:
44
+
45
+ ```typescript
46
+ const bodyEvents = events.slice(1, -1);
47
+ const firstBodyEvent = bodyEvents[0];
48
+ const isOpeningStandalone =
49
+ !this.#lineHasContent &&
50
+ firstBodyEvent !== undefined &&
51
+ firstBodyEvent.type === "text" &&
52
+ /^(\r\n|\n)/.test(firstBodyEvent.data.toString());
53
+ ```
54
+
55
+ If `isOpeningStandalone`:
56
+
57
+ - Clear `#pendingWhitespace` (suppress leading whitespace on the tag's line).
58
+ - Set `#consumeNextNewline = true` (consume newline after the closing tag in the parent stream).
59
+ - Strip the leading `\n` or `\r\n` from `templateStr` (consume newline after the opening tag).
60
+
61
+ If NOT standalone, call `#flushPendingWhitespace()` (preserve current behavior).
62
+
63
+ ### `Template-comment.spec.ts`
64
+
65
+ Update the expected value of `"suppresses a standalone comment line inside a section body"`:
66
+
67
+ ```diff
68
+ - ).toBe("\n");
69
+ + ).toBe("");
70
+ ```
71
+
72
+ The leading `\n` currently in the output comes from `{{#s}}\n` not being suppressed. After the
73
+ fix, standalone detection eliminates it.
74
+
75
+ ---
76
+
77
+ ## Test Files
78
+
79
+ `libs/stache-stream/src/lib/template/Template-section.spec.ts` — section tests (#1–#8, #11–#12)
80
+
81
+ `libs/stache-stream/src/lib/template/Template-inverted-section.spec.ts` — inverted tests (#9–#10)
82
+
83
+ ### Planned tests
84
+
85
+ | # | Template | Context | Expected output |
86
+ | --- | --- | --- | --- |
87
+ | 1 | `"{{#s}}\nbody\n{{/s}}\n"` | `{ s: true }` | `"body\n"` — standalone opening and closing tags suppressed |
88
+ | 2 | `"{{#s}}\nbody\n{{/s}}\n"` | `{ s: false }` | `""` — falsy context, nothing rendered |
89
+ | 3 | `"before\n{{#s}}\nbody\n{{/s}}\nafter"` | `{ s: true }` | `"before\nbody\nafter"` — standalone section in middle of content |
90
+ | 4 | `"before\n{{#s}}\nbody\n{{/s}}\nafter"` | `{ s: false }` | `"before\nafter"` — standalone section lines suppressed, body not rendered |
91
+ | 5 | `" {{#s}}\nbody\n{{/s}}\nafter"` | `{ s: true }` | `"body\nafter"` — leading whitespace on tag line suppressed |
92
+ | 6 | `" {{#s}}\nbody\n{{/s}}\nafter"` | `{ s: false }` | `"after"` — standalone tag lines suppressed, body not rendered |
93
+ | 7 | `" {{#s}}text{{/s}}\n"` | `{ s: true }` | `" text\n"` — not standalone: pending whitespace flushed |
94
+ | 8 | `"a {{#s}}body{{/s}}\nz"` | `{ s: true }` | `"a body\nz"` — not standalone: content precedes opening tag |
95
+ | 9 | `"{{^s}}\nbody\n{{/s}}\n"` | `{ s: false }` | `"body\n"` — standalone inverted section |
96
+ | 10 | `"before\n{{^s}}\nbody\n{{/s}}\nafter"` | `{ s: false }` | `"before\nbody\nafter"` — standalone inverted section in middle of content |
97
+ | 11 | `"{{#a}}\n{{#b}}\nbody\n{{/b}}\n{{/a}}\n"` | `{ a: true, b: true }` | `"body\n"` — nested standalone sections |
98
+ | 12 | `"{{#s}}\n{{! comment }}\n{{/s}}"` | `{ s: true }` | `""` — standalone section with standalone comment body |