Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions app/src/newsletter_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,73 @@ def normalize_date(v: str) -> str:
return (parsed or datetime.now(UTC)).strftime("%Y-%m-%d")


# Articles, coordinating conjunctions and short prepositions stay lowercase in
# a title-cased headline unless they open or close it (Chicago-style).
# fmt: off
_TITLE_MINOR_WORDS: frozenset[str] = frozenset({
"a", "an", "the", "and", "but", "or", "nor", "for", "so", "yet", "as", "at",
"by", "in", "of", "off", "on", "per", "to", "up", "via", "vs", "vs.", "from",
"with", "into", "onto",
})
# fmt: on
_NON_LATIN_SCRIPT = re.compile(r"[぀-ヿ㐀-鿿가-힯]")
# Punctuation after which the next word starts a new phrase and is capitalized.
_PHRASE_BREAK = re.compile(r"[:?!]$")


def _title_case_part(part: str, force_capital: bool) -> str:
match = re.search(r"[A-Za-z]", part)
if match is None:
return part
core = part[match.start() :]
# Leave names and identifiers as written: acronyms and brands with inner
# capitals (LLMs, AgentCore, iOS), versions and model ids (d1, GPT-6.1),
# and URLs, handles or file names.
if (
any(c.isupper() for c in core[1:])
or any(c.isdigit() for c in core)
or re.search(r"[./@_][A-Za-z]", core)
):
return part
if not force_capital and core.lower().rstrip(",;") in _TITLE_MINOR_WORDS:
return part[: match.start()] + core.lower()
return part[: match.start()] + core[0].upper() + core[1:]


def to_title_case(title: str) -> str:
"""Title-case an English headline so every card reads in one style.

Feeds mix sentence case ("Multimodal open d1 decision models for the edge")
with title case. A title in Korean, Japanese or Chinese is returned as is:
the convention has no meaning there.
"""
if _NON_LATIN_SCRIPT.search(title):
return title
words = title.split(" ")
content = [i for i, w in enumerate(words) if re.search(r"[A-Za-z]", w)]
if not content:
return title
first, last = content[0], content[-1]
out: list[str] = []
capitalize_next = True
for i, word in enumerate(words):
if not word:
out.append(word)
continue
force = capitalize_next or i in (first, last)
# Each segment of a hyphenated compound is cased on its own, so a minor
# word inside one stays lowercase ("Context-Aware", "End-to-End").
segments = word.split("-")
out.append(
"-".join(
_title_case_part(seg, force if j == 0 else j == len(segments) - 1)
for j, seg in enumerate(segments)
)
)
capitalize_next = bool(_PHRASE_BREAK.search(word))
return " ".join(out)


class Article(BaseModel):
title: str = Field(min_length=1)
link: str
Expand All @@ -120,6 +187,7 @@ class Article(BaseModel):
urls: list[str] = Field(default_factory=list)
score: float = Field(default=1.0, ge=0.0, le=1.0)
_normalize_date = field_validator("published_date", mode="before")(normalize_date)
_title_case = field_validator("title")(to_title_case)

