@accelflow/space-content 0.1.0-beta.2 → 0.1.0-beta.4

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,6 +1,13 @@
1
- # Space build content SDK
1
+ # Accelflow Space Headless CMS SDK
2
2
 
3
- Node 24+ build-time content synchronization for Space Headless CMS, with optional browser-safe table-of-contents presentation. Licensed under MIT.
3
+ The official Headless CMS SDK for [Accelflow Space](https://space.accelflow.jp/en/), developed and maintained by [Accelflow Inc.](https://accelflow.jp/).
4
+
5
+ Create, review, and publish content with your team in Space, then bring it into your own website with Vite, Next.js, or another build system. The SDK synchronizes published content at build time and provides optional rendering, table-of-contents, and author-profile utilities. Requires Node.js 24 or later. Licensed under MIT.
6
+
7
+ - [Service website](https://space.accelflow.jp/en/)
8
+ - [Headless CMS use case](https://space.accelflow.jp/en/use-cases/headless-cms/)
9
+ - [Company website — Accelflow Inc.](https://accelflow.jp/)
10
+ - [Source code](https://github.com/Accelflow/space-content-sdk) · [Report an issue](https://github.com/Accelflow/space-content-sdk/issues)
4
11
 
5
12
  The beta package is available on npm.
6
13
 
@@ -64,7 +71,7 @@ repository; the tests here exercise the client with synthetic protocol fixtures.
64
71
 
65
72
  ## Full framework examples
66
73
 
67
- See [Vite and Next.js examples](examples/README.md) for complete static builds,
74
+ See [Vite and Next.js examples](https://github.com/Accelflow/space-content-sdk/tree/main/examples#readme) for complete static builds,
68
75
  CI cache handling, HTML rendering, asset headers and deployment boundaries.
69
76
 
70
77
  ## Table of contents
@@ -75,7 +82,7 @@ The browser-safe entry is separate from the authenticated Node client:
75
82
  import { mountTableOfContents } from '@accelflow/space-content/toc';
76
83
  import '@accelflow/space-content/toc.css';
77
84
 
78
- const cleanup = mountTableOfContents(articleElement, { locale: 'ja' });
85
+ const cleanup = mountTableOfContents(articleElement, { locale: 'en' });
79
86
  // Call cleanup when replacing the article or unmounting the component.
80
87
  ```
81
88
 
@@ -85,14 +92,14 @@ In Next.js, use this browser entry in a client component effect and return the c
85
92
 
86
93
  ## Release preparation
87
94
 
88
- See [RELEASING.md](RELEASING.md) for packaging checks, initial publication and OIDC setup.
95
+ See [RELEASING.md](https://github.com/Accelflow/space-content-sdk/blob/main/RELEASING.md) for packaging checks, initial publication and OIDC setup.
89
96
 
90
97
  ## JSON and HTML rendering
91
98
 
92
99
  ```js
93
100
  import {renderArticleBody} from '@accelflow/space-content/render';
94
101
  const html = renderArticleBody(article.body, {
95
- assets: content.assets, locale: 'ja', pdfMode: 'link',
102
+ assets: content.assets, locale: 'en', pdfMode: 'link',
96
103
  renderers: {paragraph: ({children}) => `<p>${children}</p>`},
97
104
  });
98
105
  ```
@@ -116,3 +123,94 @@ Serve copied assets with their manifest MIME and `X-Content-Type-Options: nosnif
116
123
  Force Content-Disposition attachment for non-raster, non-PDF types (especially
117
124
  HTML/SVG); they must never execute at the site's origin. The examples emit those
118
125
  headers for Cloudflare-style hosts; configure equivalent rules on other hosts.
126
+
127
+ ## Author profile pages (Vite / Next.js)
128
+
129
+ `@accelflow/space-content/profile` is a framework-independent, token-free entrypoint.
130
+ It generates the author biography, site introduction, organization/publisher and
131
+ published article list from the metadata received by `syncContent()`. It also
132
+ provides `ProfilePage` JSON-LD. Requires Space's profile metadata publication support.
133
+ Old snapshots without author metadata must supply `identity` explicitly; no company
134
+ or author is silently invented. An explicit identity also supports a zero-article page.
135
+
136
+ ```js
137
+ import {createAuthorProfile, renderAuthorProfile} from '@accelflow/space-content/profile';
138
+ import '@accelflow/space-content/profile.css';
139
+
140
+ const profile = createAuthorProfile(content.articles, {
141
+ origin: 'https://your-site.example',
142
+ locale: 'en',
143
+ url: '/en/blog/authors/editorial-team/',
144
+ articleHref: article => `/en/blog/${article.slug}/`,
145
+ });
146
+ const html = renderAuthorProfile(profile, {
147
+ labels: {about: 'About the editorial team', articles: 'Articles by the editorial team'},
148
+ });
149
+ ```
150
+
151
+ Run this on the build/server side after syncing. `html` is a page fragment: place it
152
+ inside your layout, with a UTF-8 charset, viewport, page title, description and canonical
153
+ URL. Vite must prerender it (see the example); Next.js can use it in a statically
154
+ exported server page. Use `profile.author`, `siteIntroduction`, `publisher`, `articles`
155
+ and `jsonLd` to build your own React components instead of using the standard HTML.
156
+ Do not pass API tokens into the profile object or browser bundles.
157
+
158
+ The optional CSS is responsive and has `--space-profile-width`, `--space-profile-color`,
159
+ `--space-profile-font`, `--space-profile-border`, and `--space-profile-link` overrides.
160
+ You may omit it entirely. Labels can be overridden with `about`, `company`, `articles`,
161
+ `empty`; `includeStructuredData: false` lets your framework own JSON-LD insertion.
162
+ Content is treated as text, not executable HTML. Only HTTP(S) links are accepted;
163
+ unsafe custom article routes fail the build. JSON-LD escapes `<` against script breakout.
164
+ Language overrides use `translations.ja/en`, falling back per field to common values.
165
+ Draft articles, other locales and articles with a different author are excluded.
166
+
167
+ ### CMS URL and link targets
168
+
169
+ When a Space workspace has a CMS URL, the delivery API rewrites article links
170
+ before returning HTML or JSON. Use the article base URL (for example,
171
+ `https://example.com/en/blog/`). Workspace references use that base plus the
172
+ published page-tree path. Links with the same hostname become root-relative;
173
+ other HTTP(S) links retain an absolute URL and carry `target="_blank"` with
174
+ `rel="noopener noreferrer"`. The renderer preserves these safe external targets.
175
+ Fragments, email and telephone links retain their normal behavior. Consumers
176
+ with custom renderers should honor these link attributes. An empty CMS URL
177
+ retains existing API link behavior. Update the build after changing the URL.
178
+
179
+ ### Heading IDs and table-of-contents links
180
+
181
+ TOC links percent-encode the heading ID once (for example, `#%E3%81%BE%E3%81%A8%E3%82%81`).
182
+ Valid explicit heading IDs are preserved. To retain an existing site's generation
183
+ rules for headings without IDs, pass `headingId` to `renderArticleBody`:
184
+
185
+ ```js
186
+ renderArticleBody(body, {
187
+ headingId: text => text.replace(/<[^>]+>/g, '').trim().toLowerCase()
188
+ .replace(/[\s/]+/g, '-').replace(/[^\p{Letter}\p{Number}-]/gu, '')
189
+ .replace(/-{2,}/g, '-') || 'heading',
190
+ });
191
+ ```
192
+
193
+ Return an unencoded ID containing only Unicode letters, numbers, `_` or `-`.
194
+ Explicit IDs take priority over generated IDs; collisions receive numeric suffixes.
195
+ Without the option, the SDK's existing generation rules remain unchanged.
196
+
197
+ ### Paragraph spacing in tables and lists
198
+
199
+ Import the optional body stylesheet after your site's typography CSS and add
200
+ `space-content` to the element containing the rendered article:
201
+
202
+ ```js
203
+ import '@accelflow/space-content/content.css';
204
+ ```
205
+ ```html
206
+ <article class="space-content">…sanitized article HTML…</article>
207
+ ```
208
+
209
+ Single paragraphs directly inside table cells or list items have no outside
210
+ margin. Consecutive paragraphs keep a `0.5em` gap. Empty paragraphs retain a line
211
+ of height; `<br>`, nested lists and paragraph nodes are not removed. Other
212
+ paragraphs and content outside this wrapper are unchanged. Customize the gap:
213
+
214
+ ```css
215
+ .space-content { --space-content-paragraph-gap: 0.75em; }
216
+ ```
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@accelflow/space-content",
3
- "version": "0.1.0-beta.2",
3
+ "version": "0.1.0-beta.4",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -18,7 +18,13 @@
18
18
  "./render": {
19
19
  "types": "./render.d.mts",
20
20
  "import": "./render.mjs"
21
- }
21
+ },
22
+ "./profile": {
23
+ "types": "./profile.d.mts",
24
+ "import": "./profile.mjs"
25
+ },
26
+ "./profile.css": "./profile.css",
27
+ "./content.css": "./content.css"
22
28
  },
23
29
  "scripts": {
24
30
  "test": "node --test test/*.test.mjs",
@@ -34,7 +40,11 @@
34
40
  "toc.css",
35
41
  "LICENSE",
36
42
  "render.mjs",
37
- "render.d.mts"
43
+ "render.d.mts",
44
+ "profile.mjs",
45
+ "profile.d.mts",
46
+ "profile.css",
47
+ "content.css"
38
48
  ],
39
49
  "description": "Build-time Headless CMS content sync and customizable table of contents for Accelflow Space",
40
50
  "license": "MIT",
@@ -45,12 +55,16 @@
45
55
  "bugs": {
46
56
  "url": "https://github.com/Accelflow/space-content-sdk/issues"
47
57
  },
48
- "homepage": "https://github.com/Accelflow/space-content-sdk#readme",
58
+ "homepage": "https://space.accelflow.jp/en/",
49
59
  "publishConfig": {
50
60
  "access": "public",
51
61
  "tag": "beta"
52
62
  },
53
63
  "dependencies": {
54
64
  "sanitize-html": "^2.18.0"
65
+ },
66
+ "author": {
67
+ "name": "Accelflow Inc.",
68
+ "url": "https://accelflow.jp/"
55
69
  }
56
70
  }
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 CHANGED
@@ -1,6 +1,8 @@
1
1
  export interface ContentNode { type: string; text?: string; attrs?: Record<string, unknown>; marks?: Array<{type:string;attrs?:Record<string,unknown>}>; content?: ContentNode[]; }
2
2
  export interface ContentAsset {id:string;hash:string;contentType:string;filename:string;}
3
3
  export interface RenderOptions {
4
+ /** Used only when a heading has no valid explicit ID. Return an unencoded ID. */
5
+ headingId?: (text:string)=>string;
4
6
  assets?: ContentAsset[]; locale?: 'ja'|'en'; pdfMode?: 'link'|'embed';
5
7
  renderers?: Record<string,(context:{node:ContentNode;children:string;defaultHtml:string;escapeHtml:typeof escapeHtml})=>string>;
6
8
  resolveArticle?: (attrs:Record<string,unknown>)=>{href:string;title?:string}|undefined;
package/render.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import sanitizeHtml from 'sanitize-html';
2
2
  import {renderTableOfContents} from './toc.mjs';
3
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"' : '';
4
5
  const hash = /^[a-f0-9]{64}$/;
5
6
  const raster = new Set(['image/png','image/jpeg','image/gif','image/webp','image/avif']);
6
7
  function assetsIndex(assets = []) {
@@ -24,7 +25,7 @@ export function sanitizeArticleHtml(html, {assets = [], pdfMode = 'link'} = {})
24
25
  nonBooleanAttributes: sanitizeHtml.defaults.nonBooleanAttributes.filter(a=>a!=='sandbox'),
25
26
  allowedTags: [...sanitizeHtml.defaults.allowedTags,'img','details','summary','nav',...(pdfMode==='embed'?['iframe']:[])],
26
27
  allowedAttributes: {
27
- '*':['id','class'], a:['href','title','data-level','download'],
28
+ '*':['id','class'], a:['href','title','data-level','download','target','rel'],
28
29
  img:['src','alt','title','width','height','loading','referrerpolicy'],
29
30
  nav:['data-table-of-contents','data-max-level','aria-label'], details:['open','data-space-toc'],
30
31
  li:['data-level','data-checked'], ol:['start'], td:['colspan','rowspan'], th:['colspan','rowspan','scope'],
@@ -33,6 +34,7 @@ export function sanitizeArticleHtml(html, {assets = [], pdfMode = 'link'} = {})
33
34
  allowedClasses:{'*':['*']},
34
35
  allowedSchemes:['https','http','mailto','tel'], allowedSchemesByTag:{img:['https'],iframe:[]}, allowProtocolRelative:false,
35
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'}:{})}};},
36
38
  iframe: (tagName, attrs) => ({tagName,attribs:{src:attrs.src??'',title:attrs.title??'PDF',sandbox:'',loading:'lazy',referrerpolicy:'no-referrer'}}),
37
39
  },
38
40
  exclusiveFilter: frame => frame.tag==='iframe' && !(pdfMode==='embed' && paths.get(frame.attribs.src)?.contentType==='application/pdf'),
@@ -42,16 +44,18 @@ export function sanitizeArticleHtml(html, {assets = [], pdfMode = 'link'} = {})
42
44
  export function renderArticleBody(body, options = {}) {
43
45
  if (typeof body==='string') return sanitizeArticleHtml(body,options);
44
46
  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;
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);
46
49
  const text=n=>n.type==='text'?String(n.text??''):(n.content??[]).map(text).join('');
47
50
  function inspect(n,depth=0) {
48
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);
49
53
  for(const child of n.content??[])inspect(child,depth+1);
50
54
  }
51
55
  inspect(body);
52
56
  function collect(n) {
53
57
  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});}
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});}
55
59
  for(const child of n.content??[])collect(child);
56
60
  }
57
61
  collect(body);
@@ -62,7 +66,7 @@ export function renderArticleBody(body, options = {}) {
62
66
  let html='';
63
67
  if(n.type==='text') {
64
68
  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)}"`);}}
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)}`);}}
66
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))}"`);
67
71
  else if(n.type==='tableOfContents') html=renderTableOfContents(headings,{locale:options.locale??'ja',maxLevel:a.maxLevel===2?2:3});
68
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">`:'';}
@@ -71,7 +75,7 @@ export function renderArticleBody(body, options = {}) {
71
75
  const src='/space-assets/'+asset.hash, label=e(asset.filename);
72
76
  html=raster.has(asset.contentType)?`<img src="${src}" alt="${label}" loading="lazy">`:`<a href="${src}" download="${label}">${label}</a>`;
73
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;
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);}
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);}
75
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;}
76
80
  else if(n.type==='smartLink') html=e(a.label??a.categoryLabel??'');
77
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('')));