@accelflow/space-content 0.1.0-beta.1 → 0.1.0-beta.3

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 CHANGED
@@ -1,8 +1,8 @@
1
- # Space build content SDK (unpublished)
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 initial npm release is being prepared; the commands below apply once it is published.
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
- 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.
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 (no dependencies required).
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,124 @@ 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.
119
+
120
+ ## Author profile pages (Vite / Next.js)
121
+
122
+ `@accelflow/space-content/profile` is a framework-independent, token-free entrypoint.
123
+ It generates the author biography, site introduction, organization/publisher and
124
+ published article list from the metadata received by `syncContent()`. It also
125
+ provides `ProfilePage` JSON-LD. Requires Space's profile metadata publication support.
126
+ Old snapshots without author metadata must supply `identity` explicitly; no company
127
+ or author is silently invented. An explicit identity also supports a zero-article page.
128
+
129
+ ```js
130
+ import {createAuthorProfile, renderAuthorProfile} from '@accelflow/space-content/profile';
131
+ import '@accelflow/space-content/profile.css';
132
+
133
+ const profile = createAuthorProfile(content.articles, {
134
+ origin: 'https://your-site.example',
135
+ locale: 'ja',
136
+ url: '/ja/blog/authors/editorial-team/',
137
+ articleHref: article => `/ja/blog/${article.slug}/`,
138
+ });
139
+ const html = renderAuthorProfile(profile, {
140
+ labels: {about: '編集部について', articles: '編集部の記事'},
141
+ });
142
+ ```
143
+
144
+ Run this on the build/server side after syncing. `html` is a page fragment: place it
145
+ inside your layout, with a UTF-8 charset, viewport, page title, description and canonical
146
+ URL. Vite must prerender it (see the example); Next.js can use it in a statically
147
+ exported server page. Use `profile.author`, `siteIntroduction`, `publisher`, `articles`
148
+ and `jsonLd` to build your own React components instead of using the standard HTML.
149
+ Do not pass API tokens into the profile object or browser bundles.
150
+
151
+ The optional CSS is responsive and has `--space-profile-width`, `--space-profile-color`,
152
+ `--space-profile-font`, `--space-profile-border`, and `--space-profile-link` overrides.
153
+ You may omit it entirely. Labels can be overridden with `about`, `company`, `articles`,
154
+ `empty`; `includeStructuredData: false` lets your framework own JSON-LD insertion.
155
+ Content is treated as text, not executable HTML. Only HTTP(S) links are accepted;
156
+ unsafe custom article routes fail the build. JSON-LD escapes `<` against script breakout.
157
+ Language overrides use `translations.ja/en`, falling back per field to common values.
158
+ Draft articles, other locales and articles with a different author are excluded.
159
+
160
+ ### CMS URL and link targets
161
+
162
+ When a Space workspace has a CMS URL, the delivery API rewrites article links
163
+ before returning HTML or JSON. Use the article base URL (for example,
164
+ `https://example.com/ja/blog/`). Workspace references use that base plus the
165
+ published page-tree path. Links with the same hostname become root-relative;
166
+ other HTTP(S) links retain an absolute URL and carry `target="_blank"` with
167
+ `rel="noopener noreferrer"`. The renderer preserves these safe external targets.
168
+ Fragments, email and telephone links retain their normal behavior. Consumers
169
+ with custom renderers should honor these link attributes. An empty CMS URL
170
+ retains existing API link behavior. Update the build after changing the URL.
171
+
172
+ ### Heading IDs and table-of-contents links
173
+
174
+ TOC links percent-encode the heading ID once (for example, `#%E3%81%BE%E3%81%A8%E3%82%81`).
175
+ Valid explicit heading IDs are preserved. To retain an existing site's generation
176
+ rules for headings without IDs, pass `headingId` to `renderArticleBody`:
177
+
178
+ ```js
179
+ renderArticleBody(body, {
180
+ headingId: text => text.replace(/<[^>]+>/g, '').trim().toLowerCase()
181
+ .replace(/[\s/]+/g, '-').replace(/[^\p{Letter}\p{Number}-]/gu, '')
182
+ .replace(/-{2,}/g, '-') || 'heading',
183
+ });
184
+ ```
185
+
186
+ Return an unencoded ID containing only Unicode letters, numbers, `_` or `-`.
187
+ Explicit IDs take priority over generated IDs; collisions receive numeric suffixes.
188
+ Without the option, the SDK's existing generation rules remain unchanged.
189
+
190
+ ### Paragraph spacing in tables and lists
191
+
192
+ Import the optional body stylesheet after your site's typography CSS and add
193
+ `space-content` to the element containing the rendered article:
194
+
195
+ ```js
196
+ import '@accelflow/space-content/content.css';
197
+ ```
198
+ ```html
199
+ <article class="space-content">…sanitized article HTML…</article>
200
+ ```
201
+
202
+ Single paragraphs directly inside table cells or list items have no outside
203
+ margin. Consecutive paragraphs keep a `0.5em` gap. Empty paragraphs retain a line
204
+ of height; `<br>`, nested lists and paragraph nodes are not removed. Other
205
+ paragraphs and content outside this wrapper are unchanged. Customize the gap:
206
+
207
+ ```css
208
+ .space-content { --space-content-paragraph-gap: 0.75em; }
209
+ ```
package/content.css ADDED
@@ -0,0 +1,11 @@
1
+ /* Opt-in article body layout. Keep paragraphs and intentional empty lines intact. */
2
+ .space-content :where(td, th, li) > p {
3
+ margin-block: 0;
4
+ }
5
+ .space-content :where(td, th, li) > p + p {
6
+ margin-block-start: var(--space-content-paragraph-gap, 0.5em);
7
+ }
8
+ .space-content :where(td, th, li) > p:empty {
9
+ min-block-size: 1em;
10
+ min-block-size: 1lh;
11
+ }
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 > 64 * 1024 * 1024 || objects.has(o.hash))
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 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;
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
- 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
- } }
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 cacheRead(join(namespace, o.hash), o))
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 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);
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 cacheRead(join(namespace, o.hash), o))
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 data = await cacheRead(join(namespace, a.hash), objects.get(a.hash));
198
- if (!data)
188
+ const valid = await cacheValid(join(namespace, a.hash), objects.get(a.hash));
189
+ if (!valid)
199
190
  throw Error('cache_corrupt');
