Skip to content
Merged
2 changes: 1 addition & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ subtitle: "<한 줄 소개>" # 선택 — 헤더의 제목 아래에
date: YYYY-MM-DD HH:MM:SS
paper_author: "<기관>" # 논문을 낸 기관(사람 이름 아님). 논문 글에만
paper_doi: "10.48550/arXiv.<id>" # 선택, 논문 글에만 — 메타 패널의 인용 수
seo_title: "<짧은 제목>" # 선택 — 논문 제목이 너무 길 때 <title>에 대신 씁니다
seo_title: "<짧은 제목>" # 선택 — 제목이 너무 길 때 <title>에 대신 씁니다
description: >- # 선택, 평문 — 아래 설명 참고
<검색 결과에 노출될 문장, 150자 안팎>
categories: ["<유형>", "<주제>"]
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ subtitle: "<one-line pitch>" # optional — shown under the title in the h
date: YYYY-MM-DD HH:MM:SS
paper_author: "<Org>" # the paper's org, not people; paper posts only
paper_doi: "10.48550/arXiv.<id>" # optional, paper posts only — the citation count in the meta panel
seo_title: "<shorter title>" # optional — for <title> when the paper title is too long
seo_title: "<shorter title>" # optional — for <title> when the title is too long
description: >- # optional, plain text — see below
<search-snippet, ~150 chars>
categories: ["<Type>", "<Topic>"]
Expand Down
1 change: 1 addition & 0 deletions _includes/head.html
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
{%- if page.layout == 'page' -%}
{%- assign html_title = page_title | append: ' | ' | append: site.title -%}
{%- endif -%}
{%- if page.seo_title -%}{%- assign html_title = page.seo_title -%}{%- endif -%}
{%- comment -%}
A paper post's <title> says what the page is, in the words a reader searches
with: "Mistral 7B 논문 리뷰", where the bare paper title gave "Mistral 7B".
Expand Down
12 changes: 8 additions & 4 deletions _layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
{% include head.html %}

<body>
<!-- Ten focusable elements sit before <main> on every page (logo, menu
button, seven nav links, theme toggle), and a long post then leads with a
<!-- A dozen focusable elements sit before <main> on every page (logo, menu
button, the nav links, theme toggle), and a long post then leads with a
copy button per code block. Screen readers can jump by landmark; sighted
keyboard users had no shortcut. -->
<a class="skip-link" href="#content">Skip to content</a>
<a class="skip-link" href="#main-content">Skip to content</a>

{% include header.html %}
<!-- The bar's height before first paint, so a bar that wraps to two rows does
not drop the page once js/main.js runs; main.js keeps it in step.
768px is $tablet in _sass/base/_variables.scss, as in main.js. -->
<script>(function () { var n = document.querySelector('.navigation'); if (n && matchMedia('(min-width: 768px)').matches) document.documentElement.style.setProperty('--nav-height', n.offsetHeight + 'px'); })();</script>

<main class="page-content" id="content">
<main class="page-content" id="main-content">
{{ content }}
</main>

Expand Down
4 changes: 2 additions & 2 deletions _layouts/post.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,13 @@ <h1 class="title">{{ page.title }}</h1>
{% if page.adjacent_older %}
<a class="post-nav-link prev" href="{{ page.adjacent_older.url | prepend: site.baseurl }}" rel="prev">
<span class="post-nav-dir">← Previous</span>
<span class="post-nav-title">{{ page.adjacent_older.title }}</span>
<span class="post-nav-title" lang="{{ page.adjacent_older.lang | default: site.lang }}">{{ page.adjacent_older.title }}</span>
</a>
{% else %}<span></span>{% endif %}
{% if page.adjacent_newer %}
<a class="post-nav-link next" href="{{ page.adjacent_newer.url | prepend: site.baseurl }}" rel="next">
<span class="post-nav-dir">Next →</span>
<span class="post-nav-title">{{ page.adjacent_newer.title }}</span>
<span class="post-nav-title" lang="{{ page.adjacent_newer.lang | default: site.lang }}">{{ page.adjacent_newer.title }}</span>
</a>
{% else %}<span></span>{% endif %}
</nav>
Expand Down
29 changes: 24 additions & 5 deletions _sass/_layout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -60,16 +60,20 @@ main.page-content {
}
}

