@zeropress/build-core 0.1.8 → 0.1.10
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/README.md +38 -6
- package/package.json +6 -3
- package/src/api/build-site.js +47 -8
- package/src/index.d.ts +4 -4
- package/src/render/content-renderer.js +156 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ Shared build and partial-render core for ZeroPress.
|
|
|
9
9
|
This package is the deterministic rendering engine used by:
|
|
10
10
|
|
|
11
11
|
- `zeropress-admin-api-v2`
|
|
12
|
-
-
|
|
12
|
+
- [`@zeropress/build`](https://www.npmjs.com/package/@zeropress/build) package
|
|
13
13
|
|
|
14
14
|
It accepts canonical preview-data plus a validated theme package and produces static HTML artifacts through a writer interface.
|
|
15
15
|
|
|
@@ -59,7 +59,12 @@ import {
|
|
|
59
59
|
- `meta.json`
|
|
60
60
|
- writing outputs through a pluggable writer
|
|
61
61
|
|
|
62
|
-
It does not
|
|
62
|
+
It does not:
|
|
63
|
+
|
|
64
|
+
- fetch content from databases or APIs
|
|
65
|
+
- package or validate theme directories on its own unless the caller uses `buildSiteFromThemeDir`
|
|
66
|
+
- watch files, run a dev server, or perform deployment
|
|
67
|
+
- talk to queues, KV, Durable Objects, R2, GitHub, or other infrastructure directly
|
|
63
68
|
|
|
64
69
|
---
|
|
65
70
|
|
|
@@ -97,7 +102,7 @@ Returns:
|
|
|
97
102
|
}
|
|
98
103
|
],
|
|
99
104
|
manifest: {
|
|
100
|
-
generatedAt: '2026-04-02T00:00:
|
|
105
|
+
generatedAt: '2026-04-02T00:00:00Z',
|
|
101
106
|
files: [
|
|
102
107
|
{
|
|
103
108
|
path: 'index.html',
|
|
@@ -110,11 +115,29 @@ Returns:
|
|
|
110
115
|
}
|
|
111
116
|
```
|
|
112
117
|
|
|
118
|
+
Notes:
|
|
119
|
+
|
|
120
|
+
- `writer` is required
|
|
121
|
+
- `previewData` must already satisfy the canonical preview-data contract
|
|
122
|
+
- `themePackage` must already be a validated in-memory theme package
|
|
123
|
+
- `sitemap.xml` and `feed.xml` are emitted only when `site.url` is a non-empty canonical URL
|
|
124
|
+
- `robots.txt` and `meta.json` are still emitted when `generateSpecialFiles` is enabled
|
|
125
|
+
|
|
113
126
|
### `buildSelectedRoutes(input)`
|
|
114
127
|
|
|
115
128
|
Renders only selected post and list routes while preserving full-build parity for those outputs.
|
|
116
129
|
|
|
117
|
-
This is intended for
|
|
130
|
+
This is intended for targeted rebuilds, output parity checks, and other partial-render workflows.
|
|
131
|
+
|
|
132
|
+
Current selection scope is limited to:
|
|
133
|
+
|
|
134
|
+
- post detail routes
|
|
135
|
+
- index pagination routes
|
|
136
|
+
- archive routes
|
|
137
|
+
- category routes
|
|
138
|
+
- tag routes
|
|
139
|
+
|
|
140
|
+
It does not currently support selecting individual page routes.
|
|
118
141
|
|
|
119
142
|
Optional route templates are respected during both full and partial renders:
|
|
120
143
|
|
|
@@ -145,7 +168,7 @@ await buildSelectedRoutes({
|
|
|
145
168
|
|
|
146
169
|
Loads a theme directory from disk and renders it using the same core pipeline.
|
|
147
170
|
|
|
148
|
-
This is useful for local tooling
|
|
171
|
+
This is useful for local tooling that wants filesystem theme loading but still uses the same deterministic core renderer.
|
|
149
172
|
|
|
150
173
|
---
|
|
151
174
|
|
|
@@ -193,6 +216,8 @@ Theme validation is enforced through:
|
|
|
193
216
|
|
|
194
217
|
- [`@zeropress/theme-validator`](https://www.npmjs.com/package/@zeropress/theme-validator)
|
|
195
218
|
|
|
219
|
+
`buildSiteFromThemeDir()` is the convenience entry point that loads a theme directory and converts it into the required in-memory `themePackage`.
|
|
220
|
+
|
|
196
221
|
As of `preview-data v0.4`, the payload no longer carries `routes` arrays or raw HTML fragments such as `categories_html`.
|
|
197
222
|
|
|
198
223
|
Build-core now derives:
|
|
@@ -238,6 +263,13 @@ Supported options:
|
|
|
238
263
|
|
|
239
264
|
These options apply to both full builds and partial renders where relevant.
|
|
240
265
|
|
|
266
|
+
Defaults:
|
|
267
|
+
|
|
268
|
+
- `assetHashing: true`
|
|
269
|
+
- `generateSpecialFiles: true`
|
|
270
|
+
- `injectHtmx: true`
|
|
271
|
+
- `writeManifest: false`
|
|
272
|
+
|
|
241
273
|
---
|
|
242
274
|
|
|
243
275
|
## Requirements
|
|
@@ -251,7 +283,7 @@ These options apply to both full builds and partial renders where relevant.
|
|
|
251
283
|
|
|
252
284
|
- [@zeropress/preview-data-validator](https://www.npmjs.com/package/@zeropress/preview-data-validator)
|
|
253
285
|
- [@zeropress/theme-validator](https://www.npmjs.com/package/@zeropress/theme-validator)
|
|
254
|
-
- [zeropress
|
|
286
|
+
- [@zeropress/theme](https://www.npmjs.com/package/@zeropress/theme)
|
|
255
287
|
|
|
256
288
|
---
|
|
257
289
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeropress/build-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Shared ZeroPress build and partial-render core",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"node": ">=18.18.0"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@zeropress/preview-data-validator": "^0.
|
|
37
|
-
"@zeropress/theme-validator": "^0.2.4"
|
|
36
|
+
"@zeropress/preview-data-validator": "^0.4.0",
|
|
37
|
+
"@zeropress/theme-validator": "^0.2.4",
|
|
38
|
+
"highlight.js": "^11.11.1",
|
|
39
|
+
"markdown-it": "^14.1.0",
|
|
40
|
+
"markdown-it-anchor": "^9.2.0"
|
|
38
41
|
}
|
|
39
42
|
}
|
package/src/api/build-site.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { assertPreviewData } from '@zeropress/preview-data-validator';
|
|
3
3
|
import { validateThemeFiles } from '@zeropress/theme-validator';
|
|
4
4
|
import { AssetProcessor } from '../assets/asset-processor.js';
|
|
5
|
+
import { renderDocumentContent } from '../render/content-renderer.js';
|
|
5
6
|
import { ZeroPressEngine } from '../render/zeropress-engine.js';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_OPTIONS = {
|
|
@@ -28,7 +29,7 @@ export async function buildSite(input) {
|
|
|
28
29
|
await renderPost(state, post);
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
for (const page of state.
|
|
32
|
+
for (const page of state.renderData.pages) {
|
|
32
33
|
await renderPage(state, page);
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -170,7 +171,7 @@ async function finalizeBuildResult(writer, summaries, options) {
|
|
|
170
171
|
let manifest;
|
|
171
172
|
if (options.writeManifest) {
|
|
172
173
|
manifest = {
|
|
173
|
-
generatedAt: new Date()
|
|
174
|
+
generatedAt: formatUtcIsoSeconds(new Date()),
|
|
174
175
|
files: summaries.map((file) => ({ ...file })),
|
|
175
176
|
};
|
|
176
177
|
await writeOutput(
|
|
@@ -324,10 +325,15 @@ function normalizePreviewData(previewData) {
|
|
|
324
325
|
site: normalizedSite,
|
|
325
326
|
content: {
|
|
326
327
|
...previewData.content,
|
|
328
|
+
authors: previewData.content.authors.map((author) => ({
|
|
329
|
+
...author,
|
|
330
|
+
avatar: normalizeMediaField(author.avatar, normalizedSite.mediaBaseUrl),
|
|
331
|
+
})),
|
|
327
332
|
posts: previewData.content.posts
|
|
328
333
|
.map((post) => ({
|
|
329
334
|
...post,
|
|
330
|
-
|
|
335
|
+
published_at_iso: normalizeIsoTimestamp(post.published_at_iso),
|
|
336
|
+
updated_at_iso: normalizeIsoTimestamp(post.updated_at_iso),
|
|
331
337
|
featured_image: normalizeMediaField(post.featured_image, normalizedSite.mediaBaseUrl),
|
|
332
338
|
}))
|
|
333
339
|
.sort((left, right) => toDate(right.published_at_iso).getTime() - toDate(left.published_at_iso).getTime()),
|
|
@@ -342,6 +348,7 @@ function normalizePreviewData(previewData) {
|
|
|
342
348
|
}
|
|
343
349
|
|
|
344
350
|
function createRenderData(previewData) {
|
|
351
|
+
const authorsById = new Map(previewData.content.authors.map((author) => [author.id, author]));
|
|
345
352
|
const categoriesBySlug = new Map(previewData.content.categories.map((category) => [category.slug, category]));
|
|
346
353
|
const tagsBySlug = new Map(previewData.content.tags.map((tag) => [tag.slug, tag]));
|
|
347
354
|
const categoryPostsBySlug = new Map();
|
|
@@ -361,13 +368,15 @@ function createRenderData(previewData) {
|
|
|
361
368
|
}
|
|
362
369
|
}
|
|
363
370
|
|
|
364
|
-
const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, categoriesBySlug, tagsBySlug));
|
|
371
|
+
const posts = previewData.content.posts.map((post) => preparePost(post, previewData.site, authorsById, categoriesBySlug, tagsBySlug));
|
|
372
|
+
const pages = previewData.content.pages.map((page) => preparePage(page));
|
|
365
373
|
const postBySlug = new Map(posts.map((post) => [post.slug, post]));
|
|
366
374
|
const categoryLinks = renderCategoryLinks(previewData.content.categories, categoryCountBySlug);
|
|
367
375
|
const tagLinks = renderTagLinks(previewData.content.tags, tagCountBySlug);
|
|
368
376
|
|
|
369
377
|
return {
|
|
370
378
|
posts,
|
|
379
|
+
pages,
|
|
371
380
|
postBySlug,
|
|
372
381
|
indexRoutes: buildPaginatedCollection({
|
|
373
382
|
items: previewData.content.posts,
|
|
@@ -412,12 +421,30 @@ function createRenderData(previewData) {
|
|
|
412
421
|
};
|
|
413
422
|
}
|
|
414
423
|
|
|
415
|
-
function
|
|
424
|
+
function preparePage(page) {
|
|
425
|
+
const documentType = normalizeDocumentType(page.document_type);
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
...page,
|
|
429
|
+
document_type: documentType,
|
|
430
|
+
html: renderDocumentContent(page.content, documentType),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function preparePost(post, site, authorsById, categoriesBySlug, tagsBySlug) {
|
|
435
|
+
const documentType = normalizeDocumentType(post.document_type);
|
|
436
|
+
const html = renderDocumentContent(post.content, documentType);
|
|
437
|
+
const author = authorsById.get(post.author_id);
|
|
438
|
+
|
|
416
439
|
return {
|
|
417
440
|
...post,
|
|
441
|
+
document_type: documentType,
|
|
442
|
+
html,
|
|
443
|
+
author_name: normalizeNonEmptyString(author?.display_name, post.author_id),
|
|
444
|
+
author_avatar: author?.avatar,
|
|
418
445
|
published_at: formatTimestamp(post.published_at_iso, site),
|
|
419
446
|
updated_at: formatTimestamp(post.updated_at_iso, site),
|
|
420
|
-
reading_time: calculateReadingTime(
|
|
447
|
+
reading_time: calculateReadingTime(html),
|
|
421
448
|
categories_html: renderInlineTaxonomyLinks(post.category_slugs, categoriesBySlug, 'category'),
|
|
422
449
|
tags_html: renderInlineTaxonomyLinks(post.tag_slugs, tagsBySlug, 'tag'),
|
|
423
450
|
comments_html: renderCommentsContainer(site, post),
|
|
@@ -774,6 +801,10 @@ function normalizeLocale(value) {
|
|
|
774
801
|
}).join('-');
|
|
775
802
|
}
|
|
776
803
|
|
|
804
|
+
function normalizeDocumentType(value) {
|
|
805
|
+
return value === 'plaintext' || value === 'html' ? value : 'markdown';
|
|
806
|
+
}
|
|
807
|
+
|
|
777
808
|
function hasTemplate(state, templateName) {
|
|
778
809
|
return state.engine.themePackage?.templates?.has(templateName) === true;
|
|
779
810
|
}
|
|
@@ -1066,7 +1097,7 @@ function buildSitemapXml(site, emitted, generatedAt) {
|
|
|
1066
1097
|
const body = entries.map((entry) => {
|
|
1067
1098
|
const loc = escapeXml(resolveSiteUrl(site.url, entry.url));
|
|
1068
1099
|
const lastmod = entry.lastmod
|
|
1069
|
-
? `\n <lastmod>${entry.lastmod
|
|
1100
|
+
? `\n <lastmod>${formatUtcIsoSeconds(entry.lastmod)}</lastmod>`
|
|
1070
1101
|
: '';
|
|
1071
1102
|
return ` <url>\n <loc>${loc}</loc>${lastmod}\n <changefreq>${entry.changefreq}</changefreq>\n <priority>${entry.priority.toFixed(1)}</priority>\n </url>`;
|
|
1072
1103
|
}).join('\n');
|
|
@@ -1118,7 +1149,7 @@ function buildMetaJson(site, emitted, generatedAt) {
|
|
|
1118
1149
|
];
|
|
1119
1150
|
|
|
1120
1151
|
return JSON.stringify({
|
|
1121
|
-
generated: generatedAt
|
|
1152
|
+
generated: formatUtcIsoSeconds(generatedAt),
|
|
1122
1153
|
site: {
|
|
1123
1154
|
title: site.title,
|
|
1124
1155
|
description: site.description,
|
|
@@ -1199,6 +1230,14 @@ function toDate(value) {
|
|
|
1199
1230
|
return value ? new Date(value) : new Date();
|
|
1200
1231
|
}
|
|
1201
1232
|
|
|
1233
|
+
function formatUtcIsoSeconds(value) {
|
|
1234
|
+
return toDate(value).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function normalizeIsoTimestamp(value) {
|
|
1238
|
+
return value ? formatUtcIsoSeconds(value) : '';
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1202
1241
|
function escapeXml(str) {
|
|
1203
1242
|
return String(str || '')
|
|
1204
1243
|
.replace(/&/g, '&')
|
package/src/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { PreviewDataV05 } from '@zeropress/preview-data-validator';
|
|
2
2
|
|
|
3
3
|
export interface ThemePackage {
|
|
4
4
|
metadata: {
|
|
@@ -62,14 +62,14 @@ export interface BuildSiteResult {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export function buildSite(input: {
|
|
65
|
-
previewData:
|
|
65
|
+
previewData: PreviewDataV05;
|
|
66
66
|
themePackage: ThemePackage;
|
|
67
67
|
writer: BuildWriter;
|
|
68
68
|
options?: BuildOptions;
|
|
69
69
|
}): Promise<BuildSiteResult>;
|
|
70
70
|
|
|
71
71
|
export function buildSelectedRoutes(input: {
|
|
72
|
-
previewData:
|
|
72
|
+
previewData: PreviewDataV05;
|
|
73
73
|
themePackage: ThemePackage;
|
|
74
74
|
writer: BuildWriter;
|
|
75
75
|
selection: BuildSelection;
|
|
@@ -77,7 +77,7 @@ export function buildSelectedRoutes(input: {
|
|
|
77
77
|
}): Promise<BuildSiteResult>;
|
|
78
78
|
|
|
79
79
|
export function buildSiteFromThemeDir(input: {
|
|
80
|
-
previewData:
|
|
80
|
+
previewData: PreviewDataV05;
|
|
81
81
|
themeDir: string;
|
|
82
82
|
writer: BuildWriter;
|
|
83
83
|
options?: BuildOptions;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import MarkdownIt from 'markdown-it';
|
|
2
|
+
import hljs from 'highlight.js';
|
|
3
|
+
import anchor from 'markdown-it-anchor';
|
|
4
|
+
|
|
5
|
+
const markdown = new MarkdownIt({
|
|
6
|
+
html: true,
|
|
7
|
+
linkify: true,
|
|
8
|
+
typographer: true,
|
|
9
|
+
breaks: true,
|
|
10
|
+
highlight(value, language) {
|
|
11
|
+
if (language && hljs.getLanguage(language)) {
|
|
12
|
+
try {
|
|
13
|
+
return hljs.highlight(value, { language }).value;
|
|
14
|
+
} catch {
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
return hljs.highlightAuto(value).value;
|
|
21
|
+
} catch {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
markdown.use(anchor, {
|
|
28
|
+
permalink: anchor.permalink.headerLink(),
|
|
29
|
+
slugify: slugify,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export function renderDocumentContent(content, documentType = 'markdown') {
|
|
33
|
+
const normalizedContent = typeof content === 'string' ? content : '';
|
|
34
|
+
const normalizedType = normalizeDocumentType(documentType);
|
|
35
|
+
|
|
36
|
+
if (normalizedType === 'plaintext') {
|
|
37
|
+
return transformPlaintext(normalizedContent);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (normalizedType === 'html') {
|
|
41
|
+
return sanitizeHtml(normalizedContent);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return sanitizeHtml(markdown.render(normalizedContent));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function normalizeDocumentType(value) {
|
|
48
|
+
return value === 'plaintext' || value === 'html' ? value : 'markdown';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function transformPlaintext(content) {
|
|
52
|
+
const escaped = content
|
|
53
|
+
.replace(/&/g, '&')
|
|
54
|
+
.replace(/</g, '<')
|
|
55
|
+
.replace(/>/g, '>')
|
|
56
|
+
.replace(/"/g, '"')
|
|
57
|
+
.replace(/'/g, ''');
|
|
58
|
+
|
|
59
|
+
const paragraphs = escaped
|
|
60
|
+
.split(/\n\s*\n/)
|
|
61
|
+
.map((entry) => entry.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
|
|
64
|
+
if (paragraphs.length === 0) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return paragraphs.map((entry) => `<p>${entry}</p>`).join('\n');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sanitizeHtml(html) {
|
|
72
|
+
const allowedTags = new Set([
|
|
73
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
74
|
+
'p', 'br', 'hr',
|
|
75
|
+
'strong', 'em', 'u', 's', 'code', 'pre',
|
|
76
|
+
'a', 'img',
|
|
77
|
+
'ul', 'ol', 'li',
|
|
78
|
+
'blockquote',
|
|
79
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
80
|
+
'div', 'span', 'nav',
|
|
81
|
+
'iframe',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const allowedAttributes = {
|
|
85
|
+
a: new Set(['href', 'title', 'class', 'id']),
|
|
86
|
+
img: new Set(['src', 'alt', 'title', 'class', 'id', 'width', 'height']),
|
|
87
|
+
iframe: new Set(['src', 'width', 'height', 'frameborder', 'allowfullscreen', 'class']),
|
|
88
|
+
'*': new Set(['class', 'id']),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const safeUriPattern = /^(?:(?:https?|mailto|tel):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
|
|
92
|
+
|
|
93
|
+
const stripped = html
|
|
94
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
|
|
95
|
+
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
|
|
96
|
+
|
|
97
|
+
const sanitized = stripped.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)?\/?>/g, (match, tag, attributeString) => {
|
|
98
|
+
const normalizedTag = tag.toLowerCase();
|
|
99
|
+
|
|
100
|
+
if (match.startsWith('</')) {
|
|
101
|
+
return allowedTags.has(normalizedTag) ? `</${normalizedTag}>` : '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!allowedTags.has(normalizedTag)) {
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const tagAllowedAttributes = allowedAttributes[normalizedTag] || new Set();
|
|
109
|
+
const globalAllowedAttributes = allowedAttributes['*'];
|
|
110
|
+
const filteredAttributes = [];
|
|
111
|
+
|
|
112
|
+
if (attributeString) {
|
|
113
|
+
const attributePattern = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g;
|
|
114
|
+
let attributeMatch;
|
|
115
|
+
|
|
116
|
+
while ((attributeMatch = attributePattern.exec(attributeString)) !== null) {
|
|
117
|
+
const attributeName = attributeMatch[1].toLowerCase();
|
|
118
|
+
const attributeValue = attributeMatch[2] ?? attributeMatch[3] ?? attributeMatch[4] ?? '';
|
|
119
|
+
|
|
120
|
+
if (!tagAllowedAttributes.has(attributeName) && !globalAllowedAttributes.has(attributeName)) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if ((attributeName === 'href' || attributeName === 'src') && !safeUriPattern.test(attributeValue)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
filteredAttributes.push(`${attributeName}="${attributeValue}"`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const isSelfClosing = match.endsWith('/>') || normalizedTag === 'br' || normalizedTag === 'hr' || normalizedTag === 'img';
|
|
133
|
+
const attributeSuffix = filteredAttributes.length > 0 ? ` ${filteredAttributes.join(' ')}` : '';
|
|
134
|
+
return isSelfClosing ? `<${normalizedTag}${attributeSuffix} />` : `<${normalizedTag}${attributeSuffix}>`;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return sanitized
|
|
138
|
+
.split(/(<[^>]+>)/g)
|
|
139
|
+
.map((part) => {
|
|
140
|
+
if (part.startsWith('<') && part.endsWith('>')) {
|
|
141
|
+
return part;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return part.replace(/&(?!(?:[a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);)/g, '&');
|
|
145
|
+
})
|
|
146
|
+
.join('');
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function slugify(value) {
|
|
150
|
+
return String(value || '')
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.trim()
|
|
153
|
+
.replace(/[^\w\s-]/g, '')
|
|
154
|
+
.replace(/[\s_-]+/g, '-')
|
|
155
|
+
.replace(/^-+|-+$/g, '');
|
|
156
|
+
}
|