diff --git a/astro.config.mjs b/astro.config.mjs index 76e2024..ac28d54 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { readdir, rename, rmdir } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'astro/config'; @@ -16,6 +17,43 @@ const POST_LASTMOD = readPostLastmods(fileURLToPath(new URL('.', import.meta.url // `//404/`, the per-locale 404 route before `locale404` renames it. const LOCALE_404 = /^\/[^/]+\/404\/$/; +// Where `astro build` writes pages: the default `outDir`, which this config +// does not override. The sitemap filter below reads the built pages from here. +const OUT_DIR = new URL('./dist/', import.meta.url); + +/** + * Whether a built page may be listed in the sitemap: it must be indexable and + * its own canonical. A sitemap entry tells a crawler "index this URL"; listing + * a `noindex` page is the "Submitted URL marked noindex" error, and listing a + * page that canonicalizes elsewhere submits a duplicate. + * + * The answer is read from the built page itself rather than re-derived here + * from the registries, because the fallback rule has many producers — the + * marketing and glossary registries, pricing/legal/security + * (src/lib/fallback.ts), the templated cluster pages, thin topic hubs and + * archived posts — and a second copy of each would drift from the page that + * actually ships. The integration calls `filter` from its `astro:build:done` + * hook, after every page is written. `pnpm seo:smoke` asserts the result: no + * sitemap URL is noindexed or canonicalizes elsewhere. + */ +function isSelfCanonicalAndIndexable(pageUrl) { + const { pathname } = new URL(pageUrl); + if (!pathname.endsWith('/')) { + throw new Error(`sitemap filter: ${pageUrl} is not a directory URL; cannot locate its built page`); + } + let html; + try { + html = readFileSync(new URL(`.${decodeURIComponent(pathname)}index.html`, OUT_DIR), 'utf8'); + } catch (error) { + // Failing loudly: a page this cannot read would otherwise be listed or + // dropped on a guess. + throw new Error(`sitemap filter: cannot read the built page for ${pageUrl}: ${error.message}`); + } + const head = html.slice(0, html.indexOf('')); + if (//404/index.html`. Cloudflare Pages serves the nearest @@ -61,10 +99,12 @@ export default defineConfig({ mdx(), sitemap({ // The root language redirect and the per-locale 404 pages are not - // content (the top-level /404 the integration drops by itself). + // content (the top-level /404 the integration drops by itself); every + // other page is listed only if it is indexable and self-canonical. filter: (page) => { const { pathname } = new URL(page); - return pathname !== '/' && !LOCALE_404.test(pathname); + if (pathname === '/' || LOCALE_404.test(pathname)) return false; + return isSelfCanonicalAndIndexable(page); }, // Spread the item through: the integration also fills `links`, // `priority` and `changefreq`, and returning a fresh object would drop diff --git a/content/blog/business-app-in-16k-tokens/index.de.mdx b/content/blog/business-app-in-16k-tokens/index.de.mdx index ff4699c..257016d 100644 --- a/content/blog/business-app-in-16k-tokens/index.de.mdx +++ b/content/blog/business-app-in-16k-tokens/index.de.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # die App läuft — beschreiben Sie Ihrem Agent die nächste Änderung ``` -Lieber Browser statt Repository? [ObjectOS](https://www.objectos.ai) ist dieselbe Idee als betriebene Plattform — online bauen & fragen, mit AI Builder, Rechten und Audit eingebaut. +Lieber Browser statt Repository? [ObjectOS](/de/) ist dieselbe Idee als betriebene Plattform — online bauen & fragen, mit AI Builder, Rechten und Audit eingebaut. diff --git a/content/blog/business-app-in-16k-tokens/index.es.mdx b/content/blog/business-app-in-16k-tokens/index.es.mdx index 8710bfd..86d2659 100644 --- a/content/blog/business-app-in-16k-tokens/index.es.mdx +++ b/content/blog/business-app-in-16k-tokens/index.es.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # la app, funcionando — descríbele el siguiente cambio a tu agente ``` -¿Prefieres el navegador antes que un repositorio? [ObjectOS](https://www.objectos.ai) es la misma idea operada para ti — construye y pregunta online, con AI Builder, permisos y auditoría integrados. +¿Prefieres el navegador antes que un repositorio? [ObjectOS](/es/) es la misma idea operada para ti — construye y pregunta online, con AI Builder, permisos y auditoría integrados. diff --git a/content/blog/business-app-in-16k-tokens/index.fr.mdx b/content/blog/business-app-in-16k-tokens/index.fr.mdx index 6b6298f..71baa96 100644 --- a/content/blog/business-app-in-16k-tokens/index.fr.mdx +++ b/content/blog/business-app-in-16k-tokens/index.fr.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # l’app tourne — décrivez le prochain changement à votre agent ``` -Vous préférez le navigateur à un dépôt ? [ObjectOS](https://www.objectos.ai) est la même idée, opérée pour vous — construire et interroger en ligne, avec l’AI Builder, les permissions et l’audit intégrés. +Vous préférez le navigateur à un dépôt ? [ObjectOS](/fr/) est la même idée, opérée pour vous — construire et interroger en ligne, avec l’AI Builder, les permissions et l’audit intégrés. diff --git a/content/blog/business-app-in-16k-tokens/index.ja.mdx b/content/blog/business-app-in-16k-tokens/index.ja.mdx index 4da5e6d..3b6bfa3 100644 --- a/content/blog/business-app-in-16k-tokens/index.ja.mdx +++ b/content/blog/business-app-in-16k-tokens/index.ja.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # アプリはもう動いています — 次の変更をエージェントに伝えるだけ ``` -リポジトリよりブラウザ派なら、[ObjectOS](https://www.objectos.ai) が同じ考え方の運用型です — オンラインで構築と問い合わせ、AI Builder・権限・監査を内蔵しています。 +リポジトリよりブラウザ派なら、[ObjectOS](/ja/) が同じ考え方の運用型です — オンラインで構築と問い合わせ、AI Builder・権限・監査を内蔵しています。 diff --git a/content/blog/business-app-in-16k-tokens/index.ko.mdx b/content/blog/business-app-in-16k-tokens/index.ko.mdx index e44a57c..74d174b 100644 --- a/content/blog/business-app-in-16k-tokens/index.ko.mdx +++ b/content/blog/business-app-in-16k-tokens/index.ko.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # 앱은 이미 실행 중 — 다음 변경을 에이전트에게 말하기만 하면 됩니다 ``` -저장소보다 브라우저가 좋다면, [ObjectOS](https://www.objectos.ai)가 같은 아이디어의 운영형입니다 — 온라인으로 만들고 질문하기, AI Builder·권한·감사 내장. +저장소보다 브라우저가 좋다면, [ObjectOS](/ko/)가 같은 아이디어의 운영형입니다 — 온라인으로 만들고 질문하기, AI Builder·권한·감사 내장. diff --git a/content/blog/business-app-in-16k-tokens/index.mdx b/content/blog/business-app-in-16k-tokens/index.mdx index 05a2310..0f4da21 100644 --- a/content/blog/business-app-in-16k-tokens/index.mdx +++ b/content/blog/business-app-in-16k-tokens/index.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # the app, running — describe the next change to your agent ``` -Prefer the browser instead of a repo? [ObjectOS](https://www.objectos.ai) is the same idea operated for you — build & ask online, with the AI Builder, permissions, and audit built in. +Prefer the browser instead of a repo? [ObjectOS](/en/) is the same idea operated for you — build & ask online, with the AI Builder, permissions, and audit built in. diff --git a/content/blog/business-app-in-16k-tokens/index.zh-Hans.mdx b/content/blog/business-app-in-16k-tokens/index.zh-Hans.mdx index b320a23..7a7e42c 100644 --- a/content/blog/business-app-in-16k-tokens/index.zh-Hans.mdx +++ b/content/blog/business-app-in-16k-tokens/index.zh-Hans.mdx @@ -78,4 +78,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # 应用已运行——把下一个需求直接告诉你的智能体 ``` -更想在浏览器里完成?[ObjectOS](https://www.objectos.ai) 是同一个思路的托管形态——在线构建与问询,内置 AI Builder、权限与审计。 +更想在浏览器里完成?[ObjectOS](/zh-Hans/) 是同一个思路的托管形态——在线构建与问询,内置 AI Builder、权限与审计。 diff --git a/content/blog/business-app-in-16k-tokens/index.zh-Hant.mdx b/content/blog/business-app-in-16k-tokens/index.zh-Hant.mdx index 968a73c..40b78b1 100644 --- a/content/blog/business-app-in-16k-tokens/index.zh-Hant.mdx +++ b/content/blog/business-app-in-16k-tokens/index.zh-Hant.mdx @@ -79,4 +79,4 @@ npm create objectstack@latest my-app && cd my-app npx os dev --ui # 應用已執行——把下一個需求直接告訴你的智慧體 ``` -更想在瀏覽器裡完成?[ObjectOS](https://www.objectos.ai) 是同一個思路的託管形態——線上構建與問詢,內建 AI Builder、權限與審計。 +更想在瀏覽器裡完成?[ObjectOS](/zh-Hant/) 是同一個思路的託管形態——線上構建與問詢,內建 AI Builder、權限與審計。 diff --git a/content/blog/forward-deployed-engineer-tools/index.de.mdx b/content/blog/forward-deployed-engineer-tools/index.de.mdx index 5fe995b..786d4cc 100644 --- a/content/blog/forward-deployed-engineer-tools/index.de.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.de.mdx @@ -91,7 +91,7 @@ Der Freigabeketten-Code von Kunde A lässt sich nicht in die Codebasis von Kunde Jedes Engagement endet, und heute endet es schlecht — auf eine von zwei Arten. Übergeben Sie eine Plattform, mietet der Kunde seine eigene Ontologie für immer zurück — Sie sind zum Vertriebskanal geworden, und der Kunde hat gelernt, erfolgreiche Piloten zu fürchten: je besser die Ontologie, desto tiefer der Lock-in. Übergeben Sie eine Spezial-Codebasis, kann sein Team sie nicht warten; sie verrottet, und achtzehn Monate später trägt die Fäulnis Ihren Namen. -**Was der Stack ändert:** Das ist **die Ontologie-Übergabe** — das Ende, das das FDE-Playbook nie gelöst hat. Was Sie übergeben, ist das Repo des Kunden: typisierte Objekte, Flows und Berechtigungen unter Apache-2.0, dazu das kompilierte Artefakt und eine Review-Checkliste. Sein Security-Team kann die gesamte Definition prüfen — das mitgelieferte Referenzbeispiel hat 16k Tokens, selbst das vollständige HotCRM weniger als 150k, nicht 300.000 Codezeilen. Seine eigenen Coding-Agents warten das System mit derselben Schleife, die Sie benutzt haben, denn das Format wurde agentenschreibbar gebaut. Will der Kunde die Plattform betrieben haben — Browser-AI-Builder, Cloud oder Self-Managed — gibt es [ObjectOS](https://www.objectos.ai), auf derselben offenen Definition; er kann gehen, ohne die Ontologie zu verlieren. Ihren nächsten Vertrag verdienen Sie mit neuer Arbeit, nicht mit Lock-in. Dieser Unterschied ist Ihre Reputation — mit Zinseszins. +**Was der Stack ändert:** Das ist **die Ontologie-Übergabe** — das Ende, das das FDE-Playbook nie gelöst hat. Was Sie übergeben, ist das Repo des Kunden: typisierte Objekte, Flows und Berechtigungen unter Apache-2.0, dazu das kompilierte Artefakt und eine Review-Checkliste. Sein Security-Team kann die gesamte Definition prüfen — das mitgelieferte Referenzbeispiel hat 16k Tokens, selbst das vollständige HotCRM weniger als 150k, nicht 300.000 Codezeilen. Seine eigenen Coding-Agents warten das System mit derselben Schleife, die Sie benutzt haben, denn das Format wurde agentenschreibbar gebaut. Will der Kunde die Plattform betrieben haben — Browser-AI-Builder, Cloud oder Self-Managed — gibt es [ObjectOS](/de/), auf derselben offenen Definition; er kann gehen, ohne die Ontologie zu verlieren. Ihren nächsten Vertrag verdienen Sie mit neuer Arbeit, nicht mit Lock-in. Dieser Unterschied ist Ihre Reputation — mit Zinseszins. ## Das komplette Metadaten-Toolkit des FDE @@ -183,4 +183,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # die App läuft — modellieren Sie das erste Objekt mit Ihrem Agent ``` -Für Kunden, die die Plattform betrieben haben wollen, ist [ObjectOS](https://www.objectos.ai) die kommerzielle Laufzeitumgebung auf Basis von ObjectStack — online bauen & fragen, mit verwaltetem oder privatem Deployment und integrierter Governance. +Für Kunden, die die Plattform betrieben haben wollen, ist [ObjectOS](/de/) die kommerzielle Laufzeitumgebung auf Basis von ObjectStack — online bauen & fragen, mit verwaltetem oder privatem Deployment und integrierter Governance. diff --git a/content/blog/forward-deployed-engineer-tools/index.es.mdx b/content/blog/forward-deployed-engineer-tools/index.es.mdx index f84ef91..928f9e8 100644 --- a/content/blog/forward-deployed-engineer-tools/index.es.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.es.mdx @@ -91,7 +91,7 @@ El código de la cadena de aprobación del cliente A no se puede levantar hacia Todo engagement termina, y hoy termina mal de una de dos maneras. Entrega una plataforma y el cliente alquila su propia ontología para siempre — te has convertido en canal de ventas de otro, y él ha aprendido a temer los pilotos exitosos: cuanto mejor la ontología, más profundo el lock-in. Entrega una base de código a medida y su equipo no puede mantenerla; se pudre, y dieciocho meses después esa podredumbre lleva tu nombre. -**Lo que cambia el stack:** esto es **la entrega de la ontología** — el final que el playbook FDE nunca resolvió. Lo que entregas es el repo del cliente: objetos, flujos y permisos tipados bajo Apache-2.0, más el artefacto compilado y una checklist de revisión. Su equipo de seguridad puede inspeccionar la definición entera — la referencia incluida son 16k tokens y hasta el HotCRM completo queda bajo 150k, no 300.000 líneas de código. Sus propios agentes de código lo mantienen con el mismo bucle que tú usaste, porque el formato nació para ser escrito por agentes. Si quieren la plataforma operada — AI Builder en el navegador, cloud o self-managed — está [ObjectOS](https://www.objectos.ai), sobre la misma definición abierta; pueden irse sin perder la ontología. Tu siguiente contrato se gana con trabajo nuevo, no se extrae con lock-in. Esa diferencia es tu reputación, capitalizando. +**Lo que cambia el stack:** esto es **la entrega de la ontología** — el final que el playbook FDE nunca resolvió. Lo que entregas es el repo del cliente: objetos, flujos y permisos tipados bajo Apache-2.0, más el artefacto compilado y una checklist de revisión. Su equipo de seguridad puede inspeccionar la definición entera — la referencia incluida son 16k tokens y hasta el HotCRM completo queda bajo 150k, no 300.000 líneas de código. Sus propios agentes de código lo mantienen con el mismo bucle que tú usaste, porque el formato nació para ser escrito por agentes. Si quieren la plataforma operada — AI Builder en el navegador, cloud o self-managed — está [ObjectOS](/es/), sobre la misma definición abierta; pueden irse sin perder la ontología. Tu siguiente contrato se gana con trabajo nuevo, no se extrae con lock-in. Esa diferencia es tu reputación, capitalizando. ## El kit completo de metadatos del FDE @@ -183,4 +183,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # la app funcionando — modela el primer objeto con tu agente ``` -Para clientes que quieren la plataforma operada, [ObjectOS](https://www.objectos.ai) es el entorno de ejecución comercial construido sobre ObjectStack — construye y pregunta online, con despliegue gestionado o privado y gobernanza incluida. +Para clientes que quieren la plataforma operada, [ObjectOS](/es/) es el entorno de ejecución comercial construido sobre ObjectStack — construye y pregunta online, con despliegue gestionado o privado y gobernanza incluida. diff --git a/content/blog/forward-deployed-engineer-tools/index.fr.mdx b/content/blog/forward-deployed-engineer-tools/index.fr.mdx index 2e10a22..6984699 100644 --- a/content/blog/forward-deployed-engineer-tools/index.fr.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.fr.mdx @@ -91,7 +91,7 @@ Le code de chaîne de validation du client A ne se transporte pas dans la base d Tout engagement se termine, et aujourd’hui il se termine mal, d’une de deux façons. Livrez une plateforme, et le client loue sa propre ontologie pour toujours — vous êtes devenu le canal de vente d’un autre, et il a appris à craindre les pilotes réussis : meilleure est l’ontologie, plus profond est le verrouillage. Livrez une base de code sur mesure, et son équipe ne peut pas la maintenir ; elle pourrit, et dix-huit mois plus tard cette pourriture porte votre nom. -**Ce que le stack change :** c’est **la passation d’ontologie** — la fin que le playbook FDE n’a jamais résolue. Ce que vous transmettez, c’est le dépôt du client : objets, flux et permissions typés sous Apache-2.0, plus l’artefact compilé et une checklist de revue. Son équipe sécurité peut inspecter la définition entière — la référence fournie fait 16k tokens et même le HotCRM complet reste sous 150k, pas 300 000 lignes de code. Ses propres agents de code entretiennent le système avec la même boucle que vous, parce que le format est né pour être écrit par des agents. S’il veut la plateforme opérée — AI Builder dans le navigateur, cloud ou self-managed — il y a [ObjectOS](https://www.objectos.ai), sur la même définition ouverte ; il peut partir sans perdre l’ontologie. Votre prochain contrat se gagne par du travail nouveau, il ne s’extrait pas par le verrouillage. Cette différence, c’est votre réputation qui capitalise. +**Ce que le stack change :** c’est **la passation d’ontologie** — la fin que le playbook FDE n’a jamais résolue. Ce que vous transmettez, c’est le dépôt du client : objets, flux et permissions typés sous Apache-2.0, plus l’artefact compilé et une checklist de revue. Son équipe sécurité peut inspecter la définition entière — la référence fournie fait 16k tokens et même le HotCRM complet reste sous 150k, pas 300 000 lignes de code. Ses propres agents de code entretiennent le système avec la même boucle que vous, parce que le format est né pour être écrit par des agents. S’il veut la plateforme opérée — AI Builder dans le navigateur, cloud ou self-managed — il y a [ObjectOS](/fr/), sur la même définition ouverte ; il peut partir sans perdre l’ontologie. Votre prochain contrat se gagne par du travail nouveau, il ne s’extrait pas par le verrouillage. Cette différence, c’est votre réputation qui capitalise. ## La boîte à outils métadonnées complète du FDE @@ -183,4 +183,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # l’app tourne — modélisez le premier objet avec votre agent ``` -Pour les clients qui veulent la plateforme opérée, [ObjectOS](https://www.objectos.ai) est l’environnement d’exécution commercial construit sur ObjectStack — construire et interroger en ligne, avec déploiement géré ou privé et gouvernance incluse. +Pour les clients qui veulent la plateforme opérée, [ObjectOS](/fr/) est l’environnement d’exécution commercial construit sur ObjectStack — construire et interroger en ligne, avec déploiement géré ou privé et gouvernance incluse. diff --git a/content/blog/forward-deployed-engineer-tools/index.ja.mdx b/content/blog/forward-deployed-engineer-tools/index.ja.mdx index 3a8629d..466cde1 100644 --- a/content/blog/forward-deployed-engineer-tools/index.ja.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.ja.mdx @@ -91,7 +91,7 @@ export const Ticket = ObjectSchema.create({ すべてのエンゲージメントは終わり、今日その終わり方は 2 通りで、どちらも後味が悪い。プラットフォームを渡せば、顧客は自分のオントロジーを永遠に借り続けます — あなたは誰かの販売チャネルになり、顧客は「成功したパイロット」を恐れることを学びます。オントロジーが良いほどロックインは深いからです。特注コードベースを渡せば、相手のチームは維持できず、腐っていき、18 か月後にその腐敗はあなたの名前と結びつきます。 -**このスタックが変えること:** これが**オントロジーの引き継ぎ** — FDE プレイブックが解かなかった結末です。渡すのは顧客のリポジトリ:Apache-2.0 の型付きオブジェクト、フロー、権限、それにコンパイル済み成果物とレビューチェックリスト。セキュリティチームは定義全体を確認できます — 同梱リファレンスは 16k トークン、完全な HotCRM でさえ 150k 未満であり、30 万行のコードではありません。顧客自身のコーディングエージェントが、あなたが使ったのと同じループで維持を続けます — このフォーマットはエージェントが書くために作られているからです。プラットフォームの運用ごと任せたければ [ObjectOS](https://www.objectos.ai) があり、同じオープンな定義の上で動きます — 離れてもオントロジーは失われません。次の契約はロックインで搾り取るものではなく、新しい仕事で勝ち取るもの。その違いが、複利で効いてくるあなたの評判です。 +**このスタックが変えること:** これが**オントロジーの引き継ぎ** — FDE プレイブックが解かなかった結末です。渡すのは顧客のリポジトリ:Apache-2.0 の型付きオブジェクト、フロー、権限、それにコンパイル済み成果物とレビューチェックリスト。セキュリティチームは定義全体を確認できます — 同梱リファレンスは 16k トークン、完全な HotCRM でさえ 150k 未満であり、30 万行のコードではありません。顧客自身のコーディングエージェントが、あなたが使ったのと同じループで維持を続けます — このフォーマットはエージェントが書くために作られているからです。プラットフォームの運用ごと任せたければ [ObjectOS](/ja/) があり、同じオープンな定義の上で動きます — 離れてもオントロジーは失われません。次の契約はロックインで搾り取るものではなく、新しい仕事で勝ち取るもの。その違いが、複利で効いてくるあなたの評判です。 ## FDE のための完全なメタデータツールキット @@ -183,4 +183,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # アプリは動いています — 最初のオブジェクトをエージェントとモデル化 ``` -プラットフォームの運用まで任せたい顧客には、[ObjectOS](https://www.objectos.ai) — ObjectStack の上に構築された商用ランタイム環境、ブラウザで構築と問い合わせ、マネージドまたはプライベート展開、ガバナンス内蔵 — があります。 +プラットフォームの運用まで任せたい顧客には、[ObjectOS](/ja/) — ObjectStack の上に構築された商用ランタイム環境、ブラウザで構築と問い合わせ、マネージドまたはプライベート展開、ガバナンス内蔵 — があります。 diff --git a/content/blog/forward-deployed-engineer-tools/index.ko.mdx b/content/blog/forward-deployed-engineer-tools/index.ko.mdx index b2fb97c..8d32ca5 100644 --- a/content/blog/forward-deployed-engineer-tools/index.ko.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.ko.mdx @@ -91,7 +91,7 @@ export const Ticket = ObjectSchema.create({ 모든 프로젝트는 끝나고, 오늘 그 끝은 두 가지 방식 중 하나로 나쁘게 끝납니다. 플랫폼을 넘기면, 고객은 자신의 온톨로지를 영원히 빌려 씁니다 — 당신은 남의 판매 채널이 되었고, 고객은 성공한 파일럿을 두려워하는 법을 배웠습니다: 온톨로지가 좋을수록 락인은 깊으니까요. 맞춤 코드베이스를 넘기면, 그들의 팀은 유지하지 못하고, 시스템은 썩어 가고, 18개월 뒤 그 부패에는 당신 이름이 붙습니다. -**이 스택이 바꾸는 것:** 이것이 **온톨로지 인계** — FDE 플레이북이 끝내 풀지 않은 결말입니다. 당신이 넘기는 것은 고객의 저장소입니다: Apache-2.0 아래의 타입 있는 객체·플로·권한, 컴파일된 산출물, 그리고 리뷰 체크리스트. 보안팀은 정의 전체를 검토할 수 있습니다 — 번들 레퍼런스는 16k 토큰이고 완전한 HotCRM도 150k 미만이지, 30만 줄의 코드가 아닙니다. 고객 자신의 코딩 에이전트가 당신이 쓰던 그 루프로 유지보수를 이어갑니다 — 이 포맷은 애초에 에이전트가 쓰라고 만들어졌기 때문입니다. 플랫폼 운영까지 맡기고 싶다면 [ObjectOS](https://www.objectos.ai)가 있고, 같은 오픈 정의 위에서 돕니다 — 떠나도 온톨로지를 잃지 않습니다. 다음 계약은 락인으로 짜내는 것이 아니라 새로운 일로 얻어냅니다. 그 차이가 복리로 쌓이는 당신의 평판입니다. +**이 스택이 바꾸는 것:** 이것이 **온톨로지 인계** — FDE 플레이북이 끝내 풀지 않은 결말입니다. 당신이 넘기는 것은 고객의 저장소입니다: Apache-2.0 아래의 타입 있는 객체·플로·권한, 컴파일된 산출물, 그리고 리뷰 체크리스트. 보안팀은 정의 전체를 검토할 수 있습니다 — 번들 레퍼런스는 16k 토큰이고 완전한 HotCRM도 150k 미만이지, 30만 줄의 코드가 아닙니다. 고객 자신의 코딩 에이전트가 당신이 쓰던 그 루프로 유지보수를 이어갑니다 — 이 포맷은 애초에 에이전트가 쓰라고 만들어졌기 때문입니다. 플랫폼 운영까지 맡기고 싶다면 [ObjectOS](/ko/)가 있고, 같은 오픈 정의 위에서 돕니다 — 떠나도 온톨로지를 잃지 않습니다. 다음 계약은 락인으로 짜내는 것이 아니라 새로운 일로 얻어냅니다. 그 차이가 복리로 쌓이는 당신의 평판입니다. ## FDE의 완전한 메타데이터 툴킷 @@ -183,4 +183,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # 앱은 이미 실행 중 — 첫 객체를 에이전트와 함께 모델링 ``` -플랫폼 운영까지 맡기고 싶은 고객에게는 [ObjectOS](https://www.objectos.ai) — ObjectStack 위에 구축된 상용 런타임 환경, 온라인 구축과 질의, 관리형 또는 프라이빗 배포, 거버넌스 내장 — 가 있습니다. +플랫폼 운영까지 맡기고 싶은 고객에게는 [ObjectOS](/ko/) — ObjectStack 위에 구축된 상용 런타임 환경, 온라인 구축과 질의, 관리형 또는 프라이빗 배포, 거버넌스 내장 — 가 있습니다. diff --git a/content/blog/forward-deployed-engineer-tools/index.mdx b/content/blog/forward-deployed-engineer-tools/index.mdx index 62e2e4d..6cf7810 100644 --- a/content/blog/forward-deployed-engineer-tools/index.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.mdx @@ -91,7 +91,7 @@ Client A's approval-chain code can't be lifted into client B's codebase — diff Every engagement ends, and today it ends badly in one of two ways. Hand over a platform, and the client rents their own ontology back forever — you've become a sales channel, and they've learned to fear successful pilots, because the better the ontology, the deeper the lock-in. Hand over a bespoke codebase, and their team can't maintain it; it rots, and eighteen months later your name is on the rot. -**What the stack changes:** this is **the ontology handover** — the ending the FDE playbook never solved. What you hand over is the client's repo: typed objects, flows, and permissions under Apache-2.0, plus the compiled artifact and a review checklist. Their security team can inspect the whole definition — the bundled reference is 16k tokens, and even the complete HotCRM is under 150k, not 300k lines. Their own coding agents maintain it with the same loop you used, because the format was built to be agent-writable. If they want the platform operated — browser AI Builder, cloud or self-managed — that's [ObjectOS](https://www.objectos.ai), and it runs the same open definition; they can leave it without losing the ontology. Your next contract is earned by new work, not extracted by lock-in. That difference is your reputation, compounding. +**What the stack changes:** this is **the ontology handover** — the ending the FDE playbook never solved. What you hand over is the client's repo: typed objects, flows, and permissions under Apache-2.0, plus the compiled artifact and a review checklist. Their security team can inspect the whole definition — the bundled reference is 16k tokens, and even the complete HotCRM is under 150k, not 300k lines. Their own coding agents maintain it with the same loop you used, because the format was built to be agent-writable. If they want the platform operated — browser AI Builder, cloud or self-managed — that's [ObjectOS](/en/), and it runs the same open definition; they can leave it without losing the ontology. Your next contract is earned by new work, not extracted by lock-in. That difference is your reputation, compounding. ## The FDE's complete metadata toolkit @@ -185,6 +185,6 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # the app, running — model the first object with your agent ``` -For clients who want the platform operated, [ObjectOS](https://www.objectos.ai) is the commercial runtime environment built on ObjectStack — build & ask online, with managed or private deployment and governance included. +For clients who want the platform operated, [ObjectOS](/en/) is the commercial runtime environment built on ObjectStack — build & ask online, with managed or private deployment and governance included. *Companion piece, written for the founder rather than the engineer: [The forward deployed engineer model — why copying Palantir builds a consultancy](/en/blog/forward-deployed-engineer-trap/) — the margin math behind an FDE org, and three tests for whether yours is compounding or billing.* diff --git a/content/blog/forward-deployed-engineer-tools/index.zh-Hans.mdx b/content/blog/forward-deployed-engineer-tools/index.zh-Hans.mdx index 27c2320..2eca7de 100644 --- a/content/blog/forward-deployed-engineer-tools/index.zh-Hans.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.zh-Hans.mdx @@ -91,7 +91,7 @@ export const Ticket = ObjectSchema.create({ 每个项目都会结束,而今天的结束方式有两种,都不体面。交出一个平台,客户从此永远租用自己的本体——你成了别人的销售渠道,而客户学会了害怕成功的试点,因为本体越好,锁定越深。交出一个定制代码库,客户团队维护不了,它慢慢腐烂,十八个月后烂账记在你名下。 -**这套栈改变了什么:** 这就是**本体交接**——FDE 打法从未解决的结尾。你交出的是客户的仓库:Apache-2.0 下的类型化对象、流程与权限,加上编译好的制品和一份评审清单。他们的安全团队能检查整份定义——随包参考样例是 16k token,连完整 HotCRM 也不到 150k,而不是 30 万行代码。他们自己的编码智能体用你用过的同一个循环继续维护,因为这个格式生来就是给智能体写的。如果他们想要平台被托管运营——浏览器 AI Builder、云端或自管——那是 [ObjectOS](https://www.objectos.ai),跑的是同一份开放定义;哪天离开也不会失去本体。你的下一份合同靠新的工作赢来,而不是靠锁定榨出来。这个差别,就是你的口碑在复利。 +**这套栈改变了什么:** 这就是**本体交接**——FDE 打法从未解决的结尾。你交出的是客户的仓库:Apache-2.0 下的类型化对象、流程与权限,加上编译好的制品和一份评审清单。他们的安全团队能检查整份定义——随包参考样例是 16k token,连完整 HotCRM 也不到 150k,而不是 30 万行代码。他们自己的编码智能体用你用过的同一个循环继续维护,因为这个格式生来就是给智能体写的。如果他们想要平台被托管运营——浏览器 AI Builder、云端或自管——那是 [ObjectOS](/zh-Hans/),跑的是同一份开放定义;哪天离开也不会失去本体。你的下一份合同靠新的工作赢来,而不是靠锁定榨出来。这个差别,就是你的口碑在复利。 ## FDE 的完整元数据工具箱 @@ -183,4 +183,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # 应用已运行——和你的智能体一起建模第一个对象 ``` -如果客户希望平台被托管运营,[ObjectOS](https://www.objectos.ai) 是建立在 ObjectStack 之上的商业运行环境——在线构建与问询,支持托管或私有部署,治理内置。 +如果客户希望平台被托管运营,[ObjectOS](/zh-Hans/) 是建立在 ObjectStack 之上的商业运行环境——在线构建与问询,支持托管或私有部署,治理内置。 diff --git a/content/blog/forward-deployed-engineer-tools/index.zh-Hant.mdx b/content/blog/forward-deployed-engineer-tools/index.zh-Hant.mdx index 27ee3ef..0739518 100644 --- a/content/blog/forward-deployed-engineer-tools/index.zh-Hant.mdx +++ b/content/blog/forward-deployed-engineer-tools/index.zh-Hant.mdx @@ -92,7 +92,7 @@ export const Ticket = ObjectSchema.create({ 每個專案都會結束,而今天的結束方式有兩種,都不體面。交出一個平臺,客戶從此永遠租用自己的本體——你成了別人的銷售渠道,而客戶學會了害怕成功的試點,因為本體越好,鎖定越深。交出一個定製程式碼庫,客戶團隊維護不了,它慢慢腐爛,十八個月後爛賬記在你名下。 -**這套棧改變了什麼:** 這就是**本體交接**——FDE 打法從未解決的結尾。你交出的是客戶的倉庫:Apache-2.0 下的型別化物件、流程與權限,加上編譯好的製品和一份評審清單。他們的安全團隊能檢查整份定義——隨包參考樣例是 16k token,連完整 HotCRM 也不到 150k,而不是 30 萬行程式碼。他們自己的編碼智慧體用你用過的同一個迴圈繼續維護,因為這個格式生來就是給智慧體寫的。如果他們想要平臺被託管運營——瀏覽器 AI Builder、雲端或自管——那是 [ObjectOS](https://www.objectos.ai),跑的是同一份開放定義;哪天離開也不會失去本體。你的下一份合同靠新的工作贏來,而不是靠鎖定榨出來。這個差別,就是你的口碑在複利。 +**這套棧改變了什麼:** 這就是**本體交接**——FDE 打法從未解決的結尾。你交出的是客戶的倉庫:Apache-2.0 下的型別化物件、流程與權限,加上編譯好的製品和一份評審清單。他們的安全團隊能檢查整份定義——隨包參考樣例是 16k token,連完整 HotCRM 也不到 150k,而不是 30 萬行程式碼。他們自己的編碼智慧體用你用過的同一個迴圈繼續維護,因為這個格式生來就是給智慧體寫的。如果他們想要平臺被託管運營——瀏覽器 AI Builder、雲端或自管——那是 [ObjectOS](/zh-Hant/),跑的是同一份開放定義;哪天離開也不會失去本體。你的下一份合同靠新的工作贏來,而不是靠鎖定榨出來。這個差別,就是你的口碑在複利。 ## FDE 的完整後設資料工具箱 @@ -184,4 +184,4 @@ npm create objectstack@latest client-app && cd client-app npx os dev --ui # 應用已執行——和你的智慧體一起建模第一個物件 ``` -如果客戶希望平臺被託管運營,[ObjectOS](https://www.objectos.ai) 是建立在 ObjectStack 之上的商業執行環境——線上構建與問詢,支援託管或私有部署,治理內建。 +如果客戶希望平臺被託管運營,[ObjectOS](/zh-Hant/) 是建立在 ObjectStack 之上的商業執行環境——線上構建與問詢,支援託管或私有部署,治理內建。 diff --git a/public/_headers b/public/_headers new file mode 100644 index 0000000..fe82f4c --- /dev/null +++ b/public/_headers @@ -0,0 +1,10 @@ +# Cloudflare Pages response headers (https://developers.cloudflare.com/pages/configuration/headers/). +# +# Everything under /_astro/ is emitted by `astro build` with a content hash in +# its file name (`BaseLayout.Cx3k9a.css`, `cover.BvhgNIRv_j84fS.svg`): a changed +# file gets a new name, so a given URL never changes. Without this rule Pages +# serves them with `max-age=0`, and every page view revalidates every stylesheet +# and image. HTML is not listed: it keeps the default, so a deploy is visible on +# the next request. +/_astro/* + Cache-Control: public, max-age=31536000, immutable diff --git a/scripts/content-lint.mjs b/scripts/content-lint.mjs index f8137db..6392a33 100644 --- a/scripts/content-lint.mjs +++ b/scripts/content-lint.mjs @@ -504,6 +504,37 @@ function splitFrontmatter(source, file) { }; } +// ─── Search-snippet width ────────────────────────────────────────────────── +// +// A results page shows about 60 columns of a title and 160 of a description +// (it truncates by pixel width; columns are the portable stand-in). A CJK +// character is two columns: "AI 编写的企业软件" is 10 characters and 18 columns, +// and counting characters would let a Chinese or Japanese title run to twice +// the width an English one may. Past the limit the end of the line — often the +// part that distinguishes one post from another — is replaced by an ellipsis. +// +// This is a warning, never an error: a longer query-shaped title (writing rule +// 2) can be the right call. Posts first published before SNIPPET_WIDTH_SINCE +// are counted in one summary line rather than listed — about 400 of them when +// the check landed — so the warning stays readable for the post being written +// now; `--snippets` lists every one. `date`, not `updated`, decides: a title +// is written at first publication, and a revision sweep that touches 18 posts +// (setting `updated`) does not make their titles new. +const SNIPPET_WIDTH = { title: 60, description: 160 }; +const SNIPPET_WIDTH_SINCE = new Date('2026-10-06T00:00:00Z'); +const listAllSnippets = args.has('--snippets'); + +/** Display columns: East Asian wide and fullwidth characters count two. */ +function displayWidth(text) { + let width = 0; + for (const ch of text) { + width += /[\u1100-\u115F\u2E80-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u.test(ch) + ? 2 + : 1; + } + return width; +} + function words(text) { const latin = text.match(/[A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)?/g) ?? []; const han = text.match(/[\u3400-\u9fff]/g) ?? []; @@ -1058,6 +1089,7 @@ registries.locales = routableLocales(registries.blog, registries.glossary, regis const files = await walk(BLOG); const posts = []; const issues = []; +const snippetBacklog = { title: 0, description: 0 }; for (const file of files) { const rel = path.relative(ROOT, file); @@ -1115,10 +1147,30 @@ for (const file of files) { } } - if (typeof data.title === 'string') { - if (data.title.length > 95) { - addIssue(issues, rel, data, 'warn', `Title is long (${data.title.length} chars)`); + // Measured against the search snippet (see SNIPPET_WIDTH); a pre-existing + // post is counted in the summary instead of listed, unless --snippets. + const snippetListed = + listAllSnippets || Boolean(data.date && new Date(data.date) >= SNIPPET_WIDTH_SINCE); + const snippetWarn = (field, value) => { + const width = displayWidth(value); + if (width <= SNIPPET_WIDTH[field]) return; + if (!snippetListed) { + snippetBacklog[field] += 1; + return; } + const noun = field === 'title' ? 'Title' : 'Description'; + addIssue( + issues, + rel, + data, + 'warn', + `${noun} is ${width} columns wide (${[...value].length} chars); search results show about ` + + `${SNIPPET_WIDTH[field]}, so the end is cut` + ); + }; + + if (typeof data.title === 'string') { + snippetWarn('title', data.title); if (data.title.length < 12) { addIssue(issues, rel, data, 'warn', 'Title is very short'); } @@ -1128,8 +1180,11 @@ for (const file of files) { if (data.description.length < 55) { addIssue(issues, rel, data, 'warn', `Description is short (${data.description.length} chars)`); } + // AGENTS.md's ceiling for cards; the snippet width below it is advisory. if (data.description.length > 230) { addIssue(issues, rel, data, 'warn', `Description is long (${data.description.length} chars)`); + } else { + snippetWarn('description', data.description); } } @@ -1460,6 +1515,15 @@ const checked = `${posts.length} file${posts.length === 1 ? '' : 's'}, ${terms.l for (const exemption of [...honouredExemptions].sort()) { console.log(`honoured exemption: ${exemption}`); } +// Printed on a passing run too: it is the size of the backlog, not a pass. +if (snippetBacklog.title + snippetBacklog.description > 0) { + console.log( + `• snippet width: ${snippetBacklog.title} title${snippetBacklog.title === 1 ? '' : 's'} over ` + + `${SNIPPET_WIDTH.title} columns and ${snippetBacklog.description} description` + + `${snippetBacklog.description === 1 ? '' : 's'} over ${SNIPPET_WIDTH.description} in posts dated ` + + `before ${SNIPPET_WIDTH_SINCE.toISOString().slice(0, 10)} (list them: pnpm content:lint --snippets)` + ); +} if (issues.length === 0) { console.log(`✓ content lint passed (${checked} checked)`); diff --git a/scripts/lib/text-lang.mjs b/scripts/lib/text-lang.mjs new file mode 100644 index 0000000..0435919 --- /dev/null +++ b/scripts/lib/text-lang.mjs @@ -0,0 +1,300 @@ +// What language a built page's body is actually written in, scope by scope. +// +// `` is a claim, and on this site the claim and the content can +// diverge by construction: every page type with a fallback design (marketing, +// glossary, pricing, legal, security) renders at all eight locale URLs, and +// where a locale has no authored copy the body is English under the locale's +// `lang`. A reader's screen reader then pronounces English with a German or +// Japanese voice (WCAG 3.1.1 / 3.1.2), and a search engine is told a page is +// German when it is not. `scripts/seo-smoke.mjs` uses this module to assert +// that the text inside `
` is written in the language its nearest `lang` +// attribute declares — so the fix (a `lang="en"` on the fallback body) cannot +// silently come undone. +// +// It is a coarse identifier on purpose, tuned to the eight locales this site +// builds, not a general one: +// * CJK is decided by script: Hangul -> ko, kana -> ja, Han alone -> zh, with +// zh-Hans and zh-Hant told apart by characters that differ between the two +// scripts (这/這, 们/們, ...). +// * Latin-script text is decided by stopwords that are distinctive to one of +// en/de/es/fr. Words shared by two of them (de, la, en, que, es, un, ...) +// are left out of every list, so they cannot vote. +// * Too little text, or no clear winner, answers `null` — "cannot tell" — and +// the caller treats that as no evidence either way. Product names, code and +// `translate="no"` spans are not prose and are not counted. + +const VOID_ELEMENTS = new Set([ + 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', + 'track', 'wbr', +]); +/** Elements whose content is not prose in the page's language. */ +const SKIP_ELEMENTS = new Set([ + 'script', 'style', 'template', 'noscript', 'svg', 'math', 'pre', 'code', 'kbd', 'samp', + 'textarea', +]); +const RAW_TEXT_ELEMENTS = new Set(['script', 'style', 'textarea', 'title']); + +/** Index of the `>` closing a tag, honouring quoted attribute values. */ +function tagEnd(html, from) { + let quote = null; + for (let i = from; i < html.length; i++) { + const c = html[i]; + if (quote) { + if (c === quote) quote = null; + } else if (c === '"' || c === "'") { + quote = c; + } else if (c === '>') { + return i; + } + } + return -1; +} + +function attribute(source, name) { + const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, 'i').exec( + source + ); + return m ? (m[1] ?? m[2] ?? m[3]) : undefined; +} + +export function decodeEntities(value) { + return value + .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) + .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16))) + .replace(/ /g, ' ') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&'); +} + +/** Elements that do not end a run of prose: text inside them continues the block. */ +const INLINE_ELEMENTS = new Set([ + 'a', 'abbr', 'b', 'bdi', 'bdo', 'cite', 'data', 'del', 'dfn', 'em', 'i', 'ins', 'mark', 'q', + 's', 'small', 'span', 'strong', 'sub', 'sup', 'time', 'u', 'var', 'wbr', 'br', +]); + +/** + * The prose inside `
`, as blocks: runs of text between block-level + * boundaries, each tagged with the language it is declared to be in (its + * nearest ancestor `lang`, else the document's ``). A change of + * declared language starts a new block too, so a `lang="en"` span inside a + * German paragraph is its own block. Returns `null` when the document has no + * `
` — a page with no main landmark is something else's finding, and + * "no text" must not read as "no mismatch". + */ +export function mainBlocks(html) { + const htmlLang = attribute(/]*)>/i.exec(html)?.[1] ?? '', 'lang') ?? ''; + const stack = [{ name: '#root', lang: htmlLang, skip: false, inMain: false }]; + const blocks = []; + let current = null; + let sawMain = false; + let i = 0; + const top = () => stack[stack.length - 1]; + const flush = () => { + if (current && current.text.trim()) blocks.push({ lang: current.lang, text: current.text.trim() }); + current = null; + }; + const emit = (text) => { + const frame = top(); + if (!frame.inMain || frame.skip) return; + const value = decodeEntities(text); + if (!value.trim()) { + if (current) current.text += ' '; + return; + } + if (current && current.lang !== frame.lang) flush(); + current ??= { lang: frame.lang, text: '' }; + current.text += value; + }; + while (i < html.length) { + const lt = html.indexOf('<', i); + if (lt === -1) { + emit(html.slice(i)); + break; + } + if (lt > i) emit(html.slice(i, lt)); + if (html.startsWith('', lt + 4); + i = end === -1 ? html.length : end + 3; + continue; + } + if (html.startsWith(' frame.name).lastIndexOf(name); + if (at > 0) stack.length = at; + continue; + } + if (RAW_TEXT_ELEMENTS.has(name) && !selfClosing) { + const close = html.toLowerCase().indexOf(`` whose text is confidently in a language other + * than the one it is declared in, plus the declared-language totals the + * verdict was drawn from. Short blocks (a label, a button) carry too little + * text to judge and are skipped, never counted as agreement. + */ +export function languageMismatches(html) { + const blocks = mainBlocks(html); + if (blocks === null) return null; + const mismatches = []; + // Declared languages that at least one block was confirmed to be written in: + // the caller's evidence that the check measured something per locale. + const confirmed = new Set(); + let judged = 0; + for (const block of blocks) { + const detected = detectLanguage(block.text); + if (!detected) continue; + judged++; + if (sameLanguage(detected, block.lang)) confirmed.add(block.lang); + else mismatches.push({ ...block, detected }); + } + return { blocks: blocks.length, judged, confirmed, mismatches }; +} + +/** `detected` is one of the eight site locales; `declared` is any BCP 47 tag. */ +export function sameLanguage(detected, declared) { + const tag = String(declared).toLowerCase(); + if (detected === 'zh-Hans') return tag === 'zh-hans' || tag === 'zh-cn' || tag === 'zh-sg'; + if (detected === 'zh-Hant') return tag === 'zh-hant' || tag === 'zh-tw' || tag === 'zh-hk'; + return tag === detected || tag.startsWith(`${detected}-`); +} + +// Stopwords distinctive to one language. A word that is common in two of the +// four (de/la/en/que/es/un/se/no/a/o/le/et/die/...) is in none of the lists. +const STOPWORDS = { + en: [ + 'the', 'and', 'of', 'to', 'is', 'that', 'it', 'for', 'with', 'as', 'are', 'be', 'this', 'by', + 'not', 'or', 'from', 'your', 'you', 'can', 'which', 'what', 'when', 'its', 'have', 'has', + 'they', 'their', 'into', 'than', 'but', 'does', 'do', 'only', 'every', 'how', 'who', 'should', + 'would', 'about', 'there', 'these', 'those', 'where', 'without', 'each', + ], + de: [ + 'der', 'das', 'und', 'ist', 'nicht', 'mit', 'den', 'ein', 'eine', 'auf', 'für', 'von', 'zu', + 'im', 'sich', 'auch', 'als', 'wird', 'werden', 'dem', 'oder', 'bei', 'wie', 'nur', 'sind', + 'kann', 'ihre', 'ihr', 'sie', 'aus', 'nach', 'wenn', 'über', 'einen', 'durch', 'dass', 'diese', + 'jede', 'ohne', 'noch', 'statt', 'wer', 'zum', 'zur', 'einer', 'eines', + ], + es: [ + 'el', 'los', 'las', 'del', 'y', 'por', 'para', 'con', 'su', 'sus', 'al', 'lo', 'como', 'más', + 'pero', 'sin', 'sobre', 'este', 'esta', 'cada', 'puede', 'tus', 'qué', 'cómo', 'cuando', + 'también', 'ya', 'hay', 'muy', 'una', 'uno', 'está', 'están', 'quien', 'nuestro', 'otro', + 'todo', 'todos', 'hasta', 'desde', + ], + fr: [ + 'les', 'du', 'est', 'une', 'qui', 'dans', 'pour', 'pas', 'sur', 'au', 'aux', 'avec', 'ce', + 'cette', 'il', 'ne', 'par', 'sont', 'ou', 'votre', 'vos', 'chaque', 'peut', 'sans', 'être', + 'leur', 'leurs', 'mais', 'nous', 'vous', 'comme', 'tout', 'tous', 'quand', 'où', 'aussi', + 'très', 'ces', 'ses', 'déjà', 'donc', + ], +}; +const STOPWORD_LANG = new Map(); +for (const [lang, words] of Object.entries(STOPWORDS)) { + for (const word of words) { + if (STOPWORD_LANG.has(word)) throw new Error(`text-lang: stopword "${word}" is in two lists`); + STOPWORD_LANG.set(word, lang); + } +} + +// Characters that are written differently in Simplified and Traditional +// Chinese, as pairs. Common enough that any real paragraph has several. +const ZH_PAIRS = [ + '这這', '们們', '个個', '说說', '为為', '对對', '会會', '过過', '时時', '发發', '与與', '学學', + '实實', '应應', '业業', '务務', '数數', '据據', '开開', '关關', '体體', '点點', '经經', '问問', + '题題', '动動', '后後', '从從', '现現', '进進', '还還', '种種', '当當', '区區', '导導', '专專', + '无無', '条條', '产產', '将將', '设設', '计計', '质質', '价價', '权權', '调調', '选選', '构構', + '层層', '证證', '级級', '页頁', '读讀', '写寫', '语語', '让讓', '认認', '识識', '变變', '边邊', + '运運', '维維', '组組', '织織', '网網', '络絡', '软軟', '统統', '审審', '码碼', '录錄', '图圖', +]; +const HANS = new Set(ZH_PAIRS.map((pair) => pair[0])); +const HANT = new Set(ZH_PAIRS.map((pair) => pair[1])); + +const MIN_LATIN_STOPWORDS = 8; +const MIN_CJK_CHARS = 20; + +/** + * The language `text` is written in — one of the eight site locales — or + * `null` when there is too little text or no clear winner. + */ +export function detectLanguage(text) { + let hangul = 0; + let kana = 0; + let han = 0; + let latin = 0; + let hans = 0; + let hant = 0; + for (const ch of text) { + if (/\p{Script=Hangul}/u.test(ch)) hangul++; + else if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(ch) && ch !== 'ー' && ch !== '・') kana++; + else if (/\p{Script=Han}/u.test(ch)) { + han++; + if (HANS.has(ch)) hans++; + else if (HANT.has(ch)) hant++; + } else if (/\p{Script=Latin}/u.test(ch)) latin++; + } + const cjk = hangul + kana + han; + // A CJK sentence carries Latin product names and acronyms (ObjectStack, MCP, + // AI), and a Latin sentence almost never carries CJK. One CJK character + // stands for roughly a word, so it is weighed against Latin letters / 4. + if (cjk >= MIN_CJK_CHARS && cjk >= latin / 4) { + if (hangul >= (kana + han) / 2) return 'ko'; + if (kana >= (kana + han) * 0.08) return 'ja'; + if (hans + hant < 3) return null; + if (hans >= hant * 3) return 'zh-Hans'; + if (hant >= hans * 3) return 'zh-Hant'; + return null; + } + if (cjk >= MIN_CJK_CHARS && cjk * 4 >= latin / 4) return null; + const votes = { en: 0, de: 0, es: 0, fr: 0 }; + let total = 0; + for (const word of text.toLowerCase().match(/\p{L}+/gu) ?? []) { + const lang = STOPWORD_LANG.get(word); + if (!lang) continue; + votes[lang]++; + total++; + } + if (total < MIN_LATIN_STOPWORDS) return null; + const [first, second] = Object.entries(votes).sort((a, b) => b[1] - a[1]); + if (first[1] < second[1] * 2) return null; + return first[0]; +} diff --git a/scripts/seo-smoke.mjs b/scripts/seo-smoke.mjs index 44a5c94..660b48e 100644 --- a/scripts/seo-smoke.mjs +++ b/scripts/seo-smoke.mjs @@ -2,6 +2,7 @@ import { access, readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { cwd, exit } from 'node:process'; +import { decodeEntities, languageMismatches } from './lib/text-lang.mjs'; const ROOT = cwd(); const DIST = path.join(ROOT, 'dist'); @@ -70,6 +71,11 @@ const CLUSTER_SLUGS = [ const CLUSTER_PATHS = LOCALES.flatMap((locale) => CLUSTER_SLUGS.map((slug) => `/${locale}/${slug}/`) ); +// Only the English cluster pages are written per cluster; every other locale's +// is one template with the name swapped (src/lib/cluster-i18n.ts), so those are +// fallbacks: noindexed, canonical to English, and out of llms.txt and the +// sitemap. When a locale gets real per-cluster copy, this list grows with it. +const CLUSTER_AUTHORED_LOCALES = new Set(['en']); async function readDist(file) { try { @@ -150,8 +156,14 @@ for (const locale of ['en', 'zh-Hans']) { } } for (const clusterPath of CLUSTER_PATHS) { - requireContains('llms.txt', llms, `${SITE}${clusterPath}`, `missing cluster page ${clusterPath}`); - requireContains('sitemap-0.xml', sitemap, `${SITE}${clusterPath}`, `missing cluster page ${clusterPath}`); + const url = `${SITE}${clusterPath}`; + if (CLUSTER_AUTHORED_LOCALES.has(clusterPath.split('/')[1])) { + requireContains('llms.txt', llms, url, `missing cluster page ${clusterPath}`); + requireContains('sitemap-0.xml', sitemap, url, `missing cluster page ${clusterPath}`); + } else { + requireExcludes('llms.txt', llms, `(${url})`, `lists templated cluster page ${clusterPath}`); + requireExcludes('sitemap-0.xml', sitemap, `${url}`, `lists templated cluster page ${clusterPath}`); + } } // The 404 surface. Cloudflare Pages treats a deployment with no top-level @@ -458,16 +470,73 @@ if (postEntriesChecked === 0) { ); } -const htmlFiles = (await walk(DIST)) +// ─── Every built page, read once ─────────────────────────────────────────── +// +// The sweeps below are invariants over the whole build rather than lists of +// expected URLs: a fallback page has many producers (the marketing and +// glossary registries, src/lib/fallback.ts for pricing, legal and security, +// the templated cluster pages, thin topic hubs), and asserting the shape of +// the result catches a new producer that gets the rule wrong without this +// script having to learn about it first. +const allHtmlFiles = (await walk(DIST)) .filter((file) => file.endsWith('.html')) .map((file) => path.relative(DIST, file).split(path.sep).join('/')) + .sort(); +const servedUrl = (file) => `${SITE}/${file.replace(/(^|\/)index\.html$/, '$1')}`; +const attrOf = (tag, name) => { + const m = new RegExp(`\\s${name}="([^"]*)"`).exec(tag); + return m ? decodeEntities(m[1]) : undefined; +}; +const TAG = (name) => new RegExp(`<${name}\\b(?:[^>"']|"[^"]*"|'[^']*')*>`, 'g'); +const pages = new Map(); +for (const file of allHtmlFiles) { + const html = await readDist(file); + const headEnd = html.indexOf(''); + const head = headEnd === -1 ? html : html.slice(0, headEnd); + const metas = [...head.matchAll(TAG('meta'))].map(([tag]) => tag); + const links = [...head.matchAll(TAG('link'))].map(([tag]) => tag); + const meta = (key, value) => attrOf(metas.find((tag) => attrOf(tag, key) === value) ?? '', 'content'); + pages.set(file, { + file, + html, + url: servedUrl(file), + noindex: /\bnoindex\b/.test(meta('name', 'robots') ?? ''), + refresh: metas.some((tag) => (attrOf(tag, 'http-equiv') ?? '').toLowerCase() === 'refresh'), + canonical: attrOf(links.find((tag) => attrOf(tag, 'rel') === 'canonical') ?? '', 'href'), + hreflangs: links + .filter((tag) => attrOf(tag, 'rel') === 'alternate' && attrOf(tag, 'hreflang')) + .map((tag) => ({ hreflang: attrOf(tag, 'hreflang'), href: attrOf(tag, 'href') })), + ogUrl: meta('property', 'og:url'), + ogLocale: meta('property', 'og:locale'), + ogImageAlt: meta('property', 'og:image:alt'), + ogImageWidth: meta('property', 'og:image:width'), + ogImageHeight: meta('property', 'og:image:height'), + }); +} +const pageAt = (href) => { + let url; + try { + url = new URL(href); + } catch { + return undefined; + } + if (url.origin !== SITE) return undefined; + const rel = decodeURIComponent(url.pathname).replace(/^\//, ''); + return pages.get(rel === '' || rel.endsWith('/') ? `${rel}index.html` : rel); +}; +// Indexable = no `noindex` and its own canonical: the only pages a sitemap may +// list or an hreflang / canonical annotation may point at. +const isIndexable = (page) => Boolean(page) && !page.noindex && page.canonical === page.url; +const isNotFound = (file) => /(^|\/)404\.html$/.test(file); + +const htmlFiles = allHtmlFiles .filter((file) => file !== 'index.html') // Checked above: a 404 page has no canonical URL and no hreflang set. - .filter((file) => !/(^|\/)404\.html$/.test(file)); + .filter((file) => !isNotFound(file)); for (const file of htmlFiles) { - const html = await readDist(file); - const expectedPath = `/${file.replace(/index\.html$/, '')}`; + const page = pages.get(file); + const { html } = page; // Glossary paths are matched first: `/glossary/index.html` also // satisfies the two-segment marketing pattern, and falling through to it // would compute the wrong canonical for the fallback locales. @@ -475,44 +544,59 @@ for (const file of htmlFiles) { const glossaryIndexMatch = file.match(/^([^/]+)\/glossary\/index\.html$/); const marketingMatch = file.match(/^([^/]+)\/([^/]+)\/index\.html$/); - let canonicalPath = expectedPath; - let isFallbackContent = false; + // A fallback the registries name, with the canonical it must carry. Other + // fallbacks (pricing, legal, security, thin hubs) are held to the generic + // canonical rule below instead of a second copy of their source tables. + let registryCanonicalPath = null; if (glossaryTermMatch && LOCALES.includes(glossaryTermMatch[1])) { const [, locale, slug] = glossaryTermMatch; const contentLocales = glossaryContentLocales.get(slug); if (contentLocales && !contentLocales.has(locale)) { - isFallbackContent = true; - canonicalPath = `/${FALLBACK_LOCALE}/glossary/${slug}/`; + registryCanonicalPath = `/${FALLBACK_LOCALE}/glossary/${slug}/`; } } else if (glossaryIndexMatch && LOCALES.includes(glossaryIndexMatch[1])) { if (!glossaryLocales.has(glossaryIndexMatch[1])) { - isFallbackContent = true; - canonicalPath = `/${FALLBACK_LOCALE}/glossary/`; + registryCanonicalPath = `/${FALLBACK_LOCALE}/glossary/`; } } else if (marketingMatch && LOCALES.includes(marketingMatch[1])) { - const contentLocales = marketingContentLocales.get(marketingMatch[2]); - if (contentLocales && !contentLocales.has(marketingMatch[1])) { - isFallbackContent = true; - canonicalPath = `/${FALLBACK_LOCALE}/${marketingMatch[2]}/`; + const [, locale, slug] = marketingMatch; + const contentLocales = marketingContentLocales.get(slug); + if (contentLocales && !contentLocales.has(locale)) { + registryCanonicalPath = `/${FALLBACK_LOCALE}/${slug}/`; + } else if (CLUSTER_SLUGS.includes(slug) && !CLUSTER_AUTHORED_LOCALES.has(locale)) { + registryCanonicalPath = `/${FALLBACK_LOCALE}/${slug}/`; } } - const canonical = `${SITE}${canonicalPath}`; - requireContains(file, html, ``, 'missing expected canonical URL'); - requireContains(file, html, ``, 'missing expected Open Graph URL'); - if (isFallbackContent) { + if (registryCanonicalPath !== null) { + const canonical = `${SITE}${registryCanonicalPath}`; + requireContains(file, html, ``, 'missing expected canonical URL'); requireContains( file, html, '', 'fallback-content page must be noindexed' ); + } else if (page.canonical !== page.url && !page.noindex) { + // A page may name another URL as canonical only as a fallback, and a + // fallback is noindexed: an indexable page that canonicalizes elsewhere + // asks to be indexed and merged at once. + issues.push( + `${file}: canonical ${page.canonical ?? '(none)'} is not the page's own URL, but the page is not noindexed` + ); + } + if (page.ogUrl !== page.canonical) { + issues.push(`${file}: og:url ${page.ogUrl ?? '(none)'} differs from the canonical ${page.canonical ?? '(none)'}`); } requireContains(file, html, 'type="application/ld+json"', 'missing JSON-LD structured data'); const locale = LOCALES.find((candidate) => file.startsWith(`${candidate}/`)); if (locale) { requireContains(file, html, ``, 'missing matching html lang'); - requireContains(file, html, 'rel="alternate" hreflang="x-default"', 'missing x-default hreflang'); + // A noindexed page may have no indexable equivalent at all (a thin hub in + // every locale), and then it has no hreflang set to anchor an x-default. + if (!page.noindex) { + requireContains(file, html, 'rel="alternate" hreflang="x-default"', 'missing x-default hreflang'); + } requireContains(file, html, `href="${SITE}/${locale}/rss.xml"`, 'missing locale RSS discovery link'); } @@ -520,6 +604,221 @@ for (const file of htmlFiles) { requireContains(file, html, '"@type":"BlogPosting"', 'missing BlogPosting JSON-LD'); requireContains(file, html, '"@type":"BreadcrumbList"', 'missing BreadcrumbList JSON-LD'); } + if (/^[^/]+\/blog\/topics\/.+\/index\.html$/.test(file)) { + requireContains(file, html, '"@type":"BreadcrumbList"', 'topic hub missing BreadcrumbList JSON-LD'); + requireContains(file, html, '"@type":"ItemList"', 'topic hub missing ItemList JSON-LD'); + } +} + +// ─── 1. The sitemap lists only indexable, self-canonical pages ────────────── +// +// A noindexed URL in the sitemap is Search Console's "Submitted URL marked +// noindex"; a URL that canonicalizes elsewhere submits a duplicate. 190 noindexed +// fallback pages were listed before the sitemap filter read the built pages +// (astro.config.mjs). Checked against the built page each entry names, so the +// filter cannot drift from what the page says about itself. +let sitemapChecked = 0; +for (const { loc } of sitemapEntries) { + if (!loc) continue; + sitemapChecked += 1; + const page = pageAt(loc); + if (!page) issues.push(`sitemap-0.xml: lists ${loc}, which is not a built page`); + else if (page.noindex) issues.push(`sitemap-0.xml: lists noindexed page ${loc}`); + else if (page.canonical !== loc) { + issues.push(`sitemap-0.xml: lists ${loc}, whose canonical is ${page.canonical ?? '(none)'}`); + } +} +const noindexedBuilt = [...pages.values()].filter((page) => page.noindex && !isNotFound(page.file)).length; + +// ─── 2. Canonical and hreflang point only at indexable pages ──────────────── +// +// An hreflang annotation claims its target is a real, indexable page in that +// language; a canonical claims its target is the page to index. Pointing +// either at a noindexed or non-canonical URL is the pattern that indexed 20 +// English pricing/legal pages as de/es/fr/ja/ko: each was self-canonical and +// listed in every locale's hreflang set. +let annotationsChecked = 0; +for (const page of pages.values()) { + if (page.file === 'index.html') continue; + if (page.canonical && page.canonical !== page.url) { + annotationsChecked += 1; + const target = pageAt(page.canonical); + if (!isIndexable(target)) { + issues.push(`${page.file}: canonical ${page.canonical} is not an indexable, self-canonical page`); + } + } + for (const { hreflang, href } of page.hreflangs) { + annotationsChecked += 1; + if (!isIndexable(pageAt(href))) { + issues.push(`${page.file}: hreflang="${hreflang}" points at ${href}, which is not an indexable, self-canonical page`); + } + } +} + +// ─── 3. The body is written in the language it declares ───────────────────── +// +// Every block of prose inside `
` must be in the language of its nearest +// `lang` (scripts/lib/text-lang.mjs). This is what failed on 215 pages: the +// marketing, glossary, pricing and legal fallbacks rendered English under +// `lang="de"`/`"ja"`/..., and the partly translated security pages mixed +// English sections into a translated frame — wrong for a screen reader (WCAG +// 3.1.1/3.1.2) and wrong for a crawler deciding what language a page is in. +// Checked on every page, indexable or not: a noindexed fallback is still read. +let langBlocksJudged = 0; +const judgedLangs = new Set(); +for (const page of pages.values()) { + if (page.file === 'index.html') continue; + const result = languageMismatches(page.html); + if (result === null) { + issues.push(`${page.file}: has no
— its body language cannot be checked`); + continue; + } + langBlocksJudged += result.judged; + for (const lang of result.confirmed) judgedLangs.add(lang); + if (result.mismatches.length === 0) continue; + const first = result.mismatches[0]; + issues.push( + `${page.file}: ${result.mismatches.length} of ${result.judged} judged blocks in
are not in their ` + + `declared language — e.g. lang="${first.lang}" but written in ${first.detected}: ` + + `"${first.text.slice(0, 80)}…" (mark it with lang="${first.detected}", or fix the producer)` + ); +} +for (const locale of LOCALES) { + if (!judgedLangs.has(locale)) { + issues.push( + `language check: no ${locale} block was confidently identified anywhere in the build — ` + + 'the check measured nothing for that locale, which is not a pass' + ); + } +} + +// ─── 4. No internal link redirects ────────────────────────────────────────── +// +// A link that redirects costs the reader a round trip and tells a crawler the +// linked URL is not the real one. Cloudflare Pages answers a directory page +// requested without its trailing slash (or as `.../index.html`) with a 308 to +// the slash form; `/` is the language-redirect page; a `_redirects` source is +// a redirect by definition. Host and scheme variants (`http://`, the apex) are +// not this build's to answer for and are not judged. +const redirectRules = (await readFile(path.join(DIST, '_redirects'), 'utf8').catch(() => '')) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')) + .map((line) => { + const source = line.split(/\s+/)[0]; + const pattern = source + .split('*') + .map((part) => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/:[A-Za-z]\w*/g, '[^/]+')) + .join('.*'); + return { source, re: new RegExp(`^${pattern}$`) }; + }); +function redirectReason(url) { + const pathname = decodeURIComponent(url.pathname); + const rule = redirectRules.find(({ re }) => re.test(pathname)); + if (rule) return `matches the _redirects source ${rule.source}`; + if (pathname === '/') return 'is the root language redirect (meta refresh to /en/)'; + const rel = pathname.replace(/^\//, ''); + if (rel.endsWith('/index.html') && pages.has(rel)) return 'is served at its directory URL (Pages answers 308)'; + if (!rel.endsWith('/') && !path.extname(rel) && pages.has(`${rel}/index.html`)) { + return 'has no trailing slash (Pages answers 308 to the slash form)'; + } + return null; +} +let internalLinksChecked = 0; +const redirecting = new Map(); // "href — reason" -> files +for (const page of pages.values()) { + const bodyStart = page.html.indexOf(' attrOf(tag, 'href')), + page.canonical, + page.ogUrl, + ...page.hreflangs.map(({ href }) => href), + ]; + for (const href of refs) { + if (!href || href.startsWith('#')) continue; + let url; + try { + url = new URL(href, page.url); + } catch { + continue; + } + if (url.origin !== SITE) continue; + internalLinksChecked += 1; + const reason = redirectReason(url); + if (!reason) continue; + const key = `${href} — ${reason}`; + redirecting.set(key, [...(redirecting.get(key) ?? []), page.file]); + } +} +for (const [key, files] of redirecting) { + const unique = [...new Set(files)]; + issues.push( + `internal link ${key}; linked from ${unique.length} page${unique.length === 1 ? '' : 's'}: ` + + `${unique.slice(0, 3).join(', ')}${unique.length > 3 ? ', …' : ''}` + ); +} +if (internalLinksChecked === 0) { + issues.push('redirect check: found no internal links — an empty match is a failure, not a pass'); +} + +// ─── The root page ────────────────────────────────────────────────────────── +// +// `/` is a redirect (zero-delay meta refresh to /en/ plus a language script), +// so it names /en/ as its canonical, its hreflang alternates are absolute (a +// relative hreflang href is ignored), and it is not also noindexed — that pair +// asks for the page to be dropped and merged at once. +{ + const root = pages.get('index.html'); + if (root) { + if (root.canonical !== `${SITE}/en/`) { + issues.push(`index.html: canonical is ${root.canonical ?? '(none)'}, expected ${SITE}/en/`); + } + if (root.noindex && root.refresh) issues.push('index.html: combines noindex with a meta refresh'); + if (root.hreflangs.length !== LOCALES.length + 1) { + issues.push(`index.html: expected ${LOCALES.length + 1} hreflang links, found ${root.hreflangs.length}`); + } + for (const { hreflang, href } of root.hreflangs) { + if (!href?.startsWith(`${SITE}/`)) issues.push(`index.html: hreflang="${hreflang}" href "${href}" is not absolute`); + } + } +} + +// ─── Cache headers for hashed assets ──────────────────────────────────────── +// +// Every file astro writes under /_astro/ has a content hash in its name, so it +// can be cached forever; without `_headers` Pages serves them with max-age=0. +// The rule is only safe while the names really are hashed, so that is checked +// too. +{ + const headers = await readFile(path.join(DIST, '_headers'), 'utf8').catch(() => null); + if (!headers) issues.push('_headers: missing — /_astro/* is served with max-age=0'); + else if (!/^\/_astro\/\*\s*\n[ \t]+Cache-Control:\s*public,\s*max-age=31536000,\s*immutable\s*$/m.test(headers)) { + issues.push('_headers: no "/_astro/* Cache-Control: public, max-age=31536000, immutable" rule'); + } + const assets = await readdir(path.join(DIST, '_astro')).catch(() => []); + const unhashed = assets.filter((name) => !/\.[A-Za-z0-9_-]{8}(?:_[A-Za-z0-9_-]+)?\.[a-z0-9]+$/.test(name)); + if (assets.length === 0) issues.push('_astro/: no assets found — the immutable rule covers nothing'); + for (const name of unhashed) issues.push(`_astro/${name}: not content-hashed, but /_astro/* is cached as immutable`); +} + +// ─── Open Graph and the Organization node ─────────────────────────────────── +const OG_LOCALES = new Set(['en_US', 'zh_CN', 'zh_TW', 'ja_JP', 'de_DE', 'es_ES', 'fr_FR', 'ko_KR']); +for (const page of pages.values()) { + if (page.file === 'index.html') continue; + if (!OG_LOCALES.has(page.ogLocale)) { + issues.push(`${page.file}: og:locale "${page.ogLocale ?? '(none)'}" is not one of ${[...OG_LOCALES].join(', ')}`); + } + if (!page.ogImageAlt) issues.push(`${page.file}: missing og:image:alt`); + if (!page.ogImageWidth !== !page.ogImageHeight) { + issues.push(`${page.file}: og:image:width and og:image:height must be given together`); + } + requireContains( + page.file, + page.html, + '"@type":"Organization","@id":"https://www.objectos.ai/#organization"', + 'Organization JSON-LD has no @id' + ); + requireContains(page.file, page.html, '"sameAs":["https://github.com/', 'Organization JSON-LD has no sameAs'); } if (issues.length > 0) { @@ -528,4 +827,8 @@ if (issues.length > 0) { exit(1); } -console.log(`SEO smoke test passed (${htmlFiles.length} HTML pages checked)`); +console.log( + `SEO smoke test passed (${htmlFiles.length} HTML pages checked; ${sitemapChecked} sitemap URLs against ` + + `${noindexedBuilt} noindexed pages; ${annotationsChecked} canonical/hreflang annotations; ` + + `${langBlocksJudged} prose blocks language-checked; ${internalLinksChecked} internal links redirect-checked)` +); diff --git a/src/components/FooterClusters.astro b/src/components/FooterClusters.astro new file mode 100644 index 0000000..9dab4d4 --- /dev/null +++ b/src/components/FooterClusters.astro @@ -0,0 +1,75 @@ +--- +// The five topic-cluster (pillar) pages, linked from every page's footer. +// +// Before this row a cluster page's only in-site link was the home page's +// cluster strip in its own locale — /en/ai-native-app-platform/ had eight +// inbound links, seven of them language switchers. A pillar page that nothing +// links to is a page a crawler finds once and weighs as unimportant, and a +// reader on an article never learns the guide exists. Every page links all +// five here; each post a cluster lists as its reading path also links back to +// it (src/layouts/BlogPost.astro). +import { clusterCopy } from '../lib/cluster-i18n'; +import { CLUSTERS, clusterPath } from '../lib/clusters'; +import { ui, type Locale } from '../lib/i18n'; + +interface Props { + locale: Locale; +} + +const { locale } = Astro.props; +const label = ui[locale].siteNav.topicClusters; +--- + + + + diff --git a/src/components/GlossaryTermPage.astro b/src/components/GlossaryTermPage.astro index 0d62eaa..0cf8791 100644 --- a/src/components/GlossaryTermPage.astro +++ b/src/components/GlossaryTermPage.astro @@ -3,7 +3,7 @@ import { getMarketingPage, marketingPagePath } from '../content-pages/registry'; import { getGlossaryTerm, glossaryIndexPath, glossaryTermPath } from '../glossary/registry'; import { glossaryUi } from '../glossary/ui'; import type { GlossaryTerm } from '../glossary/types'; -import { postPath, type Locale } from '../lib/i18n'; +import { htmlLang, postPath, type Locale } from '../lib/i18n'; import { getPostsInLocale } from '../lib/posts'; interface Props { @@ -103,7 +103,9 @@ const hasUsage = pages.length > 0 || articles.length > 0;