@accelflow/space-content 0.0.0-stage → 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/node.d.mts +14 -0
- package/node.mjs +209 -0
- package/package.json +45 -4
- package/toc.css +22 -0
- package/toc.d.mts +10 -0
- package/toc.mjs +52 -0
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,84 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Space build content SDK (unpublished)
|
|
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
|
+
|
|
5
|
+
The initial npm release is being prepared; the commands below apply once it is published.
|
|
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
|
+
Current limits: 4,096 unique objects per batch, 64 MiB per object, 1 GiB total;
|
|
53
|
+
API publication adapter additionally limits assets to 16 MiB each. Retry count
|
|
54
|
+
is bounded (default 2). Larger manifest sharding, partial-object resume,
|
|
55
|
+
framework-specific adapters, cache GC and npm publication are follow-up work.
|
|
56
|
+
|
|
57
|
+
Tests: `npm test` from this repository root (no dependencies required).
|
|
58
|
+
Server, Worker and real-cloud integration tests remain in the Space service
|
|
59
|
+
repository; the tests here exercise the client with synthetic protocol fixtures.
|
|
60
|
+
|
|
61
|
+
## Full framework examples
|
|
62
|
+
|
|
63
|
+
See [Vite and Next.js examples](examples/README.md) for complete static builds,
|
|
64
|
+
CI cache handling, HTML rendering, asset headers and deployment boundaries.
|
|
65
|
+
|
|
66
|
+
## Table of contents
|
|
67
|
+
|
|
68
|
+
The browser-safe entry is separate from the authenticated Node client:
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import { mountTableOfContents } from '@accelflow/space-content/toc';
|
|
72
|
+
import '@accelflow/space-content/toc.css';
|
|
73
|
+
|
|
74
|
+
const cleanup = mountTableOfContents(articleElement, { locale: 'ja' });
|
|
75
|
+
// Call cleanup when replacing the article or unmounting the component.
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
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.
|
|
79
|
+
|
|
80
|
+
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.
|
|
81
|
+
|
|
82
|
+
## Release preparation
|
|
83
|
+
|
|
84
|
+
See [RELEASING.md](RELEASING.md) for packaging checks, initial publication and OIDC setup.
|
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,209 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile, rename, rm, lstat } from 'node:fs/promises';
|
|
3
|
+
import { resolve, join } from 'node:path';
|
|
4
|
+
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
5
|
+
const hashPattern = /^[a-f0-9]{64}$/;
|
|
6
|
+
function validateManifest(m, accountId, workspaceId) {
|
|
7
|
+
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))
|
|
8
|
+
throw Error('invalid_manifest');
|
|
9
|
+
const { snapshotId, ...fixed } = m;
|
|
10
|
+
if (sha(JSON.stringify(fixed)) !== snapshotId)
|
|
11
|
+
throw Error('manifest_checksum_mismatch');
|
|
12
|
+
const objects = new Map();
|
|
13
|
+
let total = 0;
|
|
14
|
+
for (const o of m.objects) {
|
|
15
|
+
if (!hashPattern.test(o.hash) || !Number.isSafeInteger(o.size) || o.size < 0 || o.size > 64 * 1024 * 1024 || objects.has(o.hash))
|
|
16
|
+
throw Error('invalid_manifest');
|
|
17
|
+
objects.set(o.hash, o);
|
|
18
|
+
total += o.size;
|
|
19
|
+
}
|
|
20
|
+
if (total > 1024 * 1024 * 1024)
|
|
21
|
+
throw Error('manifest_too_large');
|
|
22
|
+
for (const a of m.articles)
|
|
23
|
+
if (typeof a.id !== 'string' || typeof a.slug !== 'string' || typeof a.title !== 'string' || !objects.has(a.bodyHash) || !objects.has(a.metadataHash))
|
|
24
|
+
throw Error('invalid_manifest');
|
|
25
|
+
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
|
+
throw Error('invalid_manifest');
|
|
28
|
+
return objects;
|
|
29
|
+
}
|
|
30
|
+
async function limitedText(response, max) {
|
|
31
|
+
const reader = response.body?.getReader();
|
|
32
|
+
if (!reader)
|
|
33
|
+
throw Error('missing_response');
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let size = 0;
|
|
36
|
+
try {
|
|
37
|
+
while (true) {
|
|
38
|
+
const { done, value } = await reader.read();
|
|
39
|
+
if (done)
|
|
40
|
+
break;
|
|
41
|
+
size += value.length;
|
|
42
|
+
if (size > max)
|
|
43
|
+
throw Error('manifest_too_large');
|
|
44
|
+
chunks.push(Buffer.from(value));
|
|
45
|
+
}
|
|
46
|
+
return Buffer.concat(chunks).toString();
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
await reader.cancel().catch(() => { });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
class Reader {
|
|
53
|
+
constructor(stream) { if (!stream)
|
|
54
|
+
throw Error('missing_stream'); this.reader = stream.getReader(); this.buffer = new Uint8Array(); }
|
|
55
|
+
async take(max) { if (!this.buffer.length) {
|
|
56
|
+
const r = await this.reader.read();
|
|
57
|
+
if (r.done)
|
|
58
|
+
return null;
|
|
59
|
+
this.buffer = r.value;
|
|
60
|
+
} const n = Math.min(max, this.buffer.length), v = this.buffer.subarray(0, n); this.buffer = this.buffer.subarray(n); return v; }
|
|
61
|
+
async line() { const chunks = []; for (let n = 0; n < 4096; n++) {
|
|
62
|
+
const b = await this.take(1);
|
|
63
|
+
if (!b)
|
|
64
|
+
throw Error('truncated_stream');
|
|
65
|
+
if (b[0] === 10)
|
|
66
|
+
return JSON.parse(Buffer.from(chunks).toString());
|
|
67
|
+
chunks.push(b[0]);
|
|
68
|
+
} throw Error('frame_too_large'); }
|
|
69
|
+
}
|
|
70
|
+
async function cacheRead(path, o) { try {
|
|
71
|
+
if (!(await lstat(path)).isFile() || (await lstat(path)).isSymbolicLink())
|
|
72
|
+
return null;
|
|
73
|
+
const b = await readFile(path);
|
|
74
|
+
return b.length === o.size && sha(b) === o.hash ? b : null;
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
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
|
+
} }
|
|
88
|
+
async function safeDirectory(path) { await mkdir(path, { recursive: true, mode: 0o700 }); const s = await lstat(path); if (s.isSymbolicLink() || !s.isDirectory())
|
|
89
|
+
throw Error('unsafe_cache_directory'); }
|
|
90
|
+
/** Build-time only. cacheDir must be a private, trusted directory; never expose it to untrusted PRs. */
|
|
91
|
+
export async function syncContent({ apiUrl, downloadOrigin, accountId, workspaceId, token, cacheDir, outputDir, fetch: fetcher = globalThis.fetch, maxRetries = 2 }) {
|
|
92
|
+
const api = new URL(apiUrl), allowed = new URL(downloadOrigin);
|
|
93
|
+
if (api.protocol !== 'https:' || allowed.protocol !== 'https:' || api.username || api.password || allowed.username || allowed.password)
|
|
94
|
+
throw Error('https_required');
|
|
95
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 3)
|
|
96
|
+
throw Error('invalid_retry_limit');
|
|
97
|
+
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) });
|
|
98
|
+
if (!start.ok)
|
|
99
|
+
throw Error(`headless_session_${start.status}`);
|
|
100
|
+
const raw = await limitedText(start, 1100000);
|
|
101
|
+
const { session, manifest, downloadUrl, expiresAt } = JSON.parse(raw);
|
|
102
|
+
if (typeof session !== 'string' || session.length > 4096 || !Number.isFinite(expiresAt) || expiresAt <= Date.now())
|
|
103
|
+
throw Error('invalid_session');
|
|
104
|
+
const objects = validateManifest(manifest, accountId, workspaceId), url = new URL(downloadUrl);
|
|
105
|
+
if (url.origin !== allowed.origin || url.protocol !== 'https:' || url.username || url.password)
|
|
106
|
+
throw Error('untrusted_download_url');
|
|
107
|
+
const cache = resolve(cacheDir), output = resolve(outputDir);
|
|
108
|
+
if (output === cache || output.startsWith(cache + '/') || cache.startsWith(output + '/'))
|
|
109
|
+
throw Error('overlapping_directories');
|
|
110
|
+
await safeDirectory(cache);
|
|
111
|
+
const namespace = join(cache, sha(JSON.stringify([accountId, workspaceId, manifest.format, manifest.version])));
|
|
112
|
+
await safeDirectory(namespace);
|
|
113
|
+
const missing = [];
|
|
114
|
+
for (const o of objects.values())
|
|
115
|
+
if (!await cacheRead(join(namespace, o.hash), o))
|
|
116
|
+
missing.push(o.hash);
|
|
117
|
+
let pending = missing, downloads = 0;
|
|
118
|
+
for (let attempt = 0; pending.length; attempt++) {
|
|
119
|
+
if (attempt > maxRetries || Date.now() >= expiresAt)
|
|
120
|
+
throw Error('headless_transfer_incomplete');
|
|
121
|
+
let response;
|
|
122
|
+
try {
|
|
123
|
+
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()))) });
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (attempt >= maxRetries)
|
|
127
|
+
throw error;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if ([429, 502, 503, 504].includes(response.status) && attempt < maxRetries) {
|
|
131
|
+
await response.body?.cancel();
|
|
132
|
+
await new Promise(resolve => setTimeout(resolve, Math.min(1000 * 2 ** attempt, 5000)));
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!response.ok)
|
|
136
|
+
throw Error(`headless_download_${response.status}`);
|
|
137
|
+
const reader = new Reader(response.body);
|
|
138
|
+
let complete = false;
|
|
139
|
+
try {
|
|
140
|
+
const header = await reader.line();
|
|
141
|
+
if (header.version !== 1 || header.snapshotId !== manifest.snapshotId || header.count !== pending.length)
|
|
142
|
+
throw Error('invalid_stream_header');
|
|
143
|
+
const remaining = new Set(pending);
|
|
144
|
+
for (let i = 0; i < header.count; i++) {
|
|
145
|
+
const item = await reader.line(), expected = objects.get(item.hash);
|
|
146
|
+
if (!remaining.has(item.hash) || !expected || item.size !== expected.size)
|
|
147
|
+
throw Error('unexpected_object');
|
|
148
|
+
const data = Buffer.alloc(item.size);
|
|
149
|
+
let offset = 0;
|
|
150
|
+
while (offset < data.length) {
|
|
151
|
+
const chunk = await reader.take(data.length - offset);
|
|
152
|
+
if (!chunk)
|
|
153
|
+
throw Error('truncated_stream');
|
|
154
|
+
data.set(chunk, offset);
|
|
155
|
+
offset += chunk.length;
|
|
156
|
+
}
|
|
157
|
+
if (sha(data) !== item.hash)
|
|
158
|
+
throw Error('object_checksum_mismatch');
|
|
159
|
+
await save(join(namespace, item.hash), data);
|
|
160
|
+
remaining.delete(item.hash);
|
|
161
|
+
downloads++;
|
|
162
|
+
}
|
|
163
|
+
const end = await reader.line();
|
|
164
|
+
if (end.end !== true || end.snapshotId !== manifest.snapshotId || end.count !== header.count || await reader.take(1) !== null)
|
|
165
|
+
throw Error('invalid_stream_end');
|
|
166
|
+
complete = true;
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
if (error.message !== 'truncated_stream' && !['TypeError', 'AbortError', 'TimeoutError'].includes(error.name) && error.message !== 'transfer_failed')
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await reader.reader.cancel().catch(() => { });
|
|
174
|
+
}
|
|
175
|
+
pending = [];
|
|
176
|
+
for (const o of missing.map(h => objects.get(h)))
|
|
177
|
+
if (!await cacheRead(join(namespace, o.hash), o))
|
|
178
|
+
pending.push(o.hash);
|
|
179
|
+
// A missing terminal marker is never accepted even when every object arrived.
|
|
180
|
+
if (!complete && !pending.length)
|
|
181
|
+
throw Error('missing_stream_end');
|
|
182
|
+
}
|
|
183
|
+
// Fresh generation, never merge with previous output (deleted articles/assets stay excluded).
|
|
184
|
+
await safeDirectory(output);
|
|
185
|
+
const generation = join(output, randomUUID());
|
|
186
|
+
await safeDirectory(generation);
|
|
187
|
+
try {
|
|
188
|
+
const articles = [];
|
|
189
|
+
for (const a of manifest.articles) {
|
|
190
|
+
const body = await cacheRead(join(namespace, a.bodyHash), objects.get(a.bodyHash)), metadata = await cacheRead(join(namespace, a.metadataHash), objects.get(a.metadataHash));
|
|
191
|
+
if (!body || !metadata)
|
|
192
|
+
throw Error('cache_corrupt');
|
|
193
|
+
articles.push({ ...a, metadata: JSON.parse(metadata), body: JSON.parse(body) });
|
|
194
|
+
}
|
|
195
|
+
await safeDirectory(join(generation, 'space-assets'));
|
|
196
|
+
for (const a of manifest.assets) {
|
|
197
|
+
const data = await cacheRead(join(namespace, a.hash), objects.get(a.hash));
|
|
198
|
+
if (!data)
|
|
199
|
+
throw Error('cache_corrupt');
|
|
200
|
+
await writeFile(join(generation, 'space-assets', a.hash), data);
|
|
201
|
+
}
|
|
202
|
+
await writeFile(join(generation, 'content.json'), JSON.stringify({ snapshotId: manifest.snapshotId, format: manifest.format, articles, assets: manifest.assets }));
|
|
203
|
+
return { snapshotDir: generation, manifest, diagnostics: { downloadedObjects: downloads, cacheHits: objects.size - missing.length } };
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
await rm(generation, { recursive: true, force: true });
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@accelflow/space-content",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
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
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"prepack": "npm test",
|
|
22
|
+
"verify:package": "node scripts/verify-package.mjs"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"node.mjs",
|
|
26
|
+
"node.d.mts",
|
|
27
|
+
"README.md",
|
|
28
|
+
"toc.mjs",
|
|
29
|
+
"toc.d.mts",
|
|
30
|
+
"toc.css",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"description": "Build-time Headless CMS content sync and customizable table of contents for Accelflow Space",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/Accelflow/space-content-sdk.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/Accelflow/space-content-sdk/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/Accelflow/space-content-sdk#readme",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"tag": "beta"
|
|
46
|
+
}
|
|
47
|
+
}
|
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
+
}
|