@limcpf/everything-is-a-markdown 0.6.0 → 0.6.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/README.ko.md CHANGED
@@ -78,6 +78,13 @@ const config = {
78
78
  pathBase: "/blog",
79
79
  defaultOgImage: "/assets/og.png",
80
80
  },
81
+ markdown: {
82
+ mermaid: {
83
+ enabled: true,
84
+ cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
85
+ theme: "default",
86
+ },
87
+ },
81
88
  };
82
89
 
83
90
  export default config;
@@ -173,6 +180,37 @@ title: Work In Progress
173
180
  ---
174
181
  ```
175
182
 
183
+ ## Mermaid 다이어그램 지원
184
+
185
+ 코드 블록의 언어를 `mermaid`로 작성하면 브라우저에서 Mermaid 다이어그램으로 렌더링합니다.
186
+
187
+ ````md
188
+ ```mermaid
189
+ flowchart LR
190
+ A --> B
191
+ ```
192
+ ````
193
+
194
+ 문서 전환 시에도 해당 블록을 다시 렌더링합니다. 설정에서 Mermaid를 비활성화하면 소스 코드 블록 그대로 표시됩니다.
195
+
196
+ `blog.config.ts`에서 설정:
197
+
198
+ ```ts
199
+ markdown: {
200
+ mermaid: {
201
+ enabled: true, // false면 코드 블록만 표시
202
+ cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js", // http/https 또는 /, ./, ../ 경로
203
+ theme: "default", // mermaid.initialize({ theme }) 값
204
+ },
205
+ },
206
+ ```
207
+
208
+ 유효성 검증 및 런타임 가드레일:
209
+
210
+ - `markdown.mermaid.cdnUrl`에 잘못된 값(예: `javascript:`)이 들어오면 빌드 시 기본 CDN URL로 자동 폴백합니다.
211
+ - `markdown.mermaid.theme`이 유효한 식별자 형식이 아니면 빌드 시 `default`로 자동 폴백합니다.
212
+ - 런타임 로더는 실패 후 남은 Mermaid 스크립트를 정리하고, 중복 삽입을 피하며, 다음 렌더에서 재시도합니다.
213
+
176
214
  ## bunx 실행 (선택)
177
215
 
178
216
  ```bash
package/README.md CHANGED
@@ -78,6 +78,13 @@ const config = {
78
78
  pathBase: "/blog",
79
79
  defaultOgImage: "/assets/og.png",
80
80
  },
81
+ markdown: {
82
+ mermaid: {
83
+ enabled: true,
84
+ cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
85
+ theme: "default",
86
+ },
87
+ },
81
88
  };
82
89
 
83
90
  export default config;
@@ -173,6 +180,37 @@ title: Work In Progress
173
180
  ---
