Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
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
7 changes: 6 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 { remarkCjkQuotes } from './scripts/lib/cjk-quotes.mjs';
import sitemap from '@astrojs/sitemap';
import { readPostLastmods } from './scripts/lib/post-dates.mjs';

Expand Down Expand Up @@ -99,7 +100,11 @@ export default defineConfig({
// Chromium and Safari; this removes it from the rendered prose (code
// excluded). MDX inherits `markdown` config. See scripts/lib/cjk-segment-break.mjs,
// and `pnpm content:lint --dist`, which fails the build on any that remain.
markdown: { shikiConfig: { theme: 'github-dark-default' }, rehypePlugins: [rehypeCjkSegmentBreaks] },
// Smartypants curls quotes by their neighbours, which points the opening mark
// of a zh or ja pair the wrong way; `remarkCjkQuotes` runs after it and sets
// each mark by its position in its pair, in zh-Hans, zh-Hant, ja and ko files
// only. See scripts/lib/cjk-quotes.mjs; `content:lint --dist` guards it too.
markdown: { shikiConfig: { theme: 'github-dark-default' }, remarkPlugins: [remarkCjkQuotes], rehypePlugins: [rehypeCjkSegmentBreaks] },
integrations: [
mdx(),
sitemap({
Expand Down
193 changes: 192 additions & 1 deletion scripts/content-lint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
isCjkWide,
lastChar,
} from './lib/cjk-segment-break.mjs';
import { OPAQUE_INLINE, isApostrophe } from './lib/cjk-quotes.mjs';

const ROOT = cwd();
const BLOG = path.join(ROOT, 'content', 'blog');
Expand Down Expand Up @@ -571,11 +572,201 @@ async function checkBuiltCjkBreaks() {
return 1;
}

// ─── Built HTML: quotation marks in zh, ja and ko prose point the right way ─
//
// Smartypants curls an ASCII quote by its neighbours, so in Chinese, which puts
// no space before a quotation, the opening mark of a pair after a character
// comes out closing: 「大多数”给你的业务加上 AI”的说辞」. It also reads a whole
// document as one string, so a heading or table cell that starts with a quote
// takes its direction from the block before it (the Korean headings). The
// remark step in astro.config.mjs sets each mark by its position in its pair;
// this reads what the build actually emitted, on every zh, ja and ko page, so
// it also fails when that step is not wired, or when CJK prose reaches a page
// by a road the step never sees (an .astro template, a data file).
//
// The rule is the step's own, defined once in scripts/lib/cjk-quotes.mjs: in
// one block of prose, curly double marks alternate “ ” and end closed, and so
// do single marks, except an apostrophe (`isApostrophe`), which is always ’.
// The HTML scanning below is this gate's own. Straight ASCII marks have no
// direction and are not read; marks inside code, pre, kbd and samp are not
// prose and are not read either.

/** Elements that sit inside a sentence without ending its block of prose. */
const QUOTE_INLINE_ELEMENTS = new Set([...TRANSPARENT_ELEMENTS, 'br', 'wbr', 'img']);

/**
* Blocks of prose: text joined across inline elements and cut at every other
* tag. An opaque element (code, kbd, svg, …) is one `OPAQUE_INLINE` in the
* block, as it is to the remark step: a quotation may wrap inline code.
*/
function quoteBlocks(html) {
const blocks = [];
let block = '';
let opaqueDepth = 0;
const cut = () => {
if (block) blocks.push(block);
block = '';
};
let i = 0;
while (i < html.length) {
const lt = html.indexOf('<', i);
const stop = lt === -1 ? html.length : lt;
if (stop > i && opaqueDepth === 0) block += decodeEntities(html.slice(i, stop));
if (lt === -1) break;
if (html.startsWith('<!--', lt)) {
const end = html.indexOf('-->', lt + 4);
i = end === -1 ? html.length : end + 3;
continue;
}
if (html.startsWith('<!', lt)) {
cut();
const end = tagEnd(html, lt + 2);
i = end === -1 ? html.length : end + 1;
continue;
}
const tag = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(html.slice(lt, lt + 64));
if (!tag) {
if (opaqueDepth === 0) block += '<';
i = lt + 1;
continue;
}
const closing = tag[1] === '/';
const name = tag[2].toLowerCase();
const end = tagEnd(html, lt + tag[0].length);
if (end === -1) break;
const selfClosing = html[end - 1] === '/';
i = end + 1;
if (!closing && RAW_TEXT_ELEMENTS.has(name)) {
cut();
const close = html.toLowerCase().indexOf(`</${name}`, i);
const closeEnd = close === -1 ? -1 : tagEnd(html, close + name.length + 2);
i = closeEnd === -1 ? html.length : closeEnd + 1;
continue;
}
if (OPAQUE_ELEMENTS.has(name)) {
if (closing) {
opaqueDepth = Math.max(0, opaqueDepth - 1);
if (opaqueDepth === 0) block += OPAQUE_INLINE;
} else if (selfClosing) {
if (opaqueDepth === 0) block += OPAQUE_INLINE;
} else {
opaqueDepth++;
}
continue;
}
if (opaqueDepth > 0) continue;
if (name === 'br') block += '\n';
else if (name === 'img') block += OPAQUE_INLINE;
else if (!QUOTE_INLINE_ELEMENTS.has(name)) cut();
}
cut();
return blocks;
}

/**
* Every curly mark in one block whose direction disagrees with its position
* in its pair, plus a pair left open at the end of the block. Returns the
* number of marks read and the hits, each with its sentence for the report.
*/
function misdirectedQuotes(block) {
const hits = [];
let marks = 0;
let doubleOpen = false;
let singleOpen = false;
// Where the open pair started, or -1 when its opening mark was already
// reported as misdirected (a lone `”` is one problem, not two).
let doubleAt = -1;
let singleAt = -1;
const context = (at, problem) =>
`${problem}: …${block.slice(Math.max(0, at - 14), at)}⟦${block[at] ?? ''}⟧${block.slice(at + 1, at + 15)}…`
.replace(/\s+/g, ' ')
.replaceAll(OPAQUE_INLINE, '▯');
for (let i = 0; i < block.length; i++) {
const ch = block[i];
if (ch === '“' || ch === '”') {
marks++;
const expected = doubleOpen ? '”' : '“';
if (ch !== expected) hits.push(context(i, doubleOpen ? 'a closing mark printed as “' : 'an opening mark printed as ”'));
doubleOpen = !doubleOpen;
if (doubleOpen) doubleAt = ch === expected ? i : -1;
} else if (ch === '‘' || ch === '’') {
marks++;
if (isApostrophe(lastChar(block.slice(0, i)), block.slice(i + 1, i + 6), singleOpen)) {
if (ch === '‘') hits.push(context(i, 'an apostrophe printed as ‘'));
continue;
}
const expected = singleOpen ? '’' : '‘';
if (ch !== expected) hits.push(context(i, singleOpen ? 'a closing mark printed as ‘' : 'an opening mark printed as ’'));
singleOpen = !singleOpen;
if (singleOpen) singleAt = ch === expected ? i : -1;
}
}
if (doubleOpen && doubleAt !== -1) hits.push(context(doubleAt, 'a “ never closed in its block'));
if (singleOpen && singleAt !== -1) hits.push(context(singleAt, 'a ‘ never closed in its block'));
return { marks, hits };
}

/** A page whose document language is Chinese, Japanese or Korean. */
const CJK_QUOTE_LANG = /<html\b[^>]*\blang\s*=\s*["']?(?:zh|ja|ko)(?:-|["'\s>])/i;

async function checkBuiltCjkQuotes() {
const pages = (await distFiles(DIST)).filter((file) => file.endsWith('.html')).sort();
const offenders = [];
let cjkPages = 0;
let marks = 0;
let total = 0;
for (const page of pages) {
const html = await readFile(path.join(DIST, page), 'utf8');
if (!CJK_QUOTE_LANG.test(html)) continue;
cjkPages++;
const hits = [];
for (const block of quoteBlocks(html)) {
const result = misdirectedQuotes(block);
marks += result.marks;
hits.push(...result.hits);
}
total += hits.length;
if (hits.length > 0) offenders.push({ page, hits });
}
if (cjkPages === 0 || marks === 0) {
console.error(
`✗ content lint --dist found ${cjkPages === 0 ? 'no zh, ja or ko page (by `<html lang>`)' : 'no curly quotation mark on any zh, ja or ko page'} ` +
`among ${pages.length} built page${pages.length === 1 ? '' : 's'} under ${path.relative(ROOT, DIST)}/.\n` +
` Those are the pages where a quotation mark can point the wrong way, so this checked nothing, ` +
`which is not the same as nothing being wrong.`
);
return 1;
}
if (offenders.length === 0) {
console.log(
`✓ content lint --dist CJK quotation marks passed (${cjkPages} zh/ja/ko pages, ${marks} curly marks, ` +
`0 pointing the wrong way or left open)`
);
return 0;
}
for (const { page, hits } of offenders) {
console.log(`✗ ${page}: ${hits.length} quotation mark${hits.length === 1 ? '' : 's'} pointing the wrong way or left open`);
for (const hit of hits.slice(0, 5)) console.log(` ${hit}`);
if (hits.length > 5) console.log(` … and ${hits.length - 5} more`);
}
console.error(
`\n✗ content lint --dist failed (${total} quotation mark${total === 1 ? '' : 's'} pointing the wrong way or left open on ` +
`${offenders.length} zh/ja/ko page${offenders.length === 1 ? '' : 's'}; ⟦ ⟧ marks each)\n` +
` In one paragraph, heading or table cell, the first mark of a pair opens and the next closes.\n` +
` Markdown and MDX in zh-Hans, zh-Hant, ja and ko are set by \`remarkCjkQuotes\` (scripts/lib/cjk-quotes.mjs),\n` +
` wired in astro.config.mjs \`markdown.remarkPlugins\`: if these pages are posts, that step did not run, or a\n` +
` paragraph holds an odd number of marks (a stray \`"\`, or a quotation that runs over two paragraphs; close it\n` +
` in each). If the text came from somewhere else (an .astro template, a data file), fix the marks in that source.`
);
return 1;
}

if (args.has('--dist')) {
const prose = await checkBuiltProse();
const cjkBreaks = await checkBuiltCjkBreaks();
const cjkQuotes = await checkBuiltCjkQuotes();
const links = await checkBuiltLinks();
exit(prose || cjkBreaks || links);
exit(prose || cjkBreaks || cjkQuotes || links);
}

// The frontmatter taxonomy, derived from the module that declares it instead of
Expand Down
Loading
Loading