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
14 changes: 14 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 All @@ -91,6 +99,12 @@ notes. Entries describe what users experience, not internal refactors.
broken. A tab still on an older build now moves onto the new one on its next
load, without asking and without interrupting anything: it happens while the
page is still loading, once, and never over unsaved edits.
- **An update could leave the editor blank.** When a new version took over
while the page was still starting up, the request for the editor itself was
cancelled mid-flight and nothing asked for it again -- an empty white page
that reloading was the only way out of. The page now reloads itself when the
version serving it really has changed, which is what repairs it -- unless you
have unsaved edits, which nothing is allowed to reload over.
- The four home-screen buttons ("View/Edit Document", "New Word"...) no longer
flash behind the spinner while a document named in the URL is loading.
- Chinese, Japanese and Korean text in an exported PDF came out blank. The
Expand Down
23 changes: 22 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 Expand Up @@ -527,6 +535,19 @@ docs/explorations/2026-08-19-ci-e2e-sharding.md。
`DEPLOY_COUPLED`(由 `hosting-contract.test.ts` / `sw-routing.test.ts` 钉住)。
`open-local.js` / `landing-prefetch.js` 同理,2026-08-20 起补齐——它们从路由
拆分起一直漏在 SWR 上,改这两个文件的部署,落地页会一直跑旧的那份。
**controllerchange 的 reload 判据只有两条:是不是新构建 + 有没有未保存改动**
(2026-08-23)。激活新 worker 会终止旧 worker,它手上 in-flight 的 fetch 全部失败——
其中就有编辑器 iframe 自己的文档请求,没人重试,标签页永远白屏;拒绝刷新撤不回那次
交接。所以**别把"有没有文档打开"加回判据**。但也**别只按"换了就刷"**:controller
变更是常态(厂商 worker 的来回切换让我们的 sw.js 在普通加载里就被重装并 waiting,
浏览器下一次导航自己激活它),实测一次普通 reload 之后 80ms 就换人,无差别刷新会把
`sw-vendor-cache-first` 打成 `Execution context was destroyed`。新构建的判据是
`isUnseenBuild`,**缓存名快照必须在启动时取**(交接时再读,新 worker 已经建好自己的
那个了)。另有 `documentIsExpected`(`?new=` / `?file=` / `?src=` / `?open=` /
`?saved=` / `?embed=`):正在开文档的页面**不要提升**。**别去改 `sw.js` 的 install
让它对同一构建不接管**——试过,本地证不出必要性,却会让"回滚到本机跑过的构建"不再
自动投递。见
docs/explorations/2026-08-23-promotion-without-reload-blank-editor.md。
落地页那侧的提升要覆盖三种到达方式:已经 `waiting`、`installing` 中途、
以及 `updatefound` 时已经 `installed`(`statechange` 只报此后的迁移,
漏掉这一支等于整页生命周期内再没人提升它)。
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
Loading
Loading