@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,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ContentTagTagTypes,
|
|
3
|
+
ContentTagTypes,
|
|
4
|
+
Tag as TagType,
|
|
5
|
+
ValueTagTagTypes,
|
|
6
|
+
ValueTagTypes,
|
|
7
|
+
} from "../types";
|
|
8
|
+
|
|
9
|
+
export class Tag implements TagType {
|
|
10
|
+
#content: string;
|
|
11
|
+
#dynamic: boolean | undefined;
|
|
12
|
+
#raw: boolean | undefined;
|
|
13
|
+
#rawBraces: boolean | undefined;
|
|
14
|
+
#type: ValueTagTagTypes | ContentTagTagTypes | undefined;
|
|
15
|
+
#value: string;
|
|
16
|
+
|
|
17
|
+
constructor(options: TagOptions) {
|
|
18
|
+
if (!options.value) {
|
|
19
|
+
throw StacheStreamError("`options.value` must contain a string value.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
this.#content = options.content || "";
|
|
23
|
+
this.#value = options.value;
|
|
24
|
+
|
|
25
|
+
if (options.rawBraces === true) {
|
|
26
|
+
this.#rawBraces = options.rawBraces;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get content() {
|
|
31
|
+
if ((ValueTagTypes as readonly string[]).includes(this.type)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return this.#content;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get dynamic(): boolean {
|
|
39
|
+
return this.#dynamic === undefined ? false : this.#dynamic;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** This should match a key in the Context associated with the template. */
|
|
43
|
+
get key(): string {
|
|
44
|
+
return this.#tagName();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
get raw(): boolean {
|
|
48
|
+
if (this.#raw === undefined) {
|
|
49
|
+
this.#extractValueInformation();
|
|
50
|
+
|
|
51
|
+
if (this.#raw === undefined) {
|
|
52
|
+
throw StacheStreamError(
|
|
53
|
+
"Failed to extract tag raw information from the tag value."
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return this.#raw;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get type(): ValueTagTagTypes | ContentTagTagTypes {
|
|
62
|
+
if (!this.#type) {
|
|
63
|
+
this.#extractValueInformation();
|
|
64
|
+
|
|
65
|
+
if (!this.#type) {
|
|
66
|
+
throw StacheStreamError(
|
|
67
|
+
"Failed to extract tag type information from the tag value."
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return this.#type;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get value(): string {
|
|
76
|
+
return this.#normalizedTagValue();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The `value` passed to the constructor via the `option` parameter. Only really useful for
|
|
80
|
+
* logging; prefer the `Tag.value` property. */
|
|
81
|
+
get valueOption(): string {
|
|
82
|
+
return this.#value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
static copy(
|
|
86
|
+
tag: Tag,
|
|
87
|
+
options?: Partial<Omit<TagOptions, "value"> & { key?: string }>
|
|
88
|
+
): Tag {
|
|
89
|
+
const { key, ...restOptions } = options || {};
|
|
90
|
+
|
|
91
|
+
const allOptions: TagOptions = {
|
|
92
|
+
content: tag.#content,
|
|
93
|
+
rawBraces: tag.#rawBraces,
|
|
94
|
+
value: tag.#value,
|
|
95
|
+
...restOptions,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
let nextTag = new Tag(allOptions);
|
|
99
|
+
|
|
100
|
+
if (key) {
|
|
101
|
+
const nextValue = nextTag.#value.replace(nextTag.#tagName(), key);
|
|
102
|
+
nextTag = new Tag({ ...allOptions, value: nextValue });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return nextTag;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
static parse(
|
|
109
|
+
tag: string,
|
|
110
|
+
tokens: [enter: string, exit: string] = ["{", "}"]
|
|
111
|
+
): Tag | undefined {
|
|
112
|
+
let enterTokenCount = 0;
|
|
113
|
+
for (let i = 0; i < tag.length; i++) {
|
|
114
|
+
if (tag[i] !== tokens[0]) {
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
enterTokenCount++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (enterTokenCount < 2) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
enterTokenCount = enterTokenCount <= 3 ? enterTokenCount : 3;
|
|
126
|
+
const tagRight = tag.substring(enterTokenCount);
|
|
127
|
+
|
|
128
|
+
let exitTokenCount = 0;
|
|
129
|
+
for (let i = tagRight.length - 1; 0 <= i; i--) {
|
|
130
|
+
if (tagRight[i] !== tokens[1]) {
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
exitTokenCount++;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (exitTokenCount < enterTokenCount) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
exitTokenCount =
|
|
142
|
+
enterTokenCount < exitTokenCount ? enterTokenCount : exitTokenCount;
|
|
143
|
+
|
|
144
|
+
const value = tagRight.substring(0, tagRight.length - exitTokenCount);
|
|
145
|
+
|
|
146
|
+
if (!value) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// console.log(
|
|
151
|
+
// `---\nTag.parse:\n enterTokenCount=${enterTokenCount}\n exitTokenCount=${exitTokenCount}\n value='${value}'`
|
|
152
|
+
// );
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
return new Tag({
|
|
156
|
+
value,
|
|
157
|
+
rawBraces: enterTokenCount === 3,
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
appendContent(text: string): void {
|
|
165
|
+
if (!(ContentTagTypes as readonly string[]).includes(this.type)) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
this.#content = (this.#content || "") + text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
toJSON(): string {
|
|
173
|
+
return this.toString();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
toString(): string {
|
|
177
|
+
return `${this.#rawBraces ? "{{{" : "{{"}${this.#value}${
|
|
178
|
+
this.#rawBraces ? "}}}" : "}}"
|
|
179
|
+
}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#extractValueInformation(): void {
|
|
183
|
+
if (this.#rawBraces) {
|
|
184
|
+
this.#raw = true;
|
|
185
|
+
this.#type = this.value === "." ? "implicit" : "variable";
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let dynamic = false;
|
|
190
|
+
let raw = false;
|
|
191
|
+
let tagType: ValueTagTagTypes | ContentTagTagTypes = "variable";
|
|
192
|
+
const normalizedTagValue = this.value;
|
|
193
|
+
switch (normalizedTagValue[0]) {
|
|
194
|
+
case ".": {
|
|
195
|
+
tagType = "implicit";
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case ">": {
|
|
199
|
+
if (1 < normalizedTagValue.length && normalizedTagValue[1] === "*") {
|
|
200
|
+
throw Error(
|
|
201
|
+
`Dynamic partials are not implemented; tag value='${this.#value}'`
|
|
202
|
+
);
|
|
203
|
+
dynamic = true;
|
|
204
|
+
}
|
|
205
|
+
tagType = "partial";
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case "#": {
|
|
209
|
+
tagType = "section";
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case "/": {
|
|
213
|
+
tagType = "end";
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
case "&": {
|
|
217
|
+
raw = true;
|
|
218
|
+
if (normalizedTagValue.slice(1) === ".") {
|
|
219
|
+
tagType = "implicit";
|
|
220
|
+
}
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
case "^": {
|
|
224
|
+
tagType = "inverted";
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
case "!": {
|
|
228
|
+
tagType = "comment";
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
case "$": {
|
|
232
|
+
throw Error(`Blocks are not implemented; tag value='${this.#value}'`);
|
|
233
|
+
tagType = "block";
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case "<": {
|
|
237
|
+
if (1 < normalizedTagValue.length && normalizedTagValue[1] === "*") {
|
|
238
|
+
throw Error(
|
|
239
|
+
`Dynamic parents are not implemented; tag value='${this.#value}'`
|
|
240
|
+
);
|
|
241
|
+
dynamic = true;
|
|
242
|
+
}
|
|
243
|
+
throw Error(`Parents are not implemented; tag value='${this.#value}'`);
|
|
244
|
+
tagType = "parent";
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.#dynamic = dynamic;
|
|
250
|
+
this.#raw = raw;
|
|
251
|
+
this.#type = tagType;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#normalizedTagValue(): string {
|
|
255
|
+
return this.#value.trim().replace(/\s/g, "");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
#tagName(): string {
|
|
259
|
+
let val = this.#normalizedTagValue();
|
|
260
|
+
if (
|
|
261
|
+
val.startsWith("&") ||
|
|
262
|
+
val.startsWith(">") ||
|
|
263
|
+
val.startsWith("#") ||
|
|
264
|
+
val.startsWith("^") ||
|
|
265
|
+
val.startsWith("/") ||
|
|
266
|
+
val.startsWith("!") ||
|
|
267
|
+
val.startsWith("$") ||
|
|
268
|
+
val.startsWith("<")
|
|
269
|
+
) {
|
|
270
|
+
val = val.substring(1);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (val.startsWith("*")) {
|
|
274
|
+
val = val.substring(1);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (val.endsWith("?")) {
|
|
278
|
+
val = val.slice(0, -1);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return val;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export interface TagAttributes {
|
|
286
|
+
dynamic?: boolean;
|
|
287
|
+
raw: boolean;
|
|
288
|
+
tagType?: ContentTagTagTypes | ValueTagTagTypes;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
interface TagOptions {
|
|
292
|
+
rawBraces?: boolean;
|
|
293
|
+
content?: string;
|
|
294
|
+
value: string;
|
|
295
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Template
|
|
2
|
+
|
|
3
|
+
`Template` wires together `Parse`, `Tokenize`, and `Queue` to render a mustache template from a `ReadableStream<string>` against a data context. Rendered output is written incrementally to a caller-supplied `writeToOutput` function. When rendering is complete the `inactive` event is emitted.
|
|
4
|
+
|
|
5
|
+
## How it works
|
|
6
|
+
|
|
7
|
+
### Pipeline
|
|
8
|
+
|
|
9
|
+
`read()` starts the internal `Parse → Tokenize → Queue` pipeline. Each layer feeds the next:
|
|
10
|
+
|
|
11
|
+
1. `Parse` reads the stream character-by-character.
|
|
12
|
+
2. `Tokenize` groups characters into text and tag tokens.
|
|
13
|
+
3. `Queue` accumulates section/inverted tokens and dispatches all token types to `Template`'s handlers.
|
|
14
|
+
|
|
15
|
+
The three pipeline objects are created lazily on first access via private getters.
|
|
16
|
+
|
|
17
|
+
### Context lookup
|
|
18
|
+
|
|
19
|
+
`getContextValue(tag)` resolves a tag's key against the current context. Keys may be dot-separated paths (e.g. `a.b.c`); each segment is resolved in turn. If the value at any intermediate key is a function (lambda), it is called and its return value is used as the context for remaining keys. If the key is not found in the local context, the lookup falls through to the parent `contextProvider` if one was supplied.
|
|
20
|
+
|
|
21
|
+
### Token handlers
|
|
22
|
+
|
|
23
|
+
Each token type dispatched by `Queue` has a dedicated private handler:
|
|
24
|
+
|
|
25
|
+
| Token type | Handler | Behaviour |
|
|
26
|
+
| ---------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
27
|
+
| `text` | `#handleText` | Writes plain text to output, buffering trailing whitespace to support standalone tag detection. |
|
|
28
|
+
| `variable` | `#handleVariable` | Resolves the tag key to a primitive value (calling a lambda if needed) and writes it HTML-escaped, or unescaped for triple-stache `{{{` tags. |
|
|
29
|
+
| `implicit` | `#handleImplicit` | Writes the current context value directly when it is a primitive (used inside sections iterating scalar arrays). |
|
|
30
|
+
| `section` | `#handleSection` | Skips when the context value is falsy/empty. For a lambda, calls it with the raw section string. Otherwise iterates over the context value (or wraps a single object in an array) and renders the section template in a child `Template` for each item. |
|
|
31
|
+
| `inverted` | `#handleInverted` | Renders the section content only when the context value is falsy or an empty array. |
|
|
32
|
+
| `partial` | `#handlePartial` | Calls the context lambda to obtain a `ReadableStream` and optional context for the partial template, then renders it in a child `Template`. Standalone partials are indented and their trailing newline is consumed. |
|
|
33
|
+
| `inactive` | `#handleInactive` | Flushes any remaining pending whitespace then emits `inactive`. |
|
|
34
|
+
|
|
35
|
+
### Whitespace and standalone tags
|
|
36
|
+
|
|
37
|
+
Text tokens are not written immediately when they contain only horizontal whitespace (spaces and tabs). Instead, trailing whitespace is held in `#pendingWhitespace` until the next non-whitespace event. This lets `Template` detect standalone tags — tags that are the only non-whitespace content on a line — and suppress the surrounding indentation/newline for those tags as required by the mustache spec.
|
|
38
|
+
|
|
39
|
+
### Sections and partials
|
|
40
|
+
|
|
41
|
+
Both sections and partials are rendered by spawning a child `Template` instance. The child receives:
|
|
42
|
+
|
|
43
|
+
- a `ReadableStream` containing the inner template string,
|
|
44
|
+
- a `ContextProvider` whose `context` is the iteration item (sections) or partial-supplied context (partials),
|
|
45
|
+
- the same `writeToOutput` function as the parent (partials may wrap it with `makeIndentingWriter` to apply per-line indentation).
|
|
46
|
+
|
|
47
|
+
The parent `await`s the child's `inactive` event before continuing.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
import { Template } from "./Template";
|
|
53
|
+
|
|
54
|
+
let output = "";
|
|
55
|
+
|
|
56
|
+
const template = new Template({
|
|
57
|
+
readable: myReadableStream,
|
|
58
|
+
contextProvider: {
|
|
59
|
+
context: { name: "world" },
|
|
60
|
+
},
|
|
61
|
+
writeToOutput: async (text) => {
|
|
62
|
+
output += text;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
template.on("inactive", () => console.log(output));
|
|
67
|
+
template.read();
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### `new Template(options)`
|
|
73
|
+
|
|
74
|
+
| Option | Type | Description |
|
|
75
|
+
| ----------------- | ---------------------------- | -------------------------------------------------------------- |
|
|
76
|
+
| `readable` | `ReadableStream<string>` | The mustache template source. |
|
|
77
|
+
| `writeToOutput` | `WriteToOutput` | Called incrementally with rendered output chunks. |
|
|
78
|
+
| `contextProvider` | `ContextProvider` (optional) | Supplies the data context and parent lookup for this template. |
|
|
79
|
+
|
|
80
|
+
Also accepts all `EventEmitter` constructor options.
|
|
81
|
+
|
|
82
|
+
### `template.on(eventName, listener)`
|
|
83
|
+
|
|
84
|
+
Inherited from `EventEmitter`. Registers a listener for a template event.
|
|
85
|
+
|
|
86
|
+
### `template.read(): Template`
|
|
87
|
+
|
|
88
|
+
Starts the rendering pipeline. Returns `this` for chaining. Listen for `inactive` to know when rendering is complete.
|
|
89
|
+
|
|
90
|
+
### `template.getContextValue(tag): Promise<CTX | undefined>`
|
|
91
|
+
|
|
92
|
+
Resolves a tag's key against the current context, following dot-separated paths and invoking lambdas as needed. Falls through to the parent `contextProvider` when the key is not found locally. Implements `ContextProvider`.
|
|
93
|
+
|
|
94
|
+
### `template.context`
|
|
95
|
+
|
|
96
|
+
Read-only. Returns the current context from the `contextProvider`, or `{}` if none was supplied.
|
|
97
|
+
|
|
98
|
+
### Events
|
|
99
|
+
|
|
100
|
+
| Event | Payload | Description |
|
|
101
|
+
| ---------- | ----------- | --------------------------------------------------------------------------- |
|
|
102
|
+
| `inactive` | `undefined` | Emitted after all tokens have been rendered and pending whitespace flushed. |
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { TextDecoderStream } from "node:stream/web";
|
|
2
|
+
import type { ContextTypes } from "../types";
|
|
3
|
+
import { createReadableStream } from "../test/streams";
|
|
4
|
+
import { Template } from "./Template";
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
(process.env as any).LOG_LEVEL = "warn";
|
|
8
|
+
|
|
9
|
+
describe("Template comments", () => {
|
|
10
|
+
async function render(
|
|
11
|
+
template: string,
|
|
12
|
+
context?: ContextTypes
|
|
13
|
+
): Promise<string[]> {
|
|
14
|
+
return new Promise<string[]>((resolve) => {
|
|
15
|
+
const calls: string[] = [];
|
|
16
|
+
new Template({
|
|
17
|
+
contextProvider: context !== undefined ? { context } : undefined,
|
|
18
|
+
readable: createReadableStream(template).pipeThrough(
|
|
19
|
+
new TextDecoderStream()
|
|
20
|
+
),
|
|
21
|
+
writeToOutput: async (text) => {
|
|
22
|
+
calls.push(text);
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
.on("inactive", () => resolve(calls))
|
|
26
|
+
.read();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it("produces no output for a basic comment", async () => {
|
|
31
|
+
expect(await render("{{! comment }}")).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("strips an inline comment leaving surrounding text intact", async () => {
|
|
35
|
+
expect(await render("before{{! comment }}after")).toEqual([
|
|
36
|
+
"before",
|
|
37
|
+
"after",
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("treats a comment with embedded newlines as a single token", async () => {
|
|
42
|
+
expect(await render("{{! multi\nline }}")).toEqual([]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("suppresses the entire line for a standalone comment", async () => {
|
|
46
|
+
expect(await render("{{! comment }}\n")).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("suppresses leading whitespace for a standalone comment", async () => {
|
|
50
|
+
expect(await render(" {{! comment }}\n")).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("suppresses a standalone comment line in the middle of content", async () => {
|
|
54
|
+
expect((await render("before\n{{! comment }}\nafter")).join("")).toBe(
|
|
55
|
+
"before\nafter"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("removes a non-standalone comment leaving surrounding line content", async () => {
|
|
60
|
+
expect((await render("text {{! comment }}\n")).join("")).toBe("text \n");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("produces no output for a comment inside a section body", async () => {
|
|
64
|
+
expect(await render("{{#s}}{{! comment }}{{/s}}", { s: true })).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("suppresses a standalone comment line inside a section body", async () => {
|
|
68
|
+
expect(
|
|
69
|
+
(await render("{{#s}}\n{{! comment }}\n{{/s}}", { s: true })).join("")
|
|
70
|
+
).toBe("");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("treats a multi-line comment spanning source lines atomically", async () => {
|
|
74
|
+
expect(await render("{{!\n I'm a comment\n}}")).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { TextDecoderStream } from "node:stream/web";
|
|
2
|
+
import { Template } from "./Template";
|
|
3
|
+
import { createReadableStream } from "../test/streams";
|
|
4
|
+
import type { ContextTypes } from "../../types";
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
(process.env as any).LOG_LEVEL = "warn";
|
|
8
|
+
|
|
9
|
+
describe("Template inverted sections", () => {
|
|
10
|
+
async function render(
|
|
11
|
+
template: string,
|
|
12
|
+
context: ContextTypes
|
|
13
|
+
): Promise<string[]> {
|
|
14
|
+
return new Promise<string[]>((resolve) => {
|
|
15
|
+
const calls: string[] = [];
|
|
16
|
+
new Template({
|
|
17
|
+
contextProvider: { context },
|
|
18
|
+
readable: createReadableStream(template).pipeThrough(
|
|
19
|
+
new TextDecoderStream()
|
|
20
|
+
),
|
|
21
|
+
writeToOutput: async (text) => {
|
|
22
|
+
calls.push(text);
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
.on("inactive", () => resolve(calls))
|
|
26
|
+
.read();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it("renders when context is false", async () => {
|
|
31
|
+
expect(await render("{{^s}}text{{/s}}", { s: false })).toEqual(["text"]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("renders nothing when context is true", async () => {
|
|
35
|
+
expect(await render("{{^s}}text{{/s}}", { s: true })).toEqual([]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("renders when key is absent from context", async () => {
|
|
39
|
+
expect(await render("{{^s}}text{{/s}}", {})).toEqual(["text"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("renders when context is empty list", async () => {
|
|
43
|
+
expect(await render("{{^s}}text{{/s}}", { s: [] })).toEqual(["text"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders when context is empty string", async () => {
|
|
47
|
+
expect(await render("{{^s}}text{{/s}}", { s: "" })).toEqual(["text"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("renders nothing when context is a non-empty list", async () => {
|
|
51
|
+
expect(await render("{{^s}}text{{/s}}", { s: [1, 2] })).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("renders nothing when context is an object", async () => {
|
|
55
|
+
expect(await render("{{^s}}text{{/s}}", { s: {} })).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("resolves variables from parent context when rendered", async () => {
|
|
59
|
+
expect(
|
|
60
|
+
await render("{{^s}}{{name}}{{/s}}", { s: false, name: "Alice" })
|
|
61
|
+
).toEqual(["Alice"]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("section renders but inverted does not when truthy", async () => {
|
|
65
|
+
expect(
|
|
66
|
+
await render("{{#a}}X{{/a}}{{^a}}Y{{/a}}", { a: true })
|
|
67
|
+
).toEqual(["X"]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("inverted renders but section does not when falsy", async () => {
|
|
71
|
+
expect(
|
|
72
|
+
await render("{{#a}}X{{/a}}{{^a}}Y{{/a}}", { a: false })
|
|
73
|
+
).toEqual(["Y"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("standalone suppression", () => {
|
|
77
|
+
it("suppresses standalone inverted section tag lines", async () => {
|
|
78
|
+
expect((await render("{{^s}}\nbody\n{{/s}}\n", { s: false })).join("")).toBe("body\n");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("suppresses standalone inverted section in middle of content", async () => {
|
|
82
|
+
expect((await render("before\n{{^s}}\nbody\n{{/s}}\nafter", { s: false })).join("")).toBe("before\nbody\nafter");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { ReadableStream, TextDecoderStream } from "node:stream/web";
|
|
2
|
+
import { Template } from "./Template";
|
|
3
|
+
import { createReadableStream } from "../test/streams";
|
|
4
|
+
import type { ContextTypes, PartialTagContextLambda } from "../types";
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
7
|
+
(process.env as any).LOG_LEVEL = "warn";
|
|
8
|
+
|
|
9
|
+
describe("Template partials", () => {
|
|
10
|
+
function makePartial(
|
|
11
|
+
template: string,
|
|
12
|
+
context?: ContextTypes
|
|
13
|
+
): PartialTagContextLambda {
|
|
14
|
+
return async () => ({
|
|
15
|
+
context,
|
|
16
|
+
input: async () =>
|
|
17
|
+
createReadableStream(template).pipeThrough(
|
|
18
|
+
new TextDecoderStream()
|
|
19
|
+
) as ReadableStream,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function render(
|
|
24
|
+
template: string,
|
|
25
|
+
context: ContextTypes
|
|
26
|
+
): Promise<string[]> {
|
|
27
|
+
return new Promise<string[]>((resolve) => {
|
|
28
|
+
const calls: string[] = [];
|
|
29
|
+
new Template({
|
|
30
|
+
contextProvider: { context },
|
|
31
|
+
readable: createReadableStream(template).pipeThrough(
|
|
32
|
+
new TextDecoderStream()
|
|
33
|
+
),
|
|
34
|
+
writeToOutput: async (text) => {
|
|
35
|
+
calls.push(text);
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
.on("inactive", () => resolve(calls))
|
|
39
|
+
.read();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
it("renders a basic partial", async () => {
|
|
44
|
+
expect(await render("{{> p}}", { p: makePartial("hello") })).toEqual([
|
|
45
|
+
"hello",
|
|
46
|
+
]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("renders partial inline with surrounding text", async () => {
|
|
50
|
+
expect(
|
|
51
|
+
await render("before {{> p}} after", { p: makePartial("mid") })
|
|
52
|
+
).toEqual(["before ", "mid", " after"]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("produces no output for a missing partial", async () => {
|
|
56
|
+
expect(await render("{{> missing}}", {})).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("renders partial with lambda-provided context", async () => {
|
|
60
|
+
expect(
|
|
61
|
+
await render("{{> p}}", {
|
|
62
|
+
p: makePartial("{{name}}", { name: "Alice" }),
|
|
63
|
+
})
|
|
64
|
+
).toEqual(["Alice"]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("partial inherits parent context when no lambda context provided", async () => {
|
|
68
|
+
expect(
|
|
69
|
+
await render("{{> p}}", { name: "Bob", p: makePartial("{{name}}") })
|
|
70
|
+
).toEqual(["Bob"]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders partial once per section item", async () => {
|
|
74
|
+
expect(
|
|
75
|
+
await render("{{#items}}{{> p}}{{/items}}", {
|
|
76
|
+
items: [{ name: "A" }, { name: "B" }],
|
|
77
|
+
p: makePartial("{{name}}"),
|
|
78
|
+
})
|
|
79
|
+
).toEqual(["A", "B"]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("renders recursively nested partials", async () => {
|
|
83
|
+
const inner = makePartial("hi");
|
|
84
|
+
const outer = makePartial("{{> inner}}", { inner });
|
|
85
|
+
expect(await render("{{> outer}}", { outer, inner })).toEqual(["hi"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("applies indent to every line of a standalone partial", async () => {
|
|
89
|
+
const result = await render(" {{> p}}\n", {
|
|
90
|
+
p: makePartial("a\nb"),
|
|
91
|
+
});
|
|
92
|
+
expect(result.join("")).toBe(" a\n b");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("preserves trailing newline when indenting a standalone partial", async () => {
|
|
96
|
+
const result = await render(" {{> p}}\n", {
|
|
97
|
+
p: makePartial("line1\nline2\n"),
|
|
98
|
+
});
|
|
99
|
+
expect(result.join("")).toBe(" line1\n line2\n");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("does not add trailing whitespace to empty lines when indenting", async () => {
|
|
103
|
+
const result = await render(" {{> p}}\n", {
|
|
104
|
+
p: makePartial("before\n\nafter"),
|
|
105
|
+
});
|
|
106
|
+
expect(result.join("")).toBe(" before\n\n after");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not apply indent for a non-standalone partial", async () => {
|
|
110
|
+
const result = await render("text {{> p}}\n", {
|
|
111
|
+
p: makePartial("a\nb"),
|
|
112
|
+
});
|
|
113
|
+
expect(result.join("")).toBe("text a\nb\n");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("partial inside section falls back through section context to root context", async () => {
|
|
117
|
+
expect(
|
|
118
|
+
await render("{{#s}}{{> p}}{{/s}}", {
|
|
119
|
+
name: "root",
|
|
120
|
+
s: {},
|
|
121
|
+
p: makePartial("{{name}}"),
|
|
122
|
+
})
|
|
123
|
+
).toEqual(["root"]);
|
|
124
|
+
});
|
|
125
|
+
});
|