// Whatever the browser scrolls into view clears the fixed 66px header, which
// Whatever the browser scrolls into view clears the fixed header, which
// would otherwise cover it: fragment targets (the TOC, /tags/#… from another
// page, the skip link's #content) and the control keyboard focus moves to.
// page, the skip link's #main-content) and the control keyboard focus moves to.
// Done here rather than in a click handler so it also holds for links followed
// from elsewhere and for ids a CSS selector cannot parse ("#1-서론").
//
// No `scroll-behavior: smooth`. A smooth jump passes lazy images that load on
// the way and grow the page, so on long posts it stopped short of the heading.
// --nav-height is the bar's real height, set before first paint by an inline
// script in _layouts/default.html and kept in step by js/main.js (both at
// $tablet, 768px, written out there): more tabs than one
// row holds wrap it to two, and the page moves down with it.
html {
scroll-padding-top: 80px;
scroll-padding-top: calc(var(--nav-height, 66px) + 14px);
}

// Visible to assistive technology, not on screen. Used by the copy-button live
Expand Down Expand Up @@ -514,6 +518,20 @@ blockquote {
&.show {
display: block;
}

// The open phone menu is part of the fixed bar; held sideways, a phone is
// shorter than the list, and the last items sat below the screen.
@media screen and (max-width: $tablet - 1px) {
&.show {
max-height: calc(100vh - 4em);
max-height: calc(100dvh - 4em);
overflow-y: auto;
// The scroll box clips: room for the focus ring, at the end and
// wherever focus scrolls an item into view.
padding: 4px 4px 6px 0;
scroll-padding: 4px 0 6px;
}
}
}