174
181
  ```
175
182
 
183
+ ## Mermaid Diagram Support
184
+
185
+ This project supports Mermaid diagrams written as fenced code blocks:
186
+
187
+ ````
188
+ ```mermaid
189
+ flowchart LR
190
+ A --> B
191
+ ```
192
+ ````
193
+
194
+ Rendering happens in the browser on first load and when navigating to another document. If Mermaid is disabled in config or CDN loading fails, the source block is shown as-is and a warning message appears.
195
+
196
+ Configuration options (`blog.config.ts`):
197
+
198
+ ```ts
199
+ markdown: {
200
+ mermaid: {
201
+ enabled: true, // false to keep only source blocks
202
+ cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js", // CDN URL (http/https or /, ./, ../)
203
+ theme: "default", // passed to mermaid.initialize({ theme })
204
+ },
205
+ },
206
+ ```
207
+
208
+ Validation and runtime guardrails:
209
+
210
+ - Invalid `markdown.mermaid.cdnUrl` values (for example `javascript:`) automatically fall back to the default CDN URL at build time.
211
+ - Invalid `markdown.mermaid.theme` values automatically fall back to `default` at build time.
212
+ - Runtime loader removes stale Mermaid script tags after failures, avoids duplicate injection, and retries cleanly on the next render.
213
+
176
214
  ## bunx (Optional)
177
215
 
178
216
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
package/src/build.ts CHANGED
@@ -868,6 +868,7 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
868
868
  siteTitle: resolveSiteTitle(options),
869
869
  pathBase: options.seo?.pathBase ?? "",
870
870
  defaultBranch: DEFAULT_BRANCH,
871
+ mermaid: options.mermaid,
871
872
  branches,
872
873
  ui: {
873
874
  newWithinDays: options.newWithinDays,
package/src/config.ts CHANGED
@@ -25,8 +25,53 @@ const DEFAULTS = {
25
25
  imagePolicy: "omit-local" as const,
26
26
  gfm: true,
27
27
  shikiTheme: "github-dark",
28
+ mermaid: {
29
+ enabled: true,
30
+ cdnUrl: "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
31
+ theme: "default",
32
+ },
28
33
  };
29
34
 
35
+ const MERMAID_URL_MAX_LENGTH = 1024;
36
+ const MERMAID_THEME_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
37
+ const MERMAID_CDN_URL_PATTERN = /^(https?:\/\/|\/|\.{1,2}\/)[^\s"'><]+$/;
38
+
39
+ function normalizeMermaidEnabled(value: unknown): boolean {
40
+ return typeof value === "boolean" ? value : DEFAULTS.mermaid.enabled;
41
+ }
42
+
43
+ function normalizeMermaidCdnUrl(value: unknown): string {
44
+ if (typeof value !== "string") {
45
+ return DEFAULTS.mermaid.cdnUrl;
46
+ }
47
+
48
+ const normalized = value.trim();
49
+ if (!normalized) {
50
+ return DEFAULTS.mermaid.cdnUrl;
51
+ }
52
+
53
+ if (normalized.length > MERMAID_URL_MAX_LENGTH || !MERMAID_CDN_URL_PATTERN.test(normalized)) {
54
+ console.warn(`[config] invalid mermaid.cdnUrl: ${JSON.stringify(value)}. fallback to default ${JSON.stringify(DEFAULTS.mermaid.cdnUrl)}.`);
55
+ return DEFAULTS.mermaid.cdnUrl;
56
+ }
57
+
58
+ return normalized;
59
+ }
60
+
61
+ function normalizeMermaidTheme(value: unknown): string {
62
+ if (typeof value !== "string") {
63
+ return DEFAULTS.mermaid.theme;
64
+ }
65
+
66
+ const normalized = value.trim();
67
+ if (!MERMAID_THEME_PATTERN.test(normalized)) {
68
+ console.warn(`[config] invalid mermaid.theme: ${JSON.stringify(value)}. fallback to default ${JSON.stringify(DEFAULTS.mermaid.theme)}.`);
69
+ return DEFAULTS.mermaid.theme;
70
+ }
71
+
72
+ return normalized;
73
+ }
74
+
30
75
  export function parseCliArgs(argv: string[]): CliArgs {
31
76
  const [first] = argv;
32
77
  const command = first === "build" || first === "dev" || first === "clean" ? first : "build";
@@ -250,6 +295,11 @@ export function resolveBuildOptions(
250
295
  imagePolicy: userConfig.markdown?.images ?? DEFAULTS.imagePolicy,
251
296
  gfm: userConfig.markdown?.gfm ?? DEFAULTS.gfm,
252
297
  shikiTheme: userConfig.markdown?.highlight?.theme ?? DEFAULTS.shikiTheme,
298
+ mermaid: {
299
+ enabled: normalizeMermaidEnabled(userConfig.markdown?.mermaid?.enabled),
300
+ cdnUrl: normalizeMermaidCdnUrl(userConfig.markdown?.mermaid?.cdnUrl),
301
+ theme: normalizeMermaidTheme(userConfig.markdown?.mermaid?.theme),
302
+ },
253
303
  seo,
254
304
  };
255
305
  }
package/src/markdown.ts CHANGED
@@ -16,6 +16,7 @@ export interface MarkdownRenderer {
16
16
  }
17
17
 
18
18
  const FENCE_LANG_RE = /^```([\w-+#.]+)/gm;
19
+ const MERMAID_LANG = "mermaid";
19
20
  type RenderRule = NonNullable<MarkdownIt["renderer"]["rules"]["fence"]>;
20
21
  type RenderRuleArgs = Parameters<RenderRule>;
21
22
  type RuleTokens = RenderRuleArgs[0];
@@ -28,6 +29,15 @@ function escapeMarkdownLabel(input: string): string {
28
29
  return input.replace(/[\[\]]/g, "");
29
30
  }
30
31
 
32
+ function escapeHtmlText(input: string): string {
33
+ return String(input)
34
+ .replace(/&/g, "&amp;")
35
+ .replace(/</g, "&lt;")
36
+ .replace(/>/g, "&gt;")
37
+ .replace(/"/g, "&quot;")
38
+ .replace(/'/g, "&#39;");
39
+ }
40
+
31
41
  function parseWikiInner(inner: string): { target: string; label?: string } {
32
42
  const [target, label] = inner.split("|").map((part) => part.trim());
33
43
  return { target, label: label || undefined };
@@ -107,6 +117,9 @@ async function loadFenceLanguages<L extends string, T extends string>(
107
117
  break;
108
118
  }
109
119
  if (match[1]) {
120
+ if (match[1].toLowerCase() === MERMAID_LANG) {
121
+ continue;
122
+ }
110
123
  langs.add(match[1].toLowerCase());
111
124
  }
112
125
  }
@@ -152,6 +165,25 @@ function createMarkdownIt<L extends string, T extends string>(
152
165
  const lang = parts[0]?.toLowerCase() || "text";
153
166
  const fileName = parts.slice(1).join(" ") || null;
154
167
 
168
+ if (lang === MERMAID_LANG) {
169
+ const source = escapeHtmlText(token.content);
170
+ const filename = fileName ? escapeHtmlAttr(fileName) : MERMAID_LANG;
171
+ return `<div class="code-block mermaid-block">
172
+ <div class="code-header">
173
+ <div class="code-dots">
174
+ <span class="dot dot-red"></span>
175
+ <span class="dot dot-yellow"></span>
176
+ <span class="dot dot-green"></span>
177
+ </div>
178
+ <span class="code-filename">${filename}</span>
179
+ <button class="code-copy" title="Copy code" data-code="${escapeHtmlAttr(token.content)}">
180
+ <span class="material-symbols-outlined">content_copy</span>
181
+ </button>
182
+ </div>
183
+ <pre class="mermaid">${source}</pre>
184
+ </div>`;
185
+ }
186
+
155
187
  let codeHtml: string;
156
188
  try {
157
189
  codeHtml = highlighter.codeToHtml(token.content, {
@@ -1098,6 +1098,35 @@ body.mobile-toggle-left .mobile-menu-toggle {
1098
1098
  tab-size: 2;
1099
1099
  }
1100
1100
 
1101
+ .viewer-content .code-block pre.mermaid {
1102
+ margin: 0;
1103
+ border: none;
1104
+ border-radius: 0;
1105
+ padding: 0;
1106
+ background: transparent !important;
1107
+ color: inherit;
1108
+ white-space: pre-wrap;
1109
+ word-break: break-word;
1110
+ }
1111
+
1112
+ .viewer-content .code-block pre.mermaid svg {
1113
+ width: 100%;
1114
+ height: auto;
1115
+ display: block;
1116
+ }
1117
+
1118
+ .viewer-content .mermaid-render-error {
1119
+ margin: 0;
1120
+ padding: 10px 16px;
1121
+ border: 1px solid rgba(244, 63, 94, 0.35);
1122
+ border-radius: 0 0 8px 8px;
1123
+ border-top: none;
1124
+ color: var(--latte-red);
1125
+ background: rgba(244, 63, 94, 0.08);
1126
+ font-size: 0.84rem;
1127
+ line-height: 1.45;
1128
+ }
1129
+
1101
1130
  .viewer-content pre {
1102
1131
  position: relative;
1103
1132
  margin: 1.5rem 0;
@@ -15,6 +15,19 @@ const DEFAULT_SITE_TITLE = "File-System Blog";
15
15
  const BRANCH_KEY = "fsblog.branch";
16
16
  const FOCUSABLE_SELECTOR =
17
17
  'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
18
+ const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js";
19
+ const MERMAID_DEFAULT_THEME = "default";
20
+ const MERMAID_SELECTOR = "pre.mermaid";
21
+ const MERMAID_ERROR_CLASS = "mermaid-render-error";
22
+ const MERMAID_THEME_VALIDATION_RE = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
23
+ const MERMAID_URL_VALIDATION_RE = /^(https?:\/\/|\/|\.{1,2}\/)[^\s"'<>]+$/;
24
+ const mermaidRuntime = {
25
+ initialized: false,
26
+ loadingPromise: null,
27
+ scriptElement: null,
28
+ lastCdnUrl: "",
29
+ lastTheme: "",
30
+ };
18
31
 
19
32
  function escapeHtmlAttr(input) {
20
33
  return String(input)
@@ -24,6 +37,212 @@ function escapeHtmlAttr(input) {
24
37
  .replace(/>/g, "&gt;");
25
38
  }
26
39
 
40
+ function resolveMermaidConfig(manifest) {
41
+ const mermaid = manifest?.mermaid;
42
+ return {
43
+ enabled: mermaid?.enabled !== false,
44
+ cdnUrl:
45
+ typeof mermaid?.cdnUrl === "string" && mermaid.cdnUrl.trim()
46
+ ? mermaid.cdnUrl.trim()
47
+ : MERMAID_CDN,
48
+ theme:
49
+ typeof mermaid?.theme === "string" &&
50
+ MERMAID_THEME_VALIDATION_RE.test(mermaid.theme.trim())
51
+ ? mermaid.theme.trim()
52
+ : MERMAID_DEFAULT_THEME,
53
+ };
54
+ }
55
+
56
+ function toAbsoluteUrl(value) {
57
+ try {
58
+ return new URL(value, window.location.href).href;
59
+ } catch {
60
+ return value;
61
+ }
62
+ }
63
+
64
+ function normalizeMermaidTheme(value) {
65
+ const normalized = typeof value === "string" ? value.trim() : "";
66
+ if (!normalized || !MERMAID_THEME_VALIDATION_RE.test(normalized)) {
67
+ return MERMAID_DEFAULT_THEME;
68
+ }
69
+ return normalized;
70
+ }
71
+
72
+ function normalizeMermaidUrl(value) {
73
+ const normalized = typeof value === "string" ? value.trim() : "";
74
+ if (!normalized || !MERMAID_URL_VALIDATION_RE.test(normalized)) {
75
+ return MERMAID_CDN;
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ function createMermaidLoadError(message) {
81
+ const paragraph = document.createElement("p");
82
+ paragraph.className = MERMAID_ERROR_CLASS;
83
+ paragraph.textContent = message;
84
+ return paragraph;
85
+ }
86
+
87
+ function removeMermaidErrorMessage(container) {
88
+ if (!(container instanceof HTMLElement)) {
89
+ return;
90
+ }
91
+
92
+ for (const message of container.querySelectorAll(`.${MERMAID_ERROR_CLASS}`)) {
93
+ message.remove();
94
+ }
95
+ }
96
+
97
+ function showMermaidError(preview, message) {
98
+ if (!(preview instanceof HTMLElement) || !(preview.parentElement instanceof HTMLElement)) {
99
+ return;
100
+ }
101
+
102
+ removeMermaidErrorMessage(preview.parentElement);
103
+ preview.parentElement.appendChild(createMermaidLoadError(message));
104
+ }
105
+
106
+ function parseMermaidNodes() {
107
+ const contentEl = document.getElementById("viewer-content");
108
+ if (!(contentEl instanceof HTMLElement)) {
109
+ return [];
110
+ }
111
+
112
+ return Array.from(contentEl.querySelectorAll(MERMAID_SELECTOR));
113
+ }
114
+
115
+ function resetMermaidNodes(nodes) {
116
+ for (const node of nodes) {
117
+ node.removeAttribute("data-mermaid-rendered");
118
+ if (node.parentElement instanceof HTMLElement) {
119
+ removeMermaidErrorMessage(node.parentElement);
120
+ }
121
+ }
122
+ }
123
+
124
+ async function loadMermaidLibrary(config) {
125
+ if (!config.enabled) {
126
+ return null;
127
+ }
128
+
129
+ const normalized = {
130
+ ...config,
131
+ theme: normalizeMermaidTheme(config.theme),
132
+ cdnUrl: normalizeMermaidUrl(config.cdnUrl),
133
+ };
134
+
135
+ if (window.mermaid) {
136
+ if (!mermaidRuntime.initialized || mermaidRuntime.lastTheme !== normalized.theme) {
137
+ window.mermaid.initialize({
138
+ startOnLoad: false,
139
+ theme: normalized.theme,
140
+ });
141
+ mermaidRuntime.initialized = true;
142
+ mermaidRuntime.lastTheme = normalized.theme;
143
+ }
144
+ return window.mermaid;
145
+ }
146
+
147
+ if (mermaidRuntime.loadingPromise) {
148
+ return mermaidRuntime.loadingPromise;
149
+ }
150
+
151
+ const expectedAbsoluteUrl = toAbsoluteUrl(normalized.cdnUrl);
152
+ const existingScript = document.getElementById("mermaid-runtime");
153
+ if (existingScript instanceof HTMLScriptElement) {
154
+ // 이전 로드가 비정상 종료된 스크립트 잔존을 막기 위해 재시도 전에 정리한다.
155
+ existingScript.remove();
156
+ mermaidRuntime.scriptElement = null;
157
+ mermaidRuntime.initialized = false;
158
+ mermaidRuntime.lastTheme = "";
159
+ mermaidRuntime.lastCdnUrl = "";
160
+ }
161
+
162
+ mermaidRuntime.loadingPromise = new Promise((resolve, reject) => {
163
+ let script = mermaidRuntime.scriptElement;
164
+ if (!(script instanceof HTMLScriptElement)) {
165
+ script = document.createElement("script");
166
+ script.id = "mermaid-runtime";
167
+ script.src = normalized.cdnUrl;
168
+ script.async = true;
169
+ script.crossOrigin = "anonymous";
170
+ mermaidRuntime.scriptElement = script;
171
+ mermaidRuntime.lastCdnUrl = expectedAbsoluteUrl;
172
+ }
173
+
174
+ const finalize = (error) => {
175
+ mermaidRuntime.loadingPromise = null;
176
+ if (error) {
177
+ if (mermaidRuntime.scriptElement instanceof HTMLScriptElement) {
178
+ mermaidRuntime.scriptElement.remove();
179
+ mermaidRuntime.scriptElement = null;
180
+ }
181
+ mermaidRuntime.initialized = false;
182
+ mermaidRuntime.lastTheme = "";
183
+ mermaidRuntime.lastCdnUrl = "";
184
+ reject(error);
185
+ return;
186
+ }
187
+ resolve(window.mermaid ?? null);
188
+ };
189
+
190
+ script.addEventListener("load", () => {
191
+ if (window.mermaid && (!mermaidRuntime.initialized || mermaidRuntime.lastTheme !== normalized.theme)) {
192
+ window.mermaid.initialize({
193
+ startOnLoad: false,
194
+ theme: normalized.theme,
195
+ });
196
+ mermaidRuntime.initialized = true;
197
+ mermaidRuntime.lastTheme = normalized.theme;
198
+ }
199
+ finalize();
200
+ });
201
+ script.addEventListener("error", () => {
202
+ finalize(new Error(`Mermaid 라이브러리 로드 실패: ${normalized.cdnUrl}`));
203
+ });
204
+
205
+ if (!script.isConnected) {
206
+ document.head.appendChild(script);
207
+ }
208
+ });
209
+
210
+ return mermaidRuntime.loadingPromise;
211
+ }
212
+
213
+ async function renderMermaidBlocks(config) {
214
+ const blocks = parseMermaidNodes();
215
+ if (blocks.length === 0) {
216
+ return;
217
+ }
218
+
219
+ resetMermaidNodes(blocks);
220
+
221
+ try {
222
+ const mermaid = await loadMermaidLibrary(config);
223
+ if (!mermaid) {
224
+ for (const block of blocks) {
225
+ showMermaidError(block, "Mermaid 렌더링이 비활성화되어 코드 블록을 그대로 표시합니다.");
226
+ }
227
+ return;
228
+ }
229
+ if (typeof mermaid.run === "function") {
230
+ await mermaid.run({ nodes: blocks });
231
+ return;
232
+ }
233
+ if (typeof mermaid.init === "function") {
234
+ await mermaid.init({ startOnLoad: false }, blocks);
235
+ return;
236
+ }
237
+ throw new Error("Mermaid 렌더러 API가 존재하지 않습니다.");
238
+ } catch (error) {
239
+ const message = `Mermaid 렌더링 실패: ${error instanceof Error ? error.message : String(error)}`;
240
+ for (const block of blocks) {
241
+ showMermaidError(block, message);
242
+ }
243
+ }
244
+ }
245
+
27
246
  function toSafeUrlPath(input) {
28
247
  const value = String(input);
29
248
  return value
@@ -1253,6 +1472,7 @@ async function start() {
1253
1472
  }
1254
1473
  manifest = await manifestRes.json();
1255
1474
  }
1475
+ const mermaidConfig = resolveMermaidConfig(manifest);
1256
1476
  const pathBase = normalizePathBase(manifest.pathBase);
1257
1477
  const siteTitle = resolveSiteTitle(manifest);
1258
1478
  const defaultBranch = normalizeBranch(manifest.defaultBranch) || DEFAULT_BRANCH;
@@ -1445,6 +1665,7 @@ async function start() {
1445
1665
  metaEl.innerHTML = renderMeta(doc);
1446
1666
  updateBacklinks(doc);
1447
1667
  navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1668
+ await renderMermaidBlocks(mermaidConfig);
1448
1669
  document.title = composeDocumentTitle(doc.title, siteTitle);
1449
1670
  if (viewerEl instanceof HTMLElement) {
1450
1671
  viewerEl.scrollTo(0, 0);
@@ -1470,6 +1691,7 @@ async function start() {
1470
1691
 
1471
1692
  updateBacklinks(doc);
1472
1693
  navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1694
+ await renderMermaidBlocks(mermaidConfig);
1473
1695
 
1474
1696
  document.title = composeDocumentTitle(doc.title, siteTitle);
1475
1697
  if (viewerEl instanceof HTMLElement) {
package/src/types.ts CHANGED
@@ -56,6 +56,11 @@ export interface UserConfig {
56
56
  engine?: "shiki";
57
57
  theme?: string;
58
58
  };
59
+ mermaid?: {
60
+ enabled?: boolean;
61
+ cdnUrl?: string;
62
+ theme?: string;
63
+ };
59
64
  };
60
65
  seo?: UserSeoConfig;
61
66
  }
@@ -73,6 +78,11 @@ export interface BuildOptions {
73
78
  imagePolicy: ImagePolicy;
74
79
  gfm: boolean;
75
80
  shikiTheme: string;
81
+ mermaid: {
82
+ enabled: boolean;
83
+ cdnUrl: string;
84
+ theme: string;
85
+ };
76
86
  seo: BuildSeoOptions | null;
77
87
  }
78
88
 
@@ -130,6 +140,11 @@ export interface Manifest {
130
140
  pathBase: string;
131
141
  defaultBranch: string;
132
142
  branches: string[];
143
+ mermaid: {
144
+ enabled: boolean;
145
+ cdnUrl: string;
146
+ theme: string;
147
+ };
133
148
  ui: {
134
149
  newWithinDays: number;
135
150
  recentLimit: number;