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};