@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.
- package/.eslintrc.json +30 -0
- package/.swcrc +29 -0
- package/DEV.md +84 -0
- package/README.md +145 -0
- package/TASKS.md +13 -0
- package/TODO.md +28 -0
- package/docs/.nojekyll +1 -0
- package/docs/assets/hierarchy.js +1 -0
- package/docs/assets/highlight.css +120 -0
- package/docs/assets/icons.js +18 -0
- package/docs/assets/icons.svg +1 -0
- package/docs/assets/main.js +60 -0
- package/docs/assets/navigation.js +1 -0
- package/docs/assets/search.js +1 -0
- package/docs/assets/style.css +1633 -0
- package/docs/classes/StacheTransformStream.html +13 -0
- package/docs/hierarchy.html +1 -0
- package/docs/index.html +73 -0
- package/docs/interfaces/Context.html +3 -0
- package/docs/interfaces/ContextProvider.html +10 -0
- package/docs/interfaces/PartialTagContextLambda.html +11 -0
- package/docs/interfaces/PartialTagContextLambdaResult.html +7 -0
- package/docs/interfaces/SectionTagCallback.html +12 -0
- package/docs/interfaces/SectionTagContextRecord.html +4 -0
- package/docs/interfaces/Tag.html +45 -0
- package/docs/interfaces/VariableTagContextLambda.html +4 -0
- package/docs/interfaces/VariableTagContextRecord.html +3 -0
- package/docs/media/StacheStream.ts +79 -0
- package/docs/modules.html +1 -0
- package/docs/types/ContextTypes.html +3 -0
- package/docs/types/JsonType.html +2 -0
- package/docs/types/PartialTagContext.html +4 -0
- package/docs/types/SectionTagContext.html +4 -0
- package/docs/types/TemplateName.html +9 -0
- package/docs/types/VariableTagContext.html +4 -0
- package/docs/types/VariableTagContextPrimitive.html +3 -0
- package/docs-assets/images/context-dotted-found.png +0 -0
- package/docs-assets/images/context-dotted-not-found.png +0 -0
- package/docs-assets/images/context-not-found.png +0 -0
- package/package.json +3 -6
- package/project.json +26 -0
- package/src/global.d.ts +10 -0
- package/src/index.ts +67 -0
- package/src/lib/parse/Parse.spec.ts +50 -0
- package/src/lib/parse/Parse.ts +92 -0
- package/src/lib/parse/README.md +62 -0
- package/src/lib/plan_base_v2.md +33 -0
- package/src/lib/plan_comment.md +53 -0
- package/src/lib/plan_implicit-iterator.md +213 -0
- package/src/lib/plan_inverted-sections.md +160 -0
- package/src/lib/plan_partials.md +237 -0
- package/src/lib/plan_sections.md +167 -0
- package/src/lib/plan_stache-stream.md +110 -0
- package/src/lib/plan_whitespace.md +98 -0
- package/src/lib/queue/Queue.spec.ts +275 -0
- package/src/lib/queue/Queue.ts +253 -0
- package/src/lib/queue/README.md +110 -0
- package/src/lib/stache-stream/README.md +45 -0
- package/src/lib/stache-stream/StacheStream.spec.ts +107 -0
- package/src/lib/stache-stream/StacheStream.ts +79 -0
- package/src/lib/tag/README.md +95 -0
- package/src/lib/tag/Tag.spec.ts +212 -0
- package/src/lib/tag/Tag.ts +295 -0
- package/src/lib/template/README.md +102 -0
- package/src/lib/template/Template-comment.spec.ts +76 -0
- package/src/lib/template/Template-inverted-section.spec.ts +85 -0
- package/src/lib/template/Template-partials.spec.ts +125 -0
- package/src/lib/template/Template-section.spec.ts +142 -0
- package/src/lib/template/Template.spec.ts +178 -0
- package/src/lib/template/Template.ts +614 -0
- package/src/lib/test/streams.ts +36 -0
- package/src/lib/tokenize/README.md +97 -0
- package/src/lib/tokenize/Tokenize.spec.ts +364 -0
- package/src/lib/tokenize/Tokenize.ts +374 -0
- package/src/lib/{types.d.ts → types.ts} +73 -25
- package/tsconfig.json +21 -0
- package/tsconfig.lib.json +16 -0
- package/tsconfig.spec.json +21 -0
- package/typedoc.mjs +15 -0
- package/vite.config.ts +27 -0
- package/vitest.setup.ts +6 -0
- package/src/global.d.js +0 -8
- package/src/global.d.js.map +0 -1
- package/src/index.d.ts +0 -7
- package/src/index.js +0 -24
- package/src/index.js.map +0 -1
- package/src/lib/parse/Parse.d.ts +0 -14
- package/src/lib/parse/Parse.js +0 -79
- package/src/lib/parse/Parse.js.map +0 -1
- package/src/lib/queue/Queue.d.ts +0 -32
- package/src/lib/queue/Queue.js +0 -181
- package/src/lib/queue/Queue.js.map +0 -1
- package/src/lib/stache-stream/StacheStream.d.ts +0 -22
- package/src/lib/stache-stream/StacheStream.js +0 -71
- package/src/lib/stache-stream/StacheStream.js.map +0 -1
- package/src/lib/tag/Tag.d.ts +0 -33
- package/src/lib/tag/Tag.js +0 -231
- package/src/lib/tag/Tag.js.map +0 -1
- package/src/lib/template/Template.d.ts +0 -18
- package/src/lib/template/Template.js +0 -428
- package/src/lib/template/Template.js.map +0 -1
- package/src/lib/test/streams.d.ts +0 -2
- package/src/lib/test/streams.js +0 -39
- package/src/lib/test/streams.js.map +0 -1
- package/src/lib/tokenize/Tokenize.d.ts +0 -22
- package/src/lib/tokenize/Tokenize.js +0 -268
- package/src/lib/tokenize/Tokenize.js.map +0 -1
- package/src/lib/types.js +0 -33
- 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&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 |
|