diff --git a/README.ko.md b/README.ko.md index 4b839a3..c016a98 100644 --- a/README.ko.md +++ b/README.ko.md @@ -120,7 +120,7 @@ subtitle: "<한 줄 소개>" # 선택 — 헤더의 제목 아래에 date: YYYY-MM-DD HH:MM:SS paper_author: "<기관>" # 논문을 낸 기관(사람 이름 아님). 논문 글에만 paper_doi: "10.48550/arXiv." # 선택, 논문 글에만 — 메타 패널의 인용 수 -seo_title: "<짧은 제목>" # 선택 — 논문 제목이 너무 길 때 에 대신 씁니다 +seo_title: "<짧은 제목>" # 선택 — 제목이 너무 길 때 <title>에 대신 씁니다 description: >- # 선택, 평문 — 아래 설명 참고 <검색 결과에 노출될 문장, 150자 안팎> categories: ["<유형>", "<주제>"] diff --git a/README.md b/README.md index 11619d0..8a2bdd2 100644 --- a/README.md +++ b/README.md @@ -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>"] diff --git a/_includes/head.html b/_includes/head.html index e530a97..61d5592 100644 --- a/_includes/head.html +++ b/_includes/head.html @@ -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". diff --git a/_layouts/default.html b/_layouts/default.html index bdf5ff7..86ba632 100644 --- a/_layouts/default.html +++ b/_layouts/default.html @@ -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> diff --git a/_layouts/post.html b/_layouts/post.html index f85fa3b..64c111a 100644 --- a/_layouts/post.html +++ b/_layouts/post.html @@ -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> diff --git a/_sass/_layout.scss b/_sass/_layout.scss index b480185..76a58f5 100644 --- a/_sass/_layout.scss +++ b/_sass/_layout.scss @@ -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 @@ -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 { @@ -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; } @@ -612,7 +631,7 @@ blockquote { * Body */ .page-content { - margin-top: 66px; + margin-top: var(--nav-height, 66px); .wrapper { @include outer-container; diff --git a/categories.html b/categories.html index d0d6a22..54681ec 100644 --- a/categories.html +++ b/categories.html @@ -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 -%} diff --git a/docs/tech-doc.md b/docs/tech-doc.md index 24f1035..ce1dd3f 100644 --- a/docs/tech-doc.md +++ b/docs/tech-doc.md @@ -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 | 페이지 | 소스 | diff --git a/index.html b/index.html index ed0bed7..b9249ea 100644 --- a/index.html +++ b/index.html @@ -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> diff --git a/js/main.js b/js/main.js index cf28eb6..b7ec562 100644 --- a/js/main.js +++ b/js/main.js @@ -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. @@ -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 }); @@ -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'); @@ -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); }); diff --git a/script/lint-posts.rb b/script/lint-posts.rb index 27d52b2..07e1300 100644 --- a/script/lint-posts.rb +++ b/script/lint-posts.rb @@ -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] @@ -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/ @@ -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. @@ -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 @@ -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 diff --git a/test/test_lint_posts.rb b/test/test_lint_posts.rb index 29a5bdf..0db4f58 100644 --- a/test/test_lint_posts.rb +++ b/test/test_lint_posts.rb @@ -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"'))