diff --git a/.github/workflows/jekyll.yml b/.github/workflows/jekyll.yml
index 5b30ece..5800afa 100644
--- a/.github/workflows/jekyll.yml
+++ b/.github/workflows/jekyll.yml
@@ -72,20 +72,21 @@ jobs:
JEKYLL_ENV: production
- name: Validate HTML, internal links, and images
# Catches broken internal links/anchors and missing images before deploy.
- # External links are skipped (slow, flaky); hash-only hrefs are allowed.
+ # External links are skipped (slow, flaky) but must be https; hash-only
+ # hrefs are allowed.
# --swap-urls strips the Pages base_path so absolute /baseurl/... links
# resolve against _site/ regardless of what base_path the build used.
run: |
bundle exec htmlproofer ./_site \
--disable-external \
--allow-hash-href \
- --no-enforce-https \
--swap-urls "^${{ steps.pages.outputs.base_path }}:"
- name: Validate discoverability (sitemap, feed, metadata)
# Guards what htmlproofer does not look at: that the sitemap and feed
- # parse, that every page carries a description and a canonical, and that
- # no two pages share a description or title. Each check maps to a defect
- # this site actually shipped.
+ # parse, that every page carries a description and a canonical, that no
+ # two pages share a description or title, and that each plugin hook left
+ # its mark on the posts (script/check-plugin-output.rb). Each check maps
+ # to a defect this site actually shipped.
run: script/validate-site.sh ./_site
- name: Upload artifact
# Nothing to publish from a PR run; the gates above are the whole point.
diff --git a/README.ko.md b/README.ko.md
index fc69549..8a96de4 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -49,7 +49,7 @@ bundle exec jekyll serve
`_posts/`, `_sass/`, `_includes/` 아래 파일을 고치고 저장한 뒤 새로고침하면
대부분 바로 반영됩니다. 단, `_config.yml`을 바꿨을 때는 서버를 다시 시작해야 합니다.
-**4. 최종 결과물이 필요하면 프로덕션 빌드를 합니다** (CI가 하는 것과 동일).
+**4. 최종 결과물이 필요하면 빌드합니다** (CI 옵션은 `.github/workflows/jekyll.yml`, 로컬 검사는 "푸시 전에 확인하세요" 참고).
```bash
bundle exec jekyll build # 결과물은 _site/ 에 생성
@@ -81,7 +81,7 @@ _plugins/ reading_time.rb (한·영 읽기 시간 계산)
search_index.rb (search.json용 plain_text 필터)
legacy_urls.rb (슬러그 이전의 옛 글 URL 리디렉션)
translations.rb (번역본을 글 목록에서 빼기)
- posted_date.rb (파일명 날짜로 last_modified_at 채우기)
+ posted_date.rb (파일명 날짜가 더 늦으면 last_modified_at 채우기)
css/ main.scss(Sass 진입점) · search.css(검색 페이지 전용)
js/ main.js(테마 토글·코드 복사·목차·메뉴·이미지 확대 등)
search.js(검색창 동작)
@@ -91,9 +91,12 @@ search.json 전체 본문 검색 색인 (simple-jekyll-search가 사용)
test/ _plugins/ 로직의 minitest 단위 테스트
script/ lint-posts.rb (빌드 전 글 원본 규칙 검사)
validate-site.sh (빌드 후 검색 노출 검사)
+ check-plugin-output.rb (플러그인 훅이 글에 남긴 결과 검사,
+ validate-site.sh가 실행)
_data/ post_conventions.yml (카테고리·표지·통제 태그·폐기 태그의
유일한 정본. scholar-lens도 읽음)
.github/workflows/ CI: 테스트 → 린트 → 빌드 → html-proofer → validate-site, main 푸시 시 배포
+ workflow-lint.yml: actionlint와 shellcheck
```
**최상위 페이지:** `index.html`(홈), 네 개의 섹션 페이지인 `paper-reviews.md`·
@@ -116,14 +119,15 @@ subtitle: "<한 줄 소개>" # 선택 — 헤더의 제목 아래에
date: YYYY-MM-DD HH:MM:SS
paper_author: "<기관>" # 논문을 낸 기관(사람 이름 아님). Insights·의견 글이면 생략
seo_title: "<짧은 제목>" # 선택 — 논문 제목이 너무 길 때
에 대신 씁니다
-description: >- # 선택 — 아래 설명 참고
+description: >- # 선택, 평문 — 아래 설명 참고
<검색 결과에 노출될 문장, 150자 안팎>
categories: ["<유형>", "<주제>"]
tags: ["<태그-1>", "<태그-2>"]
-cover: /assets/images/.(jpg|png)
+cover: /assets/images/.(jpg|png) # Insights는 insights.jpg
use_math: true # 수식이 있을 때만 (MathJax를 불러옵니다)
lang: ko # translation_id를 쓰면 필수(ko | en)…
translation_id: <공통-슬러그> # …한국어 글과 -en 번역본을 연결합니다.
+ # 두 글 모두에 씁니다(린트가 짝을 확인).
# 목록에는 한국어 글만 나옵니다
---
```
@@ -143,7 +147,15 @@ RSS ``에 실리는 문장입니다. 생략하면 `_plugins/post_descri
고지로 시작하는 글 — 즉 대부분의 Insights 글 — 은 직접 쓰는 편이 낫습니다.
설명문은 **사이트 전체에서 중복되면 안 됩니다.** 두 페이지가 같은 설명문을 쓰면 CI가
-빌드를 실패시킵니다.
+빌드를 실패시킵니다. 설명문은 평문으로 나가므로 `$$`, `*강조*`, 백틱, 링크, 태그를 쓰면
+린트가 막습니다.
+
+### 날짜, 파일명, Liquid
+
+파일명은 게시일(`YYYY-MM-DD-`)로 시작합니다. `date:`는 그보다 이를 수(논문 발행일)는 있어도
+늦을 수는 없습니다. Jekyll은 모든 글을 먼저 Liquid로 렌더하므로, 코드 블록의 `{{ … }}`나
+`{% … %}`(프롬프트 템플릿, GitHub Actions의 `${{ … }}`)는 빈칸이 되거나 빌드를 멈춥니다.
+`{% raw %}…{% endraw %}`로 감쌉니다. 세 가지 모두 린트가 확인합니다.
### 카테고리가 URL을 결정합니다
@@ -154,7 +166,7 @@ RSS ``에 실리는 문장입니다. 생략하면 `_plugins/post_descri
**탭을 유지하고** 빈 상태 문구를 띄웁니다 — 탭이 없으면 채우는 중인 섹션이 아니라
없어진 섹션으로 읽히기 때문입니다.
- `categories[1]` — **주제**: `_data/post_conventions.yml`의 `topics` 중 하나이며, 표지
- 이미지도 거기서 정해집니다. 맞는 주제가 없으면 먼저 거기에 추가합니다. 목록에 없는 값은
+ 이미지도 거기서 정해집니다(Insights 글은 유형의 `insights.jpg`를 씁니다). 맞는 주제가 없으면 먼저 거기에 추가합니다. 목록에 없는 값은
린트가 막습니다.
Jekyll은 이 둘을 슬러그로 바꾸고(`_config.yml`의 `permalink`) 날짜와 합쳐 출력 경로를
@@ -208,14 +220,22 @@ kramdown이 내용을 그대로 보존해 `\(…\)`로 내보내므로 안전합
- `\\(…\\)`·`\\[…\\]`로 쓴 수식, 단일 `$` 옆의 TeX: 위와 같이 마크다운이 수식을 먼저
읽습니다.
-- `$$…$$` 안의 `\_`: MathJax가 역슬래시와 밑줄을 그대로 보여 줍니다. `_`로 쓰고,
+- `$$…$$` 안의 `\_`: MathJax가 아래첨자가 아닌 밑줄 문자를 그대로 그립니다. `_`로 쓰고,
`\text{…}` 안에서만 이스케이프합니다.
- 앞뒤에 빈 줄이 없는 디스플레이 `$$…$$` 줄(목록 안 포함): kramdown이 문단에 붙여 인라인
수식으로 처리합니다.
- `$$` 구간 안의 한글: 거의 언제나 통화 표기 `$$` 두 개가 한 구간으로 읽힌 것입니다.
-
-인라인 수식에서는 맨 `|` 대신 `\lvert … \rvert`를 씁니다. 표 안에서는 맨 `|`가 셀 구분자로
-읽힙니다.
+- `use_math: true` 없이 쓴 `$$` 수식: MathJax가 로드되지 않아 TeX가 글자로 보입니다.
+- HTML이 아닌 `<이름>`, ``·``, 또는 속성 없는 ``(모델의 특수 토큰): kramdown이 그대로
+ 넘기고 브라우저가 지웁니다. `코드`로 씁니다.
+- 마커 뒤에 언어 말고 다른 것이 붙은 코드 펜스(```` ```python title="a" ````)나 닫히지 않은
+ 펜스: kramdown이 펜스로 읽지 않습니다.
+- 글마다 대소문자를 다르게 쓴 태그(`Grouped-Query-…`와 `Grouped-query-…`).
+- 표 행의 `$$` 안에 있는 `|`: kramdown이 거기서 셀을 나누고, `\|`는 `|`로 바꿉니다(노름이
+ 절댓값이 됩니다). `\lVert … \rVert`를 씁니다.
+
+인라인 수식에서는 맨 `|` 대신 `\lvert … \rvert`(또는 `\mid`)를, 노름에는 `\lVert … \rVert`를 씁니다. 린트는 표 안에서만
+잡습니다.
### 푸시 전에 확인하세요
@@ -228,8 +248,9 @@ ruby script/lint-posts.rb # 글 원본이 규
bundle exec jekyll build --strict-front-matter \
--destination /tmp/site-verify # 빌드가 깨끗한가?
bundle exec htmlproofer /tmp/site-verify --disable-external \
- --allow-hash-href --no-enforce-https # 깨진 링크·이미지·앵커는 없나?
+ --allow-hash-href # 깨진 링크·이미지·앵커, http:// 링크는 없나?
script/validate-site.sh /tmp/site-verify # 사이트맵·피드·메타데이터·헤딩
+shellcheck script/*.sh assets/images/*.sh && actionlint # workflow-lint.yml이 PR과 main에서 실행
```
> **검사 결과가 말이 안 되면 먼저 떠 있는 `jekyll serve`를 찾으세요.** 파일을 감시하며
@@ -259,12 +280,8 @@ script/validate-site.sh /tmp/site-verify # 사이트맵·피
마크다운 원본의 표·수식·헤딩 규칙을 검사하고
3. `JEKYLL_ENV=production`으로 사이트를 빌드하고
4. `_site/`에 **html-proofer**를 돌려 내부 링크·이미지·앵커를 검사하고
-5. **`script/validate-site.sh`**로 사이트맵·피드가 byte 0에서 파싱되는지, 사이트맵 URL이
- 설정된 `url` 아래인지, 빌드 타임존이 고정됐는지, `robots.txt`가 막지 않고 사이트맵을 가리키는지, 렌더된
- 페이지가 1개 이상인지, 페이지당 `h1`이 1개인지, 헤딩 레벨을 건너뛰지 않는지, 모든
- 페이지에 설명문·canonical이 있는지, 설명문이 자기 제목보다 긴지, 설명문·제목이
- 중복되지 않는지, 목록 페이지에 번역본이 원본과 함께 나오지 않는지, 저작 원본이
- 배포되지 않았는지를 검사한 뒤
+5. **`script/validate-site.sh`**로 htmlproofer가 보지 않는 빌드 후 검사(사이트맵, 피드,
+ 메타데이터, 헤딩, 리디렉션, 플러그인 결과)를 돌린 뒤 — 항목은 스크립트에 있습니다
6. GitHub Pages에 배포합니다.
실패하면 대개 4·5단계입니다. Actions 로그에 어떤 링크·이미지·페이지가 문제인지 그대로
diff --git a/README.md b/README.md
index f8bb4c1..44a739c 100644
--- a/README.md
+++ b/README.md
@@ -50,8 +50,8 @@ bundle exec jekyll serve
Edit a file under `_posts/`, `_sass/`, or `_includes/`, save, and refresh — most
changes appear immediately. (Changes to `_config.yml` need a server restart.)
-**4. Build for production** (what CI does) when you want the final output in
-`_site/`:
+**4. Build** when you want the final output in `_site/` (CI's flags are in
+`.github/workflows/jekyll.yml`; "Validate before pushing" has the local gates):
```bash
bundle exec jekyll build
@@ -83,7 +83,7 @@ _plugins/ reading_time.rb (KO/EN-aware read time)
search_index.rb (plain_text filter for search.json)
legacy_urls.rb (redirects pre-slug post URLs)
translations.rb (keeps a post's translation out of the lists)
- posted_date.rb (last_modified_at from the file-name date)
+ posted_date.rb (last_modified_at from a later file-name date)
css/ main.scss (Sass entry point) · search.css (search page only)
js/ main.js (theme toggle, code-copy, TOC, menu, image zoom…)
search.js (drives the search box)
@@ -93,9 +93,12 @@ search.json Full-text search index (consumed by simple-jekyll-search)
test/ minitest unit tests for the _plugins/ logic
script/ lint-posts.rb (post sources vs the conventions, before the build)
validate-site.sh (post-build discoverability checks)
+ check-plugin-output.rb (each plugin hook's mark on the posts;
+ run by validate-site.sh)
_data/ post_conventions.yml (categories, covers, controlled and
retired tags — the one copy, also read by scholar-lens)
.github/workflows/ CI: tests → lint → build → html-proofer → validate-site; deploys on push to main
+ workflow-lint.yml: actionlint and shellcheck
```
**Top-level pages:** `index.html` (home), plus `paper-reviews.md`,
@@ -120,14 +123,15 @@ subtitle: "" # optional — shown under the title in the h
date: YYYY-MM-DD HH:MM:SS
paper_author: "" # the paper's org, not people; omit for Insights/opinion posts
seo_title: "" # optional — for when the paper title is too long
-description: >- # optional — see below
+description: >- # optional, plain text — see below
categories: ["", ""]
tags: ["", ""]
-cover: /assets/images/.(jpg|png)
+cover: /assets/images/.(jpg|png) # Insights: insights.jpg
use_math: true # ONLY if the post has equations (loads MathJax)
lang: ko # required with translation_id (ko | en)…
translation_id: # …links a Korean post to its -en twin;
+ # set on both (the lint checks the pair);
# only the Korean one is listed
---
```
@@ -148,7 +152,16 @@ enough. Write it by hand when the first paragraph opens on a pull quote or a
disclosure note — that is, on most Insights posts.
Descriptions must be **unique across the site**; CI fails the build if two pages
-share one.
+share one. They ship as plain text, so the lint rejects `$$`, `*emphasis*`,
+backticks, links and tags in them.
+
+### Dates, file names and Liquid
+
+The file name starts with the posting day (`YYYY-MM-DD-`); `date:` may be earlier
+(a paper's date) but not later. Jekyll renders every post as Liquid first, so a
+`{{ … }}` or `{% … %}` in a code block (a prompt template, a GitHub Actions
+`${{ … }}`) renders as nothing, or stops the build: wrap it in `{% raw %}…{% endraw %}`. The lint
+checks all three.
### Categories drive the URL
@@ -159,7 +172,8 @@ Categories are **two levels**:
A type with no posts yet keeps its tab and renders an empty-state line: a
missing tab reads as a section that was removed, not one still filling up.
- `categories[1]` — the **topic**, one of the `topics` in
- `_data/post_conventions.yml`, which also gives its cover image if it has one. When none fits,
+ `_data/post_conventions.yml`, which also gives its cover image if it has one (Insights
+ posts use the type's `insights.jpg` instead). When none fits,
add one there first; the lint rejects any other value.
Jekyll slugifies the two and combines them with the date to build the output
@@ -216,14 +230,24 @@ The lint also rejects what renders wrong:
- math written `\\(…\\)` or `\\[…\\]`, or TeX next to a single `$`: Markdown
reads the formula first, as above.
-- `\_` inside `$$…$$`: MathJax shows the backslash-underscore literally. Write
+- `\_` inside `$$…$$`: MathJax renders a literal underscore, not a subscript. Write
`_`; escape it only inside `\text{…}`.
- a display `$$…$$` line without a blank line before and after, in lists too:
kramdown folds it into the paragraph as inline math.
- Hangul inside a `$$` span: almost always two currency `$$`s read as one span.
-
-Inside inline math, write `\lvert … \rvert` instead of a bare `|`, which a table
-reads as a cell break.
+- `$$` math in a post without `use_math: true`: MathJax never loads, so the TeX
+ shows as text.
+- a `` that is not HTML, ``/``, or a bare `` (a model's special token):
+ kramdown passes it through and the browser drops it. Write it in `code`.
+- a code fence with more than a language after the marker (```` ```python title="a" ````),
+ or one never closed: kramdown does not read it as a fence.
+- a tag spelled two ways across posts (`Grouped-Query-…` and `Grouped-query-…`).
+- a `|` inside `$$` in a table row: kramdown splits the cell there, and turns
+ `\|` into `|` (a norm into an absolute value). Write `\lVert … \rVert`.
+
+Inside inline math, write `\lvert … \rvert` (or `\mid`) instead of a bare `|`, and
+`\lVert … \rVert` for a norm;
+the lint catches it only in tables.
### Validate before pushing
@@ -236,8 +260,9 @@ ruby script/lint-posts.rb # post sources follo
bundle exec jekyll build --strict-front-matter \
--destination /tmp/site-verify # does it build clean?
bundle exec htmlproofer /tmp/site-verify --disable-external \
- --allow-hash-href --no-enforce-https # broken links, images, anchors?
+ --allow-hash-href # broken links, images, anchors, http://?
script/validate-site.sh /tmp/site-verify # sitemap, feed, metadata, headings
+shellcheck script/*.sh assets/images/*.sh && actionlint # workflow-lint.yml, on PRs and main
```
> **If a check reports something impossible, look for a running `jekyll serve`
@@ -272,13 +297,9 @@ requests. In order, it:
Markdown source,
3. builds the site with `JEKYLL_ENV=production`,
4. runs **html-proofer** over `_site/` (internal links, images, anchors),
-5. runs **`script/validate-site.sh`** — sitemap/feed parse at byte 0, every
- sitemap URL under the configured `url`, a pinned build timezone, `robots.txt`
- not blocking and naming the sitemap, at least one rendered page, exactly one `h1` per page, no
- heading-level skips, a description and a canonical on every page, every
- description longer than its own title, no duplicate description or title, no
- listing page showing a translation beside its original, and no authoring
- sources published — and
+5. runs **`script/validate-site.sh`** — the post-build checks htmlproofer does
+ not cover (sitemap, feed, metadata, headings, redirects, plugin output); the
+ script lists them — and
6. deploys to GitHub Pages.
A failure is almost always step 4 or 5; the Actions log names the exact link,
diff --git a/_config.yml b/_config.yml
index ff17085..1212fde 100644
--- a/_config.yml
+++ b/_config.yml
@@ -6,7 +6,7 @@ plugins:
feed:
excerpt_only: true
- posts_limit: 50 # comfortably above the post count; excerpts only, so the feed stays small
+ posts_limit: 50 # above the post count (script/check-plugin-output.rb checks); excerpts only, so the feed stays small
# NOTE: the Google/Naver site-verification HTML files are intentionally NOT
# excluded — they must be served at the site root, or Search Console / Naver
@@ -55,6 +55,10 @@ url: https://bits-bytes-nn.github.io
# afternoon-dated posts onto the next day and 404 their live URLs. Pinned to UTC
# because every published URL was built that way.
timezone: UTC
+# Build posts dated later than the build. Posts here are dated 12:00 on their
+# day (21:00 KST), so a same-day merge in the Korean morning would deploy
+# without the post, and nothing rebuilds the site later. Nothing is scheduled.
+future: true
# Slugified, so "Paper Reviews" becomes paper-reviews/ rather than the
# space-escaped paper%20reviews/ that plain `:categories` produces. Posts first
# published under the old form redirect from it — see _plugins/legacy_urls.rb.
@@ -69,6 +73,12 @@ logo: /assets/my_logo.png
sass:
sourcemap: never
+# An unknown filter is an error, not a silent pass-through: without this a
+# plugin filter that fails to register renders its input (a whole post body
+# under "Read time") and every gate stays green.
+liquid:
+ strict_filters: true
+
markdown: kramdown
kramdown:
input: GFM
diff --git a/_data/post_conventions.yml b/_data/post_conventions.yml
index a4827ea..b772f71 100644
--- a/_data/post_conventions.yml
+++ b/_data/post_conventions.yml
@@ -1,8 +1,9 @@
# Post conventions, as data.
#
-# The one list of the values a post's front matter may use. script/lint-posts.rb
-# checks every post in _posts against it, in CI before the build, and
-# scholar-lens reads this same file before it opens a pull request here, so a
+# The one list of the values a post's front matter may use, and of the MathJax
+# macros head.html defines (math, below). script/lint-posts.rb checks every
+# post's front matter against it, in CI before the build, and scholar-lens
+# reads this same file before it opens a pull request here, so a
# generated post and a hand-written one answer to the same rules. The prose
# conventions (README.md "Writing a post", docs/tech-doc.md) point here instead
# of repeating the lists.
@@ -28,9 +29,14 @@ topics:
# Never in front matter. jekyll-feed reads `author` as the entry's author, which
# is the site's (see _config.yml); a paper's authors go in `paper_author`.
-# `last_modified_at` is set from the file-name date (_plugins/posted_date.rb).
+# `last_modified_at` comes from a file-name date later than `date:`
+# (_plugins/posted_date.rb).
forbidden_keys: [author, last_modified_at]
+# `lang` values. A translation is one post in each, sharing a translation_id;
+# the language switcher (_includes/language_switcher.html) knows these two.
+languages: [ko, en]
+
math:
# The macros _includes/head.html defines for MathJax on top of the standard
# set; test/test_lint_posts.rb fails when the two lists differ.
diff --git a/_includes/footer.html b/_includes/footer.html
index ca77ac9..373f56f 100644
--- a/_includes/footer.html
+++ b/_includes/footer.html
@@ -51,9 +51,9 @@
{{ site.title }}
-
-{% if content contains '
\n")
+ assert_equal [], lint("For $$i` 또는 \\ 태그, 줄바꿈, 링크.\n")
+ end
+
+ def test_fences_kramdown_does_not_read_as_fences
+ assert_finding(/info has a space/, lint("```python title=\"a.py\"\nx\n```\n"))
+ assert_finding(/never closed/, lint("````md\nx\n```\n"))
+ end
+
def test_fences_close_only_on_their_own_marker
assert_equal [], lint("````md\n```\n# not a heading\n```\n````\n")
assert_equal [], lint("~~~\n```\n# not a heading\n~~~\n")
@@ -141,6 +165,69 @@ def test_spec_macros_are_the_ones_mathjax_defines
assert_equal SPEC["math"]["defined_macros"].sort, defined.sort
end
+ def test_math_needs_use_math
+ without = FRONT.sub("use_math: true\n", "")
+ assert_finding(/no use_math/, lint("식 $$x$$입니다.\n", front: without))
+ assert_finding(/no use_math/, lint("백틱(`` ` ``)입니다.\n\n식 $$x$$입니다.\n\n`a.yml`\n", front: without))
+ assert_equal [], lint("```bash\necho $$\n```\n", front: without)
+ end
+
+ # kramdown splits a table cell at | even inside $$, and reads \| as |.
+ def test_a_bar_inside_math_in_a_table
+ assert_finding(/inside \$\$ in a table/, lint("| a | b |\n|---|---|\n| $$p(y|x)$$ | 2 |\n"))
+ assert_finding(/inside \$\$ in a table/, lint("| $$p(y|x)$$ | b |\n|---|---|\n| 1 | 2 |\n"))
+ assert_finding(/inside \$\$ in a table/, lint("| a | b |\n|---|---|\n| $$\\|\\theta\\|$$ | 2 |\n"))
+ assert_equal [], lint("| Syntax | Example |\n|---|---|\n| `$$` | $$\\lvert x \\rvert$$ |\n")
+ end
+
+ def test_a_tag_is_spelled_one_way
+ a = PostLint.parse("_posts/2026-01-01-a.md", "---\n#{FRONT}---\nText.\n")
+ b = PostLint.parse("_posts/2026-01-02-b.md", "---\n#{FRONT.sub('"Sliding-Window-Attention"', '"Sliding-window-Attention"')}---\nText.\n")
+ assert_match(/also written Sliding-Window-Attention/, PostLint.tag_cases([a, b], SPEC)[b.path].first)
+ assert_empty PostLint.tag_cases([a], SPEC)
+ end
+
+ def test_a_translation_is_a_pair
+ ko = PostLint.parse("_posts/2026-01-01-x.md", "---\n#{FRONT}translation_id: x\nlang: ko\n---\nText.\n")
+ en = PostLint.parse("_posts/2026-01-01-x-en.md", "---\n#{FRONT}translation_id: x\nlang: en\n---\nText.\n")
+ bare_en = PostLint.parse("_posts/2026-01-01-x-en.md", "---\n#{FRONT}---\nText.\n")
+ assert_empty PostLint.pairs([ko, en], SPEC)
+ assert_match(/one post in each of ko, en/, PostLint.pairs([ko, bare_en], SPEC)[ko.path].first)
+ other_ko = PostLint.parse("_posts/2026-02-01-y.md", "---\n#{FRONT}translation_id: x\nlang: ko\n---\nText.\n")
+ assert_match(/one post in each of ko, en/, PostLint.pairs([ko, other_ko], SPEC)[ko.path].first)
+ assert_match(/must set translation_id/, PostLint.pairs([ko, bare_en], SPEC)[bare_en.path].first)
+ end
+
+ def test_liquid_in_the_body_needs_raw
+ assert_finding(/Liquid tag/, lint("```text\nQuestion: {{ question }}\n```\n"))
+ assert_equal [], lint("{% raw %}\n```text\nQuestion: {{ question }}\n```\n{% endraw %}\n")
+ assert_finding(/Liquid tag/, lint("`{% raw %}{{ q }}{% endraw %}`와 `{{ context }}`\n"))
+ assert_equal [], lint("`{% raw %}{{ q }}{% endraw %}`\n")
+ assert_equal [], lint("`{%- raw -%}{{ q }}{%endraw%}`\n")
+ assert_equal [], lint("`{{ q }}`\n", front: "#{FRONT}render_with_liquid: false\n")
+ end
+
+ def test_dates_and_file_names
+ assert_finding(/after the file name/, lint(front: "#{FRONT}date: 2025-03-01 12:00:00\n"))
+ assert_finding(/after the file name/, lint(front: "#{FRONT}date: \"2025-03-01 12:00:00\"\n"))
+ assert_finding(/after the file name/, lint(front: "#{FRONT}date: 2025-02-27 20:00:00 -0800\n"))
+ assert_equal [], lint(front: "#{FRONT}date: 2025-02-28 01:00:00 +0900\n")
+ assert_equal [], lint(front: "#{FRONT}date: 2023-10-10 17:54:58\n")
+ assert_finding(/YYYY-MM-DD-/, lint(path: "_posts/mistral-7b.md"))
+ end
+
+ def test_description_is_plain_text
+ assert_finding(/plain text/, lint(front: "#{FRONT}description: \"비용은 $$O(n)$$입니다\"\n"))
+ assert_finding(/plain text/, lint(front: "#{FRONT}description: \"R1은 블록을 씁니다\"\n"))
+ assert_finding(/plain text/, lint(front: "#{FRONT}description: \"{{ question }} 자리\"\n"))
+ assert_equal [], lint(front: "#{FRONT}description: \"Mixtral 8*7B와 C++ [1]\"\n")
+ end
+
+ def test_front_matter_starts_on_line_one
+ assert_raises(ArgumentError) { PostLint.parse("_posts/2025-02-27-x.md", "\n---\n#{FRONT}---\nText.\n") }
+ assert_equal "Mistral 7B", PostLint.parse("_posts/2025-02-27-x.md", "\uFEFF---\n#{FRONT}---\nText.\n").data["title"]
+ end
+
def test_paper_author_is_one_string_without_et_al
assert_equal [], lint(front: FRONT.sub('"Mistral AI"', '"Google DeepMind, Google Research, Stanford"'))
assert_finding(/paper_author/, lint(front: FRONT.sub('"Mistral AI"', '["A", "B"]')))
diff --git a/test/test_post_description.rb b/test/test_post_description.rb
index b2af3a5..a0a1c25 100644
--- a/test/test_post_description.rb
+++ b/test/test_post_description.rb
@@ -156,9 +156,47 @@ def test_strips_code_spans_footnotes_and_inline_html
assert_equal "text-to-SQL 정확도는 16.7%였습니다.", PostDescription.derive(md)
end
- def test_strips_math
- md = "비용은 $$O(n^2)$$이고 \\(k\\)에 비례합니다.\n"
- assert_equal "비용은 이고 에 비례합니다.", PostDescription.derive(md)
+ # Math that reads as text stays: dropping it left "비용은 이고". TeX commands,
+ # and characters later passes would take for markup, still go; a post whose
+ # first paragraph leans on them needs a hand-written description.
+ def test_keeps_inline_math_that_reads_as_text
+ md = "비용은 $$O(n^2)$$이고 $$k$$에 비례합니다.\n"
+ assert_equal "비용은 O(n^2)이고 k에 비례합니다.", PostDescription.derive(md)
+ assert_equal "손실은 로 정의됩니다.", PostDescription.derive("손실은 $$\\mathcal{L}$$로 정의됩니다.\n")
+ assert_equal "비교 이고 입니다.", PostDescription.derive("비교 $$ps$$입니다.\n")
+ end
+
+ def test_comments_are_not_prose
+ md = "\n\n{::comment}\n메모\n{:/comment}\n\n본문입니다.\n"
+ assert_equal "본문입니다.", PostDescription.derive(md)
+ assert_equal "템플릿은 {{ q }} 자리입니다.", PostDescription.derive("템플릿은 {% raw %}{{ q }}{% endraw %} 자리입니다.\n")
+ end
+
+ def test_code_spans_keep_their_text
+ assert_equal "추론은 안에, a\\*b는 그대로.", PostDescription.derive("추론은 `` 안에, `a\\*b`는 그대로.\n")
+ end
+
+ def test_backslash_escapes_are_text
+ assert_equal "추론은 태그 안, 비용은 $2, 이름은 a_b입니다.", PostDescription.derive("추론은 \\ 태그 안, 비용은 \\$2, 이름은 a\\_b입니다.\n")
+ end
+
+ # `\d+[.)]\s` read a Korean date opening as an ordered list and dropped it.
+ def test_a_korean_date_opening_is_prose
+ assert_equal "2024. 5. 14. 공개된 모델입니다.", PostDescription.derive("2024. 5. 14. 공개된 모델입니다.\n")
+ end
+
+ def test_a_line_break_tag_is_a_space
+ assert_equal "첫 줄 둘째 줄", PostDescription.derive("첫 줄 둘째 줄\n")
+ end
+
+ def test_a_paragraph_may_open_with_inline_html
+ md = "핵심은 데이터 품질입니다.\n\n둘째 문단.\n"
+ assert_equal "핵심은 데이터 품질입니다.", PostDescription.derive(md)
+ assert_equal "본문.", PostDescription.derive("
공지
\n\n본문.\n")
+ assert_equal "본문.", PostDescription.derive("핵심\n\n본문.\n")
+ assert_equal "핵심은 품질이고 결론도 품질", PostDescription.derive("핵심은 품질이고 결론도 품질\n")
+ assert_equal "본문.", PostDescription.derive("그림 1: 구조\n\n본문.\n")
+ assert_equal "본문.", PostDescription.derive("공개: 저자는 AWS 직원입니다.\n\n본문.\n")
end
def test_joins_a_multiline_paragraph_with_spaces
diff --git a/test/test_related_posts.rb b/test/test_related_posts.rb
index ffd9e25..b708d3a 100644
--- a/test/test_related_posts.rb
+++ b/test/test_related_posts.rb
@@ -159,6 +159,13 @@ def test_returns_the_neighbours_in_date_order
assert_equal "/new", newer[:id]
end
+ def test_the_nearest_older_post_not_the_oldest
+ a = post("/a", date: "2023-01-01")
+ b = post("/b", date: "2024-01-01")
+ c = post("/c", date: "2025-01-01")
+ assert_equal "/b", RelatedPosts.adjacent(c, ordered(a, b, c))[0][:id]
+ end
+
def test_nil_at_each_end_of_the_archive
old = post("/old", date: "2024-01-01")
new = post("/new", date: "2026-01-01")
@@ -167,7 +174,7 @@ def test_nil_at_each_end_of_the_archive
assert_nil RelatedPosts.adjacent(new, list)[1]
end
- # The live defect: all three translation pairs share a date exactly, so
+ # The live defect: all three translation pairs shared a date exactly, so
# Jekyll's page.previous pointed the newest Korean post at its own English
# version.
def test_skips_the_translation_twin_even_on_an_identical_date
@@ -218,7 +225,9 @@ def test_a_lone_post_has_no_neighbours
end
class TestTheActualHarnessCluster < Minitest::Test
- # The real front matter of the five Insights/Agentic-AI posts.
+ # The Insights/Agentic-AI posts' front matter as of the harness posts, cut to
+ # the fields ranking reads. A snapshot, not the current _posts: it pins the
+ # ranking rules on a realistic cluster.
#
# These five used to share nothing at all: the Claude Code teardown said
# "Agentic-Architecture", the harness chronicle said "Agentic-Patterns", and
diff --git a/test/test_search_index.rb b/test/test_search_index.rb
index 70e1e85..8b1eb60 100644
--- a/test/test_search_index.rb
+++ b/test/test_search_index.rb
@@ -14,6 +14,10 @@ def test_unwraps_tags
assert_equal "본문입니다", SearchIndex.plain_text("
본문입니다
")
end
+ def test_drops_html_comments
+ assert_equal "앞 뒤", SearchIndex.plain_text("
앞 뒤
")
+ end
+
# The reason this filter exists at all: `strip_html` left entities encoded and
# the search page printed "음료 > 탄산음료" to the reader.
def test_decodes_html_entities