Skip to content
Closed
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ notes. Entries describe what users experience, not internal refactors.
document can reference still resolves; it just lands on a face that may be
shared freely.

### Changed

- **Search engines and AI assistants now see one editor, not one per page.**
Every page described the application from scratch, so a site of 154 pages
looked like 154 unrelated tools that happened to share a name. The pages now
all point at the same one, and say which of the seven languages they are in.
Nothing on the pages themselves changed.

### Fixed

- **A new version now reaches you by itself.** An update used to wait until no
Expand Down
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,15 @@ fetchFonts 字体竞态,修复见 `lib/onlyoffice-editor.ts` 的 `prepareEdito
超时)都属于这一层;`test/unit/hosting-contract.test.ts` 钉住 `_headers` 关键
规则;`test/unit/landing-pages.test.ts` 钉住全部 SEO 落地页(public/**/*.html +
index.html)的 canonical/hreflang/JSON-LD/sitemap/双语互指契约——新增落地页必须
同时补 en+zh、sitemap、llms.txt、首页卡片,否则该测试先红。**线上冒烟** `.github/workflows/prod-smoke.yml`:每次 push main 等部署
同时补 en+zh、sitemap、llms.txt、首页卡片,否则该测试先红。
**结构化数据是一张有 `@id` 的图,不是每页一份副本**(2026-08-23,学 apple.com):
`Organization` / `WebSite` / `WebApplication` / `SoftwareSourceCode` 用固定 `@id`
(`/#organization` / `/#website` / `/#app` / `/#source`)每页重复出现并互相引用,
`WebPage` / `FAQPage` / `HowTo` / `BreadcrumbList` 用 `<页面 url>#webpage` 这类
页面级 id。**app 节点的 `url` 恒为站点根**,语言写在 `WebPage.inLanguage`;
改成当前页 URL 就等于宣称七种语言是七个产品。文档页只发 app 的 stub(`appStub()`),
落地页才发完整节点。上面那个测试会拒绝重复 `@id` 与解析不到定义的 `@id` 引用。
见 docs/explorations/2026-08-23-entity-graph-from-apple.md。**线上冒烟** `.github/workflows/prod-smoke.yml`:每次 push main 等部署
上线后即跑 + 每日;`E2E_BASE_URL=<站点>` 可把任意 spec 打到部署站。CF 面板里
的 Cache Rules 等不在仓库、CI 复现不了,只能靠冒烟兜底。

Expand Down
178 changes: 128 additions & 50 deletions bin/build-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,93 @@ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const ORIGIN = 'https://edit.chaxus.com';
const REPO = 'https://github.com/ranuts/document';

/**
* Stable identities for the three things this site is about.
*
* Every page used to emit its own anonymous WebApplication / SoftwareSourceCode
* node, so 154 pages described 154 unrelated applications that happened to
* share a name. Naming them once and referring to the name instead is what
* turns a pile of pages into one entity described from many places -- the model
* apple.com uses (`#organization`, `#website`, `#brand`, then
* `manufacturer: { "@id": ... }` everywhere else). It matters more to the
* machines that answer questions about the site than to the ones that rank it:
* an assistant reading three of our pages should come away with one editor,
* not three.
*/
const ID = {
org: `${ORIGIN}/#organization`,
site: `${ORIGIN}/#website`,
app: `${ORIGIN}/#app`,
source: `${ORIGIN}/#source`,
};
const SITE_NAME = 'Online Document Editor';

/** The publisher and the site, identical on every page so they merge into one. */
const siteEntities = () => [
{
'@type': 'Organization',
'@id': ID.org,
name: 'ranuts',
url: ORIGIN + '/',
logo: `${ORIGIN}/img/pwa-512.png`,
sameAs: [REPO, 'https://github.com/ranuts', 'https://ran.chaxus.com'],
},
{
'@type': 'WebSite',
'@id': ID.site,
name: SITE_NAME,
url: ORIGIN + '/',
publisher: { '@id': ID.org },
// The site is one site in seven languages, which is a fact about the site
// and not about whichever page is being read. Each page states its own
// language on its WebPage node.
inLanguage: Object.keys(LOCALES),
},
];

/**
* The editor itself. One entity, `url` always the site root -- a per-page url
* here would make each translation look like a separate product.
*/
const appEntity = (extra = {}) => ({
'@type': 'WebApplication',
'@id': ID.app,
name: SITE_NAME,
url: ORIGIN + '/',
applicationCategory: 'BusinessApplication',
operatingSystem: 'Any (web browser)',
browserRequirements: 'Requires a modern browser with WebAssembly support',
isAccessibleForFree: true,
inLanguage: Object.keys(LOCALES),
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
// The repository is the editor's other public identity, not the org's.
sameAs: [REPO],
publisher: { '@id': ID.org },
isPartOf: { '@id': ID.site },
...extra,
});

