diff --git a/CHANGELOG.md b/CHANGELOG.md index 23444570..417ab295 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 diff --git a/CLAUDE.md b/CLAUDE.md index 5e91e62e..1918231b 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 复现不了,只能靠冒烟兜底。 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 }) {