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
66 changes: 66 additions & 0 deletions .github/SEO_AND_ANALYTICS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Help 页面 SEO 与统计规范

新增或改写 Help 页面时,按本规范检查内容、元数据、内链与统计。页面内容沿用仓库现有的 Mintlify MDX、导航和语言结构;图片规范见 [README](../README.md#image-assets)。

## 先读正文,再写标题与描述

- `title` 对应正文的具体任务、对象或问题,每种语言内保持唯一。让读者看出这篇文档能解决什么,避免关键词罗列、泛化的“完整指南”和重复套用的标题模板。Mintlify 已处理站点标题,不要机械追加品牌后缀。
- `description` 用自然的一两句话说明可完成的操作、适用场景或关键条件。依据实际正文写,不复述标题,不批量替换功能名生成同一句描述,也不承诺未提供的功能或结果。
- 标题变长时,用简短的 `sidebarTitle` 保留清晰的导航名称;修改 SEO 标题时检查原导航标签是否应保留。
- frontmatter 的 `title` 提供页面唯一 H1,正文从 `##` 开始。保留已发布的标题锚点及历史别名,避免破坏旧链接。
- 能力、套餐条件、限制和操作步骤以可核实的产品能力为准。必要限制写成具体使用条件;调研缺口、验收状态和待办放在工作记录中。

## 沿用多语言与索引规则

语言目录为 `en`、`zh`、`ru`、`de`、`fr`、`es`、`it`、`ja`、`tr`、`uk`、`ar`,导航由 [docs.json](../docs.json) 管理。将可翻译的标题、描述、导航名、正文及 alt/aria 文本写入对应语言 MDX 或现有语言资源;使用当地自然表达,不另建一套语言判断或翻译回退机制。

Canonical 应指向该页干净、稳定的正式 HTTPS URL,不含 UTM、站内来源参数、hash、localhost 或 `/src/_props/` 等内部路径。部署后检查平台实际输出,不能以本地预览的内部 URL 作为正式地址。核对各语言 hreflang 的对应关系;隐藏、归档、退役页面分别维护 [隐藏页策略](seo-hidden-pages.json)、[归档策略](seo-archive-policy.json) 和重定向。

## 按上下文添加可访问的真实链接

- 在读者需要进一步了解产品、比较方案、操作案例或客户案例的位置添加内链,链接到实际存在、语言合适的官网页面。先核对目录与目标内容,产品详情页通常直接使用 `/ai-app-builder` 等真实路径,不假设是 `/products/:slug`。
- 使用原生 `<a href>` 或最终能生成原生锚点的 Markdown 链接。卡片使用仓库现有的 `docs-card-link` 外层锚点,避免不可抓取的 `Card href`、空链接和交互元素嵌套。
- 官网内部引流不人为添加 `utm_source` 等获客参数。站内文档来源由共享脚本处理;保留真实外部推广已有的 UTM,根入口语言跳转也必须保留查询参数。

## 复用共享统计契约

[help-link-tracking.js](../help-link-tracking.js) 统一处理受信任官网链接的引荐策略、来源标记和点击事件。不要逐页嵌入 GA 脚本或重复发送 `config`、`page_view`;页面浏览继续由现有 Mintlify/GA 集成负责。受信任的公开官网链接保留 `noopener` 并移除 `noreferrer`,其他外链不套用这条规则。

| 项目 | 当前代码契约 |
| --------- | ------------------------------------------------------------------------------------------------- |
| Help 点击 | `teable_help_link_click`,表示跳转意图 |
| 官网落地 | `teable_help_link_arrive`,表示目标页面接收了有效来源标记 |
| 来源标记 | `help_source` 为公开文档路径,`help_host` 为允许的 Help 域名,由脚本添加并替换旧值 |
| 事件维度 | `source_path`、`target_path`、`source_domain`、`link_category`、`locale`;`locale` 指来源文档语言 |
| 链接类别 | `product`、`compare`、`blog`、`customer` |

当前路径验证仅接受最长 100 字符、由小写字母/数字/连字符组成的路径段,可带末尾 `/`;排除编码分隔符、内部路径和私有应用路径。Help 来源域名为 `help.teable.ai`、`help.teable.cn`,来源为语言首页、`basic`/`deploy`/`api-doc`/`compare` 下的文档或符合现有格式的 changelog。目标限于脚本列出的正式 HTTPS 官网域名、产品目录和公开比较/文章/客户案例路径。

新增产品、文档根目录、语言、路径格式或域名时,检查是否超出这些边界。需要扩展时,同步修改 Help 脚本、官网仓库 `src/lib/help-link-arrival.ts` 中的校验及双方回归检查;不要静默扩大到任意 URL。官网对应检查入口为 `node scripts/check-help-link-arrival.mjs`,在官网仓库执行。

事件只传公开、受限的路径和分类值,不传完整查询串、hash、邮箱、邀请参数、凭据或私有资源标识。现有查询参数的保留不等于允许把它们复制到事件字段;新埋点复用现有同意机制,不额外创建身份标识或覆盖原始获客来源。点击、落地、注册成功分别验收,不把按钮点击当作注册完成,也不把不同 GA4 属性自动视为同一条连续会话。

## 按改动验证并记录结果

每个独立修复单独提交;功能或页面改动完成后,在本地启动预览并检查实际 HTML,安排独立子 agent review,解决有效反馈。发现相关小问题直接修复,独立问题加入计划。纯规则文档修改检查链接和内容即可,不为文档格式另写测试。

以下命令在 Help 仓库根目录执行,与现有 CI 保持一致:

```bash
python3 -m pip install -r scripts/requirements-seo.txt
python3 -m unittest discover -s scripts -p 'test_*.py'
node --test scripts/test_*.cjs
python3 scripts/check_docs_seo.py
npx --yes mintlify@4.2.1059 validate
npx --yes mintlify@4.2.1059 broken-links --check-anchors --check-redirects
```

按 [README 的本地预览命令](../README.md#changelog-history-and-local-preview) 启动服务后,运行:

```bash
python3 scripts/check_rendered_seo.py --base-url http://localhost:3000
```

端口使用当前实际预览端口;需要全量检查时加 `--all`。来源追踪改动另外核对跳转前后参数、共享脚本加载、事件字段、重复执行和 SPA 导航;HTTP/VM 检查不能替代实际采集与 GA4 收数证据。

上线前后核对正式 Canonical/hreflang、sitemap、重定向、受影响链接及事件接收情况。记录“配置正确”“事件已发送”“GA4 已收到”“标准来源报表已入表”各自的证据,未验收的环节明确列为待验。当前自动检查覆盖元数据存在性、重复标题、H1、链接和策略一致性等;语言质量、搜索意图和功能真实性仍需编辑与 reviewer 判断。
5 changes: 5 additions & 0 deletions .github/seo-hidden-pages.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,5 +168,10 @@
"page": "en/locale-router",
"disposition": "retained-noindex",
"reason": "Language selector used by the root redirect; localized homepages are the indexable entry points."
},
{
"page": "index",
"disposition": "retained-noindex",
"reason": "Root language selector preserves acquisition parameters before choosing a localized homepage."
}
]
2 changes: 2 additions & 0 deletions .mintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Repository-only instructions; keep them out of the published Help site.
/AGENTS.md
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Teable Help 仓库说明

