diff --git a/.github/SEO_AND_ANALYTICS.md b/.github/SEO_AND_ANALYTICS.md new file mode 100644 index 00000000..263251d0 --- /dev/null +++ b/.github/SEO_AND_ANALYTICS.md @@ -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`。 +- 使用原生 `` 或最终能生成原生锚点的 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 判断。 diff --git a/.github/seo-hidden-pages.json b/.github/seo-hidden-pages.json index eb6337f2..c861fd4f 100644 --- a/.github/seo-hidden-pages.json +++ b/.github/seo-hidden-pages.json @@ -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." } ] diff --git a/.mintignore b/.mintignore new file mode 100644 index 00000000..b67b3bd4 --- /dev/null +++ b/.mintignore @@ -0,0 +1,2 @@ +# Repository-only instructions; keep them out of the published Help site. +/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5759c3c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ +# Teable Help 仓库说明 + +新增或修改页面、SEO 元数据、内链或统计时,先阅读并遵循 [.github/SEO_AND_ANALYTICS.md](.github/SEO_AND_ANALYTICS.md)。图片处理与本地开发命令见 [README.md](README.md)。 + +按独立修复分别提交,检查实际预览输出并安排独立子 agent review;将新发现的独立问题记录到当前修复计划。 diff --git a/README.md b/README.md index 0e2cfd87..d6e809c4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs.json b/docs.json index f6c1b751..0731cf3e 100644 --- a/docs.json +++ b/docs.json @@ -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" diff --git a/help-link-tracking.js b/help-link-tracking.js new file mode 100644 index 00000000..c436e3f6 --- /dev/null +++ b/help-link-tracking.js @@ -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"] + }); +})(); diff --git a/index.mdx b/index.mdx new file mode 100644 index 00000000..c8a2c4c9 --- /dev/null +++ b/index.mdx @@ -0,0 +1,6 @@ +--- +title: "Choose your language" +description: "Redirecting to the Teable Help Center in your preferred language." +hidden: true +noindex: true +--- diff --git a/language-route.js b/language-route.js index 032d2166..1c0ffed7 100644 --- a/language-route.js +++ b/language-route.js @@ -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) { @@ -90,7 +94,7 @@ } lastObservedPath = currentPath; - if (currentPath.replace(/\/$/, "") === routerPath) { + if (isRouterPath(currentPath)) { return; } @@ -104,7 +108,7 @@ window.addEventListener("popstate", rememberCurrentPageLocale); window.setInterval(rememberCurrentPageLocale, 250); - if (window.location.pathname.replace(/\/$/, "") !== routerPath) { + if (!isRouterPath(window.location.pathname)) { return; } diff --git a/scripts/check_docs_seo.py b/scripts/check_docs_seo.py index a630fe46..c3b8f00d 100644 --- a/scripts/check_docs_seo.py +++ b/scripts/check_docs_seo.py @@ -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()) @@ -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: diff --git a/scripts/check_rendered_seo.py b/scripts/check_rendered_seo.py index 4a6deecc..4fa1a5d8 100644 --- a/scripts/check_rendered_seo.py +++ b/scripts/check_rendered_seo.py @@ -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'}) diff --git a/scripts/test_docs_seo.py b/scripts/test_docs_seo.py index 95dcf1ca..3b97d133 100644 --- a/scripts/test_docs_seo.py +++ b/scripts/test_docs_seo.py @@ -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 = '''--- @@ -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())) diff --git a/scripts/test_help_link_tracking.cjs b/scripts/test_help_link_tracking.cjs new file mode 100644 index 00000000..6b11843b --- /dev/null +++ b/scripts/test_help_link_tracking.cjs @@ -0,0 +1,172 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const source = fs.readFileSync(path.join(__dirname, '..', 'help-link-tracking.js'), 'utf8'); + +function anchor(href, rel = 'noreferrer') { + const attributes = { href, rel }; + return { + get href() { return attributes.href; }, + getAttribute(name) { return attributes[name] ?? null; }, + setAttribute(name, value) { attributes[name] = value; }, + matches(selector) { return selector === 'a[href]'; }, + closest() { return this; }, + querySelectorAll() { return []; }, + }; +} +function runtime(links = [], href = 'https://help.teable.ai/zh/basic/ai/overview') { + const listeners = {}; + const observers = []; + const events = []; + const location = new URL(href); + const document = { + documentElement: {}, + querySelectorAll() { return links; }, + addEventListener(name, callback) { (listeners[name] ||= []).push(callback); }, + }; + const window = { location, addEventListener() {}, gtag(...args) { events.push(args); } }; + const context = vm.createContext({ window, document, URL, MutationObserver: class { + constructor(callback) { observers.push(callback); } + observe() {} + } }); + vm.runInContext(source, context); + return { + window, links, listeners, observers, events, + rerun() { vm.runInContext(source, context); }, + mutate(mutations) { observers.forEach(callback => callback(mutations)); }, + click(link, type = 'click', button = 0) { + (listeners[type] || []).forEach(callback => callback({ target: link, type, button })); + }, + }; +} + +test('trusted public official links retain security and other rel tokens', () => { + for (const host of ['teable.ai', 'www.teable.ai', 'teable.cn', 'www.teable.cn']) { + for (const segment of ['ai-app-builder', 'zh/compare/airtable', 'blog/example', 'customers/example']) { + const link = anchor(`https://${host}/${segment}?utm_source=existing#section`, 'nofollow NoReferrer sponsored'); + runtime([link]); + assert.equal(link.getAttribute('rel'), 'nofollow sponsored noopener'); + assert.match(link.href, /utm_source=existing&help_source=.*&help_host=help.teable.ai#section$/); + } + } +}); +test('untrusted, private, credentialed and malformed destinations remain untouched', () => { + for (const href of ['https://evil.example/products/ai', 'http://teable.ai/blog/example', + 'https://teable.ai:8443/blog/example', 'https://x@teable.ai/products/ai', + 'https://teable.ai.evil.example/blog/example', 'https://teable.ai/app/private', + 'https://teable.ai/blog/a%40b', 'https://teable.ai/customer/example', 'https://teable.ai/products/private', 'https://teable.ai/blog/a/b', 'javascript:alert(1)', 'mailto:x@teable.ai']) { + const link = anchor(href); + runtime([link]); + assert.equal(link.getAttribute('rel'), 'noreferrer', href); + assert.equal(link.href, href); + } +}); +test('late rendering and replaced attributes are repaired once without observer churn', () => { + const state = runtime(); + const link = anchor('https://teable.ai/ai-app-builder'); + state.mutate([{ type: 'childList', addedNodes: [link] }]); + assert.equal(link.getAttribute('rel'), 'noopener'); + link.setAttribute('rel', 'noreferrer noopener'); + state.mutate([{ type: 'attributes', target: link }]); + assert.equal(link.getAttribute('rel'), 'noopener'); + let writes = 0; + link.setAttribute = () => { writes++; }; + state.mutate([{ type: 'attributes', target: link }]); + assert.equal(writes, 0); + state.rerun(); + assert.equal(state.observers.length, 1); + assert.equal(state.events.length, 0); +}); + +test('markers preserve original query encoding and hashes while replacing stale markers', () => { + const link = anchor('https://teable.ai/ai-app-builder?utm_source=google&utm_medium=cpc&term=hello%20world&help_source=old&help_host=old&x=1#feature'); + const state = runtime([link], 'https://help.teable.cn/zh/basic/ai/overview?token=private#section'); + assert.equal(link.href, 'https://teable.ai/ai-app-builder?utm_source=google&utm_medium=cpc&term=hello%20world&x=1&help_source=%2Fzh%2Fbasic%2Fai%2Foverview&help_host=help.teable.cn#feature'); + assert.equal(state.events.length, 0); + state.mutate([{ type: 'attributes', target: link }]); + assert.equal(new URL(link.href).searchParams.getAll('help_source').length, 1); +}); +test('only real primary and middle clicks emit bounded sanitized events to Help GA4', () => { + const link = anchor('https://teable.ai/ai-app-builder?customer_email=private#part'); + const state = runtime([link], 'https://help.teable.cn/zh/basic/ai/overview?token=private#part'); + state.click(link); + state.click(link, 'auxclick', 1); + state.click(link, 'auxclick', 0); + state.click(link, 'click', 1); + state.click(link, 'auxclick', 2); + assert.equal(state.events.length, 2); + assert.deepEqual(JSON.parse(JSON.stringify(state.events[0])), ['event', 'teable_help_link_click', { + send_to: 'G-LQ8H9TEDRD', source_path: '/zh/basic/ai/overview', target_path: '/ai-app-builder', + source_domain: 'help.teable.cn', link_category: 'product', locale: 'zh', + page_location: 'https://help.teable.cn/zh/basic/ai/overview', + }]); + assert.ok(!JSON.stringify(state.events).includes('private')); + state.rerun(); + state.click(link); + assert.equal(state.events.length, 3); +}); +test('private, internal, unsupported, overlong and local source routes never emit or add markers', () => { + for (const path of ['/src/_props/zh/basic/ai/overview', '/zh/locale-router', '/app/private', + '/zh/basic/a%40b', '/zh/basic/' + 'x'.repeat(100)]) { + const link = anchor('https://teable.ai/ai-app-builder'); + const state = runtime([link], 'https://help.teable.ai' + path); + state.click(link); + assert.equal(state.events.length, 0, path); + assert.equal(link.href, 'https://teable.ai/ai-app-builder'); + } + const link = anchor('https://teable.ai/ai-app-builder'); + const state = runtime([link], 'http://localhost:3003/zh/basic/ai/overview'); + state.click(link); + assert.equal(state.events.length, 0); + assert.equal(link.href, 'https://teable.ai/ai-app-builder'); +}); +test('SPA navigation refreshes retained links and click context without page_view ownership', () => { + const link = anchor('https://teable.ai/blog/guide'); + const state = runtime([link]); + state.window.location = new URL('https://help.teable.ai/fr/basic/base'); + state.mutate([{ type: 'childList', addedNodes: [] }]); + assert.equal(new URL(link.href).searchParams.get('help_source'), '/fr/basic/base'); + state.window.location = new URL('https://help.teable.ai/de/basic/base'); + state.click(link); + assert.equal(state.events[0][2].source_path, '/de/basic/base'); + assert.equal(state.events[0][2].locale, 'de'); + assert.equal(state.events[0][2].link_category, 'blog'); + assert.equal(state.events.filter(event => event[1] === 'page_view').length, 0); +}); +test('early clicks queue exactly one event and rendering alone queues none', () => { + const link = anchor('https://teable.cn/zh/compare/airtable'); + const state = runtime([link]); + delete state.window.gtag; + assert.equal(state.window.dataLayer, undefined); + state.click(link); + assert.equal(state.window.dataLayer.length, 1); + assert.equal(state.window.dataLayer[0][0], 'event'); + assert.equal(state.window.dataLayer[0][1], 'teable_help_link_click'); +}); +test('every public catalog product route and all locale sources are supported', () => { + const products = ['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']; + for (const product of products) { + const link = anchor('https://teable.ai/' + product); + const state = runtime([link]); + state.click(link); + assert.equal(state.events[0][2].link_category, 'product', product); + } + for (const locale of ['en', 'zh', 'ru', 'de', 'fr', 'es', 'it', 'ja', 'tr', 'uk', 'ar']) { + for (const suffix of ['', '/basic/base', '/deploy/docker', '/api-doc/token', '/compare/airtable', '/changelog-2026-09/']) { + const link = anchor('https://teable.ai/customers/example'); + const state = runtime([link], 'https://help.teable.ai/' + locale + suffix); + state.click(link); + assert.equal(state.events[0][2].locale, locale); + assert.equal(state.events[0][2].link_category, 'customer'); + } + } +}); diff --git a/scripts/test_language_route.cjs b/scripts/test_language_route.cjs index 9a7f1295..4ff67e62 100644 --- a/scripts/test_language_route.cjs +++ b/scripts/test_language_route.cjs @@ -4,10 +4,10 @@ const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const source = fs.readFileSync(path.join(__dirname, '..', 'language-route.js'), 'utf8'); -function route({ cookie = '', languages = ['en'], pathname = '/en/locale-router' } = {}) { +function route({ cookie = '', languages = ['en'], pathname = '/en/locale-router', search = '?ref=docs', hash = '#guides' } = {}) { const destinations = []; const window = { - location: { pathname, search: '?ref=docs', hash: '#guides', replace(value) { destinations.push(value); } }, + location: { pathname, search, hash, replace(value) { destinations.push(value); } }, addEventListener() {}, setInterval() {}, }; vm.runInNewContext(source, { @@ -27,3 +27,15 @@ test('saved language wins, unsupported or malformed preferences fall back, and c assert.deepEqual(route({ languages: ['pt-BR'] }), ['/en?ref=docs#guides']); assert.deepEqual(route({ pathname: '/zh/basic/base' }), []); }); + +test('root selector preserves acquisition parameters, click IDs, encoding, and fragments', () => { + const search = '?utm_source=newsletter&utm_medium=email&utm_campaign=help%20launch&gclid=opaque%2Bclick&tag=a&tag=b'; + for (const pathname of ['/', '/index', '/index/', '/en/locale-router/']) { + assert.deepEqual(route({ pathname, search, hash: '#getting-started', languages: ['zh-CN'] }), + [`/zh${search}#getting-started`]); + assert.deepEqual(route({ pathname, search, hash: '', cookie: 'TEABLE_DOCS_LOCALE=ja', languages: ['zh-CN'] }), + [`/ja${search}`]); + } + assert.deepEqual(route({ pathname: '/', search: '', hash: '', languages: ['pt-BR', 'fr-FR'] }), ['/fr']); + assert.deepEqual(route({ pathname: '/zh', search }), []); +});