@accelflow/space-content 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Accelflow Inc.
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 CHANGED
@@ -1,3 +1,118 @@
1
- # Temporary Holding Version
1
+ # Space build content SDK
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Node 24+ build-time content synchronization for Space Headless CMS, with optional browser-safe table-of-contents presentation. Licensed under MIT.
4
+
5
+ The beta package is available on npm.
6
+
7
+ ```sh
8
+ npm install @accelflow/space-content@beta
9
+ ```
10
+
11
+ ```js
12
+ import { syncContent } from '@accelflow/space-content/node';
13
+ ```
14
+
15
+ Run content synchronization before your site's build. The `/node` entry is server-only; never include it or a Workspace token in a browser bundle.
16
+
17
+ Use `SPACE_API_URL`, `SPACE_DOWNLOAD_ORIGIN`, `SPACE_ACCOUNT_ID`,
18
+ `SPACE_WORKSPACE_ID`, `SPACE_BUILD_TOKEN` as server-only environment variables.
19
+ IDs are stable account/workspace IDs, not slugs. `.space-cache`, `.space-build`,
20
+ `.generated` must be ignored by Git and excluded from public deployment except
21
+ for the intentionally generated content consumed by the site.
22
+
23
+ `syncContent()` returns a fresh `snapshotDir` with `content.json` and
24
+ `space-assets/<sha256>`. `content.json` contains selected HTML **or** structured
25
+ JSON, title, slug, article metadata and an attachment ID/hash/MIME mapping.
26
+ HTML attachment references use `/space-assets/<sha256>`; serve these files with
27
+ the indicated MIME types. JSON rendering must resolve attachment IDs through
28
+ that mapping. Internal article links retain published paths; the site's route
29
+ mapping must match or transform them. Place static assets at the domain root,
30
+ or rewrite those URLs for a subpath deployment.
31
+
32
+ Vite integration: run sync as the first build script, import the generated JSON,
33
+ and render it using the site's SSG/prerender tool. Vite alone does not create
34
+ article HTML pages. Next.js integration: import generated JSON in server build
35
+ code, use its article slugs in `generateStaticParams`, metadata in
36
+ `generateMetadata`, and render the chosen body format. Plain Node integration
37
+ keeps both frameworks independent of SDK-specific runtime plugins.
38
+
39
+ The cache directory must be private and trusted, with no untrusted ancestor
40
+ symlinks. Share it only among trusted builds for this environment. Content is
41
+ hash-verified on every reuse and namespaced by account/workspace/format/schema.
42
+ Each build gets a new output generation; removed articles/assets are excluded.
43
+ Do not glob the cache as public content. Cache/output cleanup is the CI owner's
44
+ responsibility and must not remove running jobs' files.
45
+
46
+ A build and each new batch connection are authorized. An admitted stream may
47
+ finish after token deletion, up to its deadline. Reconnecting requires current
48
+ authorization. Verified complete objects survive a transport retry; partial
49
+ objects restart. Missing terminal markers, checksum failures, foreign objects,
50
+ or schema errors fail the build. No stale/offline fallback is used.
51
+
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.
60
+
61
+ Tests: `npm ci --ignore-scripts` then `npm test` from this repository root.
62
+ Server, Worker and real-cloud integration tests remain in the Space service
63
+ repository; the tests here exercise the client with synthetic protocol fixtures.
64
+
65
+ ## Full framework examples
66
+
67
+ See [Vite and Next.js examples](examples/README.md) for complete static builds,
68
+ CI cache handling, HTML rendering, asset headers and deployment boundaries.
69
+
70
+ ## Table of contents
71
+
72
+ The browser-safe entry is separate from the authenticated Node client:
73
+
74
+ ```js
75
+ import { mountTableOfContents } from '@accelflow/space-content/toc';
76
+ import '@accelflow/space-content/toc.css';
77
+
78
+ const cleanup = mountTableOfContents(articleElement, { locale: 'ja' });
79
+ // Call cleanup when replacing the article or unmounting the component.
80
+ ```
81
+
82
+ The default presentation is expanded on desktop and collapsed on mobile. Clicking a mobile link closes it. Titles are localized and omitted from their own entries. Use `title`, `maxLevel`, `desktopOpen`, `mobileOpen`, `mobileQuery`, or `render` to customize it. Styles are optional; see `toc.d.mts` for the complete interface.
83
+
84
+ In Next.js, use this browser entry in a client component effect and return the cleanup function. Keep `syncContent` in build/server code. Both Vite and Next.js use the same package.
85
+
86
+ ## Release preparation
87
+
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.d.mts ADDED
@@ -0,0 +1,14 @@
1
+ export interface BuildManifest {
2
+ version: 1; accountId: string; workspaceId: string; format: 'html'|'json'; snapshotId: string;
3
+ objects: Array<{hash:string;size:number}>;
4
+ articles: Array<{id:string;slug:string;title:string;metadataHash:string;bodyHash:string}>;
5
+ assets: Array<{id:string;hash:string;contentType:string;filename:string}>;
6
+ }
7
+ export interface SyncOptions {
8
+ apiUrl: string; downloadOrigin: string; accountId: string; workspaceId: string; token: string;
9
+ cacheDir: string; outputDir: string; fetch?: typeof globalThis.fetch; maxRetries?: number;
10
+ }
11
+ export function syncContent(options: SyncOptions): Promise<{
12
+ snapshotDir:string; manifest:BuildManifest;
13
+ diagnostics:{downloadedObjects:number;cacheHits:number};
14
+ }>;
package/node.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import {createReadStream} from 'node:fs';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { mkdir, readFile, writeFile, rename, rm, lstat, open, copyFile } from 'node:fs/promises';
4
+ import { resolve, join } from 'node:path';
5
+ const sha = bytes => createHash('sha256').update(bytes).digest('hex');
6
+ const hashPattern = /^[a-f0-9]{64}$/;
7
+ function validateManifest(m, accountId, workspaceId) {
8
+ if (m.version !== 1 || m.accountId !== accountId || m.workspaceId !== workspaceId || !['html', 'json'].includes(m.format) || !hashPattern.test(m.snapshotId) || !Array.isArray(m.objects) || m.objects.length > 4096 || !Array.isArray(m.articles) || !Array.isArray(m.assets))
9
+ throw Error('invalid_manifest');
10
+ const { snapshotId, ...fixed } = m;
11
+ if (sha(JSON.stringify(fixed)) !== snapshotId)
12
+ throw Error('manifest_checksum_mismatch');
13
+ const objects = new Map();
14
+ let total = 0;
15
+ for (const o of m.objects) {
16
+ if (!hashPattern.test(o.hash) || !Number.isSafeInteger(o.size) || o.size < 0 || o.size > 250_000_000 || objects.has(o.hash))
17
+ throw Error('invalid_manifest');
18
+ objects.set(o.hash, o);
19
+ total += o.size;
20
+ }
21
+ if (total > 1024 * 1024 * 1024)
22
+ throw Error('manifest_too_large');
23
+ for (const a of m.articles)
24
+ if (typeof a.id !== 'string' || typeof a.slug !== 'string' || typeof a.title !== 'string' || !objects.has(a.bodyHash) || !objects.has(a.metadataHash))
25
+ throw Error('invalid_manifest');
26
+ for (const a of m.assets)
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))
28
+ throw Error('invalid_manifest');
29
+ return objects;
30
+ }
31
+ async function limitedText(response, max) {
32
+ const reader = response.body?.getReader();
33
+ if (!reader)
34
+ throw Error('missing_response');
35
+ const chunks = [];
36
+ let size = 0;
37
+ try {
38
+ while (true) {
39
+ const { done, value } = await reader.read();
40
+ if (done)
41
+ break;
42
+ size += value.length;
43
+ if (size > max)
44
+ throw Error('manifest_too_large');
45
+ chunks.push(Buffer.from(value));
46
+ }
47
+ return Buffer.concat(chunks).toString();
48
+ }
49
+ finally {
50
+ await reader.cancel().catch(() => { });
51
+ }
52
+ }
53
+ class Reader {
54
+ constructor(stream) { if (!stream)
55
+ throw Error('missing_stream'); this.reader = stream.getReader(); this.buffer = new Uint8Array(); }
56
+ async take(max) { if (!this.buffer.length) {
57
+ const r = await this.reader.read();
58
+ if (r.done)
59
+ return null;
60
+ this.buffer = r.value;
61
+ } const n = Math.min(max, this.buffer.length), v = this.buffer.subarray(0, n); this.buffer = this.buffer.subarray(n); return v; }
62
+ async line() { const chunks = []; for (let n = 0; n < 4096; n++) {
63
+ const b = await this.take(1);
64
+ if (!b)
65
+ throw Error('truncated_stream');
66
+ if (b[0] === 10)
67
+ return JSON.parse(Buffer.from(chunks).toString());
68
+ chunks.push(b[0]);
69
+ } throw Error('frame_too_large'); }
70
+ }
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;}
77
+ }
78
+ async function cacheRead(path,o) {return await cacheValid(path,o)?readFile(path):null;}
79
+ async function safeDirectory(path) { await mkdir(path, { recursive: true, mode: 0o700 }); const s = await lstat(path); if (s.isSymbolicLink() || !s.isDirectory())
80
+ throw Error('unsafe_cache_directory'); }
81
+ /** Build-time only. cacheDir must be a private, trusted directory; never expose it to untrusted PRs. */
82
+ export async function syncContent({ apiUrl, downloadOrigin, accountId, workspaceId, token, cacheDir, outputDir, fetch: fetcher = globalThis.fetch, maxRetries = 2 }) {
83
+ const api = new URL(apiUrl), allowed = new URL(downloadOrigin);
84
+ if (api.protocol !== 'https:' || allowed.protocol !== 'https:' || api.username || api.password || allowed.username || allowed.password)
85
+ throw Error('https_required');
86
+ if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 3)
87
+ throw Error('invalid_retry_limit');
88
+ const start = await fetcher(new URL(`/v1/build/accounts/${encodeURIComponent(accountId)}/workspaces/${encodeURIComponent(workspaceId)}/sessions`, api), { method: 'POST', headers: { Authorization: `Bearer ${token}` }, redirect: 'error', signal: AbortSignal.timeout(120000) });
89
+ if (!start.ok)
90
+ throw Error(`headless_session_${start.status}`);
91
+ const raw = await limitedText(start, 1100000);
92
+ const { session, manifest, downloadUrl, expiresAt } = JSON.parse(raw);
93
+ if (typeof session !== 'string' || session.length > 4096 || !Number.isFinite(expiresAt) || expiresAt <= Date.now())
94
+ throw Error('invalid_session');
95
+ const objects = validateManifest(manifest, accountId, workspaceId), url = new URL(downloadUrl);
96
+ if (url.origin !== allowed.origin || url.protocol !== 'https:' || url.username || url.password)
97
+ throw Error('untrusted_download_url');
98
+ const cache = resolve(cacheDir), output = resolve(outputDir);
99
+ if (output === cache || output.startsWith(cache + '/') || cache.startsWith(output + '/'))
100
+ throw Error('overlapping_directories');
101
+ await safeDirectory(cache);
102
+ const namespace = join(cache, sha(JSON.stringify([accountId, workspaceId, manifest.format, manifest.version])));
103
+ await safeDirectory(namespace);
104
+ const missing = [];
105
+ for (const o of objects.values())
106
+ if (!await cacheValid(join(namespace, o.hash), o))
107
+ missing.push(o.hash);
108
+ let pending = missing, downloads = 0;
109
+ for (let attempt = 0; pending.length; attempt++) {
110
+ if (attempt > maxRetries || Date.now() >= expiresAt)
111
+ throw Error('headless_transfer_incomplete');
112
+ let response;
113
+ try {
114
+ response = await fetcher(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session, hashes: pending }), redirect: 'error', signal: AbortSignal.timeout(Math.max(1, Math.min(1800000, expiresAt - Date.now()))) });
115
+ }
116
+ catch (error) {
117
+ if (attempt >= maxRetries)
118
+ throw error;
119
+ continue;
120
+ }
121
+ if ([429, 502, 503, 504].includes(response.status) && attempt < maxRetries) {
122
+ await response.body?.cancel();
123
+ await new Promise(resolve => setTimeout(resolve, Math.min(1000 * 2 ** attempt, 5000)));
124
+ continue;
125
+ }
126
+ if (!response.ok)
127
+ throw Error(`headless_download_${response.status}`);
128
+ const reader = new Reader(response.body);
129
+ let complete = false;
130
+ try {
131
+ const header = await reader.line();
132
+ if (header.version !== 1 || header.snapshotId !== manifest.snapshotId || header.count !== pending.length)
133
+ throw Error('invalid_stream_header');
134
+ const remaining = new Set(pending);
135
+ for (let i = 0; i < header.count; i++) {
136
+ const item = await reader.line(), expected = objects.get(item.hash);
137
+ if (!remaining.has(item.hash) || !expected || item.size !== expected.size)
138
+ throw Error('unexpected_object');
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});}
151
+ remaining.delete(item.hash);
152
+ downloads++;
153
+ }
154
+ const end = await reader.line();
155
+ if (end.end !== true || end.snapshotId !== manifest.snapshotId || end.count !== header.count || await reader.take(1) !== null)
156
+ throw Error('invalid_stream_end');
157
+ complete = true;
158
+ }
159
+ catch (error) {
160
+ if (error.message !== 'truncated_stream' && !['TypeError', 'AbortError', 'TimeoutError'].includes(error.name) && error.message !== 'transfer_failed')
161
+ throw error;
162
+ }
163
+ finally {
164
+ await reader.reader.cancel().catch(() => { });
165
+ }
166
+ pending = [];
167
+ for (const o of missing.map(h => objects.get(h)))
168
+ if (!await cacheValid(join(namespace, o.hash), o))
169
+ pending.push(o.hash);
170
+ // A missing terminal marker is never accepted even when every object arrived.
171
+ if (!complete && !pending.length)
172
+ throw Error('missing_stream_end');
173
+ }
174
+ // Fresh generation, never merge with previous output (deleted articles/assets stay excluded).
175
+ await safeDirectory(output);
176
+ const generation = join(output, randomUUID());
177
+ await safeDirectory(generation);
178
+ try {
179
+ const articles = [];
180
+ for (const a of manifest.articles) {
181
+ const body = await cacheRead(join(namespace, a.bodyHash), objects.get(a.bodyHash)), metadata = await cacheRead(join(namespace, a.metadataHash), objects.get(a.metadataHash));
182
+ if (!body || !metadata)
183
+ throw Error('cache_corrupt');
184
+ articles.push({ ...a, metadata: JSON.parse(metadata), body: JSON.parse(body) });
185
+ }
186
+ await safeDirectory(join(generation, 'space-assets'));
187
+ for (const a of manifest.assets) {
188
+ const valid = await cacheValid(join(namespace, a.hash), objects.get(a.hash));
189
+ if (!valid)
190
+ throw Error('cache_corrupt');
191
+ await copyFile(join(namespace,a.hash),join(generation,'space-assets',a.hash));
192
+ }
193
+ await writeFile(join(generation, 'content.json'), JSON.stringify({ snapshotId: manifest.snapshotId, format: manifest.format, articles, assets: manifest.assets }));
194
+ return { snapshotDir: generation, manifest, diagnostics: { downloadedObjects: downloads, cacheHits: objects.size - missing.length } };
195
+ }
196
+ catch (e) {
197
+ await rm(generation, { recursive: true, force: true });
198
+ throw e;
199
+ }
200
+ }
package/package.json CHANGED
@@ -1,6 +1,56 @@
1
1
  {
2
2
  "name": "@accelflow/space-content",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.2",
4
+ "type": "module",
5
+ "engines": {
6
+ "node": ">=24"
7
+ },
8
+ "exports": {
9
+ "./node": {
10
+ "types": "./node.d.mts",
11
+ "import": "./node.mjs"
12
+ },
13
+ "./toc": {
14
+ "types": "./toc.d.mts",
15
+ "import": "./toc.mjs"
16
+ },
17
+ "./toc.css": "./toc.css",
18
+ "./render": {
19
+ "types": "./render.d.mts",
20
+ "import": "./render.mjs"
21
+ }
22
+ },
23
+ "scripts": {
24
+ "test": "node --test test/*.test.mjs",
25
+ "prepack": "npm test",
26
+ "verify:package": "node scripts/verify-package.mjs"
27
+ },
28
+ "files": [
29
+ "node.mjs",
30
+ "node.d.mts",
31
+ "README.md",
32
+ "toc.mjs",
33
+ "toc.d.mts",
34
+ "toc.css",
35
+ "LICENSE",
36
+ "render.mjs",
37
+ "render.d.mts"
38
+ ],
39
+ "description": "Build-time Headless CMS content sync and customizable table of contents for Accelflow Space",
40
+ "license": "MIT",
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/Accelflow/space-content-sdk.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/Accelflow/space-content-sdk/issues"
47
+ },
48
+ "homepage": "https://github.com/Accelflow/space-content-sdk#readme",
49
+ "publishConfig": {
50
+ "access": "public",
51
+ "tag": "beta"
52
+ },
53
+ "dependencies": {
54
+ "sanitize-html": "^2.18.0"
55
+ }
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
+ }
package/toc.css ADDED
@@ -0,0 +1,22 @@
1
+ /* Optional marketing-derived responsive theme. Override variables or omit this file. */
2
+ .space-toc { margin: 36px 0 42px; border: 1px solid var(--space-toc-border, #d9e5d8); border-radius: 0; padding: 24px 28px; background: var(--space-toc-background, #f5f9f0); }
3
+ [data-space-toc] > summary { display: flex; align-items: center; justify-content: space-between; margin: 0 0 14px; color: var(--space-toc-color, #26352b); cursor: pointer; font-family: Manrope, sans-serif; font-size: 18px; font-weight: 700; list-style: none; }
4
+ [data-space-toc] > summary::-webkit-details-marker { display: none; }
5
+ [data-space-toc] > summary::after { content: "−"; margin-left: 20px; font-size: 22px; font-weight: 500; line-height: 1; }
6
+ .space-toc:not([open]) { padding-block: 5px; }
7
+ .space-toc:not([open]) > summary { margin-bottom: 0; }
8
+ .space-toc:not([open]) > summary::after { content: "+"; }
9
+ .space-toc ol { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; counter-reset: blog-toc-h2; }
10
+ .space-toc li { margin: 0; line-height: 1.45; }
11
+ .space-toc li[data-level="2"] { display: grid; grid-template-columns: 1.5em minmax(0, 1fr); column-gap: .45em; align-items: baseline; counter-increment: blog-toc-h2; }
12
+ .space-toc li[data-level="2"]::before { content: counter(blog-toc-h2) ". "; color: #74836f; font-weight: 700; text-align: right; }
13
+ .space-toc li[data-level="3"] { padding-left: 1.25em; font-size: .94em; }
14
+ .space-toc a { color: var(--space-toc-link, #405f31); text-decoration: none; }
15
+ .space-toc a:hover { color: #65a511; text-decoration: underline; }
16
+ @media (max-width: 620px) {
17
+ .space-toc { position: sticky; top: calc(var(--space-toc-header-height, 64px) + 12px); z-index: 10; padding: 20px; }
18
+ .space-toc.is-stuck:not([open]) { width: 44px; height: 44px; margin-left: auto; padding: 0; background: #fff; border-radius: 50%; box-shadow: 0 4px 16px rgba(0, 0, 0, .14); }
19
+ .space-toc.is-stuck:not([open]) > summary { width: 42px; height: 42px; justify-content: center; margin: 0; font-size: 0; }
20
+ .space-toc.is-stuck:not([open]) > summary::before { content: ""; display: block; flex: 0 0 20px; width: 20px; height: 20px; background: currentColor; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 5h2v2H3zM7 5h14v2H7zM3 11h2v2H3zM7 11h14v2H7zM3 17h2v2H3zM7 17h14v2H7z'/%3E%3C/svg%3E") center / contain no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 5h2v2H3zM7 5h14v2H7zM3 11h2v2H3zM7 11h14v2H7zM3 17h2v2H3zM7 17h14v2H7z'/%3E%3C/svg%3E") center / contain no-repeat; }
21
+ .space-toc.is-stuck:not([open]) > summary::after { content: none; margin: 0; }
22
+ }
package/toc.d.mts ADDED
@@ -0,0 +1,10 @@
1
+ export type TocHeading = {id: string; text: string; level: number};
2
+ export type TocOptions = {
3
+ locale?: 'ja' | 'en'; title?: string; maxLevel?: 2 | 3; className?: string;
4
+ /** Trusted application renderer; escape data if returning custom HTML. */
5
+ render?: (data: {title: string; entries: (TocHeading & {href: string})[]}) => string;
6
+ mobileQuery?: string; mobileOpen?: boolean; desktopOpen?: boolean;
7
+ };
8
+ export function isTocTitle(text: string): boolean;
9
+ export function renderTableOfContents(headings: TocHeading[], options?: TocOptions): string;
10
+ export function mountTableOfContents(root: HTMLElement, options?: TocOptions): () => void;
package/toc.mjs ADDED
@@ -0,0 +1,52 @@
1
+ /** Framework-independent TOC presentation. Pass trusted CMS HTML to the host
2
+ * renderer; this module only creates text/fragment links, never executes content. */
3
+ const escape = value => String(value).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
4
+ export const isTocTitle = text => /^(?:目次|table\s+of\s+contents)$/i.test(String(text).replace(/[【】[]\[\]]/g, '').trim());
5
+ export function renderTableOfContents(headings, {locale = 'ja', title = locale === 'en' ? 'Table of contents' : '目次', maxLevel = 3, className = '', render} = {}) {
6
+ const entries = headings.filter(h => h.level >= 2 && h.level <= maxLevel && h.text.trim() && !isTocTitle(h.text)).map(h => ({...h, href: '#' + encodeURIComponent(h.id)}));
7
+ if (render) return render({title, entries});
8
+ return `<details class="space-toc ${escape(className)}" data-space-toc open><summary>${escape(title)}</summary><ol>${entries.map(h => `<li data-level="${h.level}"><a href="${escape(h.href)}">${escape(h.text)}</a></li>`).join('')}</ol></details>`;
9
+ }
10
+ /** Upgrade CMS nav blocks and install responsive behavior. Returns cleanup for
11
+ * React effects / client navigation. Custom render output must use data-space-toc
12
+ * on a details element to opt into the standard behavior. */
13
+ export function mountTableOfContents(root, options = {}) {
14
+ const win = root.ownerDocument.defaultView;
15
+ const originals = [];
16
+ for (const nav of root.querySelectorAll('nav[data-table-of-contents]')) {
17
+ const headings = [...nav.querySelectorAll('a[href^="#"]')].map(a => {
18
+ const fragment = a.getAttribute('href').slice(1);
19
+ let id; try { id = decodeURIComponent(fragment); } catch { id = fragment; }
20
+ return {id, text: a.textContent ?? '', level: Number(a.dataset.level ?? 2)};
21
+ });
22
+ const template = root.ownerDocument.createElement('template');
23
+ template.innerHTML = renderTableOfContents(headings, {maxLevel: Number(nav.dataset.maxLevel) === 2 ? 2 : 3, ...options});
24
+ const nodes = [...template.content.childNodes];
25
+ nav.replaceWith(...nodes); originals.push({nav, nodes});
26
+ }
27
+ const tocs = [...root.querySelectorAll('details[data-space-toc]')];
28
+ const media = win.matchMedia(options.mobileQuery ?? '(max-width: 620px)');
29
+ let frame = null;
30
+ const sticky = () => {
31
+ if (frame !== null) return;
32
+ frame = win.requestAnimationFrame(() => {
33
+ frame = null;
34
+ for (const toc of tocs) {
35
+ const top = parseFloat(win.getComputedStyle(toc).top) || 0;
36
+ toc.classList.toggle('is-stuck', media.matches && toc.getBoundingClientRect().top <= top + 1);
37
+ }
38
+ });
39
+ };
40
+ const sync = () => { for (const toc of tocs) toc.open = media.matches ? (options.mobileOpen ?? false) : (options.desktopOpen ?? true); sticky(); };
41
+ const click = e => {
42
+ const link = e.target.closest?.('a[href^="#"]');
43
+ const toc = link?.closest('details[data-space-toc]');
44
+ if (toc && root.contains(toc) && media.matches) { toc.open = false; sticky(); }
45
+ };
46
+ sync(); media.addEventListener('change', sync); win.addEventListener('scroll', sticky, {passive:true}); root.addEventListener('click', click);
47
+ return () => {
48
+ media.removeEventListener('change', sync); win.removeEventListener('scroll', sticky); root.removeEventListener('click', click);
49
+ if (frame !== null) win.cancelAnimationFrame(frame);
50
+ for (const {nav, nodes} of originals) { if (nodes[0]?.parentNode) nodes[0].before(nav); for (const node of nodes) node.remove(); }
51
+ };
52
+ }