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 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
+ [![latest version](https://img.shields.io/npm/v/fragtml.svg)](https://www.npmjs.com/package/fragtml)
4
+ [![Actions Status](https://github.com/bcomnes/fragtml/workflows/tests/badge.svg)](https://github.com/bcomnes/fragtml/actions)
5
+
6
+ [![downloads](https://img.shields.io/npm/dm/fragtml.svg)](https://npmtrends.com/fragtml)
7
+ ![Types in JS](https://img.shields.io/badge/types_in_js-yes-brightgreen)
8
+ [![neostandard javascript style](https://img.shields.io/badge/code_style-neostandard-7fffff?style=flat&labelColor=ff80ff)](https://github.com/neostandard/neostandard)
9
+ [![Socket Badge](https://socket.dev/api/badge/npm/package/fragtml)](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 &lt;Bret&gt;</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>&lt;script&gt;alert(1)&lt;/script&gt;</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>&lt;Archive&gt;</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,2 @@
1
+ export function escapeHtml(value: unknown): string;
2
+ //# sourceMappingURL=escape-html.d.ts.map
@@ -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 '&amp;'
13
+ case '<': return '&lt;'
14
+ case '>': return '&gt;'
15
+ case '"': return '&quot;'
16
+ case "'": return '&#x27;'
17
+ case '`': return '&#x60;'
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"}
@@ -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,2 @@
1
+ export { html } from "./create-tag.js";
2
+ //# sourceMappingURL=html.d.ts.map
@@ -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,2 @@
1
+ export function inlineArray(resultSoFar: string, parts: string[]): string;
2
+ //# sourceMappingURL=inline-array.d.ts.map
@@ -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
@@ -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
+ }
@@ -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,2 @@
1
+ export function stripIndent(value: string): string;
2
+ //# sourceMappingURL=strip-indent.d.ts.map
@@ -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,3 @@
1
+ export function isNonPrintingValue(value: unknown): boolean;
2
+ export function stripLastNewLine(value: string): string;
3
+ //# sourceMappingURL=utils.d.ts.map
@@ -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
+ }