新增或修改页面、SEO 元数据、内链或统计时,先阅读并遵循 [.github/SEO_AND_ANALYTICS.md](.github/SEO_AND_ANALYTICS.md)。图片处理与本地开发命令见 [README.md](README.md)。

按独立修复分别提交,检查实际预览输出并安排独立子 agent review;将新发现的独立问题记录到当前修复计划。
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Teable Help

新增或改写页面前,请阅读 [Help 页面 SEO 与统计规范](.github/SEO_AND_ANALYTICS.md),包含标题、描述、多语言、内链、埋点及发布验收要求。

### Development

Install the [Mintlify CLI](https://www.npmjs.com/package/mintlify) to preview the documentation changes locally. To install, use the following command
Expand Down
5 changes: 0 additions & 5 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,6 @@
}
},
"redirects": [
{
"source": "/",
"destination": "/en/locale-router",
"permanent": false
},
{
"source": "/en/basic/base/migrate-from-airtable",
"destination": "/en/basic/ai/connect-everything/migrate-airtable"
Expand Down
191 changes: 191 additions & 0 deletions help-link-tracking.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
(function () {
if (window.__teableHelpLinkTrackingInitialized) {
return;
}
window.__teableHelpLinkTrackingInitialized = true;

var officialHosts = ["teable.ai", "www.teable.ai", "teable.cn", "www.teable.cn"];
var locales = ["en", "zh", "ru", "de", "fr", "es", "it", "ja", "tr", "uk", "ar"];
// Keep this public product catalog in sync with the website arrival validator.
var productRoutes = [
"products",
"csv-compare",
"email-permutator",
"pdf-to-table",
"ai-flowchart-generator",
"lead-enrichment",
"ai-market-research",
"seo-reporting-tool",
"ai-email-reply-generator",
"online-database-builder",
"ai-newsletter-generator",
"client-portal",
"purchase-order-generator",
"ai-social-media-manager",
"ai-ads-agency",
"ai-proposal-generator",
"vendor-portal",
"directory-website-builder",
"ai-spreadsheet-generator",
"employee-portal",
"ai-report-generator",
"dashboard-creator",
"ai-app-builder",
"no-code-app-builder",
"excel-to-app",
"spreadsheet-to-app",
"online-form-builder",
"ai-web-scraper",
"ai-email-parser",
"find-influencers",
"workflow-generator",
"ai-project-management",
"ctms-system",
];

function publicSegments(pathname) {
if (pathname.length > 100 || !/^\/[a-z0-9-]+(?:\/[a-z0-9-]+)*\/?$/.test(pathname)) {
return null;
}
return pathname.replace(/\/$/, "").slice(1).split("/");
}

function linkCategory(pathname) {
var parts = publicSegments(pathname);
if (!parts) { return null; }
if (locales.indexOf(parts[0]) !== -1) { parts.shift(); }
if (parts.length === 1 && productRoutes.indexOf(parts[0]) !== -1) { return "product"; }
if (parts.length <= 2 && parts[0] === "compare") { return "compare"; }
if (parts.length <= 2 && parts[0] === "blog") { return "blog"; }
if (parts.length <= 2 && parts[0] === "customers") { return "customer"; }
return null;
}

function trustedTarget(link) {
try {
var target = new URL(link.href, window.location.href);
if (target.protocol !== "https:" || target.port || target.username || target.password ||
officialHosts.indexOf(target.hostname) === -1 || !linkCategory(target.pathname)) {
return null;
}
return target;
} catch (_) {
return null;
}
}

function sourceContext() {
var location = window.location;
if (location.protocol !== "https:" ||
["help.teable.ai", "help.teable.cn"].indexOf(location.hostname) === -1) {
return null;
}
var parts = publicSegments(location.pathname);
if (!parts || locales.indexOf(parts[0]) === -1) { return null; }
if (parts.length > 1 && ["basic", "deploy", "api-doc", "compare"].indexOf(parts[1]) === -1 &&
!(parts.length === 2 && /^changelog(?:-\d{4}(?:-\d{2})?)?$/.test(parts[1]))) {
return null;
}
return { path: location.pathname, host: location.hostname };
}

function addSourceMarkers(target, source) {
// Keep existing campaign parameters and their encoding exactly as supplied.
var original = target.search ? target.search.slice(1).split("&") : [];
var kept = original.filter(function (entry) {
try {
var name = decodeURIComponent(entry.split("=")[0].replace(/\+/g, " "));
return name !== "help_source" && name !== "help_host";
} catch (_) { return true; }
});
kept.push("help_source=" + encodeURIComponent(source.path));
kept.push("help_host=" + encodeURIComponent(source.host));
target.search = "?" + kept.join("&");
}

function prepareLink(link) {
var target = trustedTarget(link);
if (!target) {
return;
}
var tokens = (link.getAttribute("rel") || "").split(/\s+/).filter(function (token) {
return token && token.toLowerCase() !== "noreferrer";
});
if (!tokens.some(function (token) { return token.toLowerCase() === "noopener"; })) {
tokens.push("noopener");
}
var rel = tokens.join(" ");
if (link.getAttribute("rel") !== rel) {
link.setAttribute("rel", rel);
}
var source = sourceContext();
if (source) {
addSourceMarkers(target, source);
if (link.href !== target.href) { link.setAttribute("href", target.href); }
}
}

function trackClick(event) {
if ((event.type === "click" && event.button !== 0) ||
(event.type === "auxclick" && event.button !== 1)) { return; }
var link = event.target.closest && event.target.closest("a[href]");
if (!link) { return; }
var target = trustedTarget(link);
var source = sourceContext();
if (!target || !source) { return; }
prepareLink(link);
var locale = source.path.split("/")[1];
var parameters = {
send_to: "G-LQ8H9TEDRD",
source_path: source.path,
target_path: target.pathname,
source_domain: source.host,
link_category: linkCategory(target.pathname),
locale: locale,
page_location: "https://" + source.host + source.path
};
// Queue early clicks through the same gtag data layer, without configuring
// another tag or sending a page_view. Existing consent rules still apply.
function queueEvent() {
window.dataLayer = window.dataLayer || [];
window.dataLayer.push(arguments);
}
(typeof window.gtag === "function" ? window.gtag : queueEvent)(
"event", "teable_help_link_click", parameters
);
}

function prepareTree(root) {
if (root.matches && root.matches("a[href]")) {
prepareLink(root);
}
if (root.querySelectorAll) {
root.querySelectorAll("a[href]").forEach(prepareLink);
}
}

// Mintlify inserts and updates links as users navigate between documents.
prepareTree(document);
var observedPath = window.location.pathname;
function refreshForRoute() {
if (observedPath !== window.location.pathname) {
observedPath = window.location.pathname;
prepareTree(document);
}
}
document.addEventListener("click", trackClick, true);
document.addEventListener("auxclick", trackClick, true);
window.addEventListener("popstate", refreshForRoute);
new MutationObserver(function (mutations) {
refreshForRoute();
mutations.forEach(function (mutation) {
if (mutation.type === "attributes") {
prepareLink(mutation.target);
} else {
mutation.addedNodes.forEach(prepareTree);
}
});
}).observe(document.documentElement, {
childList: true, subtree: true, attributes: true, attributeFilter: ["href", "rel"]
});
})();
6 changes: 6 additions & 0 deletions index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
title: "Choose your language"
description: "Redirecting to the Teable Help Center in your preferred language."
hidden: true
noindex: true
---
10 changes: 7 additions & 3 deletions language-route.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

var locales = ["en", "zh", "ru", "de", "fr", "es", "it", "ja", "tr", "uk", "ar"];
var localeCookie = "TEABLE_DOCS_LOCALE";
var routerPath = "/en/locale-router";
var routerPaths = ["", "/index", "/en/locale-router"];

function isRouterPath(pathname) {
return routerPaths.indexOf(pathname.replace(/\/$/, "")) !== -1;
}
var lastObservedPath = window.location.pathname;

function localeFromPath(pathname) {
Expand Down Expand Up @@ -90,7 +94,7 @@
}

lastObservedPath = currentPath;
if (currentPath.replace(/\/$/, "") === routerPath) {
if (isRouterPath(currentPath)) {
return;
}

Expand All @@ -104,7 +108,7 @@
window.addEventListener("popstate", rememberCurrentPageLocale);
window.setInterval(rememberCurrentPageLocale, 250);

if (window.location.pathname.replace(/\/$/, "") !== routerPath) {
if (!isRouterPath(window.location.pathname)) {
return;
}

Expand Down
17 changes: 17 additions & 0 deletions scripts/check_docs_seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,12 @@ def retired_redirect_errors(config, locales):
return errors


def root_selector_errors(config):
if any(item.get('source') == '/' for item in config.get('redirects', [])):
return ['root selector must serve before language routing to preserve acquisition parameters']
return []


def check(root):
errors = []
config = json.loads((root / 'docs.json').read_text())
Expand All @@ -102,6 +108,17 @@ def check(root):
for title, count in titles.items():
if title and count > 1:
errors.append(f'{locale}: duplicate title {title!r} ({count} pages)')
# Root-level selector pages need the same metadata and noindex policy checks.
for path in sorted(root.glob('*.mdx')):
route = path.stem
source = path.read_text()
try:
metadata, _ = frontmatter(source)
pages[route] = (metadata, source)
errors.extend(f'{route}: {message}' for message in page_errors(source))
except (ValueError, yaml.YAMLError) as error:
errors.append(f'{route}: {error}')
errors.extend(root_selector_errors(config))
nav = list(navigation_pages(config['navigation']))
for route in nav:
if route not in pages:
Expand Down
2 changes: 1 addition & 1 deletion scripts/check_rendered_seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def main():
hashes = required_hashes(args.root)
routes = selected_pages(args.root, args.all)
def verify(route):
url_route = route[:-len('/index')] if route.endswith('/index') else route
url_route = '' if route == 'index' else route[:-len('/index')] if route.endswith('/index') else route
url = args.base_url.rstrip('/') + '/' + urllib.parse.quote(url_route, safe='/')
try:
request = urllib.request.Request(url, headers={'Accept-Encoding': 'gzip', 'User-Agent': 'Teable-Docs-SEO-Check/1.0'})
Expand Down
7 changes: 6 additions & 1 deletion scripts/test_docs_seo.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Guard the SEO failure modes found in the documentation audit."""
import unittest

from check_docs_seo import page_errors as source_errors, retired_redirect_errors, indexability_errors
from check_docs_seo import page_errors as source_errors, retired_redirect_errors, indexability_errors, root_selector_errors
from check_rendered_seo import page_errors as rendered_errors

SOURCE = '''---
Expand Down Expand Up @@ -46,6 +46,11 @@ def test_retired_routes_cannot_lose_or_change_their_permanent_redirects(self):
config['redirects'][0]['destination'] = '/en'
self.assertTrue(retired_redirect_errors(config, ['en']))

def test_root_cannot_redirect_before_query_preserving_selector(self):
self.assertEqual(root_selector_errors({'redirects': []}), [])
self.assertTrue(root_selector_errors({'redirects': [{'source': '/', 'destination': '/en/locale-router'}]}))
self.assertEqual(root_selector_errors({'redirects': [{'source': '/old', 'destination': '/new'}]}), [])

def test_indexability_is_independent_of_edited_frontmatter(self):
pages = {'en/index': ({'noindex': True}, '')}
self.assertTrue(indexability_errors(pages, set()))
Expand Down
Loading
Loading