Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
177a684
content(blog): what "the ontology is the software" means — the pillar…
claude Oct 5, 2026
ae4eabb
content(blog): re-slot the pillar definition post to Thursday 2026-10-15
claude Oct 15, 2026
5643fde
Merge remote-tracking branch 'origin/main' into ff/issue-216
claude Oct 15, 2026
9cce2db
site: correct seven claims that contradict the docs
claude Oct 6, 2026
34ea66c
Merge remote-tracking branch 'origin/main' into ff/issue-213
claude Oct 15, 2026
51e704d
fix(layout): phone-width tables, code blocks and body diagrams
claude Oct 6, 2026
435313e
fix(layout): state the overflowing-table bound with its border
claude Oct 6, 2026
0e5d9c8
fix(layout): focusable artifact code block, phone-width security table
claude Oct 6, 2026
e0ee9ba
Merge remote-tracking branch 'origin/main' into ff/issue-217
claude Oct 15, 2026
83d1088
site: fix the layout breaks, add 404 pages, gate internal links on th…
claude Oct 6, 2026
6b4409c
site: localize the rest of the header chrome
claude Oct 6, 2026
4909604
Merge remote-tracking branch 'origin/main' into ff/issue-221
claude Oct 15, 2026
5066537
content(blog/svg): fit 11 overflowing lines to their cards, lift .non…
claude Oct 6, 2026
be727dc
content(blog): describe the five decorative concept illustrations in …
claude Oct 6, 2026
b58839f
build: gate blog SVG text against the shape under it and shapes paint…
claude Oct 6, 2026
ab03e67
content(blog/svg): leave room for wider faces in two refitted lines
claude Oct 6, 2026
1023019
Merge remote-tracking branch 'origin/main' into ff/issue-209-sweep
claude Oct 15, 2026
310e45e
content(blog): README agent path CTA, canonical ObjectOS descriptor, …
claude Oct 6, 2026
2d9f5e4
content(blog): extend-existing-systems-with-ai — the data-residency b…
claude Oct 6, 2026
add901e
Merge remote-tracking branch 'origin/main' into ff/issue-218
claude Oct 15, 2026
92a9640
site(a11y): AA contrast tokens in both themes, drawer focus, disclosu…
claude Oct 6, 2026
cdff04e
Merge remote-tracking branch 'origin/main' into ff/issue-219
claude Oct 15, 2026
cbea2ee
content(blog): link the locale home, not the bare www root, from two …
claude Oct 6, 2026
25c933b
fix(seo): indexing hygiene — fallback rule, sitemap, lang, clusters, …
claude Oct 6, 2026
bab25b3
test(seo): seo-smoke guards the sitemap, body language and redirects;…
claude Oct 6, 2026
a688c3a
fix(seo): no SoftwareApplication offers until the headline price is r…
claude Oct 6, 2026
7c7595b
Merge remote-tracking branch 'origin/main' into ff/issue-220
claude Oct 15, 2026
b86611c
perf(images): serve raster blog covers through astro:assets as WebP w…
claude Oct 6, 2026
356a19c
feat(seo): render a 1200x630 JPEG share image per post from its cover
claude Oct 6, 2026
8dab22e
perf(images): move the hero into src/assets, serve it through <Image>…
claude Oct 6, 2026
cf121b0
perf(images): size card thumbnails for the width object-fit draws the…
claude Oct 6, 2026
85474eb
perf(images): serve cluster reading-path thumbnails through astro:assets
claude Oct 6, 2026
4a00b35
Merge remote-tracking branch 'origin/main' into ff/issue-224
claude Oct 15, 2026
96a0250
build: fail a drop shadow cut off by its filter region; grow 27 regio…
claude Oct 6, 2026
d6b1a46
content(blog): translate the four concept-illustration alt texts into…
claude Oct 6, 2026
f498430
content(blog/svg): keep the natural-language Runtime ring inside its …
claude Oct 6, 2026
31a2e84
Merge remote-tracking branch 'origin/main' into ff/issue-225
claude Oct 15, 2026
0b5376c
fix(blog): remove the space a hard-wrapped line leaves between two CJ…
claude Oct 6, 2026
fdafae5
Merge remote-tracking branch 'origin/main' into ff/issue-226
claude Oct 15, 2026
8683d8b
fix(blog): point quotation marks in zh, ja and ko prose the right way
claude Oct 6, 2026
b5a8ff9
fix(wechat): join CJK line breaks and direct quotation marks in the e…
claude Oct 6, 2026
832deb3
Merge remote-tracking branch 'origin/main' into ff/issue-227
claude Oct 15, 2026
3263dff
fix(blog): point quotation marks the right way in every locale
claude Oct 6, 2026
8491481
Merge remote-tracking branch 'origin/main' into ff/issue-222
claude Oct 15, 2026
7d72a13
site: non-blog copy sweep — retire token figures, canonical ObjectOS …
claude Oct 6, 2026
6f886c6
content(blog): carried positioning wording in three posts, every locale
claude Oct 6, 2026
1fbfa1c
site(approvals): AI metadata changes are their own reviewed path, not…
claude Oct 6, 2026
fe39b43
site(security): localize the FAQ kicker in ja, de, es, fr and ko
claude Oct 6, 2026
a6eb4b2
Merge remote-tracking branch 'origin/main' into ff/issue-223
claude Oct 15, 2026
c553b70
site(layout): primary CTA above the laptop fold on every marketing pa…
claude Oct 7, 2026
1a056cc
site(layout): card grids lay out by count, so no section ends on a lo…
claude Oct 7, 2026
2cf80c5
site(layout): CJK headings break between phrases, never inside a word
claude Oct 7, 2026
fe4bddc
site(layout): balanced headings, no one-word last lines
claude Oct 7, 2026
f69291f
site(layout): one face per heading role
claude Oct 7, 2026
102db6e
site(home): the hero image's media note takes the dark surface in dar…
claude Oct 7, 2026
1d098f3
site(home): video poster words are localized HTML, legible on phones …
claude Oct 7, 2026
2834605
site(blog): CJK headings break between phrases on the blog list, post…
claude Oct 7, 2026
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
10 changes: 9 additions & 1 deletion astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url';
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import { rehypeCjkSegmentBreaks } from './scripts/lib/cjk-segment-break.mjs';
import { rehypeCjkHeadingPhrases } from './scripts/lib/cjk-phrases.mjs';
import { remarkQuotes } from './scripts/lib/cjk-quotes.mjs';
import sitemap from '@astrojs/sitemap';
import { readPostLastmods } from './scripts/lib/post-dates.mjs';
Expand Down Expand Up @@ -105,7 +106,14 @@ export default defineConfig({
// that opens on a quote, Next ‘26, German „Wort”); `remarkQuotes` runs after it
// and sets each mark by its position in its pair, in every locale. See
// scripts/lib/cjk-quotes.mjs; `content:lint --dist` guards it too.
markdown: { shikiConfig: { theme: 'github-dark-default' }, remarkPlugins: [remarkQuotes], rehypePlugins: [rehypeCjkSegmentBreaks] },
// In a zh or ja post, each heading the body authors gets a <wbr> between its
// phrases (BlogPost.astro sets keep-all on them), so it never breaks inside a
// word; headings only, never inside code. See scripts/lib/cjk-phrases.mjs.
markdown: {
shikiConfig: { theme: 'github-dark-default' },
remarkPlugins: [remarkQuotes],
rehypePlugins: [rehypeCjkSegmentBreaks, rehypeCjkHeadingPhrases],
},
integrations: [
mdx(),
sitemap({
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
249 changes: 249 additions & 0 deletions scripts/lib/cjk-phrases.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
/**
* Keep-together phrases for CJK headings.
*
* Chinese and Japanese have no spaces, so a browser may break a heading
* between any two characters: measured on the marketing template it split
* 「可投 / 入生产」, 「开 / 放」, 「ソフトウェ / ア」 and 「環 / 境」 across lines.
* `word-break: keep-all` stops that, but leaves the heading no break
* opportunity at all; this module supplies them. It cuts a heading into
* phrases, and the caller renders a `<wbr>` between two phrases (the form
* BudouX emits), so a line can end only where a phrase does.
*
* Why not `word-break: auto-phrase`: it is Chromium-only and Japanese-only,
* so it fixes neither Chinese nor any other engine. A `<wbr>` with
* `keep-all` behaves the same in every engine.
*
* Phrases are built from the ICU word segmenter (`Intl.Segmenter`, dictionary
* based for both languages), whose words are too small on their own. Japanese
* attaches particles, okurigana and auxiliaries to the word before them (作|ら|
* せる would otherwise break as 作 / らせる), and katakana splits are rejoined
* (ラン|タイム). A Chinese one-character word is merged with a neighbour: a
* suffix such as 的 or 库 joins the word before it, a character last in its
* clause does too (落下来), and any other joins the word after it (可投入,
* 谁来审).
* A phrase can still be wider than the line; `overflow-wrap: anywhere` on the
* heading is the last resort for that.
*
* The output joins back to exactly the input; nothing is ever added to or
* removed from the copy.
*
* Two consumers share these definitions, so they cannot disagree:
*
* * `phraseHtml`, for headings an .astro template renders (the marketing
* template, home, security, the glossary index, blog cards, the post
* title, cluster pages). Each such heading sets `word-break: keep-all`.
* * `rehypeCjkHeadingPhrases`, wired into `markdown.rehypePlugins` in
* astro.config.mjs (MDX inherits it), for the h1-h6 a post's own body
* authors. It runs only on a zh or ja source file and only inside
* headings; code (`code`, `kbd`, `samp`, `pre`) is never entered, and a
* <wbr> is only ever added beside text, never inside an opaque element.
*/

import { OPAQUE_ELEMENTS, TRANSPARENT_ELEMENTS } from './cjk-segment-break.mjs';

const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}ー]/u;
const HAN_ONLY = /^\p{Script=Han}+$/u;
const HIRAGANA_ONLY = /^[\p{Script=Hiragana}ー]+$/u;
const KATAKANA_ONLY = /^[\p{Script=Katakana}ー・]+$/u;
const SPACE = /^\s+$/u;
/** A kanji stem with its okurigana (働き, 書き): a one-kanji suffix after it
* belongs to it (働き方, 書き手). */
const STEM_OKURIGANA = /\p{Script=Han}\p{Script=Hiragana}+$/u;
/** Punctuation that must not open a line (kinsoku): it joins the phrase before it. */
const CLOSING = /^[、。,.,.!!??::;;」』))】〕〉》”’…—%%·・〜~-]+$/u;
/** Punctuation that must not close a line: the next segment joins it. */
const OPENING = /^[「『((【〔〈《“‘]+$/u;
/** One-character Chinese words that belong to the word before them: particles
* and suffixes (的, 工程师, 数据库, 工作台) and measure words (另一次).
* Checked against every zh heading the site renders. */
const ZH_SUFFIX = new Set([...'的了着过们地得吗呢吧啊之性化者权时里上下中内外前后间式型级端师员家商人台面个次种份套步张条位项库话版']);

const segmenters = new Map();
const segmenter = (lang) => {
let s = segmenters.get(lang);
if (!s) {
s = new Intl.Segmenter(lang, { granularity: 'word' });
segmenters.set(lang, s);
}
return s;
};

/** The language whose phrase rules apply to `locale`, or null for a spaced script. */
export const phraseLanguage = (locale) =>
locale.startsWith('zh') ? 'zh' : locale === 'ja' ? 'ja' : null;

/**
* `text` cut into keep-together phrases. A text with no CJK character, or a
* locale with spaces between words, comes back as one piece.
*/
export function cjkPhrases(text, locale) {
const lang = phraseLanguage(locale);
if (!lang || !CJK.test(text)) return [text];
const segs = [...segmenter(lang).segment(text)].map((s) => s.segment);
const phrases = [];
let glueNext = false;
// A straight double quote opens or closes by parity: "同意" in the copy.
let quotes = 0;
let prevOpened = false;
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
const prev = i > 0 ? segs[i - 1] : '';
const last = phrases.length ? phrases[phrases.length - 1] : '';
const straightQuote = seg === '"';
const opens = straightQuote && quotes % 2 === 0;
if (straightQuote) quotes++;
let attach;
if (!phrases.length) attach = false;
else if (opens) attach = false;
else if (SPACE.test(seg) || CLOSING.test(seg) || straightQuote || OPENING.test(prev) || prevOpened) attach = true;
else if (SPACE.test(prev)) attach = false;
else if (lang === 'ja') {
attach =
HIRAGANA_ONLY.test(seg) ||
(KATAKANA_ONLY.test(seg) && KATAKANA_ONLY.test(prev)) ||
(seg.length === 1 &&
HAN_ONLY.test(seg) &&
(HAN_ONLY.test(prev) || KATAKANA_ONLY.test(prev) || STEM_OKURIGANA.test(prev)));
} else {
const single = seg.length === 1 && HAN_ONLY.test(seg);
const next = i + 1 < segs.length ? segs[i + 1] : '';
const suffix = single && ZH_SUFFIX.has(seg) && HAN_ONLY.test(prev);
// A one-character word last in its clause (落下来, 运行时。) has no word
// after it to join, so it joins the one before.
const tail = single && HAN_ONLY.test(prev) && !HAN_ONLY.test(next);
attach = (glueNext && HAN_ONLY.test(seg)) || suffix || tail;
}
// The next word joins this one when this is a Chinese one-character word
// that is not itself a suffix: 可 + 投入, 谁 + 来 + 审, 让 + 人. Set on
// every segment, so a space or a punctuation mark clears it.
glueNext = lang === 'zh' && seg.length === 1 && HAN_ONLY.test(seg) && !ZH_SUFFIX.has(seg);
prevOpened = opens;
if (attach) phrases[phrases.length - 1] = last + seg;
else phrases.push(seg);
}
return phrases;
}

/**
* The offsets in `text` that take a <wbr>: every phrase boundary that touches
* a CJK character on either side (a break at a space needs none).
*/
export function wbrOffsets(text, locale) {
const parts = cjkPhrases(text, locale);
const offsets = [];
let at = 0;
for (let i = 0; i < parts.length; i++) {
if (i > 0) {
const before = parts[i - 1].slice(-1);
const after = parts[i].charAt(0);
if (!SPACE.test(before) && !SPACE.test(after) && (CJK.test(before) || CJK.test(after))) offsets.push(at);
}
at += parts[i].length;
}
return offsets;
}

/** Astro's own text escaping (html-escaper), so a text with no <wbr> renders
* byte for byte as `{text}` would. */
const escapeHtml = (s) =>
s.replace(/[&<>'"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' })[c]);

/**
* `text` as HTML with a <wbr> at each of its `wbrOffsets`. Render it with
* `set:html` on a heading that sets `word-break: keep-all`. A text that needs
* no <wbr> (any spaced script) comes back exactly as Astro would escape it.
*/
export function phraseHtml(text, locale) {
let html = '';
let from = 0;
for (const at of wbrOffsets(text, locale)) {
html += escapeHtml(text.slice(from, at)) + '<wbr>';
from = at;
}
return html + escapeHtml(text.slice(from));
}

/** The locale of a post source from its file name: index.zh-Hans.mdx, index.mdx (en). */
export function localeOfPath(path) {
const m = /(?:^|[\\/])index(?:\.([A-Za-z]{2}(?:-[A-Za-z]{2,4})?))?\.mdx?$/.exec(path ?? '');
return m ? (m[1] ?? 'en') : null;
}

const HEADING = /^h[1-6]$/;

/**
* Text nodes of a heading in document order, each with its parent, and `null`
* wherever the run is interrupted: an opaque element (code is never entered),
* any element that is not inline text, an MDX node.
*/
function headingRuns(node, items) {
for (const child of node.children ?? []) {
if (child.type === 'text') {
items.push({ node: child, parent: node });
} else if (child.type === 'element' && TRANSPARENT_ELEMENTS.has(child.tagName.toLowerCase())) {
headingRuns(child, items);
} else {
items.push(null);
}
}
}

const wbr = () => ({ type: 'element', tagName: 'wbr', properties: {}, children: [] });

/** Insert a <wbr> into a run of text nodes at each offset of its joined text. */
function insertWbr(run, offsets) {
if (!offsets.length) return;
let start = 0;
for (const { node, parent } of run) {
const end = start + node.value.length;
const local = offsets.filter((at) => at >= start && at < end).map((at) => at - start);
start = end;
if (!local.length) continue;
const pieces = [];
let from = 0;
for (const at of local) {
if (at > from) pieces.push({ type: 'text', value: node.value.slice(from, at) });
pieces.push(wbr());
from = at;
}
pieces.push({ type: 'text', value: node.value.slice(from) });
const index = parent.children.indexOf(node);
parent.children.splice(index, 1, ...pieces);
}
}

/**
* Rehype plugin: in a zh or ja post, a <wbr> between the phrases of each
* heading the body authors (the post's own h1-h6), so it can break only there
* (BlogPost.astro sets `word-break: keep-all` on them). Any other file, any
* element that is not a heading, and anything inside code is left as parsed.
*/
export function rehypeCjkHeadingPhrases() {
return (tree, file) => {
const locale = localeOfPath(file?.path ?? file?.history?.[0]);
if (!locale || !phraseLanguage(locale)) return;
const headings = [];
const find = (node) => {
for (const child of node.children ?? []) {
if (child.type !== 'element') continue;
if (OPAQUE_ELEMENTS.has(child.tagName.toLowerCase())) continue;
if (HEADING.test(child.tagName.toLowerCase())) headings.push(child);
else find(child);
}
};
find(tree);
for (const heading of headings) {
const items = [];
headingRuns(heading, items);
let run = [];
for (const item of [...items, null]) {
if (item) {
run.push(item);
continue;
}
if (run.length) insertWbr(run, wbrOffsets(run.map((r) => r.node.value).join(''), locale));
run = [];
}
}
};
}
13 changes: 12 additions & 1 deletion src/components/ArticleCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Locale } from '../lib/i18n';
import { formatDate, postPath, STATUS_COLOR, ui } from '../lib/i18n';
import type { LocalePost } from '../lib/posts';
import { termLabel } from '../lib/terms';
import { phraseHtml } from '../../scripts/lib/cjk-phrases.mjs';

interface Props {
post: LocalePost;
Expand Down Expand Up @@ -72,7 +73,7 @@ const thumbLoading = variant === 'feature' ? 'eager' : 'lazy';
</span>
<time datetime={d.date.toISOString().slice(0, 10)}>{formatDate(locale, d.date)}</time>
</div>
<h2>{d.title}</h2>
<h2 set:html={phraseHtml(d.title, locale)} />
<p class="desc">{d.description}</p>
<div class="facets">
<span class="facet topic">{termLabel(d.topic, locale)}</span>
Expand Down Expand Up @@ -186,6 +187,16 @@ const thumbLoading = variant === 'feature' ? 'eager' : 'lazy';
margin: 0 0 0.4rem;
transition: color 0.18s ease;
}
/* zh and ja titles break only at the <wbr> between phrases
(scripts/lib/cjk-phrases.mjs), so a word such as 业务 or アプリケーション
is never split across lines; a phrase wider than the line still breaks,
as a last resort. */
h2:lang(zh),
h2:lang(ja) {
word-break: keep-all;
overflow-wrap: anywhere;
text-wrap: balance;
}
.card:hover h2 {
color: var(--accent);
}
Expand Down
Loading
Loading