@field_validator("link", mode="before")
@classmethod
Expand Down
14 changes: 4 additions & 10 deletions app/templates/_styles.html
Original file line number Diff line number Diff line change
Expand Up @@ -38,27 +38,20 @@
issue, which cost tens of KB against the mail-client clip budget. If a
client ignores <style>, text falls back to the client default — still
perfectly legible, so the degradation is graceful. */
.card-title,
.muted,
.lede,
.card-body,
.tags-cell,
.resources {
font-family: "Pretendard", "Inter", system-ui, sans-serif;
}
/* One title face for every heading: the issue title, the card titles, the
summary's section headings and the resources heading. */
.hero-title,
.card-title,
.card-body h3,
.resources-title {
font-family: "Pretendard", "Inter", system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
}
.card-title {
font-weight: 700;
color: #16191f;
font-size: 1.75rem;
line-height: 1.3;
letter-spacing: -0.02em;
}
.muted {
color: #6c757d;
Expand Down Expand Up @@ -164,6 +157,7 @@
margin: 0;
}
.resources-title {
font-weight: 700;
margin-bottom: 10px;
}
.resources {
Expand Down
2 changes: 1 addition & 1 deletion docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -577,7 +577,7 @@
`min-width: 800px` 요소와 고정 폭 표가 390px 화면에서도 문서 폭을 800px로 잡아, 기기가 전체를
약 절반 크기로 줄였습니다. 본문 열을 `max-width` 유동 폭으로 바꿨습니다. 같은 점검에서 흰 배경
위 주황 링크와 흰 글씨 버튼의 명암비가 2.1:1이라 링크 색과 버튼 글자색을 바꿨고, 캡션이 본문
단락처럼 보이던 `<figure>` 스타일과 32px 카드 제목을 정리했습니다. 날짜와 태그는 가운데, 카드 제목은 로고 옆 왼쪽 정렬이며, 제목은 28px로 키우고 호 제목·카드 제목·요약 소제목·참고 자료 제목을 한 제목체 규칙(굵기 700, 자간 -0.02em)으로 묶었습니다. 본문과 리드는 왼쪽 정렬입니다.
단락처럼 보이던 `<figure>` 스타일과 32px 카드 제목을 정리했습니다. 날짜와 태그는 가운데, 카드 제목은 로고 옆 왼쪽 정렬입니다. 카드 제목은 28px로 키웠고, 피드마다 대소문자 규칙이 달라 렌더 단계에서 Title Case로 맞춥니다. 본문과 리드는 왼쪽 정렬입니다.

## 오케스트레이션, 알림, 전달

Expand Down
7 changes: 7 additions & 0 deletions docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,13 @@ CHANGELOG에 있습니다.
접근성 alt 텍스트에 쓰는 `source_label`을 노출합니다. `one_liner`(한 문장 리드)와
`reading_minutes`도 여기 있습니다.

제목은 `to_title_case`로 Title Case를 맞춥니다. 피드마다 문장형 대소문자와 Title Case가
섞여 있어 카드끼리 제목 모양이 달랐기 때문입니다. 관사·등위접속사·짧은 전치사는 첫 단어나
마지막 단어, 콜론 바로 뒤가 아니면 소문자로 둡니다. 안쪽에 대문자나 숫자가 있는 단어(`LLMs`,
`AgentCore`, `d1`, `GPT-6.1`)와 URL처럼 생긴 단어는 쓰인 그대로 둡니다. 한글·일본어·중국어가
들어 있는 제목은 바꾸지 않습니다. 전부 소문자로 쓰는 고유명사(`gpt-oss`)는 구분할 수 없어
첫 글자가 대문자가 됩니다.

`normalize_date`는 들쭉날쭉한 입력 날짜를 `YYYY-MM-DD`로 맞춥니다. 크롤러가 게이트에 쓰는
`try_parse_published_date`를 그대로 쓰므로, 글을 호에 넣은 날짜는 항상 렌더됩니다. 파싱이
실패했을 때의 폴백은 **UTC** 기준 오늘입니다. Batch 이미지가 `TZ=Asia/Seoul`이라 로컬 시각을
Expand Down
53 changes: 53 additions & 0 deletions tests/test_newsletter_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,62 @@
collapse_html_whitespace,
estimate_reading_minutes,
normalize_date,
to_title_case,
)


class TestTitleCase:
@pytest.mark.parametrize(
("raw", "expected"),
[
(
"Multimodal open d1 decision models for the edge",
"Multimodal Open d1 Decision Models for the Edge",
),
(
"EmbeddingGemma 2: an open, lightweight multimodal embedding model",
"EmbeddingGemma 2: An Open, Lightweight Multimodal Embedding Model",
),
(
"Manage Amazon SageMaker HyperPod Spaces directly from SageMaker Studio",
"Manage Amazon SageMaker HyperPod Spaces Directly from SageMaker Studio",
),
(
"an end-to-end guide to vLLM on huggingface.co",
"An End-to-End Guide to vLLM on huggingface.co",
),
("what the model is looking at", "What the Model Is Looking At"),
(
"Building A Context-aware Assistant",
"Building a Context-Aware Assistant",
),
(
"Introducing Claude Haiku 5.5 on AWS",
"Introducing Claude Haiku 5.5 on AWS",
),
('"quoted" start', '"Quoted" Start'),
],
)
def test_english_headlines(self, raw: str, expected: str) -> None:
assert to_title_case(raw) == expected

def test_cjk_title_is_left_alone(self) -> None:
assert (
to_title_case("Amazon Bedrock으로 만든 에이전트")
== "Amazon Bedrock으로 만든 에이전트"
)

def test_article_title_is_title_cased(self) -> None:
article = Article(
title="a guide to agents",
link="https://example.com/a",
published_date="2026-10-01",
thumbnail="t.png",
summary="<p>s</p>",
)
assert article.title == "A Guide to Agents"


class TestValidateDate:
@pytest.mark.parametrize(
"raw,expected",
Expand Down
Loading