/**
* The same entity, stated with just enough to be a definition rather than a
* dangling reference. A documentation page is a page ABOUT the editor, not a
* listing of it -- it should not carry a price and a category and become
* eligible for an app rich result. But `about: { "@id": ... }` pointing at
* nothing is silently dropped by the consumer, which puts the page back to
* describing an anonymous application. So: named, not detailed.
*/
const appStub = () => ({ '@type': 'WebApplication', '@id': ID.app, name: SITE_NAME, url: ORIGIN + '/' });

/** The repository behind it, named so the node merges instead of repeating. */
const sourceEntity = () => ({
'@type': 'SoftwareSourceCode',
'@id': ID.source,
name: SITE_NAME,
codeRepository: REPO,
programmingLanguage: 'TypeScript',
license: 'https://www.gnu.org/licenses/agpl-3.0.html',
about: { '@id': ID.app },
});

/** Locales the shell knows about. `prefix` is the URL directory; '' = root. */
export const LOCALES = {
en: { prefix: '', lang: 'en', label: 'EN', home: '/', dir: 'ltr', og: 'en_US' },
Expand Down Expand Up @@ -625,38 +712,37 @@ function renderHome({ locale, data, locales }) {
.join('\n');

const graph = [
{
'@type': 'WebApplication',
name: 'Online Document Editor',
url,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Any (web browser)',
browserRequirements: 'Requires a modern browser with WebAssembly support',
...siteEntities(),
appEntity({
description: data.description,
inLanguage: L.lang,
isAccessibleForFree: true,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
...(data.featureList ? { featureList: data.featureList } : {}),
sameAs: [REPO, 'https://www.npmjs.com/package/@ranui/preview', 'https://ran.chaxus.com'],
...(data.ecosystem
? { isPartOf: { '@type': 'SoftwareApplication', name: data.ecosystem.name, url: data.ecosystem.url } }
? { isPartOf: [{ '@id': ID.site }, { '@type': 'SoftwareApplication', ...data.ecosystem }] }
: {}),
}),
// This page: one homepage per language, each pointing at the same app.
{
'@type': 'WebPage',
'@id': `${url}#webpage`,
url,
name: data.title,
description: data.description,
inLanguage: L.lang,
isPartOf: { '@id': ID.site },
about: { '@id': ID.app },
primaryImageOfPage: `${ORIGIN}/img/pwa-512.png`,
},
{
'@type': 'FAQPage',
'@id': `${url}#faq`,
inLanguage: L.lang,
mainEntity: data.sections.faq.items.map(({ q, a }) => ({
'@type': 'Question',
name: q,
acceptedAnswer: { '@type': 'Answer', text: a },
})),
},
{
'@type': 'SoftwareSourceCode',
name: 'Online Document Editor',
codeRepository: REPO,
programmingLanguage: 'TypeScript',
license: 'https://www.gnu.org/licenses/agpl-3.0.html',
},
sourceEntity(),
];
const jsonLd = JSON.stringify({ '@context': 'https://schema.org', '@graph': graph }, null, 2)
.split('\n')
Expand Down Expand Up @@ -737,7 +823,7 @@ function renderHome({ locale, data, locales }) {
<title>${e(data.title)}</title>
<meta name="description" content="${e(data.description)}" />
<link rel="canonical" href="${url}" />
<meta name="robots" content="index, follow" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
${alternates}
<link rel="alternate" hreflang="x-default" href="${ORIGIN + LOCALES[DEFAULT_LOCALE].home}" />
<!-- No-flash theme restore: apply a forced light/dark before first paint so a
Expand Down Expand Up @@ -958,36 +1044,24 @@ function renderPage({ page, locale, meta, body, headings, faq, steps, source })
const isLanding = page.kind === 'landing';
const cardDescription = meta.ogDescription || description;
const graph = [
// A landing page is selling the app, and Google's rich results treat a
// WebApplication node accordingly (price, category, platform). A generated
// documentation page is a page about the product, not the product.
isLanding
? {
'@type': 'WebApplication',
name: 'Online Document Editor',
url,
applicationCategory: 'BusinessApplication',
operatingSystem: 'Any (web browser)',
isAccessibleForFree: true,
inLanguage: L.lang,
description: meta.appDescription || description,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
}
: {
'@type': 'WebPage',
name: title,
url,
description,
inLanguage: L.lang,
isPartOf: { '@type': 'WebSite', name: 'Online Document Editor', url: ORIGIN + '/' },
},
...siteEntities(),
// Every page is a page; a landing page additionally describes the app, and
// Google's rich results treat a WebApplication node accordingly (price,
// category, platform). The app node is the same entity everywhere, so a
// landing page adds to its description rather than declaring a new one.
{
'@type': 'SoftwareSourceCode',
name: 'Online Document Editor',
codeRepository: REPO,
programmingLanguage: 'TypeScript',
license: 'https://www.gnu.org/licenses/agpl-3.0.html',
'@type': 'WebPage',
'@id': `${url}#webpage`,
url,
name: title,
description,
inLanguage: L.lang,
isPartOf: { '@id': ID.site },
about: { '@id': ID.app },
breadcrumb: { '@id': `${url}#breadcrumb` },
},
isLanding ? appEntity({ description: meta.appDescription || description }) : appStub(),
sourceEntity(),
];
// The steps a landing page already lists, as structured data. Taken from the
// rendered list rather than written separately: the hand-written pages kept a
Expand All @@ -996,13 +1070,17 @@ function renderPage({ page, locale, meta, body, headings, faq, steps, source })
if (isLanding && meta.howTo && steps.length >= 2) {
graph.push({
'@type': 'HowTo',
'@id': `${url}#howto`,
inLanguage: L.lang,
name: meta.howTo,
step: steps.map((text) => ({ '@type': 'HowToStep', text })),
});
}
if (faq.length >= 2) {
graph.push({
'@type': 'FAQPage',
'@id': `${url}#faq`,
inLanguage: L.lang,
mainEntity: faq.map(({ q, a }) => ({
'@type': 'Question',
name: q,
Expand All @@ -1015,7 +1093,7 @@ function renderPage({ page, locale, meta, body, headings, faq, steps, source })
crumbs.push({ '@type': 'ListItem', position: 2, name: meta.parent.name, item: ORIGIN + meta.parent.href });
}
crumbs.push({ '@type': 'ListItem', position: crumbs.length + 1, name: meta.breadcrumb || title, item: url });
graph.push({ '@type': 'BreadcrumbList', itemListElement: crumbs });
graph.push({ '@type': 'BreadcrumbList', '@id': `${url}#breadcrumb`, itemListElement: crumbs });

const translations = Object.keys(LOCALES).filter((l) => page.sources[l]);
const alternates = translations
Expand Down Expand Up @@ -1101,7 +1179,7 @@ ${related}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link href="/img/64.png" rel="shortcut icon" />
<link rel="icon" type="image/png" href="/img/64.png" />
<meta name="robots" content="index, follow" />
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#ffffff" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#000000" />

Expand Down
100 changes: 100 additions & 0 deletions docs/explorations/2026-08-23-entity-graph-from-apple.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# 照着 apple.com 抄结构化数据:把 154 个页面收拢成一个实体

日期:2026-08-23
方法:抓 `www.apple.com/`、`/iphone/`、`/shop/buy-mac/macbook-air`、
`/macbook-air/specs/`、`/iphone/compare/` 的原始 HTML,以及 robots.txt 与 sitemap,
逐项比对本站生成器的输出。

## 苹果做对的、且能搬过来的

### 1. 稳定 `@id` + 互相引用(最值钱的一条)

苹果不在每页重新定义"苹果公司"。它定义一次:

```json
{ "@id": "https://www.apple.com/#organization", "@type": "Organization", "name": "Apple", ... }
```

之后所有页面只写引用:`"manufacturer": { "@id": "https://www.apple.com/#organization" }`。
`#website`、`#webpage`、`/mac/#brand` 同理。MacBook Air 规格页的 `Product` 节点
里,manufacturer 和 brand 都只是两个 `@id`。

本站原本反过来:每个页面各发一份匿名的 `WebApplication` 和 `SoftwareSourceCode`。
**154 个页面 = 154 个碰巧同名的应用**。对排名影响有限,对回答问题的机器影响很大——
读了三个页面的助手应该得出"一个编辑器",而不是三个。

改成:

| 节点 | `@id` | 出现在 |
| -------------------------------------- | ------------------------------------------- | ------------ |
| `Organization` | `/#organization` | 每页 |
| `WebSite` | `/#website` | 每页 |
| `WebApplication` | `/#app` | 每页 |
| `SoftwareSourceCode` | `/#source` | 每页 |
| `WebPage` | `<页面 url>#webpage` | 每页(本页) |
| `FAQPage` / `HowTo` / `BreadcrumbList` | `<页面 url>#faq` / `#howto` / `#breadcrumb` | 有则有 |

`WebApplication` 的 `url` 恒为站点根,**不再是当前页面的 URL**——否则七种语言看起来
就是七个不同的产品。语言这件事挪到了它该在的地方:`WebPage.inLanguage` 说这一页是
什么语言,`WebSite.inLanguage` / `WebApplication.inLanguage` 是七种语言的数组,
本身也是一条对 LLM 有用的事实("这个编辑器有七种语言")。

落地页仍然发完整的 app 节点(价格、类别、平台,Google 的 rich result 要这些),
**文档页只发一个 stub**(`@type` + `@id` + `name` + `url`)。理由是两头都要顾:
`/help` 不该因为带了价格而去竞争应用类富媒体结果,但 `about: { "@id": ... }`
指向一个图里不存在的 id 会被消费方直接丢掉,那样这一页又退回"描述一个匿名应用"。

### 2. `max-image-preview:large`

苹果每页都发 `<meta name="robots" content="max-image-preview:large">`。本站原本只有
`index, follow`。补成
`index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1`——
后两个不是苹果的做法,但它们放开的正是 AI 摘要与搜索摘要能引用的长度上限。

### 3. `sameAs` 指向可核对的身份

苹果的 Organization 指向 **Wikidata**(`Q312`)+ 各社交账号。这是把站点绑到知识图谱
实体上的做法。本站没有 Wikidata 条目,用能核对的那几个:GitHub 组织、仓库、生态站点。
`sameAs` 从 app 节点挪到 Organization(那是组织的身份),app 自己留仓库这一条。

## 苹果做了但**不该**抄的

- **没有 `x-default`**。137 条 hreflang,一条 x-default 都没有。本站有,保持。
- **sitemap 只有 `<loc>`**,没有 lastmod/priority。本站按 git 提交日期发 lastmod,更好。
- **规格页几乎不带正文 HTML**。`/macbook-air/specs/` 324 KB,`<h1>` 之后直到页脚
再没有正文标题——规格全靠 JS 渲染。对爬虫和 LLM 都是净损失,苹果的品牌撑得住,我们不行。
- **没有 llms.txt**(404)。本站有 `/llms.txt` + `/llms-full.txt`。

## 考虑过但决定不改的

- **`hreflang="pt"` 改成 `pt-BR`**。本站的葡语内容确实是巴西葡语(og 已经是 `pt_BR`),
按苹果的做法应该声明 `pt-BR`。但苹果有 pt 和 br 两套站,我们只有一套:声明 `pt-BR`
会让葡萄牙读者落回 x-default 的英文。只有一个变体时,覆盖面胜过精确度,保持 `pt`。

## 还没做的:`/specs/` 与 `/compare/` 这两种页型

苹果给**每个**产品配一张规格页和一张对比页(sitemap 里 `/airpods-4/compare/`、
`/airpods-4/specs/` 逐个都在)。这两种页型吃的正是高意图长尾词,也正是 LLM 最容易
整段引用的密集事实。

本站有 `/open/<格式>` 与 `/convert/<格式对>`,**没有**:

- 一张"支持什么、限制是什么"的规格页:格式清单、大小上限、浏览器要求、
哪些功能只在 Chromium 上(写回原文件、剪贴板)、离线可用范围。
- 任何对比页型:`docx vs pdf` 这类格式对比,或"和需要上传的在线编辑器相比"。

这是一次内容项目(× 7 种语言),需要先定内容,没有在这次改动里做。

## 用例与反向验证

`test/unit/landing-pages.test.ts` 三条新契约,逐页跑(154 页 × 3):

1. 本页的 `WebPage` 节点 url/@id 必须是本页;
2. 出现的 `WebApplication` 必须是共享实体(`@id` = `/#app`,`url` = 站点根);
3. **图里每一个 `@id` 引用都必须能解析到图里的定义**,且没有重复 `@id`。

第 3 条当场就抓到一个真的悬空引用:文档页的 `WebPage.about` 与
`SoftwareSourceCode.about` 指向 `/#app`,而那些页面根本没定义它——`appStub()`
就是为此加的。

反向验证:`git stash` 掉 `bin/build-pages.mjs`,441 条断言变红。
Loading
Loading