200
- await writeFile(join(generation, 'space-assets', a.hash), data);
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.1",
3
+ "version": "0.1.0-beta.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -14,7 +14,17 @@
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
+ },
22
+ "./profile": {
23
+ "types": "./profile.d.mts",
24
+ "import": "./profile.mjs"
25
+ },
26
+ "./profile.css": "./profile.css",
27
+ "./content.css": "./content.css"
18
28
  },
19
29
  "scripts": {
20
30
  "test": "node --test test/*.test.mjs",
@@ -28,7 +38,13 @@
28
38
  "toc.mjs",
29
39
  "toc.d.mts",
30
40
  "toc.css",
31
- "LICENSE"
41
+ "LICENSE",
42
+ "render.mjs",
43
+ "render.d.mts",
44
+ "profile.mjs",
45
+ "profile.d.mts",
46
+ "profile.css",
47
+ "content.css"
32
48
  ],
33
49
  "description": "Build-time Headless CMS content sync and customizable table of contents for Accelflow Space",
34
50
  "license": "MIT",
@@ -43,5 +59,8 @@
43
59
  "publishConfig": {
44
60
  "access": "public",
45
61
  "tag": "beta"
62
+ },
63
+ "dependencies": {
64
+ "sanitize-html": "^2.18.0"
46
65
  }
47
66
  }
