StockTagContent.tsx 3.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. 'use client';
  2. // D2 $종목 태그 렌더러 — 서버가 내려준 본문(HTML) 안의 `$005930(삼성전자)` 토큰을
  3. // 클릭 가능한 종목 배지(/stock/005930 링크)로 치환한다.
  4. // 파싱은 서버가 저장 시 수행하고, 프론트는 표시 토큰을 렌더만 한다(본문 원문 불변).
  5. // dangerouslySetInnerHTML 로 삽입된 HTML 의 텍스트 노드만 순회해 치환(스크립트/속성 미변조).
  6. import '@/app/styles/community.scss';
  7. import { useEffect, useRef } from 'react';
  8. interface Props {
  9. content: string;
  10. className?: string;
  11. }
  12. // $ + 6자리 숫자 + (선택) (종목명). 6자리 뒤에 숫자가 이어지면 코드로 보지 않음(경계, 서버 파서와 동일).
  13. const TAG_PATTERN = /\$(\d{6})(?!\d)(?:\(([^)]{0,20})\))?/g;
  14. export default function StockTagContent({ content, className }: Props)
  15. {
  16. const ref = useRef<HTMLDivElement>(null);
  17. useEffect(() => {
  18. const root = ref.current;
  19. if (!root) {
  20. return;
  21. }
  22. // 이미 배지로 치환된 노드는 건너뛰기 위해 앵커 내부 텍스트는 제외
  23. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
  24. acceptNode(node) {
  25. if (!node.nodeValue || !node.nodeValue.includes('$')) {
  26. return NodeFilter.FILTER_REJECT;
  27. }
  28. // 이미 종목 태그 앵커 안이면 스킵
  29. let parent = node.parentElement;
  30. while (parent && parent !== root) {
  31. if (parent.classList && parent.classList.contains('stock-tag')) {
  32. return NodeFilter.FILTER_REJECT;
  33. }
  34. parent = parent.parentElement;
  35. }
  36. return NodeFilter.FILTER_ACCEPT;
  37. }
  38. });
  39. const targets: Text[] = [];
  40. let current = walker.nextNode();
  41. while (current) {
  42. targets.push(current as Text);
  43. current = walker.nextNode();
  44. }
  45. for (const textNode of targets) {
  46. const text = textNode.nodeValue ?? '';
  47. TAG_PATTERN.lastIndex = 0;
  48. if (!TAG_PATTERN.test(text)) {
  49. continue;
  50. }
  51. TAG_PATTERN.lastIndex = 0;
  52. const frag = document.createDocumentFragment();
  53. let lastIndex = 0;
  54. let match: RegExpExecArray|null;
  55. while ((match = TAG_PATTERN.exec(text)) !== null) {
  56. const [full, code, name] = match;
  57. if (match.index > lastIndex) {
  58. frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
  59. }
  60. const anchor = document.createElement('a');
  61. anchor.href = `/stock/${code}`;
  62. anchor.className = 'stock-tag';
  63. const nameSpan = document.createElement('span');
  64. nameSpan.textContent = name ? name : code;
  65. anchor.appendChild(nameSpan);
  66. const codeSpan = document.createElement('span');
  67. codeSpan.className = 'stock-tag__code';
  68. codeSpan.textContent = code;
  69. anchor.appendChild(codeSpan);
  70. frag.appendChild(anchor);
  71. lastIndex = match.index + full.length;
  72. }
  73. if (lastIndex < text.length) {
  74. frag.appendChild(document.createTextNode(text.slice(lastIndex)));
  75. }
  76. textNode.parentNode?.replaceChild(frag, textNode);
  77. }
  78. }, [content]);
  79. return (
  80. <div
  81. ref={ref}
  82. className={className}
  83. dangerouslySetInnerHTML={{ __html: content }}
  84. ></div>
  85. );
  86. }