master
1/**
2 * All components in this file should sync with the components in `src/components/user`
3 */
4import { fileIcon } from '../utils/file-icon.ts';
5import { getLinkPreview } from '../utils/link-preview.ts';
6import type { IconifyJSON } from '@iconify/types';
7import { getIconData, iconToHTML, iconToSVG, stringToIcon } from '@iconify/utils';
8import type { Element, ElementContent, Text } from 'hast';
9import { fromHtml } from 'hast-util-from-html';
10import { h } from 'hastscript';
11import type { Child } from 'hastscript';
12import { readFile } from 'node:fs/promises';
13import path from 'node:path';
14
15async function detectInstalledCollections(root: string) {
16 try {
17 const packages = [];
18 const text = await readFile(path.resolve(root, './package.json'), {
19 encoding: 'utf8',
20 });
21 const { dependencies = {}, devDependencies = {} } = JSON.parse(text);
22 packages.push(...Object.keys(dependencies));
23 packages.push(...Object.keys(devDependencies));
24 const collections = packages
25 .filter((name) => name.startsWith('@iconify-json/'))
26 .map((name) => name.replace('@iconify-json/', ''));
27 return collections;
28 } catch (err) {
29 console.error(err);
30 }
31 return [];
32}
33
34const iconSets = await detectInstalledCollections(process.cwd());
35
36async function loadCollection(name: string) {
37 if (!iconSets.find((it) => it === name)) return;
38 const icons: IconifyJSON = JSON.parse(
39 await readFile(
40 path.resolve(process.cwd(), `./node_modules/@iconify-json/${name}/icons.json`),
41 {
42 encoding: 'utf8',
43 }
44 )
45 );
46 return icons;
47}
48
49const collections: Record<string, IconifyJSON> = {};
50for (const set of iconSets) {
51 const icons = await loadCollection(set);
52 if (icons) collections[set] = icons;
53}
54
55const Collapse = function (
56 props: {
57 title: string;
58 open?: true;
59 },
60 children: Child
61) {
62 const { title, open } = props;
63 const wrapperClassName =
64 'bg-base-100 border-base-content/25 collapse-arrow collapse my-4 border';
65 const titleClassName = 'collapse-title font-semibold';
66 const contentClassName = 'collapse-content min-w-0';
67
68 const inputNode = h('input', {
69 type: 'checkbox',
70 ...(open && { checked: true }),
71 });
72 const titleNode = h('div', { class: titleClassName }, title);
73 const contentNode = h('div', { class: contentClassName }, children);
74 return h('div', { class: wrapperClassName }, [inputNode, titleNode, contentNode]);
75};
76
77type ParsedFileNode =
78 | string
79 | {
80 name: string;
81 children: ParsedFileNode[];
82 };
83
84function isElementNode(value: ElementContent): value is Element {
85 return value.type === 'element';
86}
87
88function isTextNode(value: ElementContent): value is Text {
89 return value.type === 'text';
90}
91
92function extractText(value: ElementContent): string {
93 if (isTextNode(value)) {
94 return value.value;
95 }
96
97 if (isElementNode(value)) {
98 if (value.tagName === 'ul') return '';
99 return (value.children ?? []).map(extractText).join('');
100 }
101
102 return '';
103}
104
105function normalizeName(raw: string): string {
106 return raw.replace(/\s+/g, ' ').trim();
107}
108
109function parseListElement(list: Element): ParsedFileNode[] {
110 const items: ParsedFileNode[] = [];
111
112 for (const child of list.children ?? []) {
113 if (!isElementNode(child) || child.tagName !== 'li') continue;
114
115 let nested: ParsedFileNode[] = [];
116 const nameParts: string[] = [];
117
118 for (const itemChild of child.children ?? []) {
119 if (isElementNode(itemChild) && itemChild.tagName === 'ul') {
120 nested = parseListElement(itemChild);
121 continue;
122 }
123
124 const piece = extractText(itemChild);
125 if (piece.trim().length > 0) {
126 nameParts.push(piece);
127 }
128 }
129
130 const name = normalizeName(nameParts.join(' '));
131 if (!name) continue;
132
133 if (nested.length > 0) {
134 items.push({ name, children: nested });
135 } else {
136 items.push(name);
137 }
138 }
139
140 return items;
141}
142
143function renderFileNode(node: ParsedFileNode, open: boolean): Child {
144 if (typeof node === 'string') {
145 return h(
146 'li',
147 h('span', { class: 'cursor-auto' }, Icon({ name: fileIcon(node) }), ' ', node)
148 );
149 }
150
151 const nestedChildren =
152 node.children.length > 0
153 ? [h('ul', ...node.children.map((child) => renderFileNode(child, open)))]
154 : [];
155
156 return h(
157 'li',
158 h(
159 'details',
160 { ...(open ? { open: '' } : {}) },
161 h('summary', Icon({ name: 'mdi:folder' }), ' ', node.name),
162 ...nestedChildren
163 )
164 );
165}
166
167const FileTree = function (props: { open?: boolean }, children: ElementContent[]) {
168 const listElement = children.find(
169 (child): child is Element => isElementNode(child) && child.tagName === 'ul'
170 );
171
172 if (!listElement) {
173 console.warn('[WARN] FileTree directive expects a nested list as its content.');
174 return children;
175 }
176
177 const parsed = parseListElement(listElement);
178
179 if (parsed.length === 0) {
180 console.warn('[WARN] FileTree directive content is empty.');
181 return children;
182 }
183
184 return h(
185 'ul',
186 { class: 'menu menu-sm rounded-box border-base-content/25 w-full border' },
187 ...parsed.map((node) => renderFileNode(node, props.open ?? false))
188 );
189};
190
191const Icon = function (props: {
192 name: string;
193 size?:
194 | string
195 | {
196 width: string;
197 height: string;
198 };
199}) {
200 const { name, size } = props;
201 let width = '1.25em';
202 let height = '1.25em';
203 if (size) {
204 if (typeof size === 'string') {
205 width = size;
206 height = size;
207 } else {
208 width = size.width;
209 height = size.height;
210 }
211 }
212 const className = 'inline align-text-bottom';
213
214 const { prefix, name: iconName } = stringToIcon(name, true)!;
215 const collection = collections[prefix];
216 if (!collection) {
217 console.error(`'Icon set not found: '${prefix}'`);
218 return h('span', `'Icon set not found: '${prefix}'`);
219 }
220 const iconData = getIconData(collection, iconName);
221 if (!iconData) {
222 console.error(`Icon "${iconName}" not found in icon set '${prefix}'`);
223 return h('span', `Icon "${iconName}" not found in icon set '${prefix}'`);
224 }
225 const { attributes, body } = iconToSVG(iconData);
226 attributes.width = width;
227 attributes.height = height;
228 const iconHtml = iconToHTML(body, { class: className, ...attributes });
229 return h(
230 'span',
231 {},
232 {
233 type: 'raw',
234 value: iconHtml,
235 }
236 );
237};
238
239const LinkCard = async function (props: {
240 url: string;
241 title?: string;
242 description?: string;
243 siteName?: string;
244 image?: string;
245 favicon?: string;
246}) {
247 if (!props.url) {
248 console.error('LinkCard requires a "url" property.');
249 return h(
250 'a',
251 { class: 'card border-base-content/25 my-4 overflow-hidden border' },
252 'Link card error'
253 );
254 }
255
256 const preview = await getLinkPreview(props.url, {
257 title: props.title,
258 description: props.description,
259 siteName: props.siteName,
260 image: props.image,
261 favicon: props.favicon,
262 });
263
264 const contentNodes: Child[] = [];
265
266 if (preview.image) {
267 contentNodes.push(
268 h('figure', { class: 'flex-shrink-0' }, [
269 h('img', {
270 src: preview.image,
271 alt: preview.title || 'Link preview image',
272 class: 'h-24 w-32 object-cover',
273 loading: 'lazy',
274 }),
275 ])
276 );
277 }
278
279 const metaRowChildren: Child[] = [];
280 if (preview.favicon) {
281 metaRowChildren.push(
282 h('img', {
283 src: preview.favicon,
284 alt: preview.siteName || preview.title,
285 class: 'h-6 w-6 rounded object-cover',
286 loading: 'lazy',
287 })
288 );
289 }
290
291 metaRowChildren.push(
292 h('span', { class: 'card-title truncate' }, preview.title || preview.url)
293 );
294 metaRowChildren.push(
295 h(
296 'span',
297 { class: 'text-base-content/60 text-sm' },
298 preview.siteName || new URL(preview.url).hostname
299 )
300 );
301
302 const infoColumn = h(
303 'div',
304 { class: 'grid grid-rows-2 gap-2' },
305 h('div', { class: 'flex items-center gap-2' }, ...metaRowChildren),
306 h('p', { class: 'text-base-content/50 truncate' }, preview.description || '')
307 );
308
309 const collection = collections['material-symbols'];
310 if (!collection) {
311 console.error('LinkCard icon set not found: material-symbols');
312 const bodyNode = h('div', { class: 'card-body p-4' }, infoColumn);
313 contentNodes.push(bodyNode);
314 return h(
315 'a',
316 {
317 class: 'card card-side border-base-content/25 my-4 overflow-hidden border',
318 href: preview.url,
319 title: preview.title,
320 'data-link-card': '',
321 'data-url': preview.url,
322 'data-fetched-at': preview.fetchedAt,
323 rel: 'noopener noreferrer',
324 },
325 ...contentNodes
326 );
327 }
328
329 const iconData = getIconData(collection, 'arrow-right-alt-rounded');
330 if (!iconData) {
331 console.error('LinkCard icon not found: material-symbols:arrow-right-alt-rounded');
332 const bodyNode = h('div', { class: 'card-body p-4' }, infoColumn);
333 contentNodes.push(bodyNode);
334 return h(
335 'a',
336 {
337 class: 'card card-side border-base-content/25 my-4 overflow-hidden border',
338 href: preview.url,
339 title: preview.title,
340 'data-link-card': '',
341 'data-url': preview.url,
342 'data-fetched-at': preview.fetchedAt,
343 rel: 'noopener noreferrer',
344 },
345 ...contentNodes
346 );
347 }
348
349 const { attributes, body } = iconToSVG(iconData);
350 const svgAttributes: Record<string, string> = {
351 ...attributes,
352 width: '1.875rem',
353 height: '1.875rem',
354 'aria-hidden': 'true',
355 focusable: 'false',
356 };
357
358 const parsed = fromHtml(body, { fragment: true });
359 const svgNode = h('svg', svgAttributes, ...(parsed.children as Child[]));
360
361 const iconNode = h(
362 'div',
363 { class: 'flex items-center justify-center text-base-content/60' },
364 svgNode
365 );
366
367 const bodyNode = h(
368 'div',
369 { class: 'card-body grid grid-cols-[minmax(0,1fr)_auto] items-center p-4' },
370 [infoColumn, iconNode]
371 );
372
373 contentNodes.push(bodyNode);
374
375 return h(
376 'a',
377 {
378 class: 'card card-side border-base-content/25 my-4 overflow-hidden border',
379 href: preview.url,
380 title: preview.title,
381 'data-link-card': '',
382 'data-url': preview.url,
383 'data-fetched-at': preview.fetchedAt,
384 rel: 'noopener noreferrer',
385 },
386 ...contentNodes
387 );
388};
389
390const Ruby = function (props: { base: string; text: string }) {
391 const pairs = (() => {
392 const { base, text } = props;
393 const pattern = /(?<!\\)\|/g;
394 const baseGroups = base.split(pattern);
395 let textGroups = text.split(pattern);
396 if (baseGroups.length > textGroups.length) {
397 console.warn('[WARN] Invalid ruby, base splitter number should lesser than text.');
398 console.warn(` base: "${base}"`);
399 console.warn(` text: "${text}"`);
400 return [{ base, text }];
401 }
402 textGroups[baseGroups.length - 1] = textGroups.slice(baseGroups.length - 1).join(' ');
403 textGroups = textGroups.slice(0, baseGroups.length);
404 return baseGroups.map((b, i) => ({ base: b, text: textGroups[i] }));
405 })();
406 return h(
407 'ruby',
408 {},
409 pairs.flatMap(
410 ({ base, text }) =>
411 [
412 { type: 'text', value: base },
413 h('rp', {}, '('),
414 h('rt', {}, text),
415 h('rp', {}, ')'),
416 ] as Child
417 )
418 );
419};
420
421const Tooltip = function (
422 props: {
423 tip: string;
424 position?: 'top' | 'bottom' | 'left' | 'right';
425 },
426 children: Child
427) {
428 const { tip, position } = props;
429 const wrapperClassName = 'tooltip tooltip-' + (position || 'top');
430 return h('div', { class: wrapperClassName, 'data-tip': tip }, children);
431};
432
433export const rehypeComponentsList = {
434 collapse: Collapse,
435 filetree: FileTree,
436 icon: Icon,
437 linkcard: LinkCard,
438 rubyc: Ruby,
439 tooltip: Tooltip,
440};