Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72854ba9f3 |
@@ -1,5 +1,15 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-11-01 — 新增「印度占星知识」栏目(/learn)
|
||||
|
||||
- 新页面 `/learn`:不登录也能看,可以被搜索引擎收录。左边是七个栏目加「全部」,右边按栏目列出「问句标题 + 一行摘要」;手机上栏目是一排可以横滑的按钮。
|
||||
- 文章页 `/learn/<栏目>/<文章>`:问句标题、一句话答案、审校人和日期、正文,以及「我们采用的口径」「在 Jyotisha 里看」「接着读」「参考出处」。
|
||||
- 文章是 `frontend/content/learn/` 里的 Markdown 文件,一篇一个,开头字段照 issue #157 里给占星师的模板写。字段写错时测试和构建会报出文件名和原因,不会把页面弄坏。
|
||||
- 先上一篇示例「生时校正能做到多准?」,还是初稿:页面上写「初稿 · 待占星师审校」,暂不进搜索引擎索引,占星师签上笔名和日期后自动放开。
|
||||
- 新增 `sitemap.xml` 和 `robots.txt`:站点地图只列签过审校的文章;只有正式站允许抓取。
|
||||
- 星盘页:出生资料下面那句「上升可能是……」后面,多一个链接「生时校正能做到多准?」;上升已经定下来时不出现。其他盘面要点等有了文章再接。
|
||||
- Skill 版本不变(issue #157)。
|
||||
|
||||
## 2026-10-30 — 星盘页:盘旁「盘面数据」卡,表格和盘联动
|
||||
|
||||
- 盘的右边(手机上是盘的下面)新增「盘面数据」卡,九个页签:行星、虚星、特殊点、映射、八分点、星力、大运、敌友、瑜伽。原来散在「九曜」「Vimshottari 大运」两张卡和深读「命性格局」「排盘底册」里的表都搬到这里,不再重复。
|
||||
|
||||
@@ -6,6 +6,8 @@ RUN npm ci
|
||||
|
||||
COPY frontend/src ./src
|
||||
COPY frontend/public ./public
|
||||
# 印度占星知识 Markdown (issue #157); read by `next build`, which prerenders /learn.
|
||||
COPY frontend/content ./content
|
||||
COPY frontend/next.config.ts frontend/postcss.config.mjs frontend/tsconfig.json ./
|
||||
COPY frontend/scripts ./scripts
|
||||
COPY frontend/db ./db
|
||||
|
||||
@@ -1322,6 +1322,17 @@ Yogini 还在取时,右栏用和 Chara 相同的轨道等待(`OrbitWaiting`
|
||||
- **冷静期门:** 登录后若有待执行的注销,`(app)` 布局渲染全屏 `role="alertdialog"`(`.account-deletion-gate`,z-index 90,`--color-canvas-soft` 底),中间 440px 卡片:宋体标题「账号注销中」、日期句、「退出登录」「撤销注销」两个按钮居中。不做入场动画,不出现 spinner;撤销后整页刷新。
|
||||
- 代码:`components/account-deletion-section.tsx`、`components/account-deletion-gate.tsx`;`page.tsx` 只在 `renderGeneral()` 里多挂一个组件,`Home()` 的 `useState` / `useRef` 数不变。
|
||||
|
||||
## 19. 印度占星知识 /learn(2026-11-01,issue #157)
|
||||
|
||||
产品 2026-11-01 确认原型(「印度占星知识 · 目录 / 文章页 / 手机 390 / 星盘页这个怎么看」)。不登录能看,可被搜索引擎收录;不在登录后的外壳里,和 `/privacy`、`/terms` 一样是 `.standalone-page`,样式在 `app/learn/learn.css`,底色 `--color-canvas`。
|
||||
|
||||
- **顶栏**(`.learn-header`,两页共用):左边 Logo + 宋体「Jyotisha」,链回首页;右边「知识」和描边的「排一张盘」(去首页)。不放价格。
|
||||
- **目录页 `/learn`**:宋体 h1「印度占星知识」,下面一句说明(也是页面描述)。桌面左栏 220px 是栏目(「全部」+ 七个,右边写篇数),点了只显示那一栏,地址带 `#栏目`,可以直接分享;右边每栏一个宋体 h2、一句栏目说明,下面是「问句标题 + 一行摘要」的行,整行是链接,最少 64px 高。「全部」只列有文章的栏目;单独点一个还没有文章的栏目,写「这个栏目的文章还在写。」。手机上栏目变成一排横滑的按钮,不换行。服务器先按「全部」出整页,所以每篇的标题和摘要都在 HTML 里。
|
||||
- **文章页 `/learn/<栏目>/<文件名>`**:720px 一栏。面包屑「知识 / 栏目」;宋体 h1 问句;下面一行一句话答案(`summary`,18px);再下面是审校行「审校:笔名 · 2026 年 11 月 15 日」,占星师还没签的初稿换成警示色小条「初稿 · 待占星师审校」(同 `.legal-draft` 的写法)。正文是 Markdown(`react-markdown` + `remark-gfm`,原始 HTML 丢掉):`##` 小标题 22px,正文 16px、行高 1.9;表格单元格不换行,放不下时表格自己横滑。正文后依次是:「我们采用的口径」(`--color-action-soft` 底的圆角块,`conventions` 为空就不出)、「在 Jyotisha 里看」(`tools`,56px 描边卡片,一行一个工具名加箭头,按下缩到 .98,悬停描边变主色,不写副标题)、「接着读」(`related`,44px 主色链接,上面一条细线)、「参考出处」(`sources`,13px 三级墨色)。哪一块没写就整块不出,不画空框。
|
||||
- **搜索引擎**:每页有自己的标题、描述、规范链接(固定是 `https://jyotisha.chat`,staging 的页面也指向正式站)和 Open Graph;文章页带 schema.org `Article`。签过审校的文章进 `sitemap.xml`;初稿能打开但 `noindex`,也不进站点地图。`robots.txt` 每次请求时读 `AUTH_USER_ORIGIN`:只有正式站放行(不含 `/api/`、`/admin`),其他环境一律不让抓。登录后的页面照旧 `noindex`。
|
||||
- **内容**:`frontend/content/learn/` 下一篇一个 `.md`,文件名就是网址最后一段。开头字段照 issue #157 评论里的模板(title / summary / column / order / reviewer / reviewed / tools / conventions / related / sources),由 `lib/learn/article-file.ts` 读取并用 zod 校验:标题要以「?」结尾、摘要不超过 60 字、栏目和工具只能写列表里的、审校人和日期要么都写要么都不写、「笔名」占位不算签名、同一栏目排序不能重复、「接着读」只能填存在的文件名、正文不用一级标题。写错一处,测试和 `next build` 都会报出文件名和原因,页面不会坏。栏目名、栏目说明和工具入口在 `lib/learn/catalog.ts`。所有页面构建时生成(`dynamicParams = false`,不存在的地址是 404);镜像构建要先 `COPY frontend/content`。
|
||||
- **星盘页「这个怎么看」**:对照表在 `lib/learn/chart-topics.ts`(盘面要素 → 文件名),只有文章存在才出链接,标题就是文章的问句。现在只接了一处:页头出生资料下面那句上升会不会变的话,在上升可能变(或不知道时间)时,句末接一个链接「生时校正能做到多准?」;上升定了不出。只是在已有那句话里多一个文字链接,不加卡片、不改任何卡片的高度。上升星座、当前大运、月亮星宿等位置等文章到了,在对照表里加一行再接。
|
||||
|
||||
## 外壳(2026-10-24)
|
||||
|
||||
桌面侧栏宽 200–360px,默认 288。右侧把手是组件库按钮,仍是 6px 透明细条;拖它调整宽度。把手能用 Tab 选中,左右方向键每次 16px,Home / End 到最窄 / 最宽,键盘不会把侧栏收起;读屏读作竖向分隔条,带当前宽度(BUG-1457)。拖到比 200 更窄就收成 64px 窄轨;收起不把 64 写成宽度。宽度记在本机 `sidebar_width`。侧栏和主区始终在各自的容器里:视口就绪、跨过手机断点,都不会把整页卸掉再挂上。手机用左侧抽屉,宽 `min(86vw, 320px)`。当前页高亮用已有的 `layoutId` 滑动。翻页用已有的页面进入 280ms、离开 150ms,方向跟着侧栏顺序。顶栏点数是描边按钮:硬币图标加数字,44px;「点」只在可读名称里。数字变少时往下滚,变多时往上滚,360ms;系统要求减少动态时立刻换数字。启程清单胶囊仍是 32px,仍在点数左边。账户按钮只显示头像和名字。设置仍是三个分区。会话只在收藏组内、或最近一组内拖动排序;收藏整组仍在最前。可排序的行和其他行长得一样(2026-10-27,BUG-1466):没有常驻把手,鼠标按住整行移动 6px 开始拖,触摸长按 250ms;把手只在键盘聚焦时出现,供键盘排序。能悬停的设备上「…」只在悬停、聚焦、当前行、菜单打开时出现,触摸设备常驻。这套行菜单、删除确认框和拖动排序在每个页面都一样,由外壳提供,不只首页(2026-10-28,BUG-1467);在其他页面点一行回首页打开,未登录时行仍是只读链接。输入框为空时发送按钮禁用,但输入框本身不变灰(覆盖 `InputGroup` 的 `has-disabled:` 两项)。翻页、高亮滑动和拖动排序用的库在页面出来后再载入,不进首屏。拖宽不再另载一套分栏库。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: 生时校正能做到多准?
|
||||
summary: 能把出生时间缩小到几分钟的范围,但做不到「精确到秒」。
|
||||
column: rectification
|
||||
order: 1
|
||||
# 初稿,待占星师审校:审完填上笔名和日期,例如 reviewer: 某某 / reviewed: 2026-11-15
|
||||
reviewer:
|
||||
reviewed:
|
||||
tools: [rectification, chart]
|
||||
conventions:
|
||||
- 岁差:Raman。
|
||||
- 罗睺、计都:平均交点(Mean Node)。
|
||||
- 测试用的公开人物:出生时间来自可靠的出生记录(Rodden AA 级)。
|
||||
sources:
|
||||
- Jyotisha 生时校正回测:77 位出生时间为 Rodden AA 级的公开人物(2026 年 10 月)
|
||||
---
|
||||
|
||||
## 为什么差几分钟就不一样
|
||||
|
||||
上升(Lagna)是出生那一刻东方地平线上的星座,大约每两个小时换一个。出生时间差半小时,上升可能就换了,十二宫的起点也跟着换。
|
||||
|
||||
分盘(Varga)切得更细。D9 每一格是 3°20′,上升十几分钟就走完一格;D60 每一格只有半度,上升一两分钟就走完。所以越细的分盘,越依赖准确的出生时间。
|
||||
|
||||
## 校正是怎么做的
|
||||
|
||||
1. 你说几件人生里的大事和大概的年月,比如上大学、换工作、结婚、搬家。
|
||||
2. 我们把出生时间前后的每一分钟都排一张盘,看哪些时刻和这些事对得上,对不上的排除掉。
|
||||
3. 最后给你一个范围,和范围里最可能的那一个时刻。分不出来的地方,直接告诉你分不出来。
|
||||
|
||||
## 我们自己测过的结果
|
||||
|
||||
我们用 77 位出生时间有可靠记录的公开人物来测,先不看他们记录上的时间,只用他们的人生事件去校正。
|
||||
|
||||
- **76 / 77**:记录上的出生时间落在我们给出的范围里。
|
||||
- **25 / 77**:前后 10 分钟的范围里,最可能的时刻正好是记录上那一分钟。
|
||||
|
||||
也就是说:范围几乎总是对的,但最可能的那一分钟,大约三次里对一次。所以我们给你的是范围,不是一个当成定论的时刻。
|
||||
|
||||
## 为什么不说「精确到秒」
|
||||
|
||||
有一种说法是用纳迪(Nadi)的方法可以把时间校到秒:一种是把大运再往下细分到第五层(Prana),一种是看 D150 分盘。我们用同样的 77 个人测过:用第五层重新排,找回记录上那一分钟的只有 5 例,比我们现在的做法(25 例)还差。只把岁差(Ayanamsa)换一种算法,D150 的结果在 73 例里有 73 例跟着变。这说明它们在秒这一级给出的主要是噪声。
|
||||
@@ -2,6 +2,8 @@ import type { Metadata } from "next";
|
||||
|
||||
import "@/components/chart-board/chart-board-styles";
|
||||
import { ChartPageRoute } from "@/components/chart-page/chart-page-view";
|
||||
import { chartLearnLinks } from "@/lib/learn/chart-topics";
|
||||
import { learnArticles } from "@/lib/learn/content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "星盘 · Jyotisha",
|
||||
@@ -9,5 +11,5 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function ChartPage() {
|
||||
return <ChartPageRoute />;
|
||||
return <ChartPageRoute learnLinks={chartLearnLinks(learnArticles())} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { LearnArticleView } from "@/components/learn/learn-article-view";
|
||||
import { learnArticleJsonLd, learnArticleMetadata, relatedLearnArticles } from "@/lib/learn/article-page";
|
||||
import { findLearnArticle, learnArticles } from "@/lib/learn/content";
|
||||
|
||||
type Params = { column: string; slug: string };
|
||||
|
||||
// Every article is built ahead; an address that is not one of them is a 404.
|
||||
export const dynamicParams = false;
|
||||
|
||||
export function generateStaticParams(): Params[] {
|
||||
return learnArticles().map((article) => ({ column: article.column, slug: article.slug }));
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { column, slug } = await params;
|
||||
const article = findLearnArticle(column, slug);
|
||||
return article ? learnArticleMetadata(article) : {};
|
||||
}
|
||||
|
||||
export default async function LearnArticlePage({ params }: { params: Promise<Params> }) {
|
||||
const { column, slug } = await params;
|
||||
const article = findLearnArticle(column, slug);
|
||||
if (!article) notFound();
|
||||
return (
|
||||
<>
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: learnArticleJsonLd(article) }} />
|
||||
<LearnArticleView article={article} related={relatedLearnArticles(article, learnArticles())} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "../site-styles";
|
||||
import "./learn.css";
|
||||
|
||||
/** 印度占星知识 (issue #157): public, statically generated, outside the signed-in shell. */
|
||||
export default function LearnLayout({ children }: { children: ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/* 印度占星知识 /learn (issue #157). Public reading pages outside the signed-in
|
||||
shell; tokens only, breakpoints from the globals.css allowlist (767 / 1023). */
|
||||
|
||||
.learn-page { background: var(--color-canvas); color: var(--color-ink); }
|
||||
|
||||
.learn-header { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-3); max-width: 1120px; margin: 0 auto; padding: var(--space-4) var(--space-5); }
|
||||
.learn-brand { display: inline-flex; align-items: center; gap: var(--space-2); min-height: 44px; color: var(--color-ink); font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 500; letter-spacing: -.3px; text-decoration: none; }
|
||||
.learn-brand .brand-mark { width: 28px; height: 28px; }
|
||||
.learn-header-nav { display: flex; align-items: center; gap: var(--space-1); }
|
||||
.learn-header-nav a { display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--space-3); border-radius: var(--radius-md); color: var(--color-ink-strong); font-size: var(--type-body-sm); text-decoration: none; transition: background-color 120ms var(--ease-out), color 120ms var(--ease-out); }
|
||||
.learn-header-nav a:hover { color: var(--color-ink); }
|
||||
.learn-header-nav a[aria-current="page"] { color: var(--color-ink); font-weight: 500; }
|
||||
.learn-header-nav .learn-header-cta { padding: 0 var(--space-4); border: 1px solid var(--color-border); background: var(--color-canvas); color: var(--color-ink); }
|
||||
.learn-header-nav .learn-header-cta:hover { border-color: var(--color-border-strong); background: var(--color-canvas-soft); }
|
||||
|
||||
/* Index: hero across the top, columns left, questions right. */
|
||||
.learn-index { max-width: 1120px; margin: 0 auto; padding: var(--space-8) var(--space-5) var(--space-16); }
|
||||
.learn-hero { display: grid; gap: var(--space-3); margin-bottom: var(--space-10); }
|
||||
.learn-hero h1 { margin: 0; font-family: var(--font-display); font-size: var(--type-display-lg); font-weight: 600; line-height: 1.2; letter-spacing: -.5px; text-wrap: balance; }
|
||||
.learn-hero p { max-width: 640px; margin: 0; color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.8; text-wrap: pretty; }
|
||||
|
||||
.learn-index-body { display: grid; grid-template-columns: 220px minmax(0, 1fr); align-items: start; gap: var(--space-12); }
|
||||
.learn-columns { position: sticky; top: var(--space-4); display: flex; flex-direction: column; gap: 2px; }
|
||||
.learn-column-pick { justify-content: space-between; width: 100%; padding: 0 var(--space-3); color: var(--color-ink-strong); font-size: var(--type-body-md); font-weight: 400; }
|
||||
.learn-column-pick[aria-pressed="true"] { background: var(--color-action-soft); color: var(--color-ink); font-weight: 500; }
|
||||
.learn-column-count { color: var(--color-ink-tertiary); font-size: var(--type-caption); font-variant-numeric: tabular-nums; }
|
||||
|
||||
.learn-list { display: grid; gap: var(--space-10); min-width: 0; }
|
||||
.learn-column { display: grid; gap: var(--space-1); }
|
||||
.learn-column h2 { margin: 0; font-family: var(--font-display); font-size: var(--type-title-lg); font-weight: 600; }
|
||||
.learn-column-lead { margin: 0 0 var(--space-2); color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
|
||||
.learn-column-empty { margin: var(--space-2) 0 0; color: var(--color-ink-tertiary); font-size: var(--type-body-sm); }
|
||||
.learn-article-list { margin: 0; padding: 0; list-style: none; }
|
||||
.learn-article-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); min-height: 64px; padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--color-canvas-muted); border-radius: var(--radius-lg); color: var(--color-ink); text-decoration: none; transition: background-color 140ms var(--ease-out); }
|
||||
.learn-article-row:hover { background: var(--color-canvas-soft); }
|
||||
.learn-article-row svg { flex: 0 0 auto; width: 16px; height: 16px; color: var(--color-ink-tertiary); }
|
||||
.learn-article-text { display: grid; gap: var(--space-1); min-width: 0; }
|
||||
.learn-article-title { font-size: var(--type-title-sm); font-weight: 500; }
|
||||
.learn-article-summary { color: var(--color-ink-secondary); font-size: var(--type-body-sm); }
|
||||
|
||||
/* Article: one 720px reading column. */
|
||||
.learn-article-shell { width: min(720px, 100%); margin: 0 auto; padding: var(--space-6) var(--space-5) var(--space-24); }
|
||||
.learn-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); color: var(--color-ink-tertiary); font-size: var(--type-body-sm); }
|
||||
.learn-breadcrumb a { color: inherit; }
|
||||
.learn-breadcrumb a:hover { color: var(--color-ink); }
|
||||
.learn-article { display: grid; gap: var(--space-6); margin-top: var(--space-5); }
|
||||
.learn-article-head { display: grid; gap: var(--space-3); }
|
||||
.learn-article-head h1 { margin: 0; font-family: var(--font-display); font-size: var(--type-display-md); font-weight: 600; line-height: 1.3; text-wrap: balance; }
|
||||
.learn-article-answer { margin: 0; color: var(--color-ink-strong); font-size: var(--type-title-md); line-height: 1.7; }
|
||||
.learn-byline { margin: 0; color: var(--color-ink-tertiary); font-size: var(--type-caption); }
|
||||
.learn-draft { justify-self: start; margin: 0; padding: var(--space-1) var(--space-3); border-radius: var(--radius-md); background: color-mix(in srgb, var(--color-warning) 12%, transparent); color: var(--color-warning); font-size: var(--type-caption); }
|
||||
|
||||
.learn-prose { color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.9; }
|
||||
.learn-prose > :first-child { margin-top: 0; }
|
||||
.learn-prose h2 { margin: var(--space-8) 0 var(--space-3); color: var(--color-ink); font-size: var(--type-title-lg); font-weight: 500; line-height: 1.4; }
|
||||
.learn-prose h3 { margin: var(--space-6) 0 var(--space-2); color: var(--color-ink); font-size: var(--type-title-md); font-weight: 500; }
|
||||
.learn-prose p { margin: 0 0 var(--space-4); }
|
||||
.learn-prose ul, .learn-prose ol { display: grid; gap: var(--space-2); margin: 0 0 var(--space-4); padding-left: 1.4em; }
|
||||
.learn-prose strong { color: var(--color-ink); font-weight: 600; }
|
||||
.learn-prose a { color: var(--color-action); text-underline-offset: 2px; }
|
||||
.learn-prose a:hover { color: var(--color-action-hover); }
|
||||
.learn-prose blockquote { margin: 0 0 var(--space-4); padding-left: var(--space-4); border-left: 2px solid var(--color-border); color: var(--color-ink-secondary); }
|
||||
.learn-table { max-width: 100%; margin: 0 0 var(--space-4); overflow-x: auto; }
|
||||
.learn-table table { border-collapse: collapse; font-size: var(--type-body-sm); line-height: 1.55; }
|
||||
.learn-table th, .learn-table td { padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--color-border); text-align: start; white-space: nowrap; }
|
||||
.learn-table th { color: var(--color-ink-secondary); font-weight: 500; }
|
||||
|
||||
.learn-conventions { display: grid; gap: var(--space-2); padding: var(--space-4) var(--space-5); border-radius: var(--radius-lg); background: var(--color-action-soft); }
|
||||
.learn-conventions-title { margin: 0; color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; }
|
||||
.learn-conventions ul { display: grid; gap: var(--space-1); margin: 0; padding-left: 1.2em; color: var(--color-ink-strong); font-size: var(--type-body-md); line-height: 1.7; }
|
||||
|
||||
.learn-tools, .learn-related, .learn-sources { display: grid; gap: var(--space-3); }
|
||||
.learn-tools h2, .learn-related h2, .learn-sources h2 { margin: 0; font-size: var(--type-title-md); font-weight: 500; }
|
||||
.learn-tool-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-3); }
|
||||
.learn-tool { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); min-height: 56px; padding: 0 var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas); color: var(--color-ink); font-size: var(--type-body-md); font-weight: 500; text-decoration: none; transition: transform 120ms var(--ease-out), border-color 120ms var(--ease-out); }
|
||||
.learn-tool:hover { border-color: var(--color-action); }
|
||||
.learn-tool:active { transform: scale(.98); }
|
||||
.learn-tool svg { flex: 0 0 auto; width: 16px; height: 16px; color: var(--color-action); }
|
||||
.learn-related { padding-top: var(--space-5); border-top: 1px solid var(--color-canvas-muted); }
|
||||
.learn-related ul { margin: 0; padding: 0; list-style: none; }
|
||||
.learn-related a { display: flex; align-items: center; min-height: 44px; color: var(--color-action); font-size: var(--type-body-md); }
|
||||
.learn-related a:hover { color: var(--color-action-hover); }
|
||||
.learn-sources ul { display: grid; gap: var(--space-1); margin: 0; padding-left: 1.2em; color: var(--color-ink-tertiary); font-size: var(--type-caption); line-height: 1.6; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.learn-header { padding: var(--space-3) var(--space-4); }
|
||||
.learn-index { padding: var(--space-5) var(--space-4) var(--space-12); }
|
||||
.learn-hero { margin-bottom: var(--space-6); }
|
||||
.learn-hero h1 { font-size: var(--type-display-md); }
|
||||
/* Columns become one row that scrolls sideways, never wrapping. */
|
||||
.learn-index-body { grid-template-columns: minmax(0, 1fr); gap: var(--space-6); }
|
||||
.learn-columns { position: static; flex-direction: row; gap: var(--space-1); margin: 0 calc(var(--space-4) * -1); padding: 0 var(--space-4); overflow-x: auto; scrollbar-width: none; }
|
||||
.learn-columns::-webkit-scrollbar { display: none; }
|
||||
.learn-column-pick { flex: 0 0 auto; width: auto; gap: var(--space-2); }
|
||||
.learn-article-row { padding: var(--space-3) var(--space-2); }
|
||||
.learn-article-shell { padding: var(--space-4) var(--space-4) var(--space-16); }
|
||||
.learn-article-head h1 { font-size: var(--type-display-sm); }
|
||||
.learn-article-answer { font-size: var(--type-body-md); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.learn-article-row, .learn-tool, .learn-header-nav a { transition: none; }
|
||||
.learn-tool:active { transform: none; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { LearnIndexPage } from "@/components/learn/learn-index-page";
|
||||
import { LEARN_INDEX_DESCRIPTION, LEARN_INDEX_TITLE, LEARN_PATH, learnAbsoluteUrl } from "@/lib/learn/catalog";
|
||||
import { learnArticles } from "@/lib/learn/content";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `${LEARN_INDEX_TITLE} · Jyotisha`,
|
||||
description: LEARN_INDEX_DESCRIPTION,
|
||||
alternates: { canonical: learnAbsoluteUrl(LEARN_PATH) },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "zh_CN",
|
||||
siteName: "Jyotisha",
|
||||
title: LEARN_INDEX_TITLE,
|
||||
description: LEARN_INDEX_DESCRIPTION,
|
||||
url: learnAbsoluteUrl(LEARN_PATH),
|
||||
},
|
||||
};
|
||||
|
||||
export default function LearnPage() {
|
||||
return <LearnIndexPage articles={learnArticles()} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { learnRobots } from "@/lib/learn/robots";
|
||||
|
||||
// Read per request: one image serves staging and production, and only
|
||||
// production (AUTH_USER_ORIGIN = https://jyotisha.chat) may be crawled.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return learnRobots(process.env.AUTH_USER_ORIGIN);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { learnSitemapEntries } from "@/lib/learn/article-page";
|
||||
import { learnArticles } from "@/lib/learn/content";
|
||||
|
||||
/** Public, indexable pages only: 印度占星知识 and its signed articles (issue #157). */
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return learnSitemapEntries(learnArticles());
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
type ChartViewTabId,
|
||||
} from "@/lib/chart-view-contract";
|
||||
import { RECTIFICATION_PAGE_HREF } from "@/lib/chat-session-url";
|
||||
import { chartLearnLink, type ChartLearnLinks } from "@/lib/learn/chart-topics";
|
||||
import { setComposerDraft } from "@/lib/composer-draft";
|
||||
import { lagnaPrecheckLine } from "@/lib/lagna-precheck";
|
||||
import type { ChartViewLayer } from "@/lib/chart-view-engine";
|
||||
@@ -60,7 +61,7 @@ const loadChartBirthEditor = () => import("@/components/chart-page/chart-birth-e
|
||||
.then((module) => module.ChartBirthEditor);
|
||||
const ChartBirthEditor = dynamic(loadChartBirthEditor, { ssr: false, loading: () => null });
|
||||
|
||||
export function ChartPageRoute() {
|
||||
export function ChartPageRoute({ learnLinks }: { learnLinks?: ChartLearnLinks }) {
|
||||
const { view, pendingLayers, layerFailures, requestLayer, reload } = useChartPage();
|
||||
const ok = view?.status === "ok" ? view : null;
|
||||
// 「那一刻的天空」 is prefetched as soon as the chart is ok (TASK-birth-sky-cover T3).
|
||||
@@ -80,17 +81,21 @@ export function ChartPageRoute() {
|
||||
onBirthSaved={reload}
|
||||
birthSky={birthSky.cover}
|
||||
birthSkyPending={birthSky.pending}
|
||||
learnLinks={learnLinks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLagnaPrecheckNote({ value, self }: { value: ChartViewLagnaPrecheck; self: boolean }) {
|
||||
function ChartLagnaPrecheckNote({ value, self, learnLinks }: { value: ChartViewLagnaPrecheck; self: boolean; learnLinks?: ChartLearnLinks }) {
|
||||
const line = lagnaPrecheckLine(value, { self });
|
||||
// 「这个怎么看」 (issue #157): only while the ascendant can still change, and only when the article exists.
|
||||
const learn = value.state === "fixed" ? null : chartLearnLink(learnLinks, "birth-time-uncertain");
|
||||
return (
|
||||
<p className="chart-lagna-precheck">
|
||||
{line.before}
|
||||
{line.linked ? <a href={RECTIFICATION_PAGE_HREF}>出生时间校正</a> : null}
|
||||
{line.after}
|
||||
{learn ? <> <Link href={learn.href}>{learn.title}</Link></> : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -287,6 +292,7 @@ export function ChartPageView({
|
||||
birthSkyPending = false,
|
||||
onBirthSaved,
|
||||
onAsk = askFromChartPage,
|
||||
learnLinks,
|
||||
}: {
|
||||
/** 「问 AI」: fill the chat composer and go to the chat; never send (issue #114 Q7). */
|
||||
onAsk?: (question: string) => void;
|
||||
@@ -300,6 +306,8 @@ export function ChartPageView({
|
||||
/** The cover is still painting: the button already shows and opens when it lands. */
|
||||
birthSkyPending?: boolean;
|
||||
onBirthSaved?: () => Promise<void> | void;
|
||||
/** 盘面要素 → 印度占星知识 articles, resolved on the server (lib/learn/chart-topics.ts). */
|
||||
learnLinks?: ChartLearnLinks;
|
||||
}) {
|
||||
const [system, setSystem] = useState<ChartSystem>(initialTab === "western" ? "western" : "vedic");
|
||||
const [deepTab, setDeepTab] = useState<ChartDeepTab>(deepTabFor(initialTab));
|
||||
@@ -354,7 +362,7 @@ export function ChartPageView({
|
||||
<Pencil aria-hidden="true" />
|
||||
</Button>
|
||||
{view.lagnaPrecheck ? (
|
||||
<ChartLagnaPrecheckNote value={view.lagnaPrecheck} self={(view.subjectId ?? "self") === "self"} />
|
||||
<ChartLagnaPrecheckNote value={view.lagnaPrecheck} self={(view.subjectId ?? "self") === "self"} learnLinks={learnLinks} />
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import Link from "next/link";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
import { LearnSiteHeader } from "@/components/learn/learn-site-header";
|
||||
import type { LearnArticle } from "@/lib/learn/article-file";
|
||||
import { LEARN_PATH, LEARN_TOOLS, learnColumn, learnColumnHref } from "@/lib/learn/catalog";
|
||||
|
||||
const markdownComponents: Components = {
|
||||
a: ({ children, href }) => (href && /^https?:\/\//.test(href)
|
||||
? <a href={href} rel="noreferrer" target="_blank">{children}</a>
|
||||
: <a href={href}>{children}</a>),
|
||||
// Tables never wrap a cell; a wide one scrolls inside its own box.
|
||||
table: ({ children }) => (
|
||||
<div className="learn-table">
|
||||
<table>{children}</table>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
/** 2026-11-01 → 2026 年 11 月 1 日 */
|
||||
function longDate(value: string): string {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return `${year} 年 ${month} 月 ${day} 日`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One 印度占星知识 article (issue #157 prototype): question, one-line answer,
|
||||
* reviewer and date, the Markdown body, 「我们采用的口径」, 「在 Jyotisha 里看」,
|
||||
* 「接着读」 and the sources. Until the astrologer signs it, the reviewer line
|
||||
* says it is a draft.
|
||||
*/
|
||||
export function LearnArticleView({ article, related }: { article: LearnArticle; related: readonly LearnArticle[] }) {
|
||||
const column = learnColumn(article.column);
|
||||
return (
|
||||
<main className="standalone-page learn-page">
|
||||
<LearnSiteHeader current="article" />
|
||||
<div className="learn-article-shell">
|
||||
<nav className="learn-breadcrumb" aria-label="位置">
|
||||
<Link href={LEARN_PATH}>知识</Link>
|
||||
<span aria-hidden="true">/</span>
|
||||
<Link href={learnColumnHref(article.column)}>{column.name}</Link>
|
||||
</nav>
|
||||
<article className="learn-article">
|
||||
<header className="learn-article-head">
|
||||
<h1>{article.title}</h1>
|
||||
<p className="learn-article-answer">{article.summary}</p>
|
||||
{article.draft ? (
|
||||
<p className="learn-draft" role="note">初稿 · 待占星师审校</p>
|
||||
) : (
|
||||
<p className="learn-byline">审校:{article.reviewer} · <time dateTime={article.reviewed!}>{longDate(article.reviewed!)}</time></p>
|
||||
)}
|
||||
</header>
|
||||
<div className="learn-prose">
|
||||
<ReactMarkdown components={markdownComponents} remarkPlugins={[remarkGfm]} skipHtml>
|
||||
{article.body}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{article.conventions.length ? (
|
||||
<aside className="learn-conventions" aria-labelledby="learn-conventions-title">
|
||||
<p id="learn-conventions-title" className="learn-conventions-title">我们采用的口径</p>
|
||||
<ul>
|
||||
{article.conventions.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</aside>
|
||||
) : null}
|
||||
{article.tools.length ? (
|
||||
<section className="learn-tools" aria-labelledby="learn-tools-title">
|
||||
<h2 id="learn-tools-title">在 Jyotisha 里看</h2>
|
||||
<div className="learn-tool-grid">
|
||||
{article.tools.map((id) => (
|
||||
<Link key={id} className="learn-tool" href={LEARN_TOOLS[id].href}>
|
||||
{LEARN_TOOLS[id].label}
|
||||
<ArrowRight aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{related.length ? (
|
||||
<section className="learn-related" aria-labelledby="learn-related-title">
|
||||
<h2 id="learn-related-title">接着读</h2>
|
||||
<ul>
|
||||
{related.map((item) => (
|
||||
<li key={item.slug}><Link href={item.href}>{item.title}</Link></li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
{article.sources.length ? (
|
||||
<section className="learn-sources" aria-labelledby="learn-sources-title">
|
||||
<h2 id="learn-sources-title">参考出处</h2>
|
||||
<ul>
|
||||
{article.sources.map((item) => <li key={item}>{item}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
</article>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { LearnIndex, type LearnIndexColumn } from "@/components/learn/learn-index";
|
||||
import { LearnSiteHeader } from "@/components/learn/learn-site-header";
|
||||
import type { LearnArticle } from "@/lib/learn/article-file";
|
||||
import { LEARN_COLUMNS, LEARN_INDEX_DESCRIPTION, LEARN_INDEX_TITLE } from "@/lib/learn/catalog";
|
||||
|
||||
/** Only what the list shows goes to the client component; the bodies stay on the server. */
|
||||
export function learnIndexColumns(articles: readonly LearnArticle[]): LearnIndexColumn[] {
|
||||
return LEARN_COLUMNS.map((column) => ({
|
||||
...column,
|
||||
articles: articles
|
||||
.filter((article) => article.column === column.id)
|
||||
.map(({ slug, title, summary, href }) => ({ slug, title, summary, href })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function LearnIndexPage({ articles }: { articles: readonly LearnArticle[] }) {
|
||||
return (
|
||||
<main className="standalone-page learn-page">
|
||||
<LearnSiteHeader current="index" />
|
||||
<div className="learn-index">
|
||||
<section className="learn-hero" aria-labelledby="learn-title">
|
||||
<h1 id="learn-title">{LEARN_INDEX_TITLE}</h1>
|
||||
<p>{LEARN_INDEX_DESCRIPTION}</p>
|
||||
</section>
|
||||
<LearnIndex columns={learnIndexColumns(articles)} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { LearnColumnId } from "@/lib/learn/catalog";
|
||||
|
||||
export type LearnIndexArticle = { readonly slug: string; readonly title: string; readonly summary: string; readonly href: string };
|
||||
export type LearnIndexColumn = {
|
||||
readonly id: LearnColumnId;
|
||||
readonly name: string;
|
||||
readonly lead: string;
|
||||
readonly articles: readonly LearnIndexArticle[];
|
||||
};
|
||||
|
||||
type ColumnPick = LearnColumnId | "all";
|
||||
|
||||
function subscribeHash(onChange: () => void) {
|
||||
window.addEventListener("hashchange", onChange);
|
||||
return () => window.removeEventListener("hashchange", onChange);
|
||||
}
|
||||
const readHash = () => window.location.hash.slice(1);
|
||||
const serverHash = () => "";
|
||||
|
||||
/** `#rectification` picks that column (even an empty one); anything else is 全部, which skips empty columns. */
|
||||
export function learnIndexSelection(columns: readonly LearnIndexColumn[], hash: string) {
|
||||
const active: ColumnPick = columns.some((column) => column.id === hash) ? (hash as LearnColumnId) : "all";
|
||||
const shown = active === "all" ? columns.filter((column) => column.articles.length > 0) : columns.filter((column) => column.id === active);
|
||||
return { active, shown };
|
||||
}
|
||||
|
||||
/**
|
||||
* /learn: columns on the left (a scrolling row on phones), questions on the
|
||||
* right. The server renders 「全部」, so every article is in the HTML; picking a
|
||||
* column only hides the others and keeps `#column` in the address for sharing.
|
||||
*/
|
||||
export function LearnIndex({ columns }: { columns: readonly LearnIndexColumn[] }) {
|
||||
const hash = useSyncExternalStore(subscribeHash, readHash, serverHash);
|
||||
const { active, shown } = learnIndexSelection(columns, hash);
|
||||
|
||||
// replaceState instead of assigning the hash: no jump to the anchor, no history entry.
|
||||
const pick = (next: ColumnPick) => {
|
||||
window.history.replaceState(null, "", next === "all" ? window.location.pathname : `#${next}`);
|
||||
window.dispatchEvent(new HashChangeEvent("hashchange"));
|
||||
};
|
||||
const total = columns.reduce((count, column) => count + column.articles.length, 0);
|
||||
const entries: { id: ColumnPick; name: string; count: number }[] = [
|
||||
{ id: "all", name: "全部", count: total },
|
||||
...columns.map((column) => ({ id: column.id, name: column.name, count: column.articles.length })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="learn-index-body">
|
||||
<nav className="learn-columns" aria-label="栏目">
|
||||
{entries.map((entry) => (
|
||||
<Button
|
||||
key={entry.id}
|
||||
variant="ghost"
|
||||
className="learn-column-pick"
|
||||
aria-pressed={active === entry.id}
|
||||
onClick={() => pick(entry.id)}
|
||||
>
|
||||
{entry.name}
|
||||
<span className="learn-column-count">{entry.count}</span>
|
||||
</Button>
|
||||
))}
|
||||
</nav>
|
||||
<div className="learn-list">
|
||||
{shown.map((column) => (
|
||||
<section key={column.id} id={column.id} className="learn-column" aria-labelledby={`learn-column-${column.id}`}>
|
||||
<h2 id={`learn-column-${column.id}`}>{column.name}</h2>
|
||||
<p className="learn-column-lead">{column.lead}</p>
|
||||
{column.articles.length ? (
|
||||
<ul className="learn-article-list">
|
||||
{column.articles.map((article) => (
|
||||
<li key={article.slug}>
|
||||
<Link className="learn-article-row" href={article.href}>
|
||||
<span className="learn-article-text">
|
||||
<span className="learn-article-title">{article.title}</span>
|
||||
<span className="learn-article-summary">{article.summary}</span>
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" />
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="learn-column-empty">这个栏目的文章还在写。</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { LEARN_PATH } from "@/lib/learn/catalog";
|
||||
|
||||
/** Top row of the public /learn pages: the wordmark, 知识 and 排一张盘. */
|
||||
export function LearnSiteHeader({ current }: { current: "index" | "article" }) {
|
||||
return (
|
||||
<header className="learn-header">
|
||||
<Link className="learn-brand" href="/">
|
||||
<span className="brand-mark" aria-hidden="true" />
|
||||
Jyotisha
|
||||
</Link>
|
||||
<nav className="learn-header-nav" aria-label="页面">
|
||||
<Link href={LEARN_PATH} aria-current={current === "index" ? "page" : undefined}>知识</Link>
|
||||
<Link className="learn-header-cta" href="/">排一张盘</Link>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { LEARN_COLUMN_IDS, LEARN_TOOL_IDS, learnArticleHref, type LearnColumnId, type LearnToolId } from "./catalog.ts";
|
||||
|
||||
/**
|
||||
* One 印度占星知识 article file: `---` frontmatter, then the Markdown body.
|
||||
*
|
||||
* The frontmatter is the small subset of YAML the template in issue #157 uses —
|
||||
* `key: value`, `key: [a, b]`, a `key:` followed by ` - item` lines, and `#`
|
||||
* comments — read here instead of pulling in a YAML library. Anything outside
|
||||
* that subset is an error with the file name and line, so a bad file fails the
|
||||
* tests and the build, never the page.
|
||||
*/
|
||||
|
||||
export const LEARN_SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
/** The template's placeholder; an article signed with it is not signed. */
|
||||
const REVIEWER_PLACEHOLDER = "笔名";
|
||||
|
||||
export type LearnArticle = {
|
||||
readonly slug: string;
|
||||
readonly column: LearnColumnId;
|
||||
readonly order: number;
|
||||
readonly title: string;
|
||||
readonly summary: string;
|
||||
/** Pen name. Null on a draft that the astrologer has not signed yet. */
|
||||
readonly reviewer: string | null;
|
||||
/** YYYY-MM-DD, set together with `reviewer`. */
|
||||
readonly reviewed: string | null;
|
||||
readonly draft: boolean;
|
||||
readonly tools: readonly LearnToolId[];
|
||||
readonly conventions: readonly string[];
|
||||
readonly related: readonly string[];
|
||||
readonly sources: readonly string[];
|
||||
readonly body: string;
|
||||
readonly href: string;
|
||||
};
|
||||
|
||||
export class LearnArticleError extends Error {
|
||||
constructor(file: string, message: string) {
|
||||
super(`${file}: ${message}`);
|
||||
this.name = "LearnArticleError";
|
||||
}
|
||||
}
|
||||
|
||||
type RawValue = string | string[];
|
||||
|
||||
function unquote(value: string): string {
|
||||
const quoted = /^"(.*)"$/.exec(value) ?? /^'(.*)'$/.exec(value);
|
||||
return quoted ? quoted[1] : value;
|
||||
}
|
||||
|
||||
/** Drops a trailing ` # comment` (YAML: only after whitespace, never inside quotes). */
|
||||
function stripComment(value: string): string {
|
||||
let quote: string | null = null;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
} else if (char === "\"" || char === "'") {
|
||||
quote = char;
|
||||
} else if (char === "#" && (index === 0 || /\s/.test(value[index - 1]))) {
|
||||
return value.slice(0, index).trimEnd();
|
||||
}
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function inlineList(value: string): string[] {
|
||||
const inner = value.slice(1, -1).trim();
|
||||
if (!inner) return [];
|
||||
return inner.split(/[,,]/).map((item) => unquote(item.trim())).filter(Boolean);
|
||||
}
|
||||
|
||||
export function parseFrontmatter(file: string, block: string): Record<string, RawValue> {
|
||||
const result: Record<string, RawValue> = {};
|
||||
const blockLists = new Set<string>();
|
||||
let listKey: string | null = null;
|
||||
block.split("\n").forEach((rawLine, index) => {
|
||||
const lineNumber = index + 2; // line 1 is the opening `---`
|
||||
const line = rawLine.replace(/\s+$/, "");
|
||||
if (!line.trim() || line.trim().startsWith("#")) return;
|
||||
const item = /^\s+-\s*(.*)$/.exec(line) ?? /^-\s+(.*)$/.exec(line);
|
||||
if (item) {
|
||||
if (!listKey) throw new LearnArticleError(file, `第 ${lineNumber} 行:「- 」前面要有一个以冒号结尾的字段`);
|
||||
const value = unquote(stripComment(item[1]));
|
||||
if (value) (result[listKey] as string[]).push(value);
|
||||
return;
|
||||
}
|
||||
const field = /^([A-Za-z_][A-Za-z0-9_]*)\s*[::]\s*(.*)$/.exec(line);
|
||||
if (!field || /^\s/.test(line)) {
|
||||
throw new LearnArticleError(file, `第 ${lineNumber} 行看不懂:${line.trim()}`);
|
||||
}
|
||||
const [, key, rest] = field;
|
||||
if (key in result) throw new LearnArticleError(file, `第 ${lineNumber} 行:「${key}」写了两次`);
|
||||
const value = stripComment(rest);
|
||||
listKey = null;
|
||||
if (!value) {
|
||||
result[key] = [];
|
||||
blockLists.add(key);
|
||||
listKey = key;
|
||||
} else if (value.startsWith("[") && value.endsWith("]")) {
|
||||
result[key] = inlineList(value);
|
||||
} else {
|
||||
result[key] = unquote(value);
|
||||
}
|
||||
});
|
||||
// `reviewer:` with nothing after it means "not yet", the same as leaving the line out.
|
||||
for (const key of blockLists) if ((result[key] as string[]).length === 0) delete result[key];
|
||||
return result;
|
||||
}
|
||||
|
||||
const text = z.string().trim().min(1);
|
||||
const list = <T extends z.ZodTypeAny>(item: T) => z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.array(item),
|
||||
).default([]);
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "日期写成 2026-11-01 这样").refine((value) => {
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}, "不是一个真实的日期");
|
||||
|
||||
export const learnFrontmatterSchema = z.object({
|
||||
title: text.max(40, "标题不超过 40 个字").regex(/[??]$/, "标题用问句,以「?」结尾"),
|
||||
summary: text.max(60, "摘要不超过 60 个字"),
|
||||
column: z.enum(LEARN_COLUMN_IDS, { errorMap: () => ({ message: `栏目只能是 ${LEARN_COLUMN_IDS.join(" / ")}` }) }),
|
||||
order: z.string().regex(/^[1-9]\d*$/, "排序写一个正整数").transform(Number),
|
||||
reviewer: text.refine((value) => value !== REVIEWER_PLACEHOLDER, "把「笔名」换成真实的笔名").optional(),
|
||||
reviewed: isoDate.optional(),
|
||||
tools: list(z.enum(LEARN_TOOL_IDS, { errorMap: () => ({ message: `工具只能是 ${LEARN_TOOL_IDS.join(" / ")}` }) })),
|
||||
conventions: list(text),
|
||||
related: list(z.string().regex(LEARN_SLUG_PATTERN, "接着读填文章的文件名(不带 .md)")),
|
||||
sources: list(text),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (Boolean(value.reviewer) !== Boolean(value.reviewed)) {
|
||||
context.addIssue({ code: z.ZodIssueCode.custom, path: ["reviewed"], message: "审校人和审校日期要一起写,或者都不写(初稿)" });
|
||||
}
|
||||
});
|
||||
|
||||
function describe(error: z.ZodError): string {
|
||||
return error.issues.map((issue) => {
|
||||
const where = issue.path.join(".");
|
||||
if (issue.code === z.ZodIssueCode.unrecognized_keys) return `不认识的字段:${issue.keys.join("、")}`;
|
||||
if (issue.code === z.ZodIssueCode.invalid_type && issue.received === "undefined") return `缺少「${where}」`;
|
||||
return where ? `「${where}」${issue.message}` : issue.message;
|
||||
}).join(";");
|
||||
}
|
||||
|
||||
/** `fileName` is the base name, e.g. `rectification-accuracy.md`; it becomes the slug. */
|
||||
export function parseLearnArticle(fileName: string, source: string): LearnArticle {
|
||||
const slug = fileName.replace(/\.md$/, "");
|
||||
if (!fileName.endsWith(".md") || !LEARN_SLUG_PATTERN.test(slug)) {
|
||||
throw new LearnArticleError(fileName, "文件名只用小写英文字母、数字和连字符,以 .md 结尾");
|
||||
}
|
||||
const normalized = source.replace(/^/, "").replace(/\r\n?/g, "\n");
|
||||
const match = /^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/.exec(normalized);
|
||||
if (!match) throw new LearnArticleError(fileName, "文件开头要有一对 --- 包住的字段");
|
||||
const parsed = learnFrontmatterSchema.safeParse(parseFrontmatter(fileName, match[1]));
|
||||
if (!parsed.success) throw new LearnArticleError(fileName, describe(parsed.error));
|
||||
const body = match[2].trim();
|
||||
if (!body) throw new LearnArticleError(fileName, "正文是空的");
|
||||
if (/^#\s/m.test(body)) throw new LearnArticleError(fileName, "正文不要用一级标题(# ),标题已经在 title 里,小标题用 ## ");
|
||||
const meta = parsed.data;
|
||||
const reviewer = meta.reviewer ?? null;
|
||||
return {
|
||||
slug,
|
||||
column: meta.column,
|
||||
order: meta.order,
|
||||
title: meta.title,
|
||||
summary: meta.summary,
|
||||
reviewer,
|
||||
reviewed: meta.reviewed ?? null,
|
||||
draft: reviewer === null,
|
||||
tools: [...new Set(meta.tools)],
|
||||
conventions: meta.conventions,
|
||||
related: meta.related,
|
||||
sources: meta.sources,
|
||||
body,
|
||||
href: learnArticleHref({ column: meta.column, slug }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Checks that need every file: unique order per column, 接着读 pointing at real articles. Returns them sorted. */
|
||||
export function checkLearnArticles(articles: readonly LearnArticle[]): LearnArticle[] {
|
||||
const slugs = new Set(articles.map((article) => article.slug));
|
||||
const orders = new Map<string, string>();
|
||||
for (const article of articles) {
|
||||
const key = `${article.column}#${article.order}`;
|
||||
const taken = orders.get(key);
|
||||
if (taken) throw new LearnArticleError(`${article.slug}.md`, `和 ${taken}.md 在同一栏目里排序都是 ${article.order}`);
|
||||
orders.set(key, article.slug);
|
||||
for (const related of article.related) {
|
||||
if (related === article.slug) throw new LearnArticleError(`${article.slug}.md`, "接着读不要填自己");
|
||||
if (!slugs.has(related)) throw new LearnArticleError(`${article.slug}.md`, `接着读里的「${related}」没有这篇文章`);
|
||||
}
|
||||
}
|
||||
return [...articles].sort((a, b) => LEARN_COLUMN_IDS.indexOf(a.column) - LEARN_COLUMN_IDS.indexOf(b.column) || a.order - b.order);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Metadata, MetadataRoute } from "next";
|
||||
|
||||
import type { LearnArticle } from "./article-file.ts";
|
||||
import { LEARN_PATH, LEARN_SITE_ORIGIN, learnAbsoluteUrl, learnColumn } from "./catalog.ts";
|
||||
|
||||
/**
|
||||
* Search-engine side of /learn. A draft (no reviewer yet) is readable but kept
|
||||
* out of the index and the sitemap until the astrologer signs it.
|
||||
*/
|
||||
export function learnArticleMetadata(article: LearnArticle): Metadata {
|
||||
const url = learnAbsoluteUrl(article.href);
|
||||
return {
|
||||
title: `${article.title} · 印度占星知识 · Jyotisha`,
|
||||
description: article.summary,
|
||||
alternates: { canonical: url },
|
||||
robots: article.draft ? { index: false, follow: true } : { index: true, follow: true },
|
||||
openGraph: {
|
||||
type: "article",
|
||||
locale: "zh_CN",
|
||||
siteName: "Jyotisha",
|
||||
title: article.title,
|
||||
description: article.summary,
|
||||
url,
|
||||
section: learnColumn(article.column).name,
|
||||
...(article.reviewed ? { modifiedTime: article.reviewed } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** schema.org Article, `<` escaped so the text cannot close the script tag. */
|
||||
export function learnArticleJsonLd(article: LearnArticle): string {
|
||||
const url = learnAbsoluteUrl(article.href);
|
||||
const data: Record<string, unknown> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: article.title,
|
||||
description: article.summary,
|
||||
inLanguage: "zh-CN",
|
||||
url,
|
||||
mainEntityOfPage: url,
|
||||
articleSection: learnColumn(article.column).name,
|
||||
isPartOf: { "@type": "CollectionPage", name: "印度占星知识", url: learnAbsoluteUrl(LEARN_PATH) },
|
||||
publisher: { "@type": "Organization", name: "Jyotisha", url: LEARN_SITE_ORIGIN },
|
||||
};
|
||||
if (article.reviewer && article.reviewed) {
|
||||
data.author = { "@type": "Person", name: article.reviewer };
|
||||
data.dateModified = article.reviewed;
|
||||
}
|
||||
return JSON.stringify(data).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
/** 「接着读」 in the order the author listed them. */
|
||||
export function relatedLearnArticles(article: LearnArticle, all: readonly LearnArticle[]): LearnArticle[] {
|
||||
return article.related.flatMap((slug) => all.filter((candidate) => candidate.slug === slug));
|
||||
}
|
||||
|
||||
export function learnSitemapEntries(articles: readonly LearnArticle[]): MetadataRoute.Sitemap {
|
||||
const signed = articles.filter((article) => !article.draft);
|
||||
const latest = signed.map((article) => article.reviewed!).sort().at(-1);
|
||||
return [
|
||||
{ url: learnAbsoluteUrl(LEARN_PATH), ...(latest ? { lastModified: latest } : {}), changeFrequency: "weekly", priority: 0.8 },
|
||||
...signed.map((article) => ({
|
||||
url: learnAbsoluteUrl(article.href),
|
||||
lastModified: article.reviewed!,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.6,
|
||||
})),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 印度占星知识 (/learn, issue #157): the fixed parts every article refers to.
|
||||
* Client-safe — no file access here; the Markdown loader is `content.ts`.
|
||||
*
|
||||
* The column ids and tool ids are what the astrologer writes in an article's
|
||||
* frontmatter (`column:` and `tools:`), so renaming one breaks her files.
|
||||
*/
|
||||
|
||||
/** Canonical origin for search engines. Staging serves the same pages but points them here. */
|
||||
export const LEARN_SITE_ORIGIN = "https://jyotisha.chat";
|
||||
export const LEARN_PATH = "/learn";
|
||||
|
||||
export const LEARN_INDEX_TITLE = "印度占星知识";
|
||||
export const LEARN_INDEX_DESCRIPTION = "从「它是什么」讲到「怎么看一件具体的事」。术语第一次出现时会括注原文;派别有分歧的地方,会写明我们用的是哪一种。";
|
||||
|
||||
export const LEARN_COLUMN_IDS = ["start", "chart", "varga", "time", "topics", "rectification", "ours"] as const;
|
||||
export type LearnColumnId = (typeof LEARN_COLUMN_IDS)[number];
|
||||
|
||||
export type LearnColumn = { readonly id: LearnColumnId; readonly name: string; readonly lead: string };
|
||||
|
||||
export const LEARN_COLUMNS: readonly LearnColumn[] = [
|
||||
{ id: "start", name: "入门", lead: "第一次接触印度占星,先看这几篇。" },
|
||||
{ id: "chart", name: "星盘里有什么", lead: "九曜、星座、宫位,一张盘由哪些部分组成。" },
|
||||
{ id: "varga", name: "分盘", lead: "同一个出生时刻,按不同的切法排出的盘。" },
|
||||
{ id: "time", name: "时间", lead: "什么时候、哪一段时间,用的是哪几套方法。" },
|
||||
{ id: "topics", name: "怎么看具体问题", lead: "事业、感情、钱、家庭,各看盘上的哪里。" },
|
||||
{ id: "rectification", name: "生时校正", lead: "出生时间不准时,怎么把它找回来。" },
|
||||
{ id: "ours", name: "我们的口径", lead: "我们用哪种算法、为什么,以及哪些话我们不说。" },
|
||||
];
|
||||
|
||||
export function learnColumn(id: LearnColumnId): LearnColumn {
|
||||
return LEARN_COLUMNS.find((column) => column.id === id)!;
|
||||
}
|
||||
|
||||
export const LEARN_TOOL_IDS = ["chart", "rectification", "annual", "synastry", "report"] as const;
|
||||
export type LearnToolId = (typeof LEARN_TOOL_IDS)[number];
|
||||
|
||||
/** 「在 Jyotisha 里看」 entries. 年运 has no page of its own; it is a part of the personal report. */
|
||||
export const LEARN_TOOLS: Readonly<Record<LearnToolId, { readonly label: string; readonly href: string }>> = {
|
||||
chart: { label: "看我的星盘", href: "/chart" },
|
||||
rectification: { label: "开始生时校正", href: "/rectification" },
|
||||
annual: { label: "在个人报告里看年运", href: "/reports" },
|
||||
synastry: { label: "婚恋合盘", href: "/synastry" },
|
||||
report: { label: "生成个人报告", href: "/reports" },
|
||||
};
|
||||
|
||||
export function learnColumnHref(column: LearnColumnId): string {
|
||||
return `${LEARN_PATH}#${column}`;
|
||||
}
|
||||
|
||||
export function learnArticleHref(article: { readonly column: LearnColumnId; readonly slug: string }): string {
|
||||
return `${LEARN_PATH}/${article.column}/${article.slug}`;
|
||||
}
|
||||
|
||||
export function learnAbsoluteUrl(path: string): string {
|
||||
return `${LEARN_SITE_ORIGIN}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { LearnArticle } from "./article-file.ts";
|
||||
|
||||
/**
|
||||
* 盘面要素 → 文章 (issue #157): where the chart page may show a 「这个怎么看」
|
||||
* link. A link appears only when the article it names exists; add a row here
|
||||
* when the astrologer's article for that element arrives. Keys to come follow
|
||||
* the same shape, e.g. `ascendant:Cancer`, `mahadasha:Rahu`, `nakshatra:Pushya`.
|
||||
*/
|
||||
export type ChartLearnTopic =
|
||||
| "birth-time-uncertain"
|
||||
| `ascendant:${string}`
|
||||
| `mahadasha:${string}`
|
||||
| `nakshatra:${string}`;
|
||||
|
||||
export const CHART_LEARN_TOPICS: Readonly<Partial<Record<ChartLearnTopic, string>>> = {
|
||||
// The header line that says the ascendant may still change with the birth time.
|
||||
"birth-time-uncertain": "rectification-accuracy",
|
||||
};
|
||||
|
||||
export type ChartLearnLink = { readonly href: string; readonly title: string };
|
||||
export type ChartLearnLinks = Readonly<Partial<Record<ChartLearnTopic, ChartLearnLink>>>;
|
||||
|
||||
/** Resolved on the server (the chart route reads the articles at build time) and handed to the page as plain data. */
|
||||
export function chartLearnLinks(articles: readonly Pick<LearnArticle, "slug" | "href" | "title">[]): ChartLearnLinks {
|
||||
const links: Partial<Record<ChartLearnTopic, ChartLearnLink>> = {};
|
||||
for (const [topic, slug] of Object.entries(CHART_LEARN_TOPICS) as [ChartLearnTopic, string][]) {
|
||||
const article = articles.find((candidate) => candidate.slug === slug);
|
||||
if (article) links[topic] = { href: article.href, title: article.title };
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
export function chartLearnLink(links: ChartLearnLinks | null | undefined, topic: ChartLearnTopic): ChartLearnLink | null {
|
||||
return links?.[topic] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { checkLearnArticles, parseLearnArticle, type LearnArticle } from "./article-file.ts";
|
||||
|
||||
/**
|
||||
* Reads `frontend/content/learn/*.md`, one file per article (issue #157). Runs
|
||||
* at build time: every /learn page is statically generated, and an invalid
|
||||
* file throws here, which fails `next build` and the loader test.
|
||||
*/
|
||||
export const LEARN_CONTENT_DIR = path.join(process.cwd(), "content", "learn");
|
||||
|
||||
export function readLearnArticles(directory: string): LearnArticle[] {
|
||||
const files = readdirSync(directory).filter((name) => name.endsWith(".md")).sort();
|
||||
return checkLearnArticles(files.map((name) => parseLearnArticle(name, readFileSync(path.join(directory, name), "utf8"))));
|
||||
}
|
||||
|
||||
let cached: LearnArticle[] | null = null;
|
||||
|
||||
export function learnArticles(): readonly LearnArticle[] {
|
||||
cached ??= readLearnArticles(LEARN_CONTENT_DIR);
|
||||
return cached;
|
||||
}
|
||||
|
||||
export function findLearnArticle(column: string, slug: string): LearnArticle | null {
|
||||
return learnArticles().find((article) => article.column === column && article.slug === slug) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
import { LEARN_SITE_ORIGIN, learnAbsoluteUrl } from "./catalog.ts";
|
||||
|
||||
/** Production lets crawlers in (signed-in pages carry their own noindex); every other host is closed. */
|
||||
export function learnRobots(userOrigin: string | undefined): MetadataRoute.Robots {
|
||||
if (userOrigin?.trim().replace(/\/$/, "") !== LEARN_SITE_ORIGIN) {
|
||||
return { rules: { userAgent: "*", disallow: "/" } };
|
||||
}
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/", disallow: ["/api/", "/admin"] },
|
||||
sitemap: learnAbsoluteUrl("/sitemap.xml"),
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { CHART_VIEW_LAYERS, type ChartViewLayer } from "../src/lib/chart-view-en
|
||||
import { chartViewFailureResponse } from "../src/lib/chart-view-failure.ts";
|
||||
import { CHART_VIEW_COPY } from "../src/lib/chart-view-labels.ts";
|
||||
import { CHART_SETTINGS_HREF } from "../src/lib/settings-url.ts";
|
||||
import { chartLearnLinks } from "../src/lib/learn/chart-topics.ts";
|
||||
import { learnArticles } from "../src/lib/learn/content.ts";
|
||||
import { westernLabelBox } from "../src/components/chart-page/western-wheel-layout.ts";
|
||||
import { cssDeclarations } from "./css-contract-test-support.ts";
|
||||
import { goldenSky } from "./birth-sky-test-support.ts";
|
||||
@@ -211,6 +213,33 @@ test("someone else's chart states the ascendant without a rectification link", a
|
||||
assert.match(selfUnknown, />出生时间校正</);
|
||||
});
|
||||
|
||||
test("the ascendant line links its 印度占星知识 article only while the ascendant can change (issue #157)", async () => {
|
||||
const view = await okView();
|
||||
const learnLinks = chartLearnLinks(learnArticles());
|
||||
const article = /href="\/learn\/rectification\/rectification-accuracy">生时校正能做到多准?<\/a>/;
|
||||
const mayChange = {
|
||||
state: "may_change" as const,
|
||||
signs: ["白羊", "金牛"],
|
||||
changeAt: ["12:01"],
|
||||
windowMinutes: 31,
|
||||
precisionKind: "minutes" as const,
|
||||
beforeMinutes: 15,
|
||||
afterMinutes: 15,
|
||||
};
|
||||
const two = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: mayChange }, learnLinks }));
|
||||
assert.match(two, /<p class="chart-lagna-precheck">[^]*?出生时间校正<\/a>。 <a [^>]*href="\/learn\/rectification\/rectification-accuracy"/);
|
||||
const other = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, subjectId: "person-1", lagnaPrecheck: mayChange }, learnLinks }));
|
||||
assert.match(other, article);
|
||||
const fixed = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: {
|
||||
state: "fixed", signs: ["白羊"], windowMinutes: 5, precisionKind: "minutes", beforeMinutes: 2, afterMinutes: 2,
|
||||
} }, learnLinks }));
|
||||
assert.doesNotMatch(fixed, /href="\/learn\//);
|
||||
// No article for the topic, no link.
|
||||
const none = withSidebarProvider(React.createElement(ChartPageView, { view: { ...view, lagnaPrecheck: mayChange }, learnLinks: {} }));
|
||||
assert.doesNotMatch(none, /href="\/learn\//);
|
||||
assert.match(none, />出生时间校正<\/a>。<\/p>/);
|
||||
});
|
||||
|
||||
test("the four tabs render and can each be selected", async () => {
|
||||
const view = await okView();
|
||||
for (const tab of CHART_VIEW_TABS) {
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { LearnArticleView } from "../src/components/learn/learn-article-view.tsx";
|
||||
import { learnIndexSelection } from "../src/components/learn/learn-index.tsx";
|
||||
import { LearnIndexPage, learnIndexColumns } from "../src/components/learn/learn-index-page.tsx";
|
||||
import { checkLearnArticles, parseLearnArticle } from "../src/lib/learn/article-file.ts";
|
||||
import { learnArticleJsonLd, learnArticleMetadata, learnSitemapEntries, relatedLearnArticles } from "../src/lib/learn/article-page.ts";
|
||||
import { LEARN_COLUMN_IDS, LEARN_COLUMNS } from "../src/lib/learn/catalog.ts";
|
||||
import { CHART_LEARN_TOPICS, chartLearnLink, chartLearnLinks } from "../src/lib/learn/chart-topics.ts";
|
||||
import { learnArticles, readLearnArticles } from "../src/lib/learn/content.ts";
|
||||
import { learnRobots } from "../src/lib/learn/robots.ts";
|
||||
import { generateStaticParams } from "../src/app/learn/[column]/[slug]/page.tsx";
|
||||
import sitemap from "../src/app/sitemap.ts";
|
||||
|
||||
// 印度占星知识 /learn (issue #157, prototype approved 2026-11-01).
|
||||
Object.assign(globalThis, { React });
|
||||
const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8");
|
||||
|
||||
function article(fields: Record<string, string>, body = "## 小标题\n\n正文。", name = "sample-article.md") {
|
||||
const head = Object.entries({ title: "示例是什么?", summary: "一句话答案。", column: "start", order: "1", ...fields })
|
||||
.filter(([, value]) => value !== "")
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
return parseLearnArticle(name, `---\n${head}\n---\n\n${body}\n`);
|
||||
}
|
||||
|
||||
const rejects = (fields: Record<string, string>, message: RegExp, body?: string, name?: string) =>
|
||||
assert.throws(() => article(fields, body, name), message);
|
||||
|
||||
test("the shipped content is the one example article, an unsigned draft", () => {
|
||||
const articles = learnArticles();
|
||||
assert.deepEqual(articles.map((item) => item.href), ["/learn/rectification/rectification-accuracy"]);
|
||||
const [only] = articles;
|
||||
assert.equal(only.title, "生时校正能做到多准?");
|
||||
assert.equal(only.summary, "能把出生时间缩小到几分钟的范围,但做不到「精确到秒」。");
|
||||
assert.equal(only.draft, true);
|
||||
assert.equal(only.reviewer, null);
|
||||
assert.equal(only.reviewed, null);
|
||||
assert.deepEqual(only.tools, ["rectification", "chart"]);
|
||||
assert.deepEqual(only.conventions, ["岁差:Raman。", "罗睺、计都:平均交点(Mean Node)。", "测试用的公开人物:出生时间来自可靠的出生记录(Rodden AA 级)。"]);
|
||||
assert.match(only.body, /^## 为什么差几分钟就不一样/);
|
||||
assert.match(only.body, /\*\*76 \/ 77\*\*/);
|
||||
assert.doesNotMatch(only.body, /一定|注定/);
|
||||
});
|
||||
|
||||
test("the frontmatter template from the issue parses: comments, inline and block lists", () => {
|
||||
const parsed = parseLearnArticle("rectify-how.md", [
|
||||
"---",
|
||||
"title: 生时校正能做到多准? # 问句",
|
||||
"summary: 能把出生时间缩小到几分钟的范围,但做不到「精确到秒」。 # 一句话答案,60 字以内",
|
||||
"column: rectification # 栏目",
|
||||
"order: 2 # 栏目里的排序",
|
||||
"reviewer: 青禾",
|
||||
"reviewed: 2026-11-01",
|
||||
"tools: [rectification, chart, rectification]",
|
||||
"conventions: # 「我们采用的口径」",
|
||||
" - 岁差:Raman",
|
||||
" - \"罗睺、计都:平均交点 # Mean\"",
|
||||
"related: []",
|
||||
"sources:",
|
||||
" - BPHS 第 x 章",
|
||||
"---",
|
||||
"",
|
||||
"## 小标题",
|
||||
"",
|
||||
"正文。术语第一次出现时括注,例如:上升(Lagna)。",
|
||||
].join("\r\n"));
|
||||
assert.equal(parsed.slug, "rectify-how");
|
||||
assert.equal(parsed.order, 2);
|
||||
assert.equal(parsed.reviewer, "青禾");
|
||||
assert.equal(parsed.draft, false);
|
||||
assert.deepEqual(parsed.tools, ["rectification", "chart"]);
|
||||
assert.deepEqual(parsed.conventions, ["岁差:Raman", "罗睺、计都:平均交点 # Mean"]);
|
||||
assert.deepEqual(parsed.related, []);
|
||||
assert.deepEqual(parsed.sources, ["BPHS 第 x 章"]);
|
||||
assert.equal(parsed.href, "/learn/rectification/rectify-how");
|
||||
});
|
||||
|
||||
test("a bad article file is rejected with the file name and a reason she can act on", () => {
|
||||
rejects({ title: "" }, /sample-article\.md: 缺少「title」/);
|
||||
rejects({ title: "示例" }, /标题用问句/);
|
||||
rejects({ summary: "长".repeat(61) }, /摘要不超过 60 个字/);
|
||||
rejects({ column: "career" }, /栏目只能是 start \/ chart/);
|
||||
rejects({ order: "0" }, /排序写一个正整数/);
|
||||
rejects({ order: "first" }, /排序写一个正整数/);
|
||||
rejects({ reviewer: "笔名", reviewed: "2026-11-01" }, /把「笔名」换成真实的笔名/);
|
||||
rejects({ reviewer: "青禾" }, /审校人和审校日期要一起写/);
|
||||
rejects({ reviewed: "2026-11-01" }, /审校人和审校日期要一起写/);
|
||||
rejects({ reviewer: "青禾", reviewed: "2026-02-30" }, /不是一个真实的日期/);
|
||||
rejects({ reviewer: "青禾", reviewed: "2026/11/01" }, /日期写成 2026-11-01/);
|
||||
rejects({ tools: "[chart, horoscope]" }, /工具只能是/);
|
||||
rejects({ related: "[Other Article]" }, /接着读填文章的文件名/);
|
||||
rejects({ author: "某人" }, /不认识的字段:author/);
|
||||
rejects({}, /正文不要用一级标题/, "# 标题\n\n正文。");
|
||||
rejects({}, /正文是空的/, "");
|
||||
rejects({}, /文件名只用小写英文字母/, undefined, "Sample Article.md");
|
||||
assert.throws(() => parseLearnArticle("x.md", "## 没有字段\n"), /文件开头要有一对 ---/);
|
||||
assert.throws(() => parseLearnArticle("x.md", "---\ntitle: 示例?\ntitle: 又一次?\n---\n正文"), /第 3 行:「title」写了两次/);
|
||||
assert.throws(() => parseLearnArticle("x.md", "---\n - 孤零零的一项\n---\n正文"), /第 2 行:「- 」前面要有一个以冒号结尾的字段/);
|
||||
assert.throws(() => parseLearnArticle("x.md", "---\n标题 生时校正\n---\n正文"), /第 2 行看不懂/);
|
||||
});
|
||||
|
||||
test("checks across files: order is unique per column, 接着读 points at real articles, list is in column order", () => {
|
||||
const a = article({ column: "time", order: "1" }, undefined, "a.md");
|
||||
const b = article({ column: "start", order: "2", related: "[a]" }, undefined, "b.md");
|
||||
const c = article({ column: "start", order: "1" }, undefined, "c.md");
|
||||
assert.deepEqual(checkLearnArticles([a, b, c]).map((item) => item.slug), ["c", "b", "a"]);
|
||||
assert.throws(() => checkLearnArticles([c, article({ column: "start", order: "1" }, undefined, "d.md")]), /d\.md: 和 c\.md 在同一栏目里排序都是 1/);
|
||||
assert.throws(() => checkLearnArticles([article({ related: "[missing]" }, undefined, "e.md")]), /接着读里的「missing」没有这篇文章/);
|
||||
assert.throws(() => checkLearnArticles([article({ related: "[f]" }, undefined, "f.md")]), /接着读不要填自己/);
|
||||
assert.deepEqual(relatedLearnArticles(b, [a, b, c]).map((item) => item.slug), ["a"]);
|
||||
});
|
||||
|
||||
test("the loader reads every .md in the folder and fails on the first bad one", () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "learn-"));
|
||||
try {
|
||||
writeFileSync(path.join(dir, "one.md"), "---\ntitle: 一?\nsummary: 一。\ncolumn: start\norder: 1\n---\n正文");
|
||||
writeFileSync(path.join(dir, "notes.txt"), "not an article");
|
||||
assert.deepEqual(readLearnArticles(dir).map((item) => item.slug), ["one"]);
|
||||
writeFileSync(path.join(dir, "two.md"), "---\ntitle: 二?\nsummary: 二。\ncolumn: nowhere\norder: 1\n---\n正文");
|
||||
assert.throws(() => readLearnArticles(dir), /two\.md: 「column」栏目只能是/);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("every article is a static page and nothing else is (dynamicParams = false)", () => {
|
||||
assert.deepEqual(generateStaticParams(), [{ column: "rectification", slug: "rectification-accuracy" }]);
|
||||
const route = read("../src/app/learn/[column]/[slug]/page.tsx");
|
||||
assert.match(route, /export const dynamicParams = false;/);
|
||||
assert.match(route, /if \(!article\) notFound\(\);/);
|
||||
assert.match(read("../src/app/learn/page.tsx"), /export const dynamic = "force-static";/);
|
||||
});
|
||||
|
||||
test("metadata: canonical on the production origin; a draft is readable but not indexed", () => {
|
||||
const [draft] = learnArticles();
|
||||
const meta = learnArticleMetadata(draft);
|
||||
assert.equal(meta.title, "生时校正能做到多准? · 印度占星知识 · Jyotisha");
|
||||
assert.equal(meta.description, draft.summary);
|
||||
assert.deepEqual(meta.alternates, { canonical: "https://jyotisha.chat/learn/rectification/rectification-accuracy" });
|
||||
assert.deepEqual(meta.robots, { index: false, follow: true });
|
||||
const signed = article({ reviewer: "青禾", reviewed: "2026-11-15", title: "a<b?" });
|
||||
assert.deepEqual(learnArticleMetadata(signed).robots, { index: true, follow: true });
|
||||
const jsonLd = learnArticleJsonLd(signed);
|
||||
assert.doesNotMatch(jsonLd, /</);
|
||||
const data = JSON.parse(jsonLd);
|
||||
assert.equal(data["@type"], "Article");
|
||||
assert.equal(data.headline, "a<b?");
|
||||
assert.equal(data.dateModified, "2026-11-15");
|
||||
assert.deepEqual(data.author, { "@type": "Person", name: "青禾" });
|
||||
assert.equal(JSON.parse(learnArticleJsonLd(draft)).author, undefined);
|
||||
});
|
||||
|
||||
test("the sitemap lists /learn and signed articles only", () => {
|
||||
assert.deepEqual(sitemap().map((entry) => entry.url), ["https://jyotisha.chat/learn"]);
|
||||
const signed = article({ reviewer: "青禾", reviewed: "2026-11-15" }, undefined, "signed.md");
|
||||
const later = article({ reviewer: "青禾", reviewed: "2026-12-01", order: "2" }, undefined, "later.md");
|
||||
const entries = learnSitemapEntries([...learnArticles(), signed, later]);
|
||||
assert.deepEqual(entries.map((entry) => [entry.url, entry.lastModified]), [
|
||||
["https://jyotisha.chat/learn", "2026-12-01"],
|
||||
["https://jyotisha.chat/learn/start/signed", "2026-11-15"],
|
||||
["https://jyotisha.chat/learn/start/later", "2026-12-01"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("robots.txt opens production only", () => {
|
||||
assert.deepEqual(learnRobots("https://jyotisha.chat"), {
|
||||
rules: { userAgent: "*", allow: "/", disallow: ["/api/", "/admin"] },
|
||||
sitemap: "https://jyotisha.chat/sitemap.xml",
|
||||
});
|
||||
assert.deepEqual(learnRobots("https://staging.jyotisha.chat"), { rules: { userAgent: "*", disallow: "/" } });
|
||||
assert.deepEqual(learnRobots(undefined), { rules: { userAgent: "*", disallow: "/" } });
|
||||
assert.match(read("../src/app/robots.ts"), /export const dynamic = "force-dynamic";[\s\S]*learnRobots\(process\.env\.AUTH_USER_ORIGIN\)/);
|
||||
});
|
||||
|
||||
test("index page: 全部 plus the seven columns, every question with its summary, no article body", () => {
|
||||
const markup = renderToStaticMarkup(<LearnIndexPage articles={learnArticles()} />);
|
||||
assert.match(markup, /<main class="standalone-page learn-page">/);
|
||||
assert.match(markup, /<h1 id="learn-title">印度占星知识<\/h1>/);
|
||||
const picks = [...markup.matchAll(/<button [^>]*aria-pressed="(true|false)" class="[^"]*learn-column-pick[^"]*"[^>]*>([^<]+)<span class="learn-column-count">(\d+)<\/span>/g)]
|
||||
.map((hit) => [hit[2], hit[3], hit[1]]);
|
||||
assert.deepEqual(picks, [
|
||||
["全部", "1", "true"],
|
||||
...LEARN_COLUMNS.map((column) => [column.name, column.id === "rectification" ? "1" : "0", "false"]),
|
||||
]);
|
||||
assert.equal(LEARN_COLUMN_IDS.length, 7);
|
||||
// 全部 lists the columns that have articles.
|
||||
assert.deepEqual([...markup.matchAll(/<section id="([a-z]+)" class="learn-column"/g)].map((hit) => hit[1]), ["rectification"]);
|
||||
assert.match(markup, /<a class="learn-article-row" href="\/learn\/rectification\/rectification-accuracy">[\s\S]*?生时校正能做到多准?[\s\S]*?能把出生时间缩小到几分钟的范围/);
|
||||
assert.doesNotMatch(markup, /为什么差几分钟就不一样/);
|
||||
assert.deepEqual(learnIndexColumns(learnArticles()).find((column) => column.id === "rectification")!.articles, [{
|
||||
slug: "rectification-accuracy",
|
||||
title: "生时校正能做到多准?",
|
||||
summary: "能把出生时间缩小到几分钟的范围,但做不到「精确到秒」。",
|
||||
href: "/learn/rectification/rectification-accuracy",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("picking a column: #id shows that column alone, empty or not; anything else is 全部", () => {
|
||||
const columns = learnIndexColumns(learnArticles());
|
||||
const ids = (hash: string) => {
|
||||
const { active, shown } = learnIndexSelection(columns, hash);
|
||||
return [active, shown.map((column) => column.id)];
|
||||
};
|
||||
assert.deepEqual(ids(""), ["all", ["rectification"]]);
|
||||
assert.deepEqual(ids("rectification"), ["rectification", ["rectification"]]);
|
||||
assert.deepEqual(ids("time"), ["time", ["time"]]);
|
||||
assert.deepEqual(ids("nonsense"), ["all", ["rectification"]]);
|
||||
const client = read("../src/components/learn/learn-index.tsx");
|
||||
assert.match(client, /window\.history\.replaceState\(null, "", next === "all" \? window\.location\.pathname : `#\$\{next\}`\);/);
|
||||
assert.match(client, /<p className="learn-column-empty">这个栏目的文章还在写。<\/p>/);
|
||||
});
|
||||
|
||||
test("article page: question, answer, draft note, body, 口径, tools; no 接着读 without related", () => {
|
||||
const [draft] = learnArticles();
|
||||
const markup = renderToStaticMarkup(<LearnArticleView article={draft} related={[]} />);
|
||||
assert.match(markup, /<a href="\/learn">知识<\/a><span aria-hidden="true">\/<\/span><a href="\/learn#rectification">生时校正<\/a>/);
|
||||
assert.match(markup, /<h1>生时校正能做到多准?<\/h1><p class="learn-article-answer">能把出生时间缩小到几分钟的范围,但做不到「精确到秒」。<\/p>/);
|
||||
assert.match(markup, /<p class="learn-draft" role="note">初稿 · 待占星师审校<\/p>/);
|
||||
assert.doesNotMatch(markup, /审校:|\[笔名\]/);
|
||||
assert.match(markup, /<h2>为什么差几分钟就不一样<\/h2>/);
|
||||
assert.match(markup, /<li><strong>76 \/ 77<\/strong>:记录上的出生时间落在我们给出的范围里。<\/li>/);
|
||||
assert.match(markup, /<aside class="learn-conventions" aria-labelledby="learn-conventions-title"><p id="learn-conventions-title" class="learn-conventions-title">我们采用的口径<\/p><ul><li>岁差:Raman。<\/li>/);
|
||||
assert.match(markup, /<a class="learn-tool" href="\/rectification">开始生时校正<svg[\s\S]*?<a class="learn-tool" href="\/chart">看我的星盘<svg/);
|
||||
assert.match(markup, /<h2 id="learn-sources-title">参考出处<\/h2>/);
|
||||
assert.doesNotMatch(markup, /接着读/);
|
||||
});
|
||||
|
||||
test("a signed article shows reviewer and date, 接着读, and drops raw HTML from the Markdown", () => {
|
||||
const other = article({ order: "2" }, undefined, "other.md");
|
||||
const signed = article(
|
||||
{ reviewer: "青禾", reviewed: "2026-11-15", related: "[other]" },
|
||||
"## 小标题\n\n<script>alert(1)</script>\n\n| 层 | 宽度 |\n| --- | --- |\n| 第一层 | 15 分钟 |\n\n[外部](https://example.com) 和 [站内](/chart)",
|
||||
);
|
||||
const markup = renderToStaticMarkup(<LearnArticleView article={signed} related={relatedLearnArticles(signed, [signed, other])} />);
|
||||
assert.match(markup, /<p class="learn-byline">审校:青禾 · <time dateTime="2026-11-15">2026 年 11 月 15 日<\/time><\/p>/);
|
||||
assert.doesNotMatch(markup, /learn-draft|<script>/);
|
||||
assert.match(markup, /<div class="learn-table"><table>/);
|
||||
assert.match(markup, /<a href="https:\/\/example.com" rel="noreferrer" target="_blank">外部<\/a>/);
|
||||
assert.match(markup, /<a href="\/chart">站内<\/a>/);
|
||||
assert.match(markup, /<h2 id="learn-related-title">接着读<\/h2><ul><li><a href="\/learn\/start\/other">示例是什么?<\/a><\/li><\/ul>/);
|
||||
// No 口径, tools or sources written: those blocks are left out, not drawn empty.
|
||||
assert.doesNotMatch(markup, /我们采用的口径|在 Jyotisha 里看|参考出处/);
|
||||
});
|
||||
|
||||
test("chart page lookup: a topic links only when its article exists, and the table names real articles", () => {
|
||||
const links = chartLearnLinks(learnArticles());
|
||||
assert.deepEqual(chartLearnLink(links, "birth-time-uncertain"), {
|
||||
href: "/learn/rectification/rectification-accuracy",
|
||||
title: "生时校正能做到多准?",
|
||||
});
|
||||
assert.equal(chartLearnLink(links, "ascendant:Cancer"), null);
|
||||
assert.equal(chartLearnLink(links, "mahadasha:Rahu"), null);
|
||||
assert.equal(chartLearnLink(null, "birth-time-uncertain"), null);
|
||||
assert.deepEqual(chartLearnLinks([]), {});
|
||||
const slugs = new Set(learnArticles().map((item) => item.slug));
|
||||
for (const [topic, slug] of Object.entries(CHART_LEARN_TOPICS)) {
|
||||
assert.ok(slugs.has(slug!), `${topic} points at ${slug}.md, which does not exist`);
|
||||
}
|
||||
const route = read("../src/app/(app)/chart/page.tsx");
|
||||
assert.match(route, /<ChartPageRoute learnLinks=\{chartLearnLinks\(learnArticles\(\)\)\} \/>/);
|
||||
});
|
||||
|
||||
test("the web image copies the content folder before next build (BUG-1140 class)", () => {
|
||||
const dockerfile = read("../../deploy/railway-web.Dockerfile");
|
||||
const copy = dockerfile.indexOf("COPY frontend/content ./content");
|
||||
assert.ok(copy > 0, "deploy/railway-web.Dockerfile must copy frontend/content");
|
||||
assert.ok(copy < dockerfile.indexOf("RUN npm run build"));
|
||||
});
|
||||
|
||||
test("styles: tokens only, tables never wrap, phone columns scroll in one row", () => {
|
||||
const css = read("../src/app/learn/learn.css");
|
||||
assert.doesNotMatch(css.replace(/\/\*[\s\S]*?\*\//g, ""), /#[0-9a-fA-F]{3,8}\b|rgba?\(/);
|
||||
assert.match(css, /\.learn-table th, \.learn-table td \{[^}]*white-space: nowrap;/);
|
||||
assert.match(css, /@media \(max-width: 767px\) \{[\s\S]*\.learn-columns \{[^}]*flex-direction: row;[^}]*overflow-x: auto;/);
|
||||
assert.match(read("../src/app/learn/layout.tsx"), /import "\.\.\/site-styles";\nimport "\.\/learn\.css";/);
|
||||
});
|
||||
|
||||
test("signed-in pages stay noindex", () => {
|
||||
assert.match(read("../src/app/(app)/chart/page.tsx"), /robots: \{ index: false, follow: false \}/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user