@accelflow/space-content 0.1.0-beta.1 → 0.1.0-beta.2
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 +41 -7
- package/node.mjs +28 -37
- package/package.json +12 -3
- package/render.d.mts +11 -0
- package/render.mjs +95 -0
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# Space build content SDK
|
|
1
|
+
# Space build content SDK
|
|
2
2
|
|
|
3
3
|
Node 24+ build-time content synchronization for Space Headless CMS, with optional browser-safe table-of-contents presentation. Licensed under MIT.
|
|
4
4
|
|
|
5
|
-
The
|
|
5
|
+
The beta package is available on npm.
|
|
6
6
|
|
|
7
7
|
```sh
|
|
8
8
|
npm install @accelflow/space-content@beta
|
|
@@ -49,12 +49,16 @@ authorization. Verified complete objects survive a transport retry; partial
|
|
|
49
49
|
objects restart. Missing terminal markers, checksum failures, foreign objects,
|
|
50
50
|
or schema errors fail the build. No stale/offline fallback is used.
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
Transport limits: 4,096 unique objects per snapshot, 250,000,000 bytes per object,
|
|
53
|
+
1 GiB per snapshot. Space applies the effective plan at upload (Free 10 MB, Team
|
|
54
|
+
50 MB, Enterprise 250 MB, decimal bytes). Delivery accepts those admitted files;
|
|
55
|
+
it does not re-count them as new storage usage or apply a lower per-file quota.
|
|
56
|
+
The snapshot budget is a separate operational limit. Large objects stream to disk;
|
|
57
|
+
complete objects are hash-verified before cache reuse. Partial objects restart on
|
|
58
|
+
retry (default 2 retries). Cache GC and snapshot sharding remain host responsibilities.
|
|
59
|
+
Large attachments require the matching Space delivery service update.
|
|
56
60
|
|
|
57
|
-
Tests: `npm test` from this repository root
|
|
61
|
+
Tests: `npm ci --ignore-scripts` then `npm test` from this repository root.
|
|
58
62
|
Server, Worker and real-cloud integration tests remain in the Space service
|
|
59
63
|
repository; the tests here exercise the client with synthetic protocol fixtures.
|
|
60
64
|
|
|
@@ -82,3 +86,33 @@ In Next.js, use this browser entry in a client component effect and return the c
|
|
|
82
86
|
## Release preparation
|
|
83
87
|
|
|
84
88
|
See [RELEASING.md](RELEASING.md) for packaging checks, initial publication and OIDC setup.
|
|
89
|
+
|
|
90
|
+
## JSON and HTML rendering
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
import {renderArticleBody} from '@accelflow/space-content/render';
|
|
94
|
+
const html = renderArticleBody(article.body, {
|
|
95
|
+
assets: content.assets, locale: 'ja', pdfMode: 'link',
|
|
96
|
+
renderers: {paragraph: ({children}) => `<p>${children}</p>`},
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Use this Node/build-time entry for both JSON (default) and legacy HTML. It renders
|
|
101
|
+
headings, TOC, formatting, lists, tables, columns, links, images and attachment
|
|
102
|
+
IDs, then sanitizes all output, including custom renderer output. Callbacks are
|
|
103
|
+
trusted application code, never stored executable templates. Unknown nodes retain
|
|
104
|
+
safe children. Dynamic smart links fall back to their label; supply a custom
|
|
105
|
+
renderer for application-specific behavior. Use `resolveArticle` and
|
|
106
|
+
`resolveSubArticles` for routes and child navigation.
|
|
107
|
+
|
|
108
|
+
PDFs default to download links. `pdfMode: 'embed'` permits only local PDF paths
|
|
109
|
+
listed in the manifest, with an empty sandbox and no referrer, plus a download
|
|
110
|
+
link. Browser PDF viewers may not work in a sandbox; the link remains available.
|
|
111
|
+
Arbitrary iframes, scripts, event handlers, inline styles, SVG and unsafe URLs
|
|
112
|
+
are removed. JSON is not inherently trusted. The browser-only TOC module and its
|
|
113
|
+
responsive CSS can be used unchanged with rendered JSON.
|
|
114
|
+
|
|
115
|
+
Serve copied assets with their manifest MIME and `X-Content-Type-Options: nosniff`.
|
|
116
|
+
Force Content-Disposition attachment for non-raster, non-PDF types (especially
|
|
117
|
+
HTML/SVG); they must never execute at the site's origin. The examples emit those
|
|
118
|
+
headers for Cloudflare-style hosts; configure equivalent rules on other hosts.
|
package/node.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {createReadStream} from 'node:fs';
|
|
1
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { mkdir, readFile, writeFile, rename, rm, lstat } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readFile, writeFile, rename, rm, lstat, open, copyFile } from 'node:fs/promises';
|
|
3
4
|
import { resolve, join } from 'node:path';
|
|
4
5
|
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
5
6
|
const hashPattern = /^[a-f0-9]{64}$/;
|
|
@@ -12,7 +13,7 @@ function validateManifest(m, accountId, workspaceId) {
|
|
|
12
13
|
const objects = new Map();
|
|
13
14
|
let total = 0;
|
|
14
15
|
for (const o of m.objects) {
|
|
15
|
-
if (!hashPattern.test(o.hash) || !Number.isSafeInteger(o.size) || o.size < 0 || o.size >
|
|
16
|
+
if (!hashPattern.test(o.hash) || !Number.isSafeInteger(o.size) || o.size < 0 || o.size > 250_000_000 || objects.has(o.hash))
|
|
16
17
|
throw Error('invalid_manifest');
|
|
17
18
|
objects.set(o.hash, o);
|
|
18
19
|
total += o.size;
|
|
@@ -23,7 +24,7 @@ function validateManifest(m, accountId, workspaceId) {
|
|
|
23
24
|
if (typeof a.id !== 'string' || typeof a.slug !== 'string' || typeof a.title !== 'string' || !objects.has(a.bodyHash) || !objects.has(a.metadataHash))
|
|
24
25
|
throw Error('invalid_manifest');
|
|
25
26
|
for (const a of m.assets)
|
|
26
|
-
if (typeof a.id !== 'string' || typeof a.contentType !== 'string' || typeof a.filename !== 'string' || !objects.has(a.hash))
|
|
27
|
+
if (typeof a.id !== 'string' || typeof a.contentType !== 'string' || typeof a.filename !== 'string' || /[\r\n\x00]/.test(a.filename) || !/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+$/i.test(a.contentType) || !objects.has(a.hash))
|
|
27
28
|
throw Error('invalid_manifest');
|
|
28
29
|
return objects;
|
|
29
30
|
}
|
|
@@ -67,24 +68,14 @@ class Reader {
|
|
|
67
68
|
chunks.push(b[0]);
|
|
68
69
|
} throw Error('frame_too_large'); }
|
|
69
70
|
}
|
|
70
|
-
async function
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
async function cacheValid(path,o) {
|
|
72
|
+
try {
|
|
73
|
+
const stat=await lstat(path);if(!stat.isFile()||stat.isSymbolicLink()||stat.size!==o.size)return false;
|
|
74
|
+
const digest=createHash('sha256');for await(const chunk of createReadStream(path))digest.update(chunk);
|
|
75
|
+
return digest.digest('hex')===o.hash;
|
|
76
|
+
}catch(e){if(e.code==='ENOENT')return false;throw e;}
|
|
75
77
|
}
|
|
76
|
-
|
|
77
|
-
if (e.code === 'ENOENT')
|
|
78
|
-
return null;
|
|
79
|
-
throw e;
|
|
80
|
-
} }
|
|
81
|
-
async function save(path, data) { const temp = path + '.' + randomUUID() + '.partial'; try {
|
|
82
|
-
await writeFile(temp, data, { flag: 'wx', mode: 0o600 });
|
|
83
|
-
await rename(temp, path);
|
|
84
|
-
}
|
|
85
|
-
finally {
|
|
86
|
-
await rm(temp, { force: true });
|
|
87
|
-
} }
|
|
78
|
+
async function cacheRead(path,o) {return await cacheValid(path,o)?readFile(path):null;}
|
|
88
79
|
async function safeDirectory(path) { await mkdir(path, { recursive: true, mode: 0o700 }); const s = await lstat(path); if (s.isSymbolicLink() || !s.isDirectory())
|
|
89
80
|
throw Error('unsafe_cache_directory'); }
|
|
90
81
|
/** Build-time only. cacheDir must be a private, trusted directory; never expose it to untrusted PRs. */
|
|
@@ -112,7 +103,7 @@ export async function syncContent({ apiUrl, downloadOrigin, accountId, workspace
|
|
|
112
103
|
await safeDirectory(namespace);
|
|
113
104
|
const missing = [];
|
|
114
105
|
for (const o of objects.values())
|
|
115
|
-
if (!await
|
|
106
|
+
if (!await cacheValid(join(namespace, o.hash), o))
|
|
116
107
|
missing.push(o.hash);
|
|
117
108
|
let pending = missing, downloads = 0;
|
|
118
109
|
for (let attempt = 0; pending.length; attempt++) {
|
|
@@ -145,18 +136,18 @@ export async function syncContent({ apiUrl, downloadOrigin, accountId, workspace
|
|
|
145
136
|
const item = await reader.line(), expected = objects.get(item.hash);
|
|
146
137
|
if (!remaining.has(item.hash) || !expected || item.size !== expected.size)
|
|
147
138
|
throw Error('unexpected_object');
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
await
|
|
139
|
+
const path=join(namespace,item.hash), temp=path+'.'+randomUUID()+'.partial';
|
|
140
|
+
const file=await open(temp,'wx',0o600), digest=createHash('sha256');
|
|
141
|
+
try {
|
|
142
|
+
let received=0;
|
|
143
|
+
while(received<item.size) {
|
|
144
|
+
const chunk=await reader.take(item.size-received);
|
|
145
|
+
if(!chunk)throw Error('truncated_stream');
|
|
146
|
+
received+=chunk.length;digest.update(chunk);await file.writeFile(chunk);
|
|
147
|
+
}
|
|
148
|
+
if(digest.digest('hex')!==item.hash)throw Error('object_checksum_mismatch');
|
|
149
|
+
await file.close();await rename(temp,path);
|
|
150
|
+
} finally {await file.close().catch(()=>{});await rm(temp,{force:true});}
|
|
160
151
|
remaining.delete(item.hash);
|
|
161
152
|
downloads++;
|
|
162
153
|
}
|
|
@@ -174,7 +165,7 @@ export async function syncContent({ apiUrl, downloadOrigin, accountId, workspace
|
|
|
174
165
|
}
|
|
175
166
|
pending = [];
|
|
176
167
|
for (const o of missing.map(h => objects.get(h)))
|
|
177
|
-
if (!await
|
|
168
|
+
if (!await cacheValid(join(namespace, o.hash), o))
|
|
178
169
|
pending.push(o.hash);
|
|
179
170
|
// A missing terminal marker is never accepted even when every object arrived.
|
|
180
171
|
if (!complete && !pending.length)
|
|
@@ -194,10 +185,10 @@ export async function syncContent({ apiUrl, downloadOrigin, accountId, workspace
|
|
|
194
185
|
}
|
|
195
186
|
await safeDirectory(join(generation, 'space-assets'));
|
|
196
187
|
for (const a of manifest.assets) {
|
|
197
|
-
const
|
|
198
|
-
if (!
|
|
188
|
+
const valid = await cacheValid(join(namespace, a.hash), objects.get(a.hash));
|
|
189
|
+
if (!valid)
|
|
199
190
|
throw Error('cache_corrupt');
|
|
200
|
-
await
|
|
191
|
+
await copyFile(join(namespace,a.hash),join(generation,'space-assets',a.hash));
|
|
201
192
|
}
|
|
202
193
|
await writeFile(join(generation, 'content.json'), JSON.stringify({ snapshotId: manifest.snapshotId, format: manifest.format, articles, assets: manifest.assets }));
|
|
203
194
|
return { snapshotDir: generation, manifest, diagnostics: { downloadedObjects: downloads, cacheHits: objects.size - missing.length } };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@accelflow/space-content",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
"types": "./toc.d.mts",
|
|
15
15
|
"import": "./toc.mjs"
|
|
16
16
|
},
|
|
17
|
-
"./toc.css": "./toc.css"
|
|
17
|
+
"./toc.css": "./toc.css",
|
|
18
|
+
"./render": {
|
|
19
|
+
"types": "./render.d.mts",
|
|
20
|
+
"import": "./render.mjs"
|
|
21
|
+
}
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"test": "node --test test/*.test.mjs",
|
|
@@ -28,7 +32,9 @@
|
|
|
28
32
|
"toc.mjs",
|
|
29
33
|
"toc.d.mts",
|
|
30
34
|
"toc.css",
|
|
31
|
-
"LICENSE"
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"render.mjs",
|
|
37
|
+
"render.d.mts"
|
|
32
38
|
],
|
|
33
39
|
"description": "Build-time Headless CMS content sync and customizable table of contents for Accelflow Space",
|
|
34
40
|
"license": "MIT",
|
|
@@ -43,5 +49,8 @@
|
|
|
43
49
|
"publishConfig": {
|
|
44
50
|
"access": "public",
|
|
45
51
|
"tag": "beta"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"sanitize-html": "^2.18.0"
|
|
46
55
|
}
|
|
47
56
|
}
|
package/render.d.mts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ContentNode { type: string; text?: string; attrs?: Record<string, unknown>; marks?: Array<{type:string;attrs?:Record<string,unknown>}>; content?: ContentNode[]; }
|
|
2
|
+
export interface ContentAsset {id:string;hash:string;contentType:string;filename:string;}
|
|
3
|
+
export interface RenderOptions {
|
|
4
|
+
assets?: ContentAsset[]; locale?: 'ja'|'en'; pdfMode?: 'link'|'embed';
|
|
5
|
+
renderers?: Record<string,(context:{node:ContentNode;children:string;defaultHtml:string;escapeHtml:typeof escapeHtml})=>string>;
|
|
6
|
+
resolveArticle?: (attrs:Record<string,unknown>)=>{href:string;title?:string}|undefined;
|
|
7
|
+
resolveSubArticles?: (attrs:Record<string,unknown>)=>Array<{href:string;title:string}>;
|
|
8
|
+
}
|
|
9
|
+
export function escapeHtml(value:unknown):string;
|
|
10
|
+
export function sanitizeArticleHtml(html:string, options?:RenderOptions):string;
|
|
11
|
+
export function renderArticleBody(body:ContentNode|string, options?:RenderOptions):string;
|
package/render.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html';
|
|
2
|
+
import {renderTableOfContents} from './toc.mjs';
|
|
3
|
+
export const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
4
|
+
const hash = /^[a-f0-9]{64}$/;
|
|
5
|
+
const raster = new Set(['image/png','image/jpeg','image/gif','image/webp','image/avif']);
|
|
6
|
+
function assetsIndex(assets = []) {
|
|
7
|
+
const ids = new Map(), paths = new Map();
|
|
8
|
+
for (const a of assets) {
|
|
9
|
+
if (!a || typeof a.id !== 'string' || !hash.test(a.hash) || typeof a.contentType !== 'string' || typeof a.filename !== 'string' || ids.has(a.id)) throw Error('invalid_render_asset');
|
|
10
|
+
ids.set(a.id,a); paths.set('/space-assets/'+a.hash,a);
|
|
11
|
+
}
|
|
12
|
+
return {ids,paths};
|
|
13
|
+
}
|
|
14
|
+
function safeUrl(value, image = false) {
|
|
15
|
+
if (typeof value !== 'string' || /[\u0000-\u0020\u007f\\]/.test(value)) return '';
|
|
16
|
+
if (/^(?:\/(?!\/)|#)/.test(value)) return value;
|
|
17
|
+
try {const u=new URL(value);return !u.username && !u.password && (image ? u.protocol==='https:' : ['https:','http:','mailto:','tel:'].includes(u.protocol)) ? value : '';} catch {return '';}
|
|
18
|
+
}
|
|
19
|
+
/** Build-time HTML sanitizer. Only known local PDF assets may be embedded, opt-in. */
|
|
20
|
+
export function sanitizeArticleHtml(html, {assets = [], pdfMode = 'link'} = {}) {
|
|
21
|
+
if (typeof html !== 'string') throw Error('invalid_html_body');
|
|
22
|
+
const {paths}=assetsIndex(assets);
|
|
23
|
+
return sanitizeHtml(html, {
|
|
24
|
+
nonBooleanAttributes: sanitizeHtml.defaults.nonBooleanAttributes.filter(a=>a!=='sandbox'),
|
|
25
|
+
allowedTags: [...sanitizeHtml.defaults.allowedTags,'img','details','summary','nav',...(pdfMode==='embed'?['iframe']:[])],
|
|
26
|
+
allowedAttributes: {
|
|
27
|
+
'*':['id','class'], a:['href','title','data-level','download'],
|
|
28
|
+
img:['src','alt','title','width','height','loading','referrerpolicy'],
|
|
29
|
+
nav:['data-table-of-contents','data-max-level','aria-label'], details:['open','data-space-toc'],
|
|
30
|
+
li:['data-level','data-checked'], ol:['start'], td:['colspan','rowspan'], th:['colspan','rowspan','scope'],
|
|
31
|
+
iframe:['src','title','sandbox','loading','referrerpolicy'],
|
|
32
|
+
},
|
|
33
|
+
allowedClasses:{'*':['*']},
|
|
34
|
+
allowedSchemes:['https','http','mailto','tel'], allowedSchemesByTag:{img:['https'],iframe:[]}, allowProtocolRelative:false,
|
|
35
|
+
transformTags: {
|
|
36
|
+
iframe: (tagName, attrs) => ({tagName,attribs:{src:attrs.src??'',title:attrs.title??'PDF',sandbox:'',loading:'lazy',referrerpolicy:'no-referrer'}}),
|
|
37
|
+
},
|
|
38
|
+
exclusiveFilter: frame => frame.tag==='iframe' && !(pdfMode==='embed' && paths.get(frame.attribs.src)?.contentType==='application/pdf'),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/** Render Space's Tiptap JSON document. Custom callbacks are trusted code; their HTML is still sanitized. */
|
|
42
|
+
export function renderArticleBody(body, options = {}) {
|
|
43
|
+
if (typeof body==='string') return sanitizeArticleHtml(body,options);
|
|
44
|
+
if (!body || body.type!=='doc' || !Array.isArray(body.content)) throw Error('invalid_document');
|
|
45
|
+
const {ids}=assetsIndex(options.assets), headings=[], anchors=new Map(), used=new Set();let count=0;
|
|
46
|
+
const text=n=>n.type==='text'?String(n.text??''):(n.content??[]).map(text).join('');
|
|
47
|
+
function inspect(n,depth=0) {
|
|
48
|
+
if (!n || typeof n!=='object' || typeof n.type!=='string' || ++count>100000 || depth>64 || (n.content!==undefined&&!Array.isArray(n.content))) throw Error('invalid_document');
|
|
49
|
+
for(const child of n.content??[])inspect(child,depth+1);
|
|
50
|
+
}
|
|
51
|
+
inspect(body);
|
|
52
|
+
function collect(n) {
|
|
53
|
+
if(n.type==='tableOfContents')return;
|
|
54
|
+
if(n.type==='heading') {const level=Number.isInteger(n.attrs?.level)&&n.attrs.level>=1&&n.attrs.level<=6?n.attrs.level:2;const title=text(n);let id=typeof n.attrs?.id==='string'&&/^[\p{L}\p{N}_-]+$/u.test(n.attrs.id)?n.attrs.id:title.toLowerCase().replace(/[^\p{L}\p{N}_-]+/gu,'-').replace(/^-|-$/g,'')||'heading';const base=id;let index=2;while(used.has(id))id=base+'-'+index++;used.add(id);anchors.set(n,id);headings.push({id,level,text:title});}
|
|
55
|
+
for(const child of n.content??[])collect(child);
|
|
56
|
+
}
|
|
57
|
+
collect(body);
|
|
58
|
+
const e=escapeHtml;
|
|
59
|
+
const wrap=(tag,children,attrs='')=>`<${tag}${attrs}>${children}</${tag}>`;
|
|
60
|
+
function render(n) {
|
|
61
|
+
const a=n.attrs??{}, children=(n.content??[]).map(render).join('');
|
|
62
|
+
let html='';
|
|
63
|
+
if(n.type==='text') {
|
|
64
|
+
html=e(n.text);
|
|
65
|
+
for(const mark of Array.isArray(n.marks)?n.marks:[]) {const tag={bold:'strong',italic:'em',underline:'u',strike:'s',code:'code'}[mark?.type];if(tag)html=wrap(tag,html);else if(mark?.type==='link'){const href=safeUrl(mark.attrs?.href);if(href)html=wrap('a',html,` href="${e(href)}"`);}}
|
|
66
|
+
} else if(n.type==='heading') html=wrap('h'+(Number.isInteger(a.level)&&a.level>=1&&a.level<=6?a.level:2),children,` id="${e(anchors.get(n))}"`);
|
|
67
|
+
else if(n.type==='tableOfContents') html=renderTableOfContents(headings,{locale:options.locale??'ja',maxLevel:a.maxLevel===2?2:3});
|
|
68
|
+
else if(n.type==='image') {const src=safeUrl(a.src,true);html=src?`<img src="${e(src)}" alt="${e(a.alt)}" loading="lazy" referrerpolicy="no-referrer">`:'';}
|
|
69
|
+
else if(n.type==='attachment') {
|
|
70
|
+
const asset=ids.get(a.attachmentId);if(!asset)throw Error('attachment_not_in_manifest');
|
|
71
|
+
const src='/space-assets/'+asset.hash, label=e(asset.filename);
|
|
72
|
+
html=raster.has(asset.contentType)?`<img src="${src}" alt="${label}" loading="lazy">`:`<a href="${src}" download="${label}">${label}</a>`;
|
|
73
|
+
if(asset.contentType==='application/pdf'&&options.pdfMode==='embed')html=`<iframe class="space-pdf" src="${src}" title="${label}" sandbox="" loading="lazy" referrerpolicy="no-referrer"></iframe>`+html;
|
|
74
|
+
} else if(n.type==='externalLinkCard') {const href=safeUrl(a.href);html=href?wrap('a',e(a.title||href),` href="${e(href)}"`):e(a.title);}
|
|
75
|
+
else if(n.type==='articleReference') {const target=options.resolveArticle?.(a);const href=safeUrl(target?.href);const label=e(a.labelMode==='custom'?a.customLabel:target?.title??a.fallbackTitle??a.title??'');html=href?wrap('a',label,` href="${e(href)}"`):label;}
|
|
76
|
+
else if(n.type==='smartLink') html=e(a.label??a.categoryLabel??'');
|
|
77
|
+
else if(n.type==='subArticleList') html=wrap('nav',wrap('ol',(options.resolveSubArticles?.(a)??[]).map(x=>{const href=safeUrl(x.href);return href?wrap('li',wrap('a',e(x.title),` href="${e(href)}"`)):'';}).join('')));
|
|
78
|
+
else if(n.type==='hardBreak')html='<br>';
|
|
79
|
+
else if(n.type==='horizontalRule')html='<hr>';
|
|
80
|
+
else if(n.type==='codeBlock')html=wrap('pre',wrap('code',e(text(n)),` class="language-${e(String(a.language??'text').replace(/[^\w-]/g,''))}"`));
|
|
81
|
+
else if(n.type==='columnLayout')html=wrap('div',children,' class="space-columns"');
|
|
82
|
+
else if(n.type==='column')html=wrap('div',children,' class="space-column"');
|
|
83
|
+
else {
|
|
84
|
+
const tag={doc:null,paragraph:'p',bulletList:'ul',orderedList:'ol',listItem:'li',taskList:'ul',taskItem:'li',blockquote:'blockquote',table:'table',tableRow:'tr',tableCell:'td',tableHeader:'th'}[n.type];
|
|
85
|
+
let attrs='';
|
|
86
|
+
if(n.type==='orderedList'&&Number.isSafeInteger(a.start)&&a.start>0)attrs=` start="${a.start}"`;
|
|
87
|
+
if(n.type==='taskItem')attrs=` class="space-task" data-checked="${a.checked===true}"`;
|
|
88
|
+
if(['tableCell','tableHeader'].includes(n.type))for(const key of ['colspan','rowspan'])if(Number.isInteger(a[key])&&a[key]>0&&a[key]<=100)attrs+=` ${key}="${a[key]}"`;
|
|
89
|
+
html=tag?wrap(tag,children,attrs):children;
|
|
90
|
+
}
|
|
91
|
+
const custom=Object.hasOwn(options.renderers??{},n.type)?options.renderers[n.type]:null;
|
|
92
|
+
return custom?custom({node:n,children,defaultHtml:html,escapeHtml:e}):html;
|
|
93
|
+
}
|
|
94
|
+
return sanitizeArticleHtml(render(body),options);
|
|
95
|
+
}
|