diff --git a/CHANGELOG.md b/CHANGELOG.md index c84754ed..a3fb2803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 印度占星 Skill 更新日志 +## 2026-09-09 — 长报告星盘改成两栏卡片,滚动时不再整页重建 + +个人长报告里的本命盘、婚盘、月亮盘和 Vargas I 不再叠在同一块:每张盘和它的标题进一张卡片,连续的卡片走原来的两栏网格;窄屏和打印仍是单栏。滚动目录高亮时,正文里的图不会被拆掉重画。导出的 `.md` 仍带引擎 SVG、不含网页用的 JSON 围栏。Skill 版本不变。 + + ## 2026-09-09 — 首页「深入看今日」算完会写出三节回答,思考区不再漏英文词渣 从首页点「深入看今日」时,按「时运」计算,不再被改成「综合」。正文按三节写:今日趋势、适合推进 / 需要避开、一个行动。某一节没写出字会再试一次;分段写作时不能再调排盘工具。思考区按整句过滤,不会再出现缺词英文或 `run-jyotish-` 残段。空回答仍然不扣点。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0cb1177d..8999b95d 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9426,7 +9426,7 @@ - 修复:引擎在每张 SVG 旁追加 `jyotish-chart` 围栏 JSON;阅读页只认围栏,zod 校验后用北印式菱形组件自绘。导出 `.md` 前剥掉围栏,保留 SVG 给外部阅读器。 - 验证:`tests/test_report_chart_block.py` 7 项(22 块围栏 == 22 个 ``,度数/星座/逆行与 core_chart 一致,且该文件在 `CORE_PYTEST_TARGETS`);`frontend/tests/report-chart-block.test.ts`、`frontend/tests/personal-report-markdown-view.test.tsx`(组件 SVG 有 `role="img"`,不含引擎 `viewBox="0 0 420 480"`,坏块显示「图盘数据无效」且无 ` code.language-jyotish-chart`」包进 `.personal-report-chart-card`,相邻卡片再包进既有的 `.personal-report-chart-grid`(单张加 `is-single`)。不放开 `skipHtml`,不引入 `rehype-raw`。 +- 验证:`frontend/tests/personal-report-markdown-view.test.tsx`:3 对连续标题+围栏(第 3 对夹一段 `p`)得到 1 个 grid / 3 张卡,卡内顺序为 `h4` →(`p`)→ `figure`,后面的普通 `h4` 不在卡内;单对带 `is-single`;golden 22 张 `figure` 全在卡内;`globals.css` 中同时含 `personal-report-chart` 与 `float:` / `margin-left: -50%` 的规则为 0。 +- 防复发:布局合同走 DOM 结构(grid/card 包裹)和 CSS 禁令,不只看「有没有 ``」。 +- 相关记录:BUG-607、`TASK-report-chart-layout-fix-20260909.md` +- 复发自:无(BUG-607 的两栏浮动方案回归,不是「标题下没图」复发) +- 修复版本:待发布 + +## BUG-617 | 滚动报告时整篇 Markdown 卸载重建,页面卡顿 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-09 +- 影响面:`personal-report-markdown-view.tsx` 目录高亮与文章树 +- 用户现象:长报告页(尤其 Vargas I 19 张盘揭开后)一滚动就明显卡,像整页在闪。 +- 触发条件:打开含星盘围栏的长报告,滚动使目录高亮从一个 `h2`/`h3` 切到下一个。 +- 根因:`activeId` 放在 `PersonalReportMarkdownView` 顶层,IntersectionObserver 每次切换都 `setActiveId`。`markdownComponents()` 每渲染新建一套 `h2`/`h3`/`h4`/`pre` 函数;React 按元素 type 引用比较,type 变了就卸载重建整棵子树。09-06 Markdown 直渲时已是这个结构,当时没有 SVG,代价没被感知;22 张盘进来后每次滚过一个标题就销毁再创建全部多边形和 ``。 +- 修复:目录高亮下沉到 `ReportToc`;`PersonalReportMarkdownView` 不再持有滚动态。lead 与 `LazyMarkdownSection` 的 `renderMarkdown` 结果整树 `useMemo`,`LazyMarkdownSection` 本身 `memo()`。 +- 验证:源码合同:`renderMarkdown(` 只在 `useMemo` 内调用;`PersonalReportMarkdownView` 函数体没有 `useState`。本仓测试是 `renderToStaticMarkup`,数不了重渲染次数;真机条目写在 `docs/testing/report-chart-render-20260909.md` 第 7 节。 +- 防复发:文章树不得订阅目录高亮 state。`components` 映射可以每次 `renderMarkdown` 新建(id 去重 cursor 需要),但调用点必须被 `useMemo` 包住。 +- 相关记录:BUG-616、`TASK-report-md-page-20260906.md`、`TASK-report-chart-layout-fix-20260909.md` +- 复发自:无(09-06 直渲结构在星盘进入后才变成可感知卡顿) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-report-chart-layout-fix-20260909.md b/docs/tasks/PROGRESS-report-chart-layout-fix-20260909.md new file mode 100644 index 00000000..3a57350d --- /dev/null +++ b/docs/tasks/PROGRESS-report-chart-layout-fix-20260909.md @@ -0,0 +1,91 @@ +# PROGRESS · 报告页星盘重叠 + 滚动重建(2026-09-09) + +- 工作树:`.worktrees/report-chart-layout-fix-20260909` +- 分支:`codex/report-chart-layout-fix-20260909`(track `origin/staging`) +- 基线:`origin/staging` @ `584a56bd`(任务书本身;代码基线 `17b36f3a`) +- BUG:**BUG-616**、**BUG-617** +- 未改 `.gitea/workflows/**`、`frontend/src/app/page.tsx`、下载剥围栏;不提升 `main`,未 push + +## 做了什么 + +| 任务 | 状态 | 说明 | +| --- | --- | --- | +| 1 两栏成组 | 完成 | `report-chart-grid-rehype.ts` 把 D… / Moon Chart 标题 + 可选 `p` + `jyotish-chart` 围栏包进 `.personal-report-chart-card`,相邻卡进既有 `.personal-report-chart-grid`;删 float / `margin-left: -50%` | +| 2 滚动不重建 | 完成 | `ReportToc` 持有 `activeId` 与 observer;lead / `LazyMarkdownSection` 的 `renderMarkdown` 在 `useMemo` 内;`LazyMarkdownSection` `memo()` | +| 3 记录 | 完成 | BUG-616/617、CHANGELOG、DESIGN、走查清单第 7 节、本文件;任务板改「待验收」 | + +没有砍让步项。rehype 两栏一次做成,未退到 D2 单栏。 + +## 口径(与任务书一致) + +- 两栏保留,不再用浮动凑。 +- `skipHtml` / 禁止 `rehype-raw` / 不新增 npm 依赖。 +- 下载 `.md` 仍剥围栏、保留标题。 +- 本仓测试无 DOM 运行时,重渲染次数不能自动验;真机两条写在走查清单第 7 节。 + +## 组件树(改动前后) + +改前: + +``` +PersonalReportMarkdownView + useState(activeId) + IntersectionObserver ← 目录一变,整树重渲染 + renderMarkdown(lead) ← 每次新建 components,子树卸载 + LazyMarkdownSection (无 memo) + renderMarkdown(section) ← 同上 +``` + +改后: + +``` +PersonalReportMarkdownView ← 无 useState,只随 markdown 变 + ReportToc + useState(activeId) + IntersectionObserver + TocList × 2 + article + lead = useMemo(renderMarkdown) + memo(LazyMarkdownSection) + content = useMemo(renderMarkdown | h2 占位) +``` + +滚动只重绘 `ReportToc`。文章树里的 SVG 保持同一组 DOM 节点(真机第 7 节)。 + +## 偏离 + +1. **两栏折断点跟既有 document-view grid,是 760px 而不是任务书正文里的 860px。** `globals.css` 里 `.personal-report-chart-grid` 的单栏媒体查询一直是 `max-width: 760px`;860px 只折叠 TOC。未改这个既有数字。 +2. **rehype 插件导出了 `groupReportChartNodes`。** 给同一文件的工厂函数用,测试仍走 `PersonalReportMarkdownView` 的静态标记,没有另开 hast 单测。 + +## 测试数字 + +| 项 | 本轮实测 | 结论 | +| --- | --- | --- | +| `tsc --noEmit` | 0 错 | 通过 | +| `npm run lint` | 0 error / 108 warning | warning 数与 BUG-607 收工相同;新文件无 error | +| `tsx --test tests/personal-report-markdown-view.test.tsx` | 6 passed | 原 1 条 + 本单 5 条 | +| `globals.css` gzip | 开工 `35791` B → 工作树 `35612` B | −179 B / −0.50%,在 ±2% 内 | +| `next build --webpack` `/` | `○ Static` | 交叉 `node_modules` 符号链接,与 BUG-607 相同改用 webpack | +| 首页 CSS gzip(构建产物) | 最大块 `a083d2bd2a5e75cb.css` = 36,083 B | 相对 BUG-607 的 35,779 B 为 +0.85%,在 ±2% 内 | +| `npm test` tests | 3008 | 高于 BUG-607 的 2975(staging 后来加了测试,本单 +5) | +| `npm test` pass | 2997 | | +| `npm test` fail | 11 | 全是 Docker Postgres fixture / 迁移 / 超时 / onboarding deadline flake,与图盘无关 | +| `npm test` skipped | 0 | Docker 可用 | + +11 条失败(均非本轮图盘代码): + +1. `admin-database.test.ts` — `database migration failed` +2. `database-admin-account-reset.test.ts` — 同上 +3. `database-admin-owner-recovery.test.ts` — 同上 +4. `database-billing-audit.test.ts` — 同上(billing order adjustments…) +5. `database-billing-product-admin.test.ts` — 300s 超时(当时并行跑过 `next build`) +6. `database-migrate.test.ts` — 300s 超时(同上) +7. `database-self-hosted-identity.test.ts` — Better Auth OTP fixture +8. `onboarding-route.test.ts` — `slow Agent generation` 把 `deadline` 当成了失败(flake) +9–11. `rectification-v9-database.test.ts` 三条 — `database migration failed` + +无 Docker 基线 28 条本轮未复跑(Docker 可用)。图盘相关 6 条 markdown-view + 既有 `report-chart-block.test.ts` 全绿。 + +## 没做的 + +- **没有在浏览器里打开过一份真实 `/reports/`。** 无登录态。走查清单第 4 节和第 7 节待真机。 +- 未 push。代码进 staging 会走 `Staging Backend Quality Gate`。 +- 收工时 `origin/staging` 已含 BUG-614~615、BUG-618~620;合并写在本提交之后。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 4aac237c..a7002cf5 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -121,7 +121,7 @@ | `TASK-report-list-500-20260907.md`(仓库根) | `PROGRESS-report-list-500-20260907.md` | 列表 PostgREST JSON 路径 500 | 待验收 | `b466a6fc`(BUG-574) | | — | `PROGRESS-report-progress-20260909.md` | 生成等待屏只有 spinner 与秒表:后端 `progressPercent` / `progressPhase` 与分章行已产出,前端解析后一字未渲染,且分章行在 `generating` 时根本不出服务端。改为按章分格进度条 + 章节清单,停滞 90 秒改「用时较长,仍在写」;不画百分比条、不做插值动画、不报预计剩余 | 已验收 | `848e39e6`、`5565b632`(BUG-601) | | `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `` 被 `skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 已验收(2fdcb14f):tsc/lint 0 error、chart 测试 29/29、`/` Static、gzip +0.96%;围栏构造在 try 外的 P2 并入 `TASK-upstream-sync2-fix-20260909.md` 任务 3 | `codex/report-chart-render-20260909`(BUG-607) | -| `TASK-report-chart-layout-fix-20260909.md` | — | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待领取 | 分支 `codex/report-chart-layout-fix-20260909`(BUG-616/617) | +| `TASK-report-chart-layout-fix-20260909.md` | `PROGRESS-report-chart-layout-fix-20260909.md` | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待验收 | `codex/report-chart-layout-fix-20260909`(BUG-616/617) | ### 前端基础与工程 diff --git a/docs/testing/report-chart-render-20260909.md b/docs/testing/report-chart-render-20260909.md index 9139fef4..e75e6f67 100644 --- a/docs/testing/report-chart-render-20260909.md +++ b/docs/testing/report-chart-render-20260909.md @@ -25,8 +25,8 @@ ## 4. 手机与打印 -- [ ] iPhone Safari 竖屏:图单列、居中,不撑出左右滚动(宽表除外)。 -- [ ] 桌面 ≥720px:Vargas I 的图是两列。 +- [ ] iPhone Safari 竖屏:图单列,不撑出左右滚动(宽表除外),标题在卡内、图在标题下,互不重叠。 +- [ ] 桌面宽于 760px:Vargas I 的图是两列卡片,标题在各自卡顶,没有叠盘、没有标题堆成一列。 - [ ] 打印预览:图仍是单列,不会变成两列挤在纸上。 ## 5. 导出 `.md` @@ -37,4 +37,11 @@ - [ ] 打开一份 09-06 之后、本修复之前生成的报告:标题还在,不报错,不出现「图盘数据无效」(因为根本没有围栏块)。图可以没有。 -未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON,或暗色主题下图看不见,把现象和报告页截图贴回。 +## 7. 滚动不重建(BUG-617) + +本仓前端测试是 `renderToStaticMarkup`,没有 DOM 运行时,数不了 React 重渲染。下面两条必须在真机(Chrome)上对照一份 Vargas I 已全部揭开的新报告。 + +- [ ] Vargas I 全部揭开后连续滚动 10 秒:Chrome Performance 没有 >100ms 的长帧。 +- [ ] Elements 面板选中 D1 的 `` 之后滚动、目录高亮切换:选中不丢,该 `` DOM 节点还是同一个。 + +未打勾的项写成「未验证」。发现南印度方格、空白标题、导出文件里大段 JSON、暗色主题下图看不见、盘重叠,或滚动时 Elements 里 SVG 被替换,把现象和报告页截图贴回。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index ecb9a865..895edaf0 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -357,7 +357,7 @@ Text release is paced, not animated: the frame buffer commits at most once per a - **Structure:** sticky screen chrome (back, secondary print, primary “导出报告(.md)”), then the longform Markdown body with a sticky heading TOC. The five-chapter writer document is storage-only and is not rendered. Reports without Markdown show “旧版本报告,请重新生成”. - **Surface:** page floor `--color-canvas-soft`; the Markdown article is a `--color-canvas` sheet with a hairline and `--radius-lg`. Print flattens the sheet, hides chrome and the TOC, and pins the light palette. - **Typography:** Markdown `h2` uses `--type-display-sm` serif at weight 400. `h3` uses `--type-title-md` sans at weight 500. Body is `--type-body-md` at 1.65. Honesty labels (`blocked` / `conflict` / `parameter_sensitive`) stay visible; the reading-nav and quality-matrix sections are not collapsed. -- **Charts:** each `####` chart heading is followed by a North Indian diamond figure, not engine SVG HTML. Figures are max 360px and centered. From 720px, consecutive heading+figure pairs sit two to a row; print and narrower viewports stay one column. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. +- **Charts:** a rehype pass groups each `####` chart heading (optional note paragraph, then the `jyotish-chart` fence) into `.personal-report-chart-card` and consecutive cards into the existing `.personal-report-chart-grid`. A lone card uses `is-single`. The grid is two columns; print and the 760px breakpoint stay one column. Do not float chart headings or figures, and do not use negative margins to fake columns. Occupants are one-character Chinese names plus integer degree (`日 12°`), with `逆` after the name when retrograde (`土逆 3°`). House corners show rasi numbers 1–12. Chart JSON fences are stripped from the `.md` download. Markdown still uses `react-markdown` + `remark-gfm`, `skipHtml`, and no `rehype-raw`. The article tree is memoized; TOC highlight state lives in `ReportToc`, not in `PersonalReportMarkdownView`. - **Accent:** headings stay ink. Dark ink is never a page-scale rule or card edge. - **Tables:** wide Markdown tables sit in `overflow-x: auto` wrappers; print unwraps them. - **Width:** 1120px for the reader chrome so the TOC rail can sit beside the article; the TOC stacks into a drawer below 860px. diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 17b362c0..3683f4b1 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -3832,31 +3832,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class font-size: var(--type-title-sm); font-weight: 500; } -@media (min-width: 720px) { - /* Charts live in the eager 摘要 lead, not only later lazy H2 sections. */ - .personal-report-md-lead > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure), - .personal-report-md-section > h4.personal-report-chart-heading:has(+ .personal-report-chart-figure) { - float: left; - width: 50%; - box-sizing: border-box; - padding-inline: var(--space-3); - margin-top: var(--space-5); - margin-bottom: 0; - } - .personal-report-md-lead > h4.personal-report-chart-heading + .personal-report-chart-figure, - .personal-report-md-section > h4.personal-report-chart-heading + .personal-report-chart-figure { - float: left; - width: 50%; - margin-left: -50%; - margin-top: var(--space-5); - padding-top: 2.4em; - box-sizing: border-box; - max-width: none; - } - .personal-report-md-lead > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure), - .personal-report-md-section > .personal-report-chart-figure + :not(h4):not(.personal-report-chart-figure) { - clear: both; - } +.personal-report-md-article .personal-report-chart-grid { + margin: var(--space-5) 0 var(--space-6); +} +.personal-report-md-article .personal-report-chart-card h4 { + margin: 0; + font-size: var(--type-title-sm); + font-weight: 500; +} +.personal-report-chart-card .personal-report-chart-figure { + max-width: none; + margin: var(--space-3) 0 0; } @media (max-width: 860px) { .personal-report-md-layout { grid-template-columns: 1fr; } @@ -4418,23 +4404,18 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .personal-report-narrative p { font-size: 9.6pt; line-height: 1.72; orphans: 3; widows: 3; } .personal-report-lead-grid { grid-template-columns: minmax(0, 1.04fr) minmax(64mm, .96fr); gap: 8mm; } .personal-report-chart-svg { max-width: none; margin-top: 3mm; } - .personal-report-md-article .personal-report-chart-figure { - float: none; + .personal-report-md-article .personal-report-chart-card .personal-report-chart-figure { + max-width: none; width: auto; - max-width: 360px; - margin-left: auto; - margin-right: auto; + margin: 3mm 0 0; padding-top: 0; break-inside: avoid; } - .personal-report-md-article h4.personal-report-chart-heading { - float: none; - width: auto; - margin-left: auto; - margin-right: auto; - max-width: 360px; + .personal-report-md-article .personal-report-chart-card h4 { + max-width: none; padding-inline: 0; } + .personal-report-chart-card { break-inside: avoid; } .personal-report-chart-grid, .personal-report-chart-grid.is-single { grid-template-columns: 1fr; } .personal-report-chart-grid.is-single { max-width: 118mm; } diff --git a/frontend/src/components/personal-report/personal-report-markdown-view.tsx b/frontend/src/components/personal-report/personal-report-markdown-view.tsx index b35da9f5..65546c6c 100644 --- a/frontend/src/components/personal-report/personal-report-markdown-view.tsx +++ b/frontend/src/components/personal-report/personal-report-markdown-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { memo, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -10,6 +10,7 @@ import { parseReportChartBlock, toNorthIndianChart, } from "@/lib/report-chart-block"; +import { reportChartGrid } from "@/lib/report-chart-grid-rehype"; import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg"; import { buildLongformOutline, @@ -90,6 +91,7 @@ function renderMarkdown(markdown: string, headings: readonly LongformHeading[]) observer.disconnect(); }, [visible]); + const content = useMemo( + () => (visible ? renderMarkdown(section.markdown, section.headings) : ( +