ul li.nav-link {
Expand Down Expand Up @@ -560,7 +578,8 @@ blockquote {
// from 768px to about 785px, iPad portrait included. The bar wrapped to
// two rows, 130px tall, and hid the top of every page beneath it. 1.1em
// above it: seven tabs at 1.5em wrapped in the fallback font, before the
// web font arrives.
// web font arrives. A tab past what one row holds still wraps the bar;
// the page follows its height (--nav-height, below).
@include media ($tablet) {
padding-right: 1em;
}
Expand Down Expand Up @@ -612,7 +631,7 @@ blockquote {
* Body
*/
.page-content {
margin-top: 66px;
margin-top: var(--nav-height, 66px);

.wrapper {
@include outer-container;
Expand Down
4 changes: 2 additions & 2 deletions categories.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
title: "Categories"
permalink: /categories/
description: >-
Every post on this blog grouped by category — Paper Reviews, Paper Summaries,
Paper Surveys, Tech Guides, and Insights.
Every post on this blog grouped by category: paper reviews, summaries and
surveys, tech guides, and insights.
---

{%- comment -%}
Expand Down
4 changes: 4 additions & 0 deletions docs/tech-doc.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@ _site/paper-reviews/language-models/2025/01/23/<slug>.html
달린 모든 페이지**를 찾아 `nav_order` 숫자 순으로 자동 나열한다. 새 탭을 넣고 싶으면 페이지
프런트매터에 두 값을 더하면 된다. 탭 이름은 페이지 `title`이고, `nav_title`이 있으면 그것을 쓴다 —
메뉴 폭이 48em이라 "Paper"로 시작하는 탭 셋이 두 줄로 넘쳤기 때문이다(Summaries·Reviews·Surveys).
탭이 한 줄에 다 들어가지 않으면 메뉴가 두 줄로 내려가고, `js/main.js`가 실제 헤더 높이를
`--nav-height`로 넘겨 본문과 앵커 위치가 함께 내려간다(첫 화면 전에는 `default.html`의 인라인
스크립트가 같은 값을 넣는다). 지금 탭 이름은 합쳐 53자이고, 웹 글꼴로는 한 줄에 58자 안팎까지
들어가지만 폴백 글꼴로는 여유가 없다.
현재 순서:

| nav_order | 페이지 | 소스 |
Expand Down
4 changes: 2 additions & 2 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ <h1 class="title">{{ site.title }}</h1>
<div class="wrapper">
<ul class="post-list">
{% for post in paginator.posts %}
<li>
<li lang="{{ post.lang | default: site.lang }}">
<h2>
<a class="post-link" href="{{ post.url | prepend: site.baseurl }}" lang="{{ post.lang | default: site.lang }}"
<a class="post-link" href="{{ post.url | prepend: site.baseurl }}"
>{{ post.title }}</a
>
</h2>
Expand Down
22 changes: 20 additions & 2 deletions js/main.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// Site behaviors: theme toggle, code-copy, mobile menu, nav highlight,
// sticky-nav class, image zoom (GLightbox), and the post table of contents.
// Vanilla JS, no jQuery. The fixed-header offset for in-page jumps is CSS
// (`scroll-padding-top` in _sass/_layout.scss), not script.
// (`scroll-padding-top` in _sass/_layout.scss); this keeps its --nav-height
// in step with the bar.
document.addEventListener('DOMContentLoaded', function () {
// Dark-mode toggle. Light is the default; dark is opt-in and persisted.
// The OS setting is intentionally NOT followed.
Expand Down Expand Up @@ -121,6 +122,19 @@ document.addEventListener('DOMContentLoaded', function () {
// Add a class to the nav once the page is scrolled
var nav = document.querySelector('.navigation');
if (nav) {
// The page starts below the fixed bar, which grows to two rows when its
// tabs do not fit on one. The phone menu's dropdown is not the bar: there
// the bar stays one row and the open menu covers the page on purpose.
var wide = window.matchMedia('(min-width: 768px)'); // $tablet, as in default.html
var syncNavHeight = function () {
if (wide.matches) document.documentElement.style.setProperty('--nav-height', nav.offsetHeight + 'px');
else document.documentElement.style.removeProperty('--nav-height');
};
if (window.ResizeObserver) new ResizeObserver(syncNavHeight).observe(nav);
else window.addEventListener('resize', syncNavHeight);
if (wide.addEventListener) wide.addEventListener('change', syncNavHeight);
else wide.addListener(syncNavHeight); // Safari before 14
syncNavHeight();
window.addEventListener('scroll', function () {
nav.classList.toggle('scrolled', window.pageYOffset > 50);
}, { passive: true });
Expand Down Expand Up @@ -247,6 +261,9 @@ document.addEventListener('DOMContentLoaded', function () {
var heads = document.querySelectorAll('.post-content h2[id]');
if (heads.length >= 3) {
var ul = toc.querySelector('ul');
// Headings that number themselves ("1. 배경") would read "2 1. 배경". Most of
// them, not one: a lone "1. …" among plain headings keeps the numbers.
var numbered = Array.prototype.filter.call(heads, function (h) { return /^\s*\d+[.)]\s/.test(h.textContent); }).length > heads.length / 2;
heads.forEach(function (h, i) {
var li = document.createElement('li');
var a = document.createElement('a');
Expand All @@ -256,7 +273,8 @@ document.addEventListener('DOMContentLoaded', function () {
num.textContent = i + 1;
// Text node, not innerHTML: a heading that names a tag ("<think> 토큰")
// would otherwise be parsed as markup.
a.append(num, h.textContent);
if (numbered) a.append(h.textContent);
else a.append(num, h.textContent);
li.appendChild(a);
ul.appendChild(li);
});
Expand Down
66 changes: 48 additions & 18 deletions script/lint-posts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ def self.check(post, spec)
def self.lightbox(post)
# Fenced code keeps its line count, so an example there is neither read nor
# shifts the line numbers after it.
body = post.body.gsub(/^\s*(`{3,}|~{3,}).*?^\s*\1[^\n]*/m) { |code| "\n" * code.count("\n") }
lines = post.body.split("\n", -1)
body = fences(lines).zip(lines).map { |state, line| state ? "" : line }.join("\n")
body.to_enum(:scan, LIGHTBOX).filter_map do
m = Regexp.last_match
href = m[0][/\shref=["']([^"']+)/i, 1]
Expand Down Expand Up @@ -187,6 +188,34 @@ def self.translation(post, spec)
end

FENCE = /\A\s*(`{3,}|~{3,})/

# Per line: [:open, match] on a fence's first line, :code inside, :close on
# its last, nil outside. A fence inside a blockquote ("> ```") is code too,
# and only such a fence closes on a quoted marker: a "> ```" line inside a
# bare ```markdown block is part of its example. A blank line ends the
# blockquote, and with it a quoted fence left open (:unclosed).
def self.fences(lines)
fence = nil
quoted = false
lines.map do |line|
text = unquote(line)
if fence.nil?
next unless (m = text.match(FENCE))

fence = m[1]
quoted = line.match?(/\A\s*>/)
[:open, m]
elsif quoted && line.strip.empty?
fence = nil
:unclosed
elsif (quoted ? text : line).strip.match?(/\A#{Regexp.escape(fence[0])}{#{fence.size},}\z/)
fence = nil
:close
else
:code
end
end
end
# A code span, opened and closed by the same run of backticks (``$$`` too).
CODE_SPAN = /(`+).*?\1/

Expand All @@ -201,9 +230,9 @@ def self.strip_code(text)
# writes, not all of HTML.
HTML_ELEMENTS = %w[
a abbr audio b blockquote br caption center cite code col colgroup dd del details
div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i iframe img ins kbd li mark
ol p picture pre script section small source span strong sub summary sup svg
table tbody td tfoot th thead tr ul video wbr
dfn div dl dt em figcaption figure h1 h2 h3 h4 h5 h6 hr i iframe img ins kbd li
mark ol p picture pre q samp script section small source span strong sub summary
sup svg table tbody td tfoot th thead time tr track u ul var video wbr
circle defs g line path polygon polyline rect text tspan use
].to_set # the last row: an inline <svg>'s children
# Elements that mean nothing bare: <img> or <audio> alone is a token, not media.
Expand Down Expand Up @@ -233,26 +262,27 @@ def self.body(post)
found = []
in_references = false
lines = post.body.split("\n", -1)
fence = nil # the marker that opened the code fence we are in
fence_line = nil
states = fences(lines)
fence_line = nil # where a still-open code fence started
block = nil # the line a still-open multi-line $$ block started on
para = nil # the line a paragraph with an odd number of $$ started on
math_seen = false
lines.each_with_index do |line, i|
n = post.body_line + i
if (m = line.match(FENCE))
if fence.nil?
fence = m[1]
fence_line = n
# kramdown's GFM fence takes one word after the marker; with more it
# renders the whole block as inline code.
found << "#{n}: code fence info has a space; write just the language" if line.strip.match?(/\A(`{3,}|~{3,})\s*\S+\s+\S/)
elsif line.strip.match?(/\A#{Regexp.escape(fence[0])}{#{fence.size},}\z/)
fence = nil
end
state = states[i]
if state.is_a?(Array)
fence_line = n
# kramdown's GFM fence takes one word after the marker; with more it
# renders the whole block as inline code.
found << "#{n}: code fence info has a space; write just the language" if unquote(line).strip.match?(/\A(`{3,}|~{3,})\s*\S+\s+\S/)
elsif state == :close
fence_line = nil
elsif state == :unclosed
found << "#{fence_line}: code fence never closed (a nested fence closes the outer one)"
fence_line = nil
next
end
next if fence
next if state

# A display block over several lines opens on a line starting with $$ and
# closes on the next line with an odd number of them: `$$` alone, or
Expand Down Expand Up @@ -294,7 +324,7 @@ def self.body(post)
found.concat(table(lines, i, n)) if table_start?(lines, i)
end
found << "#{block}: $$ block never closed" if block
found << "#{fence_line}: code fence never closed (a nested fence closes the outer one)" if fence
found << "#{fence_line}: code fence never closed (a nested fence closes the outer one)" if fence_line
found << "#{para}: unbalanced $$ in the paragraph" if para
# MathJax loads only on posts that ask for it; without it $$...$$ ships as TeX.
found << "1: the post has $$ math but no use_math: true" if math_seen && post.data["use_math"] != true
Expand Down
14 changes: 14 additions & 0 deletions test/test_lint_posts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,20 @@ def test_translationese_and_a_colon_ending_in_prose
assert_equal [], lint("### References\n\n* 보여지는 것들: 논문\n")
end

# scholar-lens quotes a prompt as a fenced block inside "> ".
def test_a_fence_inside_a_blockquote_is_code
assert_equal [], lint("> ```text\n> 답을 <think> 안에 쓰세요: 그다음 답합니다\n> ```\n")
assert_equal [], lint("> ```html\n> <a class=\"glightbox\" href=\"/p\"><img src=\"/a.png\"></a>\n> ```\n")
assert_finding(/<think> is read as HTML/, lint("> 답을 <think> 안에 씁니다.\n"))
# A quoted fence in a Markdown example is part of the example.
assert_equal [], lint("```markdown\n> ```python\n> <think>\n> ```\n<think>\n```\n")
assert_finding(/code fence never closed/, lint("앞 문단입니다.\n\n> ```text\n> <think>\n"))
# The blockquote ends at the blank line, and the fence with it.
found = lint("> ```text\n> 인용입니다.\n\n<think> 뒤 문단입니다.\n\n```python\nx\n```\n")
assert_finding(/code fence never closed/, found)
assert_finding(/<think> is read as HTML/, found)
end

def test_paper_doi_is_a_bare_doi_on_paper_posts_only
with_doi = ->(doi) { FRONT.sub("paper_author: \"Mistral AI\"\n", "\\0paper_doi: #{doi}\n") }
assert_equal [], lint(front: with_doi.call('"10.48550/arXiv.2310.06825"'))
Expand Down