diff --git a/CHANGELOG.md b/CHANGELOG.md index 23444570..acdcc3e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 5e91e62e..24a8b6e6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 复现不了,只能靠冒烟兜底。 @@ -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` 只报此后的迁移, 漏掉这一支等于整页生命周期内再没人提升它)。 diff --git a/bin/build-pages.mjs b/bin/build-pages.mjs index d607b902..28baa1e9 100644 --- a/bin/build-pages.mjs +++ b/bin/build-pages.mjs @@ -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' }, @@ -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') @@ -737,7 +823,7 @@ function renderHome({ locale, data, locales }) { ${e(data.title)} - + ${alternates}