{section.title}

+ )), + [visible, section], + ); return (
- {visible ? renderMarkdown(section.markdown, section.headings) : ( -

{section.title}

- )} + {content}
); -} +}); -export function PersonalReportMarkdownView({ markdown }: { markdown: string }) { - const outline = useMemo(() => buildLongformOutline(markdown), [markdown]); - const [activeId, setActiveId] = useState(outline.headings[0]?.id ?? ""); +function ReportToc({ headings }: { headings: readonly LongformHeading[] }) { + const [activeId, setActiveId] = useState(headings[0]?.id ?? ""); useEffect(() => { - if (outline.headings.length === 0 || typeof IntersectionObserver === "undefined") return; + if (headings.length === 0 || typeof IntersectionObserver === "undefined") return; const observer = new IntersectionObserver((entries) => { const visible = entries .filter((entry) => entry.isIntersecting) @@ -142,29 +147,41 @@ export function PersonalReportMarkdownView({ markdown }: { markdown: string }) { const id = visible[0]?.target.id; if (id) setActiveId(id); }, { rootMargin: "-20% 0px -70% 0px", threshold: [0, 1] }); - for (const item of outline.headings) { + for (const item of headings) { const node = document.getElementById(item.id); if (node) observer.observe(node); } return () => observer.disconnect(); - }, [outline.headings]); + }, [headings]); + + return ( + + ); +} + +export function PersonalReportMarkdownView({ markdown }: { markdown: string }) { + const outline = useMemo(() => buildLongformOutline(markdown), [markdown]); + const lead = useMemo( + () => (outline.leadMarkdown ? renderMarkdown(outline.leadMarkdown, outline.headings) : null), + [outline], + ); return (
- +
- {outline.leadMarkdown ? ( + {lead ? (
- {renderMarkdown(outline.leadMarkdown, outline.headings)} + {lead}
) : null} {outline.sections.filter((section) => !section.eager).map((section) => ( diff --git a/frontend/src/lib/report-chart-grid-rehype.ts b/frontend/src/lib/report-chart-grid-rehype.ts new file mode 100644 index 00000000..ef8f3d0f --- /dev/null +++ b/frontend/src/lib/report-chart-grid-rehype.ts @@ -0,0 +1,127 @@ +const CHART_HEADING_RE = /^(?:D\d{1,3}\b|Moon Chart)/; + +type HastProperties = { + className?: string | string[] | null; + [key: string]: unknown; +}; + +type HastNode = { + type: string; + tagName?: string; + value?: string; + properties?: HastProperties; + children?: HastNode[]; +}; + +type HastRoot = { + type: "root"; + children: HastNode[]; +}; + +function classNames(node: HastNode | undefined): string[] { + const value = node?.properties?.className; + if (Array.isArray(value)) return value.map(String); + if (typeof value === "string") return value.split(/\s+/).filter(Boolean); + return []; +} + +function hasClass(node: HastNode | undefined, name: string): boolean { + return classNames(node).includes(name); +} + +function isWhitespace(node: HastNode | undefined): boolean { + return Boolean(node && node.type === "text" && !String(node.value ?? "").trim()); +} + +function flattenText(node: HastNode | undefined): string { + if (!node) return ""; + if (node.type === "text") return String(node.value ?? ""); + if (!Array.isArray(node.children)) return ""; + return node.children.map(flattenText).join(""); +} + +function isElement(node: HastNode | undefined, tagName: string): node is HastNode { + return Boolean(node && node.type === "element" && node.tagName === tagName); +} + +function isChartHeading(node: HastNode | undefined): boolean { + return isElement(node, "h4") && CHART_HEADING_RE.test(flattenText(node).trim()); +} + +function isChartPre(node: HastNode | undefined): boolean { + if (!isElement(node, "pre") || !Array.isArray(node.children)) return false; + const code = node.children.find((child) => isElement(child, "code")); + return hasClass(code, "language-jyotish-chart") || hasClass(code, "jyotish-chart"); +} + +function skipWhitespace(nodes: readonly HastNode[], index: number): number { + let cursor = index; + while (cursor < nodes.length && isWhitespace(nodes[cursor])) cursor += 1; + return cursor; +} + +function element(tagName: string, className: string[], children: HastNode[]): HastNode { + return { + type: "element", + tagName, + properties: { className }, + children, + }; +} + +function parseCard(nodes: readonly HastNode[], index: number): { card: HastNode; next: number } | null { + const heading = nodes[index]; + if (!isChartHeading(heading)) return null; + const children: HastNode[] = [heading]; + let cursor = skipWhitespace(nodes, index + 1); + if (isElement(nodes[cursor], "p")) { + const afterParagraph = skipWhitespace(nodes, cursor + 1); + if (isChartPre(nodes[afterParagraph])) { + children.push(nodes[cursor] as HastNode); + cursor = afterParagraph; + } + } + if (!isChartPre(nodes[cursor])) return null; + children.push(nodes[cursor] as HastNode); + return { + card: element("div", ["personal-report-chart-card"], children), + next: cursor + 1, + }; +} + +export function groupReportChartNodes(nodes: readonly HastNode[]): HastNode[] { + const grouped: HastNode[] = []; + let index = 0; + while (index < nodes.length) { + const first = parseCard(nodes, index); + if (!first) { + grouped.push(nodes[index] as HastNode); + index += 1; + continue; + } + const cards = [first.card]; + index = first.next; + while (index < nodes.length) { + const peeked = skipWhitespace(nodes, index); + const next = parseCard(nodes, peeked); + if (!next) break; + cards.push(next.card); + index = next.next; + } + grouped.push(element( + "div", + cards.length === 1 + ? ["personal-report-chart-grid", "is-single"] + : ["personal-report-chart-grid"], + cards, + )); + } + return grouped; +} + +export function reportChartGrid() { + return function groupReportCharts(tree: HastRoot) { + if (tree?.type !== "root" || !Array.isArray(tree.children)) return; + tree.children = groupReportChartNodes(tree.children); + }; +} diff --git a/frontend/tests/personal-report-markdown-view.test.tsx b/frontend/tests/personal-report-markdown-view.test.tsx index 464279fa..cc70b5bc 100644 --- a/frontend/tests/personal-report-markdown-view.test.tsx +++ b/frontend/tests/personal-report-markdown-view.test.tsx @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import React from "react"; import { renderToStaticMarkup } from "react-dom/server"; import test from "node:test"; @@ -20,6 +21,9 @@ const SAMPLE_BLOCK: ReportChartBlock = { ], }; +const D9_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "D9", title: "D9 — Navamsa(婚盘)" }; +const MOON_BLOCK: ReportChartBlock = { ...SAMPLE_BLOCK, id: "MOON", title: "Moon Chart(月亮参考盘)" }; + const MARKDOWN = [ "#### D1 — Rashi Chart(本命盘)", "", @@ -34,6 +38,27 @@ const MARKDOWN = [ "```", ].join("\n"); +const GOLDEN = JSON.parse( + readFileSync(new URL("./fixtures/report-chart-blocks-golden.json", import.meta.url), "utf8"), +) as { blocks: ReportChartBlock[] }; + +function fence(block: ReportChartBlock): string { + return ["```jyotish-chart", JSON.stringify(block), "```"].join("\n"); +} + +function countClass(markup: string, className: string): number { + const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return (markup.match(new RegExp(`(?:class|className)="[^"]*\\b${escaped}\\b`, "g")) ?? []).length; +} + +function cardInnerHtml(markup: string): string[] { + const cards: string[] = []; + const re = /
([\s\S]*?)<\/div>/g; + let match: RegExpExecArray | null; + while ((match = re.exec(markup))) cards.push(match[1] ?? ""); + return cards; +} + test("markdown view draws the North Indian component and skips engine SVG HTML", () => { const markup = renderToStaticMarkup( React.createElement(PersonalReportMarkdownView, { markdown: MARKDOWN }), @@ -46,3 +71,101 @@ test("markdown view draws the North Indian component and skips engine SVG HTML", assert.doesNotMatch(markup, /