fragtml 0.0.1
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/LICENSE +21 -0
- package/README.md +387 -0
- package/index.d.ts +8 -0
- package/index.d.ts.map +1 -0
- package/index.js +9 -0
- package/lib/create-tag.d.ts +15 -0
- package/lib/create-tag.d.ts.map +1 -0
- package/lib/create-tag.js +84 -0
- package/lib/escape-html.d.ts +2 -0
- package/lib/escape-html.d.ts.map +1 -0
- package/lib/escape-html.js +21 -0
- package/lib/fragment.d.ts +20 -0
- package/lib/fragment.d.ts.map +1 -0
- package/lib/fragment.js +48 -0
- package/lib/html-result.d.ts +16 -0
- package/lib/html-result.d.ts.map +1 -0
- package/lib/html-result.js +43 -0
- package/lib/html.d.ts +2 -0
- package/lib/html.d.ts.map +1 -0
- package/lib/html.js +1 -0
- package/lib/inline-array.d.ts +2 -0
- package/lib/inline-array.d.ts.map +1 -0
- package/lib/inline-array.js +36 -0
- package/lib/raw.d.ts +12 -0
- package/lib/raw.d.ts.map +1 -0
- package/lib/raw.js +45 -0
- package/lib/render.d.ts +15 -0
- package/lib/render.d.ts.map +1 -0
- package/lib/render.js +278 -0
- package/lib/strip-indent.d.ts +2 -0
- package/lib/strip-indent.d.ts.map +1 -0
- package/lib/strip-indent.js +26 -0
- package/lib/utils.d.ts +3 -0
- package/lib/utils.d.ts.map +1 -0
- package/lib/utils.js +15 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 Bret Comnes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
# fragtml
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/fragtml)
|
|
4
|
+
[](https://github.com/bcomnes/fragtml/actions)
|
|
5
|
+
|
|
6
|
+
[](https://npmtrends.com/fragtml)
|
|
7
|
+

|
|
8
|
+
[](https://github.com/neostandard/neostandard)
|
|
9
|
+
[](https://socket.dev/npm/package/fragtml)
|
|
10
|
+
|
|
11
|
+
A safe-by-default, string-generating HTML tagged template library with inline fragment support for server-rendered hypermedia apps.
|
|
12
|
+
|
|
13
|
+
`fragtml` is inspired by `common-tags` HTML formatting behavior and by htmx-style [template fragments](https://htmx.org/essays/template-fragments/). It lets you keep a full template and its partial update fragments together in one JavaScript template function.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install fragtml
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Basic usage
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
import html, { render } from 'fragtml'
|
|
25
|
+
|
|
26
|
+
const name = '<Bret>'
|
|
27
|
+
const result = html`<p>Hello ${name}</p>`
|
|
28
|
+
|
|
29
|
+
render(result)
|
|
30
|
+
// '<p>Hello <Bret></p>'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`html` returns an intermediate result object, not a primitive string. Use `render()` at route-handler boundaries:
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
return render(html`<h1>${title}</h1>`)
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The returned object also supports direct string coercion:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
const result = html`<p>${'Hello'}</p>`
|
|
43
|
+
|
|
44
|
+
String(result)
|
|
45
|
+
result.toString()
|
|
46
|
+
`${result}`
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Safe interpolation
|
|
50
|
+
|
|
51
|
+
Static template HTML is left as-is. Ordinary substitutions are escaped:
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
render(html`<p>${'<script>alert(1)</script>'}</p>`)
|
|
55
|
+
// '<p><script>alert(1)</script></p>'
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The following non-printing values are omitted:
|
|
59
|
+
|
|
60
|
+
- `null`
|
|
61
|
+
- `undefined`
|
|
62
|
+
- booleans
|
|
63
|
+
- `NaN`
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
render(html`<p>${null}${false}${Number.NaN}${0}</p>`)
|
|
67
|
+
// '<p>0</p>'
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Trusted raw HTML
|
|
71
|
+
|
|
72
|
+
Use `raw()` for trusted HTML that should not be escaped:
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import html, { raw, render } from 'fragtml'
|
|
76
|
+
|
|
77
|
+
render(html`<p>${raw('<strong>trusted</strong>')}</p>`)
|
|
78
|
+
// '<p><strong>trusted</strong></p>'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The tag also exposes the same helper as `.raw`:
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
html.raw === raw
|
|
85
|
+
|
|
86
|
+
render(html`<p>${html.raw('<em>trusted</em>')}</p>`)
|
|
87
|
+
// '<p><em>trusted</em></p>'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Only pass trusted HTML to `raw()`. User input should be interpolated normally so it is escaped.
|
|
91
|
+
|
|
92
|
+
There is no public `unsafeHtml` tag in v1. Prefer local, explicit trust boundaries with `raw()`.
|
|
93
|
+
|
|
94
|
+
## Composition
|
|
95
|
+
|
|
96
|
+
Nested `html` results are treated as trusted `fragtml` output, while their own substitutions remain escaped:
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
const button = html`<button>${'<Archive>'}</button>`
|
|
100
|
+
|
|
101
|
+
render(html`
|
|
102
|
+
<div hx-target="this">
|
|
103
|
+
${button}
|
|
104
|
+
</div>
|
|
105
|
+
`)
|
|
106
|
+
// '<div hx-target="this">\n <button><Archive></button>\n</div>'
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Arrays are inlined with indentation-aware formatting:
|
|
110
|
+
|
|
111
|
+
```js
|
|
112
|
+
const items = ['one', 'two'].map((item) => html`<li>${item}</li>`)
|
|
113
|
+
|
|
114
|
+
render(html`
|
|
115
|
+
<ul>
|
|
116
|
+
${items}
|
|
117
|
+
</ul>
|
|
118
|
+
`)
|
|
119
|
+
// '<ul>\n <li>one</li>\n <li>two</li>\n</ul>'
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
String substitutions containing newlines are split and aligned to the surrounding indentation.
|
|
123
|
+
|
|
124
|
+
## Boolean attributes
|
|
125
|
+
|
|
126
|
+
Use `?name=${condition}` to toggle boolean attributes. When the value is truthy, `fragtml` renders the bare attribute. When the value is falsey, it omits the attribute.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
render(html`<button ?disabled=${loading}>Save</button>`)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
When `loading` is truthy:
|
|
133
|
+
|
|
134
|
+
```html
|
|
135
|
+
<button disabled>Save</button>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
When `loading` is falsey:
|
|
139
|
+
|
|
140
|
+
```html
|
|
141
|
+
<button>Save</button>
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
This syntax is useful for native HTML boolean attributes such as `disabled`, `checked`, `selected`, `readonly`, `required`, `multiple`, `autofocus`, `hidden`, and `open`.
|
|
145
|
+
|
|
146
|
+
Only the unquoted form is supported:
|
|
147
|
+
|
|
148
|
+
```js
|
|
149
|
+
html`<button ?disabled=${loading}>Save</button>`
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Quoted forms are intentionally unsupported in v1:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
html`<button ?disabled="${loading}">Save</button>`
|
|
156
|
+
html`<button ?disabled='${loading}'>Save</button>`
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Bound tags
|
|
160
|
+
|
|
161
|
+
`html(options)` returns a configured tag. This is useful for fragment rendering and for editor syntax highlighting, because many editors highlight tagged templates better when the tag is a simple identifier.
|
|
162
|
+
|
|
163
|
+
```js
|
|
164
|
+
import { createHtml, render } from 'fragtml'
|
|
165
|
+
|
|
166
|
+
export function view ({ fragmentId }) {
|
|
167
|
+
const html = createHtml({ fragmentId })
|
|
168
|
+
|
|
169
|
+
return render(html`
|
|
170
|
+
<main>...</main>
|
|
171
|
+
`)
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`createHtml` is an alias of `html`:
|
|
176
|
+
|
|
177
|
+
```js
|
|
178
|
+
import html, { createHtml } from 'fragtml'
|
|
179
|
+
|
|
180
|
+
createHtml === html
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
You can also use the short fragment-target form:
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
const html = createHtml('archive-ui')
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
which is equivalent to:
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
const html = createHtml({ fragmentId: 'archive-ui' })
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Fragments
|
|
196
|
+
|
|
197
|
+
Fragments mark a named range inside a larger template. Rendering without `fragmentId` returns the whole template. Rendering with `fragmentId` returns only that fragment.
|
|
198
|
+
|
|
199
|
+
This mirrors the htmx article’s idea:
|
|
200
|
+
|
|
201
|
+
```txt
|
|
202
|
+
#fragment archive-ui
|
|
203
|
+
...
|
|
204
|
+
#end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
In `fragtml`, use boundary tokens:
|
|
208
|
+
|
|
209
|
+
```js
|
|
210
|
+
${html.fragment.start('archive-ui')}
|
|
211
|
+
...
|
|
212
|
+
${html.fragment.end}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Example
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
import { createHtml, render } from 'fragtml'
|
|
219
|
+
|
|
220
|
+
export function contactDetail ({ contact, fragmentId }) {
|
|
221
|
+
const html = createHtml({ fragmentId })
|
|
222
|
+
|
|
223
|
+
return render(html`
|
|
224
|
+
<html>
|
|
225
|
+
<body>
|
|
226
|
+
<div hx-target="this">
|
|
227
|
+
${html.fragment.start('archive-ui')}
|
|
228
|
+
${contact.archived
|
|
229
|
+
? html`<button hx-patch="/contacts/${contact.id}/unarchive">Unarchive</button>`
|
|
230
|
+
: html`<button hx-delete="/contacts/${contact.id}">Archive</button>`}
|
|
231
|
+
${html.fragment.end}
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<h3>Contact</h3>
|
|
235
|
+
<p>${contact.email}</p>
|
|
236
|
+
</body>
|
|
237
|
+
</html>
|
|
238
|
+
`)
|
|
239
|
+
}
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Render the whole page:
|
|
243
|
+
|
|
244
|
+
```js
|
|
245
|
+
contactDetail({ contact })
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Render only the archive button fragment:
|
|
249
|
+
|
|
250
|
+
```js
|
|
251
|
+
contactDetail({ contact, fragmentId: 'archive-ui' })
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Fragment boundary tokens are not included in either output.
|
|
255
|
+
|
|
256
|
+
### Fragment rules
|
|
257
|
+
|
|
258
|
+
- Fragment IDs must be unique within a rendered template.
|
|
259
|
+
- Missing fragments throw `FragmentNotFoundError`.
|
|
260
|
+
- Duplicate fragment IDs throw `DuplicateFragmentError`.
|
|
261
|
+
- `html.fragment.end` without a matching start throws `FragmentBoundaryError`.
|
|
262
|
+
- An unclosed `html.fragment.start(id)` throws `FragmentBoundaryError`.
|
|
263
|
+
|
|
264
|
+
## Nested fragments
|
|
265
|
+
|
|
266
|
+
Nested fragments are supported with stack semantics. This is useful when a larger region can be re-rendered as a whole, but a smaller region inside it is also a valid htmx update target.
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
import { createHtml, render } from 'fragtml'
|
|
270
|
+
|
|
271
|
+
export function page ({ fragmentId }) {
|
|
272
|
+
const html = createHtml({ fragmentId })
|
|
273
|
+
|
|
274
|
+
return render(html`
|
|
275
|
+
${html.fragment.start('outer')}
|
|
276
|
+
<section>
|
|
277
|
+
<h2>Outer</h2>
|
|
278
|
+
|
|
279
|
+
${html.fragment.start('inner')}
|
|
280
|
+
<button>Inner update target</button>
|
|
281
|
+
${html.fragment.end}
|
|
282
|
+
</section>
|
|
283
|
+
${html.fragment.end}
|
|
284
|
+
`)
|
|
285
|
+
}
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Rendering `outer` includes the nested `inner` content:
|
|
289
|
+
|
|
290
|
+
```js
|
|
291
|
+
page({ fragmentId: 'outer' })
|
|
292
|
+
// '<section>\n <h2>Outer</h2>\n\n <button>Inner update target</button>\n</section>'
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Rendering `inner` returns only the inner fragment:
|
|
296
|
+
|
|
297
|
+
```js
|
|
298
|
+
page({ fragmentId: 'inner' })
|
|
299
|
+
// '<button>Inner update target</button>'
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Use nested fragments sparingly. Prefer flat fragments unless you actually need both a parent region and a child region as independently renderable update targets.
|
|
303
|
+
|
|
304
|
+
## API
|
|
305
|
+
|
|
306
|
+
### `html`
|
|
307
|
+
|
|
308
|
+
Safe-by-default template tag.
|
|
309
|
+
|
|
310
|
+
```js
|
|
311
|
+
html`<p>${value}</p>`
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Also acts as a factory for bound tags:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
const h = html({ fragmentId: 'name' })
|
|
318
|
+
const h = html('name')
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### `createHtml`
|
|
322
|
+
|
|
323
|
+
Alias of `html`, intended for local tag naming:
|
|
324
|
+
|
|
325
|
+
```js
|
|
326
|
+
import { createHtml } from 'fragtml'
|
|
327
|
+
|
|
328
|
+
const html = createHtml({ fragmentId })
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### `render(value)`
|
|
332
|
+
|
|
333
|
+
Converts a `fragtml` result to a primitive string.
|
|
334
|
+
|
|
335
|
+
```js
|
|
336
|
+
render(html`<p>${value}</p>`)
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### `raw(value)` / `html.raw(value)`
|
|
340
|
+
|
|
341
|
+
Marks trusted HTML so it is inserted without escaping.
|
|
342
|
+
|
|
343
|
+
```js
|
|
344
|
+
html`<p>${raw('<strong>trusted</strong>')}</p>`
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
### Boolean attributes
|
|
348
|
+
|
|
349
|
+
Use unquoted `?name=${condition}` syntax to toggle a boolean attribute.
|
|
350
|
+
|
|
351
|
+
```js
|
|
352
|
+
html`<button ?disabled=${loading}>Save</button>`
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### `html.fragment.start(id)`
|
|
356
|
+
|
|
357
|
+
Starts a named fragment range.
|
|
358
|
+
|
|
359
|
+
```js
|
|
360
|
+
${html.fragment.start('archive-ui')}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### `html.fragment.end`
|
|
364
|
+
|
|
365
|
+
Ends the most recently opened fragment range.
|
|
366
|
+
|
|
367
|
+
```js
|
|
368
|
+
${html.fragment.end}
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
### Error classes
|
|
372
|
+
|
|
373
|
+
```js
|
|
374
|
+
import {
|
|
375
|
+
DuplicateFragmentError,
|
|
376
|
+
FragmentBoundaryError,
|
|
377
|
+
FragmentNotFoundError
|
|
378
|
+
} from 'fragtml'
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
## TypeScript
|
|
382
|
+
|
|
383
|
+
`fragtml` is written in typed JavaScript and ships generated declaration files.
|
|
384
|
+
|
|
385
|
+
## License
|
|
386
|
+
|
|
387
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export default html;
|
|
2
|
+
import { html } from './lib/html.js';
|
|
3
|
+
export const createHtml: import("./lib/create-tag.js").HtmlTag;
|
|
4
|
+
import { raw } from './lib/raw.js';
|
|
5
|
+
import { render } from './lib/render.js';
|
|
6
|
+
export { html, raw, render };
|
|
7
|
+
export { DuplicateFragmentError, FragmentBoundaryError, FragmentNotFoundError } from "./lib/render.js";
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
package/index.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.js"],"names":[],"mappings":";qBAAqB,eAAe;AAIpC,+DAAuB;oBAHH,cAAc;uBACX,iBAAiB"}
|
package/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { html } from './lib/html.js'
|
|
2
|
+
import { raw } from './lib/raw.js'
|
|
3
|
+
import { render } from './lib/render.js'
|
|
4
|
+
|
|
5
|
+
const createHtml = html
|
|
6
|
+
|
|
7
|
+
export default html
|
|
8
|
+
export { createHtml, html, raw, render }
|
|
9
|
+
export { DuplicateFragmentError, FragmentBoundaryError, FragmentNotFoundError } from './lib/render.js'
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const html: HtmlTag;
|
|
2
|
+
export type RenderOptions = {
|
|
3
|
+
fragmentId?: string | undefined;
|
|
4
|
+
};
|
|
5
|
+
export type CompiledTemplate = {
|
|
6
|
+
strings: readonly string[];
|
|
7
|
+
};
|
|
8
|
+
export type HtmlTag = ((strings: TemplateStringsArray | readonly string[], ...substitutions: unknown[]) => HtmlResult) & ((options?: RenderOptions | string) => HtmlTag) & {
|
|
9
|
+
fragment: FragmentHelpers;
|
|
10
|
+
raw: (value: unknown) => RawHtml;
|
|
11
|
+
};
|
|
12
|
+
import { HtmlResult } from './html-result.js';
|
|
13
|
+
import type { FragmentHelpers } from './fragment.js';
|
|
14
|
+
import type { RawHtml } from './raw.js';
|
|
15
|
+
//# sourceMappingURL=create-tag.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"create-tag.d.ts","sourceRoot":"","sources":["create-tag.js"],"names":[],"mappings":"AA+EA,mBADW,OAAO,CACoB;;iBArExB,MAAM,GAAG,SAAS;;;aAKlB,SAAS,MAAM,EAAE;;sBAmElB,CAAC,CAAC,OAAO,EAAE,oBAAoB,GAAG,SAAS,MAAM,EAAE,EAAE,GAAG,aAAa,EAAE,OAAO,EAAE,KAAK,UAAU,CAAC,GAAG,CAAC,CAAC,OAAO,CAAC,EAAE,aAAa,GAAG,MAAM,KAAK,OAAO,CAAC,GAAG;IAAE,QAAQ,EAAE,eAAe,CAAC;IAAC,GAAG,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,OAAO,CAAA;CAAE;2BA9EtM,kBAAkB;qCAJR,eAAe;6BACvB,UAAU"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/** @import { FragmentHelpers } from './fragment.js' */
|
|
2
|
+
/** @import { RawHtml } from './raw.js' */
|
|
3
|
+
|
|
4
|
+
import { createFragmentHelpers } from './fragment.js'
|
|
5
|
+
import { HtmlResult } from './html-result.js'
|
|
6
|
+
import { raw } from './raw.js'
|
|
7
|
+
import { renderResult } from './render.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} RenderOptions
|
|
11
|
+
* @property {string | undefined} [fragmentId]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {object} CompiledTemplate
|
|
16
|
+
* @property {readonly string[]} strings
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** @type {WeakMap<TemplateStringsArray | readonly string[], CompiledTemplate>} */
|
|
20
|
+
const templateCache = new WeakMap()
|
|
21
|
+
const fragment = createFragmentHelpers()
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {TemplateStringsArray | readonly string[]} strings
|
|
25
|
+
* @returns {CompiledTemplate}
|
|
26
|
+
*/
|
|
27
|
+
function compileTemplate (strings) {
|
|
28
|
+
const cached = templateCache.get(strings)
|
|
29
|
+
|
|
30
|
+
if (cached) return cached
|
|
31
|
+
|
|
32
|
+
const compiled = { strings: Array.from(strings) }
|
|
33
|
+
templateCache.set(strings, compiled)
|
|
34
|
+
return compiled
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {unknown} value
|
|
39
|
+
* @returns {value is TemplateStringsArray | readonly string[]}
|
|
40
|
+
*/
|
|
41
|
+
function isTemplateStrings (value) {
|
|
42
|
+
return Array.isArray(value)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {RenderOptions | string | undefined} options
|
|
47
|
+
* @returns {RenderOptions}
|
|
48
|
+
*/
|
|
49
|
+
function normalizeOptions (options) {
|
|
50
|
+
if (typeof options === 'string') return { fragmentId: options }
|
|
51
|
+
return options ? { ...options } : {}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {RenderOptions} options
|
|
56
|
+
* @returns {HtmlTag}
|
|
57
|
+
*/
|
|
58
|
+
function createBoundTag (options) {
|
|
59
|
+
// eslint-disable-next-line @stylistic/no-extra-parens
|
|
60
|
+
const tag = /** @type {HtmlTag} */ (function tag (strings, ...substitutions) {
|
|
61
|
+
if (!isTemplateStrings(strings)) {
|
|
62
|
+
return createBoundTag(normalizeOptions(/** @type {RenderOptions | string | undefined} */ strings))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new HtmlResult(
|
|
66
|
+
compileTemplate(strings),
|
|
67
|
+
substitutions,
|
|
68
|
+
options,
|
|
69
|
+
renderResult
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
tag.fragment = fragment
|
|
74
|
+
tag.raw = raw
|
|
75
|
+
|
|
76
|
+
return tag
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** @type {HtmlTag} */
|
|
80
|
+
export const html = createBoundTag({})
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @typedef {((strings: TemplateStringsArray | readonly string[], ...substitutions: unknown[]) => HtmlResult) & ((options?: RenderOptions | string) => HtmlTag) & { fragment: FragmentHelpers, raw: (value: unknown) => RawHtml }} HtmlTag
|
|
84
|
+
*/
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"escape-html.d.ts","sourceRoot":"","sources":["escape-html.js"],"names":[],"mappings":"AAQA,kCAHW,OAAO,GACL,MAAM,CAclB"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const htmlEscapes = /[&<>"'`]/g
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Escapes text for safe HTML interpolation.
|
|
5
|
+
*
|
|
6
|
+
* @param {unknown} value
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function escapeHtml (value) {
|
|
10
|
+
return String(value).replace(htmlEscapes, (character) => {
|
|
11
|
+
switch (character) {
|
|
12
|
+
case '&': return '&'
|
|
13
|
+
case '<': return '<'
|
|
14
|
+
case '>': return '>'
|
|
15
|
+
case '"': return '"'
|
|
16
|
+
case "'": return '''
|
|
17
|
+
case '`': return '`'
|
|
18
|
+
default: return character
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function createFragmentHelpers(): FragmentHelpers;
|
|
2
|
+
export function isFragmentBoundary(value: unknown): value is FragmentBoundary;
|
|
3
|
+
export const fragmentBoundarySymbol: unique symbol;
|
|
4
|
+
export type FragmentStartBoundary = {
|
|
5
|
+
[fragmentBoundarySymbol]: true;
|
|
6
|
+
kind: "start";
|
|
7
|
+
id: string;
|
|
8
|
+
};
|
|
9
|
+
export type FragmentEndBoundary = {
|
|
10
|
+
[fragmentBoundarySymbol]: true;
|
|
11
|
+
kind: "end";
|
|
12
|
+
};
|
|
13
|
+
export type FragmentBoundary = FragmentStartBoundary | FragmentEndBoundary;
|
|
14
|
+
export type FragmentHelpers = {
|
|
15
|
+
start: typeof start;
|
|
16
|
+
end: FragmentEndBoundary;
|
|
17
|
+
};
|
|
18
|
+
declare function start(id: string): FragmentStartBoundary;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=fragment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fragment.d.ts","sourceRoot":"","sources":["fragment.js"],"names":[],"mappings":"AAiCA,yCAFa,eAAe,CAI3B;AAMD,0CAHW,OAAO,GACL,KAAK,IAAI,gBAAgB,CAQrC;AAxCD,mDAAwE;oCAN3D;IAAE,CAAC,sBAAsB,CAAC,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,OAAO,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE;kCAC7D;IAAE,CAAC,sBAAsB,CAAC,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE;+BAC/C,qBAAqB,GAAG,mBAAmB;8BAC3C;IAAE,KAAK,EAAE,OAAO,KAAK,CAAC;IAAC,GAAG,EAAE,mBAAmB,CAAA;CAAE;AAc9D,2BAHW,MAAM,GACJ,qBAAqB,CAYjC"}
|
package/lib/fragment.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {{ [fragmentBoundarySymbol]: true, kind: 'start', id: string }} FragmentStartBoundary
|
|
3
|
+
* @typedef {{ [fragmentBoundarySymbol]: true, kind: 'end' }} FragmentEndBoundary
|
|
4
|
+
* @typedef {FragmentStartBoundary | FragmentEndBoundary} FragmentBoundary
|
|
5
|
+
* @typedef {{ start: typeof start, end: FragmentEndBoundary }} FragmentHelpers
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const fragmentBoundarySymbol = Symbol('fragtml.fragmentBoundary')
|
|
9
|
+
|
|
10
|
+
const end = Object.freeze(/** @type {FragmentEndBoundary} */ ({
|
|
11
|
+
[fragmentBoundarySymbol]: true,
|
|
12
|
+
kind: 'end'
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} id
|
|
17
|
+
* @returns {FragmentStartBoundary}
|
|
18
|
+
*/
|
|
19
|
+
function start (id) {
|
|
20
|
+
if (typeof id !== 'string' || id.length === 0) {
|
|
21
|
+
throw new TypeError('fragment.start(id) requires a non-empty string id')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
[fragmentBoundarySymbol]: true,
|
|
26
|
+
kind: 'start',
|
|
27
|
+
id
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {FragmentHelpers}
|
|
33
|
+
*/
|
|
34
|
+
export function createFragmentHelpers () {
|
|
35
|
+
return Object.freeze({ start, end })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {unknown} value
|
|
40
|
+
* @returns {value is FragmentBoundary}
|
|
41
|
+
*/
|
|
42
|
+
export function isFragmentBoundary (value) {
|
|
43
|
+
return !!(
|
|
44
|
+
value &&
|
|
45
|
+
typeof value === 'object' &&
|
|
46
|
+
/** @type {Record<symbol, unknown>} */(value)[fragmentBoundarySymbol] === true
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function isHtmlResult(value: unknown): value is HtmlResult;
|
|
2
|
+
export const htmlResultSymbol: unique symbol;
|
|
3
|
+
export class HtmlResult {
|
|
4
|
+
constructor(compiled: CompiledTemplate, substitutions: unknown[], options: RenderOptions, render: (result: HtmlResult) => string);
|
|
5
|
+
compiled: CompiledTemplate;
|
|
6
|
+
substitutions: unknown[];
|
|
7
|
+
options: RenderOptions;
|
|
8
|
+
render: (result: HtmlResult) => string;
|
|
9
|
+
toString(): string;
|
|
10
|
+
valueOf(): string;
|
|
11
|
+
[Symbol.toPrimitive](): string;
|
|
12
|
+
[htmlResultSymbol]: boolean;
|
|
13
|
+
}
|
|
14
|
+
import type { CompiledTemplate } from './create-tag.js';
|
|
15
|
+
import type { RenderOptions } from './create-tag.js';
|
|
16
|
+
//# sourceMappingURL=html-result.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html-result.d.ts","sourceRoot":"","sources":["html-result.js"],"names":[],"mappings":"AAoCA,oCAHW,OAAO,GACL,KAAK,IAAI,UAAU,CAQ/B;AAxCD,6CAA4D;AAE5D;IAOE,sBALW,gBAAgB,iBAChB,OAAO,EAAE,WACT,aAAa,UACb,CAAC,MAAM,EAAE,UAAU,KAAK,MAAM,EAQxC;IAJC,2BAAwB;IACxB,yBAAkC;IAClC,uBAAsB;IACtB,iBAPkB,UAAU,KAAK,MAAM,CAOnB;IAGtB,mBAEC;IAED,kBAEC;IAED,+BAEC;IAjBC,4BAA6B;CAkBhC;sCA9BoD,iBAAiB;mCAAjB,iBAAiB"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** @import { CompiledTemplate, RenderOptions } from './create-tag.js' */
|
|
2
|
+
|
|
3
|
+
export const htmlResultSymbol = Symbol('fragtml.htmlResult')
|
|
4
|
+
|
|
5
|
+
export class HtmlResult {
|
|
6
|
+
/**
|
|
7
|
+
* @param {CompiledTemplate} compiled
|
|
8
|
+
* @param {unknown[]} substitutions
|
|
9
|
+
* @param {RenderOptions} options
|
|
10
|
+
* @param {(result: HtmlResult) => string} render
|
|
11
|
+
*/
|
|
12
|
+
constructor (compiled, substitutions, options, render) {
|
|
13
|
+
this[htmlResultSymbol] = true
|
|
14
|
+
this.compiled = compiled
|
|
15
|
+
this.substitutions = substitutions
|
|
16
|
+
this.options = options
|
|
17
|
+
this.render = render
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
toString () {
|
|
21
|
+
return this.render(this)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
valueOf () {
|
|
25
|
+
return this.render(this)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
[Symbol.toPrimitive] () {
|
|
29
|
+
return this.render(this)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {unknown} value
|
|
35
|
+
* @returns {value is HtmlResult}
|
|
36
|
+
*/
|
|
37
|
+
export function isHtmlResult (value) {
|
|
38
|
+
return value instanceof HtmlResult || !!(
|
|
39
|
+
value &&
|
|
40
|
+
typeof value === 'object' &&
|
|
41
|
+
/** @type {Record<symbol, unknown>} */(value)[htmlResultSymbol] === true
|
|
42
|
+
)
|
|
43
|
+
}
|
package/lib/html.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"html.d.ts","sourceRoot":"","sources":["html.js"],"names":[],"mappings":""}
|
package/lib/html.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { html } from './create-tag.js'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"inline-array.d.ts","sourceRoot":"","sources":["inline-array.js"],"names":[],"mappings":"AAOA,yCAJW,MAAM,SACN,MAAM,EAAE,GACN,MAAM,CAiBlB"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { stripLastNewLine } from './utils.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {string} resultSoFar
|
|
5
|
+
* @param {string[]} parts
|
|
6
|
+
* @returns {string}
|
|
7
|
+
*/
|
|
8
|
+
export function inlineArray (resultSoFar, parts) {
|
|
9
|
+
const indentation = resultSoFar.match(/(?:\n)([^\S\n]+)$/)
|
|
10
|
+
|
|
11
|
+
return parts.reduce((result, part, index) => {
|
|
12
|
+
const isFirstPart = index === 0
|
|
13
|
+
const strippedPart = stripLastNewLine(part)
|
|
14
|
+
|
|
15
|
+
return ''.concat(
|
|
16
|
+
result,
|
|
17
|
+
isFirstPart ? '' : indentation ? '\n' : ' ',
|
|
18
|
+
indentation
|
|
19
|
+
? prefixLines(indentation[1] ?? '', strippedPart, isFirstPart)
|
|
20
|
+
: strippedPart
|
|
21
|
+
)
|
|
22
|
+
}, '')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} prefix
|
|
27
|
+
* @param {string} value
|
|
28
|
+
* @param {boolean} skipFirst
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
function prefixLines (prefix, value, skipFirst) {
|
|
32
|
+
return value
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((line, index) => skipFirst && index === 0 ? line : `${prefix}${line}`)
|
|
35
|
+
.join('\n')
|
|
36
|
+
}
|
package/lib/raw.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export function raw(value: unknown): RawHtml;
|
|
2
|
+
export function isRawHtml(value: unknown): value is RawHtml;
|
|
3
|
+
export const rawHtmlSymbol: unique symbol;
|
|
4
|
+
export class RawHtml {
|
|
5
|
+
constructor(value: unknown);
|
|
6
|
+
value: unknown;
|
|
7
|
+
toString(): string;
|
|
8
|
+
valueOf(): string;
|
|
9
|
+
[Symbol.toPrimitive](): string;
|
|
10
|
+
[rawHtmlSymbol]: boolean;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=raw.d.ts.map
|
package/lib/raw.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"raw.d.ts","sourceRoot":"","sources":["raw.js"],"names":[],"mappings":"AA8BA,2BAHW,OAAO,GACL,OAAO,CAInB;AAMD,iCAHW,OAAO,GACL,KAAK,IAAI,OAAO,CAQ5B;AA5CD,0CAAsD;AAEtD;IAIE,mBAFW,OAAO,EAKjB;IADC,eAAkB;IAGpB,mBAEC;IAED,kBAEC;IAED,+BAEC;IAdC,yBAA0B;CAe7B"}
|
package/lib/raw.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const rawHtmlSymbol = Symbol('fragtml.rawHtml')
|
|
2
|
+
|
|
3
|
+
export class RawHtml {
|
|
4
|
+
/**
|
|
5
|
+
* @param {unknown} value
|
|
6
|
+
*/
|
|
7
|
+
constructor (value) {
|
|
8
|
+
this[rawHtmlSymbol] = true
|
|
9
|
+
this.value = value
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
toString () {
|
|
13
|
+
return String(this.value)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
valueOf () {
|
|
17
|
+
return String(this.value)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
[Symbol.toPrimitive] () {
|
|
21
|
+
return String(this.value)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Marks a value as trusted HTML so it is inserted without escaping.
|
|
27
|
+
*
|
|
28
|
+
* @param {unknown} value
|
|
29
|
+
* @returns {RawHtml}
|
|
30
|
+
*/
|
|
31
|
+
export function raw (value) {
|
|
32
|
+
return new RawHtml(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {unknown} value
|
|
37
|
+
* @returns {value is RawHtml}
|
|
38
|
+
*/
|
|
39
|
+
export function isRawHtml (value) {
|
|
40
|
+
return value instanceof RawHtml || !!(
|
|
41
|
+
value &&
|
|
42
|
+
typeof value === 'object' &&
|
|
43
|
+
/** @type {Record<symbol, unknown>} */(value)[rawHtmlSymbol] === true
|
|
44
|
+
)
|
|
45
|
+
}
|
package/lib/render.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function render(value: unknown): string;
|
|
2
|
+
export function renderResult(result: HtmlResult): string;
|
|
3
|
+
export class FragmentNotFoundError extends Error {
|
|
4
|
+
constructor(id: string);
|
|
5
|
+
fragmentId: string;
|
|
6
|
+
}
|
|
7
|
+
export class DuplicateFragmentError extends Error {
|
|
8
|
+
constructor(id: string);
|
|
9
|
+
fragmentId: string;
|
|
10
|
+
}
|
|
11
|
+
export class FragmentBoundaryError extends Error {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
import type { HtmlResult } from './html-result.js';
|
|
15
|
+
//# sourceMappingURL=render.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render.d.ts","sourceRoot":"","sources":["render.js"],"names":[],"mappings":"AAoJA,8BAHW,OAAO,GACL,MAAM,CAQlB;AAMD,qCAHW,UAAU,GACR,MAAM,CAMlB;AAxJD;IAIE,gBAFW,MAAM,EAMhB;IADC,mBAAoB;CAEvB;AAED;IAIE,gBAFW,MAAM,EAMhB;IADC,mBAAoB;CAEvB;AAED;IAIE,qBAFW,MAAM,EAKhB;CACF;gCA1C+B,kBAAkB"}
|
package/lib/render.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/** @import { HtmlResult } from './html-result.js' */
|
|
2
|
+
|
|
3
|
+
import { escapeHtml } from './escape-html.js'
|
|
4
|
+
import { isFragmentBoundary } from './fragment.js'
|
|
5
|
+
import { isHtmlResult } from './html-result.js'
|
|
6
|
+
import { inlineArray } from './inline-array.js'
|
|
7
|
+
import { isRawHtml } from './raw.js'
|
|
8
|
+
import { stripIndent } from './strip-indent.js'
|
|
9
|
+
import { isNonPrintingValue } from './utils.js'
|
|
10
|
+
|
|
11
|
+
const booleanAttributeSuffix = /(^|\s)\?([A-Za-z_:][A-Za-z0-9_:.-]*)=$/
|
|
12
|
+
|
|
13
|
+
export class FragmentNotFoundError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} id
|
|
16
|
+
*/
|
|
17
|
+
constructor (id) {
|
|
18
|
+
super(`Fragment not found: ${id}`)
|
|
19
|
+
this.name = 'FragmentNotFoundError'
|
|
20
|
+
this.fragmentId = id
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DuplicateFragmentError extends Error {
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} id
|
|
27
|
+
*/
|
|
28
|
+
constructor (id) {
|
|
29
|
+
super(`Duplicate fragment: ${id}`)
|
|
30
|
+
this.name = 'DuplicateFragmentError'
|
|
31
|
+
this.fragmentId = id
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class FragmentBoundaryError extends Error {
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} message
|
|
38
|
+
*/
|
|
39
|
+
constructor (message) {
|
|
40
|
+
super(message)
|
|
41
|
+
this.name = 'FragmentBoundaryError'
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
class RenderContext {
|
|
46
|
+
/**
|
|
47
|
+
* @param {string | undefined} fragmentId
|
|
48
|
+
*/
|
|
49
|
+
constructor (fragmentId) {
|
|
50
|
+
this.fragmentId = fragmentId
|
|
51
|
+
this.full = ''
|
|
52
|
+
/** @type {{ id: string, content: string }[]} */
|
|
53
|
+
this.stack = []
|
|
54
|
+
/** @type {Map<string, string>} */
|
|
55
|
+
this.fragments = new Map()
|
|
56
|
+
/** @type {Set<string>} */
|
|
57
|
+
this.seen = new Set()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {string} value
|
|
62
|
+
*/
|
|
63
|
+
append (value) {
|
|
64
|
+
this.full += value
|
|
65
|
+
|
|
66
|
+
for (const fragment of this.stack) {
|
|
67
|
+
fragment.content += value
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
trimTrailingLineWhitespace () {
|
|
72
|
+
this.full = trimTrailingLineWhitespace(this.full)
|
|
73
|
+
|
|
74
|
+
for (const fragment of this.stack) {
|
|
75
|
+
fragment.content = trimTrailingLineWhitespace(fragment.content)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {unknown} value
|
|
81
|
+
* @returns {boolean}
|
|
82
|
+
*/
|
|
83
|
+
consumeBooleanAttribute (value) {
|
|
84
|
+
const match = this.full.match(booleanAttributeSuffix)
|
|
85
|
+
|
|
86
|
+
if (!match) return false
|
|
87
|
+
|
|
88
|
+
const prefix = match[1] ?? ''
|
|
89
|
+
const name = match[2] ?? ''
|
|
90
|
+
const replacement = value ? `${prefix}${name}` : ''
|
|
91
|
+
|
|
92
|
+
this.full = replaceBooleanAttributeSuffix(this.full, replacement)
|
|
93
|
+
|
|
94
|
+
for (const fragment of this.stack) {
|
|
95
|
+
fragment.content = replaceBooleanAttributeSuffix(fragment.content, replacement)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string} id
|
|
103
|
+
*/
|
|
104
|
+
startFragment (id) {
|
|
105
|
+
if (this.seen.has(id)) {
|
|
106
|
+
throw new DuplicateFragmentError(id)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
this.seen.add(id)
|
|
110
|
+
this.stack.push({ id, content: '' })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
endFragment () {
|
|
114
|
+
const fragment = this.stack.pop()
|
|
115
|
+
|
|
116
|
+
if (!fragment) {
|
|
117
|
+
throw new FragmentBoundaryError('Unexpected fragment end with no open fragment')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
this.fragments.set(fragment.id, fragment.content)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
finish () {
|
|
124
|
+
if (this.stack.length > 0) {
|
|
125
|
+
const open = this.stack.at(-1)
|
|
126
|
+
throw new FragmentBoundaryError(`Unclosed fragment: ${open?.id}`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (this.fragmentId) {
|
|
130
|
+
const fragment = this.fragments.get(this.fragmentId)
|
|
131
|
+
|
|
132
|
+
if (fragment == null) {
|
|
133
|
+
throw new FragmentNotFoundError(this.fragmentId)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return stripIndent(fragment)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return stripIndent(this.full)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Converts a renderable value to a primitive string.
|
|
145
|
+
*
|
|
146
|
+
* @param {unknown} value
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function render (value) {
|
|
150
|
+
if (isHtmlResult(value)) return renderResult(value)
|
|
151
|
+
if (isRawHtml(value)) return String(value)
|
|
152
|
+
if (Array.isArray(value)) return value.map(render).join('')
|
|
153
|
+
if (isNonPrintingValue(value)) return ''
|
|
154
|
+
return escapeHtml(value)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {HtmlResult} result
|
|
159
|
+
* @returns {string}
|
|
160
|
+
*/
|
|
161
|
+
export function renderResult (result) {
|
|
162
|
+
const context = new RenderContext(result.options.fragmentId)
|
|
163
|
+
renderResultInto(result, context)
|
|
164
|
+
return context.finish()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* @param {HtmlResult} result
|
|
169
|
+
* @param {RenderContext} context
|
|
170
|
+
*/
|
|
171
|
+
function renderResultInto (result, context) {
|
|
172
|
+
const { strings } = result.compiled
|
|
173
|
+
const { substitutions } = result
|
|
174
|
+
|
|
175
|
+
context.append(strings[0] ?? '')
|
|
176
|
+
|
|
177
|
+
for (let index = 0; index < substitutions.length; index++) {
|
|
178
|
+
const substitution = substitutions[index]
|
|
179
|
+
const nextString = strings[index + 1] ?? ''
|
|
180
|
+
|
|
181
|
+
if (isFragmentBoundary(substitution)) {
|
|
182
|
+
context.trimTrailingLineWhitespace()
|
|
183
|
+
appendSubstitution(context, substitution)
|
|
184
|
+
context.append(stripLeadingBoundaryLineWhitespace(nextString))
|
|
185
|
+
} else {
|
|
186
|
+
appendSubstitution(context, substitution)
|
|
187
|
+
context.append(nextString)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {RenderContext} context
|
|
194
|
+
* @param {unknown} value
|
|
195
|
+
*/
|
|
196
|
+
function appendSubstitution (context, value) {
|
|
197
|
+
if (isFragmentBoundary(value)) {
|
|
198
|
+
if (value.kind === 'start') context.startFragment(value.id)
|
|
199
|
+
else context.endFragment()
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (context.consumeBooleanAttribute(value)) {
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (isHtmlResult(value)) {
|
|
208
|
+
renderResultInto(value, context)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
context.append(renderSubstitution(value, context.full))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {unknown} value
|
|
217
|
+
* @param {string} resultSoFar
|
|
218
|
+
* @returns {string}
|
|
219
|
+
*/
|
|
220
|
+
function renderSubstitution (value, resultSoFar) {
|
|
221
|
+
if (isNonPrintingValue(value)) return ''
|
|
222
|
+
if (isRawHtml(value)) return String(value)
|
|
223
|
+
if (isHtmlResult(value)) return renderNestedResult(value)
|
|
224
|
+
|
|
225
|
+
if (Array.isArray(value)) {
|
|
226
|
+
const parts = value
|
|
227
|
+
.filter((part) => !isNonPrintingValue(part))
|
|
228
|
+
.flatMap((part) => {
|
|
229
|
+
const rendered = renderSubstitution(part, resultSoFar)
|
|
230
|
+
return typeof part === 'string' && part.includes('\n')
|
|
231
|
+
? rendered.split('\n')
|
|
232
|
+
: [rendered]
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
return inlineArray(resultSoFar, parts)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (typeof value === 'string' && value.includes('\n')) {
|
|
239
|
+
return inlineArray(resultSoFar, value.split('\n').map(escapeHtml))
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return escapeHtml(value)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {HtmlResult} result
|
|
247
|
+
* @returns {string}
|
|
248
|
+
*/
|
|
249
|
+
function renderNestedResult (result) {
|
|
250
|
+
const context = new RenderContext(undefined)
|
|
251
|
+
renderResultInto(result, context)
|
|
252
|
+
return context.finish()
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* @param {string} value
|
|
257
|
+
* @returns {string}
|
|
258
|
+
*/
|
|
259
|
+
function trimTrailingLineWhitespace (value) {
|
|
260
|
+
return value.replace(/[ \t]*$/, '')
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* @param {string} value
|
|
265
|
+
* @param {string} replacement
|
|
266
|
+
* @returns {string}
|
|
267
|
+
*/
|
|
268
|
+
function replaceBooleanAttributeSuffix (value, replacement) {
|
|
269
|
+
return value.replace(booleanAttributeSuffix, replacement)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @param {string} value
|
|
274
|
+
* @returns {string}
|
|
275
|
+
*/
|
|
276
|
+
function stripLeadingBoundaryLineWhitespace (value) {
|
|
277
|
+
return value.replace(/^\r?\n/, '')
|
|
278
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strip-indent.d.ts","sourceRoot":"","sources":["strip-indent.js"],"names":[],"mappings":"AAMA,mCAHW,MAAM,GACJ,MAAM,CAqBlB"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart trims and strips the shared indentation from a template result.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} value
|
|
5
|
+
* @returns {string}
|
|
6
|
+
*/
|
|
7
|
+
export function stripIndent (value) {
|
|
8
|
+
const trimmed = value
|
|
9
|
+
.replace(/^[ \t]*\r?\n/, '')
|
|
10
|
+
.replace(/\r?\n[ \t]*$/, '')
|
|
11
|
+
|
|
12
|
+
const lines = trimmed.split(/\r?\n/)
|
|
13
|
+
let minIndent = Infinity
|
|
14
|
+
|
|
15
|
+
for (const line of lines) {
|
|
16
|
+
if (line.trim() === '') continue
|
|
17
|
+
const indent = line.match(/^[ \t]*/)?.[0].length ?? 0
|
|
18
|
+
minIndent = Math.min(minIndent, indent)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!Number.isFinite(minIndent) || minIndent === 0) return trimmed
|
|
22
|
+
|
|
23
|
+
return lines
|
|
24
|
+
.map((line) => line.trim() === '' ? '' : line.slice(minIndent))
|
|
25
|
+
.join('\n')
|
|
26
|
+
}
|
package/lib/utils.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["utils.js"],"names":[],"mappings":"AAIA,0CAHW,OAAO,GACL,OAAO,CAInB;AAMD,wCAHW,MAAM,GACJ,MAAM,CAIlB"}
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {unknown} value
|
|
3
|
+
* @returns {boolean}
|
|
4
|
+
*/
|
|
5
|
+
export function isNonPrintingValue (value) {
|
|
6
|
+
return value == null || typeof value === 'boolean' || (typeof value === 'number' && Number.isNaN(value))
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} value
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function stripLastNewLine (value) {
|
|
14
|
+
return value.replace(/\n$/, '')
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "fragtml",
|
|
3
|
+
"description": "WIP - nothing to see here",
|
|
4
|
+
"version": "0.0.1",
|
|
5
|
+
"author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/bcomnes/fragtml/issues"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {},
|
|
10
|
+
"devDependencies": {
|
|
11
|
+
"@voxpelli/tsconfig": "^16.1.0",
|
|
12
|
+
"@types/node": "^25.0.0",
|
|
13
|
+
"neostandard": "^0.13.0",
|
|
14
|
+
"npm-run-all2": "^9.0.0",
|
|
15
|
+
"releasearoni": "^0.2.0",
|
|
16
|
+
"typescript": "~6.0.3"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20",
|
|
20
|
+
"npm": ">=10"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/bcomnes/fragtml",
|
|
23
|
+
"keywords": [],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"module": "index.js",
|
|
27
|
+
"main": "index.js",
|
|
28
|
+
"types": "index.d.ts",
|
|
29
|
+
"files": [
|
|
30
|
+
"index.js",
|
|
31
|
+
"index.d.ts",
|
|
32
|
+
"index.d.ts.map",
|
|
33
|
+
"lib"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/bcomnes/fragtml.git"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"prepublishOnly": "releasearoni",
|
|
41
|
+
"postpublish": "npm run clean",
|
|
42
|
+
"test": "run-s test:*",
|
|
43
|
+
"test:lint": "eslint",
|
|
44
|
+
"test:tsc": "tsc",
|
|
45
|
+
"test:node-test": "node --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test",
|
|
46
|
+
"version": "releasearoni version",
|
|
47
|
+
"build": "npm run clean && run-p build:*",
|
|
48
|
+
"build:declaration": "tsc -p declaration.tsconfig.json",
|
|
49
|
+
"clean": "run-p clean:*",
|
|
50
|
+
"clean:declarations-top": "rm -rf $(find . -maxdepth 1 -type f -name '*.d.ts*')",
|
|
51
|
+
"clean:declarations-lib": "rm -rf $(find lib -type f -name '*.d.ts*' ! -name '*-types.d.ts')"
|
|
52
|
+
},
|
|
53
|
+
"funding": {
|
|
54
|
+
"type": "individual",
|
|
55
|
+
"url": "https://github.com/sponsors/bcomnes"
|
|
56
|
+
}
|
|
57
|
+
}
|