package/profile.css ADDED
@@ -0,0 +1,9 @@
1
+ /* Optional marketing-inspired theme. Override custom properties or use your own CSS. */
2
+ .space-profile{max-width:var(--space-profile-width,960px);margin:0 auto;padding:clamp(24px,5vw,64px) 24px;color:var(--space-profile-color,#17332a);font-family:var(--space-profile-font,system-ui,sans-serif);line-height:1.8;overflow-wrap:anywhere}
3
+ .space-profile header{padding-bottom:40px;border-bottom:1px solid var(--space-profile-border,#dce4e0)}
4
+ .space-profile h1{font-size:clamp(28px,4vw,48px);line-height:1.3;font-weight:600}
5
+ .space-profile h2{font-size:clamp(22px,3vw,30px);line-height:1.4;margin:0 0 20px}
6
+ .space-profile section{margin-top:48px}.space-profile p{margin:0 0 20px}.space-profile-text{white-space:pre-line}
7
+ .space-profile-eyebrow{font-size:14px;font-weight:700}.space-profile a{color:var(--space-profile-link,#237568);text-underline-offset:4px}
8
+ .space-profile-logo{max-width:180px;max-height:72px;object-fit:contain;margin-bottom:16px}.space-profile li{margin:12px 0}
9
+ @media(max-width:620px){.space-profile{padding:24px 20px}.space-profile section{margin-top:32px}.space-profile header{padding-bottom:24px}}
package/profile.d.mts ADDED
@@ -0,0 +1,7 @@
1
+ export type ProfileParty = {name:string;url?:string;type?:'Person'|'Organization';id?:string;logo?:string;description?:string;organizationName?:string;organizationUrl?:string;translations?:Partial<Record<'ja'|'en',Partial<Pick<ProfileParty,'name'|'url'|'description'|'organizationName'|'organizationUrl'>>>>};
2
+ export type SiteIntroduction = {heading:string;body:string;translations?:Partial<Record<'ja'|'en',Partial<{heading:string;body:string}>>>};
3
+ export type ProfileIdentity = {author?:ProfileParty;publisher?:ProfileParty;siteIntroduction?:SiteIntroduction};
4
+ export type ProfileArticle = {id?:string;slug:string;title:string;metadata?:Record<string,unknown>;[key:string]:unknown};
5
+ export type AuthorProfile = {locale:'ja'|'en';url:string;author:ProfileParty;publisher?:ProfileParty;siteIntroduction?:{heading:string;body:string};articles:{id:string;title:string;url:string}[];jsonLd:Record<string,unknown>};
6
+ export function createAuthorProfile(articles:ProfileArticle[],options:{origin:string;locale?:'ja'|'en';identity?:ProfileIdentity;url?:string;articleHref?:(article:ProfileArticle)=>string}):AuthorProfile;
7
+ export function renderAuthorProfile(profile:AuthorProfile,options?:{labels?:Partial<Record<'about'|'company'|'articles'|'empty',string>>;includeStructuredData?:boolean}):string;
package/profile.mjs ADDED
@@ -0,0 +1,56 @@
1
+ const escape = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
2
+ function url(value, origin) {
3
+ if (typeof value !== 'string' || /[\u0000-\u0020\u007f\\]/.test(value) || value.startsWith('//')) return '';
4
+ try {const u=new URL(value,origin);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password ? u.href : '';} catch {return '';}
5
+ }
6
+ function party(value, locale) {
7
+ if (!value || typeof value!=='object') return undefined;
8
+ const p={...value};
9
+ for(const key of ['name','url','description','organizationName','organizationUrl']) if(typeof value.translations?.[locale]?.[key]==='string')p[key]=value.translations[locale][key];
10
+ if(typeof p.name!=='string'||!p.name.trim())return undefined;
11
+ return {name:p.name,type:p.type==='Person'?'Person':'Organization',url:url(p.url),id:url(p.id),logo:url(p.logo),description:typeof p.description==='string'?p.description:'',organizationName:typeof p.organizationName==='string'?p.organizationName:'',organizationUrl:url(p.organizationUrl)};
12
+ }
13
+ function schemaParty(p) {
14
+ return {'@type':p.type,name:p.name,...(p.url?{url:p.url}:{}),...(p.id?{'@id':p.id}:{}),...(p.logo?{logo:p.logo}:{}),...(p.description?{description:p.description}:{})};
15
+ }
16
+ const sameAuthor=(a,b)=> (a.id&&b.id)?a.id===b.id:(a.url&&b.url)?a.url===b.url:a.name===b.name;
17
+ /** Build a profile view model from published SDK articles. Does not fetch or access tokens. */
18
+ export function createAuthorProfile(articles, options) {
19
+ const {origin,locale='ja',identity,articleHref}=options??{};
20
+ if(!['ja','en'].includes(locale))throw Error('invalid_profile_locale');
21
+ const base=url(origin);if(!base)throw Error('invalid_profile_origin');
22
+ const candidates=articles.filter(a=>(a.metadata?.locale??a.locale??'ja')===locale && (a.metadata??a).draft!==true);
23
+ const metadata=identity??candidates.find(a=>(a.metadata??a).author)?.metadata??candidates.find(a=>a.author)??{};
24
+ const author=party(metadata.author,locale);if(!author)throw Error('profile_author_required');
25
+ const publisher=party(metadata.publisher,locale);
26
+ const source=metadata.siteIntroduction;
27
+ const intro=source?{...source,...source.translations?.[locale]}:undefined;
28
+ const siteIntroduction=intro&&typeof intro.heading==='string'&&typeof intro.body==='string'?{heading:intro.heading,body:intro.body}:undefined;
29
+ const canonicalUrl=url(options.url??author.url,base);if(!canonicalUrl)throw Error('invalid_profile_url');
30
+ const list=candidates.filter(a=>{const p=party((a.metadata??a).author,locale);return !p||sameAuthor(p,author);}).map(a=>{
31
+ let href;
32
+ if(articleHref)href=url(articleHref(a),base);
33
+ else {
34
+ const raw=(a.metadata??a).path??'/'+a.slug;
35
+ if(typeof raw!=='string'||!/^\/(?!\/)/.test(raw)||raw.slice(1).split('/').some(s=>!/^[-_\p{L}\p{N}]+$/u.test(s)))throw Error('invalid_profile_article_path');
36
+ href=url('/articles/'+raw.slice(1).split('/').map(encodeURIComponent).join('/')+'/',base);
37
+ }
38
+ if(!href)throw Error('invalid_profile_article_url');
39
+ return {id:a.id??a.slug,title:String(a.title??''),url:href};
40
+ });
41
+ const entity=schemaParty(author);
42
+ if(author.organizationName)entity[author.type==='Person'?'affiliation':'parentOrganization']={...(publisher&&publisher.url===author.organizationUrl?schemaParty(publisher):{'@type':'Organization'}),name:author.organizationName,...(author.organizationUrl?{url:author.organizationUrl}:{})};
43
+ const jsonLd={'@context':'https://schema.org','@type':'ProfilePage','@id':canonicalUrl+'#profile',url:canonicalUrl,name:author.name,description:author.description,inLanguage:locale==='ja'?'ja-JP':'en-US',mainEntity:entity,...(publisher?{publisher:schemaParty(publisher)}:{})};
44
+ return {locale,url:canonicalUrl,author,publisher,siteIntroduction,articles:list,jsonLd};
45
+ }
46
+ /** Safe default HTML. Customize labels/CSS or render the view model in React instead. */
47
+ export function renderAuthorProfile(profile,{labels={},includeStructuredData=true}={}) {
48
+ const ja=profile.locale==='ja';
49
+ const text={about:ja?'プロフィール':'Profile',company:ja?'運営会社':'Published by',articles:ja?'記事一覧':'Articles',empty:ja?'公開中の記事はまだありません。':'There are no published articles yet.',...labels};
50
+ const a=profile.author,p=profile.publisher;
51
+ const link=(href,label)=>url(href)?`<a href="${escape(url(href))}">${escape(label)}</a>`:escape(label);
52
+ const logo=p?.logo&&url(p.logo)?`<img class="space-profile-logo" src="${escape(url(p.logo))}" alt="${escape(p.name)}" loading="lazy" referrerpolicy="no-referrer">`:'';
53
+ const company=a.organizationName||p?.name;
54
+ const data=includeStructuredData?`<script type="application/ld+json">${JSON.stringify(profile.jsonLd).replaceAll('<','\\u003c')}</script>`:'';
55
+ return `${data}<article class="space-profile" lang="${ja?'ja':'en'}"><header><p class="space-profile-eyebrow">${escape(text.about)}</p><h1>${escape(a.name)}</h1><p class="space-profile-text">${escape(a.description)}</p></header>${profile.siteIntroduction?`<section><h2>${escape(profile.siteIntroduction.heading)}</h2><p class="space-profile-text">${escape(profile.siteIntroduction.body)}</p></section>`:''}${company?`<section><h2>${escape(text.company)}</h2>${logo}<p>${link(a.organizationUrl||p?.url,company)}</p></section>`:''}<section><h2>${escape(text.articles)}</h2>${profile.articles.length?`<ul>${profile.articles.map(item=>`<li>${link(item.url,item.title)}</li>`).join('')}</ul>`:`<p>${escape(text.empty)}</p>`}</section></article>`;
56
+ }
package/render.d.mts ADDED
@@ -0,0 +1,13 @@
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
+ /** Used only when a heading has no valid explicit ID. Return an unencoded ID. */
5
+ headingId?: (text:string)=>string;
6
+ assets?: ContentAsset[]; locale?: 'ja'|'en'; pdfMode?: 'link'|'embed';
7
+ renderers?: Record<string,(context:{node:ContentNode;children:string;defaultHtml:string;escapeHtml:typeof escapeHtml})=>string>;
8
+ resolveArticle?: (attrs:Record<string,unknown>)=>{href:string;title?:string}|undefined;
9
+ resolveSubArticles?: (attrs:Record<string,unknown>)=>Array<{href:string;title:string}>;
10
+ }
11
+ export function escapeHtml(value:unknown):string;
12
+ export function sanitizeArticleHtml(html:string, options?:RenderOptions):string;
13
+ export function renderArticleBody(body:ContentNode|string, options?:RenderOptions):string;
package/render.mjs ADDED
@@ -0,0 +1,99 @@
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 linkTarget = attrs => attrs?.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
5
+ const hash = /^[a-f0-9]{64}$/;
6
+ const raster = new Set(['image/png','image/jpeg','image/gif','image/webp','image/avif']);
7
+ function assetsIndex(assets = []) {
8
+ const ids = new Map(), paths = new Map();
9
+ for (const a of assets) {
10
+ 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');
11
+ ids.set(a.id,a); paths.set('/space-assets/'+a.hash,a);
12
+ }
13
+ return {ids,paths};
14
+ }
15
+ function safeUrl(value, image = false) {
16
+ if (typeof value !== 'string' || /[\u0000-\u0020\u007f\\]/.test(value)) return '';
17
+ if (/^(?:\/(?!\/)|#)/.test(value)) return value;
18
+ 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 '';}
19
+ }
20
+ /** Build-time HTML sanitizer. Only known local PDF assets may be embedded, opt-in. */
21
+ export function sanitizeArticleHtml(html, {assets = [], pdfMode = 'link'} = {}) {
22
+ if (typeof html !== 'string') throw Error('invalid_html_body');
23
+ const {paths}=assetsIndex(assets);
24
+ return sanitizeHtml(html, {
25
+ nonBooleanAttributes: sanitizeHtml.defaults.nonBooleanAttributes.filter(a=>a!=='sandbox'),
26
+ allowedTags: [...sanitizeHtml.defaults.allowedTags,'img','details','summary','nav',...(pdfMode==='embed'?['iframe']:[])],
27
+ allowedAttributes: {
28
+ '*':['id','class'], a:['href','title','data-level','download','target','rel'],
29
+ img:['src','alt','title','width','height','loading','referrerpolicy'],
30
+ nav:['data-table-of-contents','data-max-level','aria-label'], details:['open','data-space-toc'],
31
+ li:['data-level','data-checked'], ol:['start'], td:['colspan','rowspan'], th:['colspan','rowspan','scope'],
32
+ iframe:['src','title','sandbox','loading','referrerpolicy'],
33
+ },
34
+ allowedClasses:{'*':['*']},
35
+ allowedSchemes:['https','http','mailto','tel'], allowedSchemesByTag:{img:['https'],iframe:[]}, allowProtocolRelative:false,
36
+ transformTags: {
37
+ a: (tagName, attrs) => {const {target,rel,...rest}=attrs;return {tagName,attribs:{...rest,...(target==='_blank'&&/^https?:\/\//i.test(safeUrl(attrs.href))?{target:'_blank',rel:'noopener noreferrer'}:{})}};},
38
+ iframe: (tagName, attrs) => ({tagName,attribs:{src:attrs.src??'',title:attrs.title??'PDF',sandbox:'',loading:'lazy',referrerpolicy:'no-referrer'}}),
39
+ },
40
+ exclusiveFilter: frame => frame.tag==='iframe' && !(pdfMode==='embed' && paths.get(frame.attribs.src)?.contentType==='application/pdf'),
41
+ });
42
+ }
43
+ /** Render Space's Tiptap JSON document. Custom callbacks are trusted code; their HTML is still sanitized. */
44
+ export function renderArticleBody(body, options = {}) {
45
+ if (typeof body==='string') return sanitizeArticleHtml(body,options);
46
+ if (!body || body.type!=='doc' || !Array.isArray(body.content)) throw Error('invalid_document');
47
+ const {ids}=assetsIndex(options.assets), headings=[], anchors=new Map(), used=new Set(), reserved=new Set();let count=0;
48
+ const validId=id=>typeof id==='string' && /^[\p{L}\p{N}_-]+$/u.test(id);
49
+ const text=n=>n.type==='text'?String(n.text??''):(n.content??[]).map(text).join('');
50
+ function inspect(n,depth=0) {
51
+ if (!n || typeof n!=='object' || typeof n.type!=='string' || ++count>100000 || depth>64 || (n.content!==undefined&&!Array.isArray(n.content))) throw Error('invalid_document');
52
+ if(n.type==='heading' && validId(n.attrs?.id))reserved.add(n.attrs.id);
53
+ for(const child of n.content??[])inspect(child,depth+1);
54
+ }
55
+ inspect(body);
56
+ function collect(n) {
57
+ if(n.type==='tableOfContents')return;
58
+ 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);const explicit=validId(n.attrs?.id);let id=explicit?n.attrs.id:options.headingId?options.headingId(title):title.toLowerCase().replace(/[^\p{L}\p{N}_-]+/gu,'-').replace(/^-|-$/g,'')||'heading';if(!validId(id))throw Error('invalid_heading_id');const base=id;let index=2;while(used.has(id)||(!explicit&&reserved.has(id))||(id!==base&&reserved.has(id)))id=base+'-'+index++;used.add(id);anchors.set(n,id);headings.push({id,level,text:title});}
59
+ for(const child of n.content??[])collect(child);
60
+ }
61
+ collect(body);
62
+ const e=escapeHtml;
63
+ const wrap=(tag,children,attrs='')=>`<${tag}${attrs}>${children}</${tag}>`;
64
+ function render(n) {
65
+ const a=n.attrs??{}, children=(n.content??[]).map(render).join('');
66
+ let html='';
67
+ if(n.type==='text') {
68
+ html=e(n.text);
69
+ 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)}"${linkTarget(mark.attrs)}`);}}
70
+ } 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))}"`);
71
+ else if(n.type==='tableOfContents') html=renderTableOfContents(headings,{locale:options.locale??'ja',maxLevel:a.maxLevel===2?2:3});
72
+ 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">`:'';}
73
+ else if(n.type==='attachment') {
74
+ const asset=ids.get(a.attachmentId);if(!asset)throw Error('attachment_not_in_manifest');
75
+ const src='/space-assets/'+asset.hash, label=e(asset.filename);
76
+ html=raster.has(asset.contentType)?`<img src="${src}" alt="${label}" loading="lazy">`:`<a href="${src}" download="${label}">${label}</a>`;
77
+ 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;
78
+ } else if(n.type==='externalLinkCard') {const href=safeUrl(a.href);html=href?wrap('a',e(a.title||href),` href="${e(href)}"${linkTarget(a)}`):e(a.title);}
79
+ 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;}
80
+ else if(n.type==='smartLink') html=e(a.label??a.categoryLabel??'');
81
+ 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('')));
82
+ else if(n.type==='hardBreak')html='<br>';
83
+ else if(n.type==='horizontalRule')html='<hr>';
84
+ else if(n.type==='codeBlock')html=wrap('pre',wrap('code',e(text(n)),` class="language-${e(String(a.language??'text').replace(/[^\w-]/g,''))}"`));
85
+ else if(n.type==='columnLayout')html=wrap('div',children,' class="space-columns"');
86
+ else if(n.type==='column')html=wrap('div',children,' class="space-column"');
87
+ else {
88
+ 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];
89
+ let attrs='';
90
+ if(n.type==='orderedList'&&Number.isSafeInteger(a.start)&&a.start>0)attrs=` start="${a.start}"`;
91
+ if(n.type==='taskItem')attrs=` class="space-task" data-checked="${a.checked===true}"`;
92
+ 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]}"`;
93
+ html=tag?wrap(tag,children,attrs):children;
94
+ }
95
+ const custom=Object.hasOwn(options.renderers??{},n.type)?options.renderers[n.type]:null;
96
+ return custom?custom({node:n,children,defaultHtml:html,escapeHtml:e}):html;
97
+ }
98
+ return sanitizeArticleHtml(render(body),options);
99
+ }