From 9bca7407add5b66cbb9381f2d868a491eeb0f200 Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 2 Nov 2026 13:18:19 +0000 Subject: [PATCH 1/2] Add per-page SEO tags, llms.txt, Markdown pages, robots.txt and analytics Fills the gaps left by moving off GitBook: - Each page gets its own meta description (front matter, or its first paragraph), a canonical URL, and og/twitter title, description and URL. Five pages with weak opening lines get a hand-written description. - /llms.txt, /llms-full.txt, and a .md copy of every page, linked from each page's head as a text/markdown alternate. - robots.txt allowing all crawlers and pointing at the sitemap. - Cloudflare Web Analytics (cookieless). - Fixes 12 links that showed a raw file name ("refund-policy.md") instead of the page title, which GitBook used to fill in. npm run check now fails on these. Co-Authored-By: Claude Opus 5.5 --- .vitepress/config.mts | 31 ++++- .vitepress/llms.ts | 145 +++++++++++++++++++++++ .vitepress/seo.ts | 100 ++++++++++++++++ CONTRIBUTING.md | 19 +++ api/results.md | 4 + billing/subscriptions.md | 10 +- configuration/maestro-versions.md | 2 +- configuration/runner-type.md | 4 + getting-started/devices-configuration.md | 8 +- getting-started/flows-and-workspaces.md | 4 + getting-started/quickstart.md | 4 + legal/terms-and-conditions.md | 8 +- public/robots.txt | 4 + scripts/check-links.mjs | 6 +- 14 files changed, 333 insertions(+), 16 deletions(-) create mode 100644 .vitepress/llms.ts create mode 100644 .vitepress/seo.ts create mode 100644 public/robots.txt diff --git a/.vitepress/config.mts b/.vitepress/config.mts index c08918c..5e4e868 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -2,17 +2,21 @@ import { appendFileSync } from 'node:fs'; import { join } from 'node:path'; import { defineConfig } from 'vitepress'; import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs'; +import { writeLlmsFiles } from './llms'; +import { SITE, describe, headFor } from './seo'; import { ROOT, readSummary, repoPathRedirects, sourceRelativeLinks } from './summary'; const { sections, rewrites } = readSummary(); +const title = 'DeviceCloud'; +const description = 'Documentation for DeviceCloud, the fast and affordable Maestro Cloud alternative.'; export default defineConfig({ - title: 'DeviceCloud', - description: 'Documentation for DeviceCloud, the fast and affordable Maestro Cloud alternative.', + title, + description, lang: 'en', cleanUrls: true, lastUpdated: true, - sitemap: { hostname: 'https://docs.devicecloud.dev' }, + sitemap: { hostname: SITE }, head: [ ['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }], @@ -28,8 +32,27 @@ export default defineConfig({ ['meta', { property: 'og:site_name', content: 'DeviceCloud Docs' }], ['meta', { property: 'og:image', content: 'https://docs.devicecloud.dev/og-image.png' }], ['meta', { name: 'twitter:card', content: 'summary_large_image' }], + // Cloudflare Web Analytics: no cookies, so no consent banner needed. + // The token is public by design. Dashboard: Cloudflare → Analytics & Logs → Web Analytics. + [ + 'script', + { + defer: '', + src: 'https://static.cloudflareinsights.com/beacon.min.js', + 'data-cf-beacon': '{"token": "4ff5256222644de58b626f5701863f1d"}', + }, + ], ], + // A description for each page (its first paragraph, unless front matter sets + // one), plus canonical and social tags. See .vitepress/seo.ts. + transformPageData(pageData) { + describe(pageData, ROOT); + }, + transformHead({ pageData }) { + return headFor(pageData, title); + }, + // Pages live where they always have; SUMMARY.md maps them to their public URLs. srcDir: '.', srcExclude: ['SUMMARY.md', 'CONTRIBUTING.md', 'CLAUDE.md', 'AGENTS.md', '.github/**', 'node_modules/**'], @@ -39,6 +62,8 @@ export default defineConfig({ // keep those working too. public/_redirects holds the hand-written ones. buildEnd({ outDir }) { appendFileSync(join(outDir, '_redirects'), '\n# Generated from SUMMARY.md\n' + repoPathRedirects(rewrites)); + // /llms.txt, /llms-full.txt and a .md copy of every page. See .vitepress/llms.ts. + writeLlmsFiles({ outDir, srcDir: ROOT, rewrites, sections, siteTitle: title, siteDescription: description }); }, markdown: { diff --git a/.vitepress/llms.ts b/.vitepress/llms.ts new file mode 100644 index 0000000..63678ad --- /dev/null +++ b/.vitepress/llms.ts @@ -0,0 +1,145 @@ +/** + * Plain-Markdown copies of the docs for AI agents and LLM tools, written into + * the build output: + * + * /llms.txt an index of every page, per https://llmstxt.org + * /llms-full.txt every page in one file + * /.md each page on its own, e.g. /cli-reference/cloud.md + * + * VitePress-only syntax is turned into standard Markdown: callouts become + * blockquotes, tabs become bold labels, and links become absolute .md URLs. + */ +import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join, posix } from 'node:path'; +import type { Section } from './summary'; +import { SITE, firstParagraph, markdownPathOf, shorten } from './seo'; + +const LABELS: Record = { info: 'Note', tip: 'Tip', warning: 'Warning', danger: 'Danger' }; + +interface Page { + file: string; // source path, e.g. cli/dcd-cloud.md + title: string; + description: string; + markdown: string; +} + +export function writeLlmsFiles(opts: { + outDir: string; + srcDir: string; + rewrites: Record; + sections: Section[]; + siteTitle: string; + siteDescription: string; +}) { + const { outDir, srcDir, rewrites, sections } = opts; + const assets = readdirSync(join(outDir, 'assets')); + const mdUrl = (file: string) => SITE + markdownPathOf(rewrites[file]); + + const load = (file: string): Page => { + const source = readFileSync(join(srcDir, file), 'utf8'); + const fm = source.match(/^---\n([\s\S]*?)\n---\n/); + const fmDescription = fm?.[1].match(/^description:\s*(.+)$/m)?.[1].trim().replace(/^['"]|['"]$/g, ''); + const markdown = toPlainMarkdown(source.slice(fm ? fm[0].length : 0), file, rewrites, assets, mdUrl); + const title = markdown.match(/^# (.+)$/m)?.[1].trim() ?? file; + return { file, title, description: fmDescription ?? shorten(firstParagraph(source)), markdown }; + }; + + const home = load('README.md'); + const groups = sections.map((s) => ({ name: s.text, pages: s.items.map((i) => load(i.file)) })); + const all = [home, ...groups.flatMap((g) => g.pages)]; + + for (const page of all) { + const out = join(outDir, markdownPathOf(rewrites[page.file])); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, page.markdown); + } + + const entry = (p: Page) => `- [${p.title}](${mdUrl(p.file)})${p.description ? `: ${p.description}` : ''}`; + const index = [ + `# ${opts.siteTitle}`, + '', + `> ${opts.siteDescription}`, + '', + home.description, + '', + `Every page is also available as Markdown: add \`.md\` to its URL. All pages in one file: ${SITE}/llms-full.txt`, + '', + ...groups.flatMap((g) => [`## ${g.name}`, '', ...g.pages.map(entry), '']), + ].join('\n'); + writeFileSync(join(outDir, 'llms.txt'), index); + + const full = all.map((p) => `\n\n${p.markdown.trim()}\n`).join('\n---\n\n'); + writeFileSync(join(outDir, 'llms-full.txt'), full); + + return all.length; +} + +function toPlainMarkdown( + body: string, + file: string, + rewrites: Record, + assets: string[], + mdUrl: (file: string) => string, +) { + const out: string[] = []; + let fence: string | null = null; + let quote: string | null = null; // label of the callout we're inside, if any + + for (const line of body.split('\n')) { + const fenceMark = line.match(/^\s*(```+|~~~+)/)?.[1]; + if (fenceMark) { + if (!fence) fence = fenceMark; + else if (fenceMark.startsWith(fence)) fence = null; + out.push(quote ? `> ${line}` : line); + continue; + } + if (fence) { + out.push(quote ? `> ${line}` : line); + continue; + } + + const open = line.match(/^:::\s*(info|tip|warning|danger)\b\s*(.*)$/); + if (open) { + quote = LABELS[open[1]]; + out.push(`> **${open[2].trim() || quote}:**`, '>'); + continue; + } + if (/^:{4,}tabs\s*$/.test(line) || /^:{4,}\s*$/.test(line)) continue; + if (/^:::\s*$/.test(line)) { + quote = null; + continue; + } + const tab = line.match(/^==\s+(.+)$/); + if (tab) { + out.push(`**${tab[1].trim()}**`); + continue; + } + + const text = rewriteLinks(line, file, rewrites, assets, mdUrl); + out.push(quote ? (text ? `> ${text}` : '>') : text); + } + return out.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'; +} + +function rewriteLinks( + line: string, + file: string, + rewrites: Record, + assets: string[], + mdUrl: (file: string) => string, +) { + return line.replace(/(!?)\[([^\]]*)\]\(([^)\s]+)\)/g, (match, bang, text, href) => { + if (/^[a-z]+:|^#|^\//i.test(href)) return match; + const [path, hash] = href.split('#'); + const target = posix.normalize(posix.join(posix.dirname(file), decodeURI(path))); + if (bang) { + // Images are content-hashed in the build: assets/name.png -> /assets/name..png + const base = posix.basename(target).replace(/\.[^.]+$/, ''); + const ext = posix.extname(target); + const built = assets.find((a) => a.startsWith(base + '.') && a.endsWith(ext)); + return built ? `![${text}](${SITE}/assets/${built})` : match; + } + if (!rewrites[target]) return match; + return `[${text}](${mdUrl(target)}${hash ? '#' + hash : ''})`; + }); +} diff --git a/.vitepress/seo.ts b/.vitepress/seo.ts new file mode 100644 index 0000000..27f9c08 --- /dev/null +++ b/.vitepress/seo.ts @@ -0,0 +1,100 @@ +/** + * Per-page SEO: a description for each page, plus canonical and social tags. + * + * A page's description is its `description:` front matter if it has one. + * Otherwise it's the page's first paragraph of prose, shortened to fit in a + * search result. To change what Google shows for a page, add front matter: + * + * --- + * description: One or two sentences about this page. + * --- + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { HeadConfig, PageData } from 'vitepress'; + +export const SITE = 'https://docs.devicecloud.dev'; +const MAX = 160; + +/** Turns inline Markdown into plain text. */ +export function plain(md: string) { + return md + .replace(/!\[[^\]]*\]\([^)]*\)/g, '') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/(\*\*|__)(.+?)\1/g, '$2') + .replace(/(^|\W)[*_](.+?)[*_](?=\W|$)/g, '$1$2') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** The first paragraph of prose after the title: not a heading, list, table, code or callout. */ +export function firstParagraph(source: string) { + const body = source.replace(/^---\n[\s\S]*?\n---\n/, '').replace(//g, ''); + const lines = body.split('\n'); + let inFence = false; + let inBlock = 0; + let para: string[] = []; + for (const line of lines) { + if (/^\s*(```|~~~)/.test(line)) inFence = !inFence; + if (inFence) continue; + if (/^:{3,}\s*\S/.test(line)) inBlock++; + else if (/^:{3,}\s*$/.test(line)) inBlock = Math.max(0, inBlock - 1); + if (inBlock || /^:{3,}/.test(line)) continue; + if (line.trim() === '') { + if (para.length) break; + continue; + } + if (/^\s*(#|\||[-*+] |\d+\. |<|!\[|>|```|---$|==)/.test(line)) { + if (para.length) break; + continue; + } + para.push(line); + } + // A sentence that leads into a list or code block ("…as follows:") ends with a full stop instead. + return plain(para.join(' ')).replace(/:$/, '.'); +} + +export function shorten(text: string, max = MAX) { + if (text.length <= max) return text; + const cut = text.slice(0, max - 1); + return cut.slice(0, cut.lastIndexOf(' ')).replace(/[,;:.\s—-]+$/, '') + '…'; +} + +/** For transformPageData: fill in pageData.description from the page itself. */ +export function describe(pageData: PageData, srcDir: string) { + if (pageData.frontmatter.description) return; + const text = firstParagraph(readFileSync(join(srcDir, pageData.filePath), 'utf8')); + if (text) pageData.description = shorten(text); +} + +/** Public URL path of a page, from its rewritten path (e.g. "cli-reference/cloud.md"). */ +export const pathOf = (relativePath: string) => + '/' + relativePath.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, ''); + +/** Markdown copy of a page (see llms.ts). The home page's copy is /index.md. */ +export const markdownPathOf = (relativePath: string) => { + const p = pathOf(relativePath); + return (p === '/' ? '/index' : p) + '.md'; +}; + +/** For transformHead: canonical URL, social tags, and the Markdown alternate. */ +export function headFor(pageData: PageData, siteTitle: string): HeadConfig[] { + const url = SITE + pathOf(pageData.relativePath); + const title = pageData.relativePath === 'index.md' ? `${siteTitle} Docs` : `${pageData.title} | ${siteTitle}`; + const description = pageData.frontmatter.description ?? pageData.description; + return [ + ['link', { rel: 'canonical', href: url }], + ['link', { rel: 'alternate', type: 'text/markdown', href: SITE + markdownPathOf(pageData.relativePath) }], + ['meta', { property: 'og:url', content: url }], + ['meta', { property: 'og:title', content: title }], + ['meta', { name: 'twitter:title', content: title }], + ...(description + ? ([ + ['meta', { property: 'og:description', content: description }], + ['meta', { name: 'twitter:description', content: description }], + ] as HeadConfig[]) + : []), + ]; +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fa5943..a1d55b3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,3 +51,22 @@ Content for the second tab. `::: tip` and `::: danger` also work. Put images in `assets/` and link them with a relative path and alt text: `![What it shows](../assets/name.png)`. + +## Search results and AI agents + +Each page's search-result description is its first paragraph, shortened. If +that paragraph is a weak summary (for example, it just leads into a list), set +one in front matter. Quote it, because a colon inside breaks the YAML: + +```md +--- +description: "One or two sentences about what this page covers." +--- +``` + +The build also writes plain-Markdown copies for AI tools: `/llms.txt` (an +index), `/llms-full.txt` (everything), and a `.md` copy of every page, such as +`/cli-reference/cloud.md`. You don't need to do anything for these. + +Link to pages with real link text. `[Device Matrix](../configuration/device-matrix.md)` +is right, while `[device-matrix.md](...)` is caught by `npm run check`. diff --git a/api/results.md b/api/results.md index 4fc431e..36b17e7 100644 --- a/api/results.md +++ b/api/results.md @@ -1,3 +1,7 @@ +--- +description: "REST API endpoints for one upload's test results: statuses and failure reasons, JUnit and HTML reports, and artifact downloads." +--- + # Results All results endpoints operate on a specific upload identified by its UUID. diff --git a/billing/subscriptions.md b/billing/subscriptions.md index 0bff0b5..cdd292c 100644 --- a/billing/subscriptions.md +++ b/billing/subscriptions.md @@ -10,11 +10,11 @@ Subscriptions have replaced one-off credit purchases. Reach out to support if yo ### Pro Plan -This subscription costs $25 per month and comes with $25 of usage included which can be used as per [test-run-billing.md](test-run-billing.md "mention"). +This subscription costs $25 per month and comes with $25 of usage included which can be used as per [Test Run Billing](test-run-billing.md). Additional usage beyond $25 will be billed as overage and charged in your next monthly payment. -This plan lets you run up to 5 iOS and 5 Android tests in parallel. Read about [concurrency-and-parallel-runs.md](../getting-started/concurrency-and-parallel-runs.md "mention") to understand how this may be reduced at peak times. +This plan lets you run up to 5 iOS and 5 Android tests in parallel. Read about [Concurrency & Parallel Runs](../getting-started/concurrency-and-parallel-runs.md) to understand how this may be reduced at peak times. If you require support when using this plan, you can contact our team and community via [Discord](https://discord.gg/gm3mJwcNw8).\ \ @@ -22,15 +22,15 @@ This plan supports unlimited users and unlimited apps. Results are retained for ### Max Plan -This subscription costs $200 per month and comes with $200 of usage included which can be used as per [test-run-billing.md](test-run-billing.md "mention"). +This subscription costs $200 per month and comes with $200 of usage included which can be used as per [Test Run Billing](test-run-billing.md). Additional usage beyond $200 will be billed as overage and charged in your next monthly payment. This plan lets you run up to 20 iOS and 20 Android tests in parallel. -In addition, it offers **Concurrency Protection** to ensure you always have at least 1 test running even at very busy times. Read more [concurrency-and-parallel-runs.md](../getting-started/concurrency-and-parallel-runs.md "mention") to understand how this works. +In addition, it offers **Concurrency Protection** to ensure you always have at least 1 test running even at very busy times. Read more [Concurrency & Parallel Runs](../getting-started/concurrency-and-parallel-runs.md) to understand how this works. -This plan supports [enterprise-single-sign-on.md](../security/enterprise-single-sign-on.md "mention") and priority support via email. +This plan supports [Enterprise SSO](../security/enterprise-single-sign-on.md) and priority support via email. This plan supports unlimited users and unlimited apps. Results are retained for 6 months. diff --git a/configuration/maestro-versions.md b/configuration/maestro-versions.md index 7bd61f1..a69029e 100644 --- a/configuration/maestro-versions.md +++ b/configuration/maestro-versions.md @@ -20,7 +20,7 @@ We currently support the following versions of Maestro: * 2.12.0 ::: warning -Maestro 2.2.0 is deprecated and we're targeting removal on 9 November 2026. This date is subject to change based on usage. If you pin `--maestro-version` to 2.2.0, move it to 2.6.1 or newer (the default is 2.10.0). See the [#maestro-support-policy](maestro-versions.md#maestro-support-policy "mention") for more information. +Maestro 2.2.0 is deprecated and we're targeting removal on 9 November 2026. This date is subject to change based on usage. If you pin `--maestro-version` to 2.2.0, move it to 2.6.1 or newer (the default is 2.10.0). See the [Maestro Support Policy](maestro-versions.md#maestro-support-policy) for more information. ::: Maestro 2.0.4, 2.0.9, 2.1.0, 2.5.0, 2.5.1 and 2.6.0 were removed on 19 October 2026. Runs pinned to these versions are rejected. diff --git a/configuration/runner-type.md b/configuration/runner-type.md index 0e7a44b..aa8cedf 100644 --- a/configuration/runner-type.md +++ b/configuration/runner-type.md @@ -1,3 +1,7 @@ +--- +description: "The hardware each DeviceCloud test runs on, and the larger runner types, such as M4 for iOS, you can choose for resource-heavy apps and tests." +--- + # Runner Type By default each test run uses the following underlying hardware specifications: diff --git a/getting-started/devices-configuration.md b/getting-started/devices-configuration.md index a654a0b..f84c3df 100644 --- a/getting-started/devices-configuration.md +++ b/getting-started/devices-configuration.md @@ -1,3 +1,7 @@ +--- +description: "Every device, Android API level and iOS version DeviceCloud supports, the defaults you get without flags, and the support policy for adding and removing them." +--- + # Devices & OS Versions ### Default Devices @@ -31,7 +35,7 @@ dcd cloud app.apk test.yaml --android-api-level 37 API levels 29-31 (Android 10-12) were removed on 19 October 2026. -Need Google Play? See [google-play-apis.md](../configuration/google-play-apis.md) +Need Google Play? See [Google Play APIs](../configuration/google-play-apis.md) ### Android Devices @@ -127,7 +131,7 @@ The iPhone Duo starts folded, on its cover screen, and the Pixel 10 Pro Fold sta ### Targeting a single flow -The flags above set the device for the whole upload. When only one flow needs a particular device then that flow can name its own device in its YAML instead. See [per-flow-devices.md](../configuration/per-flow-devices.md). +The flags above set the device for the whole upload. When only one flow needs a particular device then that flow can name its own device in its YAML instead. See [Per-flow Devices](../configuration/per-flow-devices.md). ### Running your suite across several devices diff --git a/getting-started/flows-and-workspaces.md b/getting-started/flows-and-workspaces.md index 76bc7c3..9282d3a 100644 --- a/getting-started/flows-and-workspaces.md +++ b/getting-started/flows-and-workspaces.md @@ -1,3 +1,7 @@ +--- +description: "Run a single Maestro flow, a directory of flows, or the flows listed in a workspace config file with dcd cloud." +--- + # Executing & Referencing Flows There are multiple ways to execute flows from the CLI: diff --git a/getting-started/quickstart.md b/getting-started/quickstart.md index 81106d9..ea90439 100644 --- a/getting-started/quickstart.md +++ b/getting-started/quickstart.md @@ -1,3 +1,7 @@ +--- +description: "Install the dcd CLI, log in, and run your first Maestro flow on DeviceCloud. Switching from Maestro Cloud is usually a one-line change." +--- + # Quickstart The dcd command line is intentionally designed to mimic the Maestro Cloud API. In most cases, you can just change `maestro cloud` to `dcd cloud`: diff --git a/legal/terms-and-conditions.md b/legal/terms-and-conditions.md index 933aa0f..3317ffa 100644 --- a/legal/terms-and-conditions.md +++ b/legal/terms-and-conditions.md @@ -12,7 +12,7 @@ We may amend these terms and conditions from time to time, and such amendments s **1. The Platform** -1.1 The Company is the exclusive owner of the Platform, and the Company and/or its licensors own all Intellectual Property rights in the Platform. The Company grants to you a non-exclusive, non-transferable, non-sublicensable, royalty-free licence to use the Platform, subject to these terms and conditions (the “**Licence**”). The Platform may be accessed by you via a web browser or other device owned or controlled by you. The Platform is designed to allow you to connect and automate your mobile app development workflow. The Platform utilises open source software, which is attributed [here](open-source-notices.md "mention")‍ +1.1 The Company is the exclusive owner of the Platform, and the Company and/or its licensors own all Intellectual Property rights in the Platform. The Company grants to you a non-exclusive, non-transferable, non-sublicensable, royalty-free licence to use the Platform, subject to these terms and conditions (the “**Licence**”). The Platform may be accessed by you via a web browser or other device owned or controlled by you. The Platform is designed to allow you to connect and automate your mobile app development workflow. The Platform utilises open source software, which is attributed [here](open-source-notices.md)‍ 1.2 You acknowledge that you are licensed to use the Platform only in accordance with these express terms and conditions, and not further or otherwise. Nothing in these terms and conditions transfers any ownership right in the Platform to you or any other party. @@ -26,7 +26,7 @@ We may amend these terms and conditions from time to time, and such amendments s 2.2 One-off credit purchases have been discontinued. Any credit previously purchased remains on your team account as a legacy balance and is used for test runs before any usage included in a subscription. Usage included in a subscription is valid for the subscription period (typically one month). -2.3 The Company may suspend your access to the Platform, and terminate the Licence, at any time where you (or a member of your organisation) have materially breached these terms and conditions (which includes failing to make payment). Where the Company does so, no refunds will be provided for the then-current period of your subscription. Read the full [refund-policy.md](refund-policy.md "mention") +2.3 The Company may suspend your access to the Platform, and terminate the Licence, at any time where you (or a member of your organisation) have materially breached these terms and conditions (which includes failing to make payment). Where the Company does so, no refunds will be provided for the then-current period of your subscription. Read the full [Refund Policy](refund-policy.md) 2.4 Where your Licence ends for any reason, you will no longer have access to the information stored on the Platform. The Company has no obligation to maintain any of your data following termination, and is permitted to delete it once your Licence ends. The Company reserves the right, although is not obliged to, retain your data for a short period for record keeping purposes, or to ensure that any such termination of your Licence has not been inadvertent. @@ -42,7 +42,7 @@ We may amend these terms and conditions from time to time, and such amendments s 3.4 The Company uses commercially reasonable endeavours to ensure the Platform is available 24 hours a day, 7 days a week, but is not liable for any downtime or where the Platform is not available to you. Circumstances may occur that are outside the Company’s control (including but not limited to acts of God, floods, fires, earthquakes, pandemics, civil unrest, acts of terror, failure of internet Platform providers or other third party contractors), and the Company is not responsible for failure to provide the Platform in such circumstances. -3.5 The Company has implemented, and maintains, safeguards to ensure the protection and security of your data that is uploaded to the Platform. The Company complies with applicable data protection legislation, including the UK Data Protection Act 2018. For more information on how the Company processes and stores personal data, please see the Company’s [privacy-policy.md](privacy-policy.md "mention"). +3.5 The Company has implemented, and maintains, safeguards to ensure the protection and security of your data that is uploaded to the Platform. The Company complies with applicable data protection legislation, including the UK Data Protection Act 2018. For more information on how the Company processes and stores personal data, please see the Company’s [Privacy Policy](privacy-policy.md). 3.6 You are responsible for the quality, accuracy and completeness of your data. The Company does not verify or evaluate your data, nor does the Company advise you on any compliance or operational-related matters in any way. The Platform does not provide legal or regulatory guidance regarding compliance obligations in certain locations or industries. If you require such advice, please contact an independent professional. You are the owner of your data, and nothing in these terms and conditions transfers any such ownership to the Company.‍ @@ -94,5 +94,5 @@ We may amend these terms and conditions from time to time, and such amendments s 6.3 Enterprise Agreements automatically renew every 12 months unless either party provides 90 days' written notice before the renewal date. -6.4 Enterprise Agreement customers gain the benefits of the [service-level-agreements.md](service-level-agreements.md "mention") +6.4 Enterprise Agreement customers gain the benefits of the [Service Level Agreements](service-level-agreements.md) diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..ce37eaa --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://docs.devicecloud.dev/sitemap.xml diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index 1dee025..84aa5aa 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -18,11 +18,15 @@ for (const f of files) { pages.set(urlOf(f), { ids: new Set([...html.matchAll(/\sid="([^"]+)"/g)].map((m) => m[1])), links: [...main.matchAll(/]*href="([^"]+)"/g)].map((m) => m[1].replace(/&/g, '&')), + // GitBook "mention" links ([page.md](page.md "mention")) show the raw file + // name here, so link text that looks like a file name or anchor is a bug. + rawText: [...main.matchAll(/]*>([^<]*(?:\.md|^#[\w-]+))<\/a>/g)].map((m) => m[1]), }); } let bad = 0; -for (const [url, { links }] of pages) { +for (const [url, { links, rawText }] of pages) { + for (const text of rawText) { console.log(`${url}: link text "${text}" is a file name; use the page title`); bad++; } for (const href of links) { if (/^[a-z]+:|^\/\//.test(href) || href.startsWith('/assets/')) continue; const [path, hash] = href.split('#'); From e20f7b8553c679247d72cd98ed175d249533bfed Mon Sep 17 00:00:00 2001 From: Tom Riglar Date: Mon, 2 Nov 2026 13:44:01 +0000 Subject: [PATCH 2/2] fix(check): catch '#anchor' link text, not just file names The '^' in the anchor branch came after '' in the pattern, so with no m flag it could never match: a link like [#maestro-support-policy](...) passed. Each branch now has to fill the whole link text. Co-Authored-By: Claude Opus 5.5 --- scripts/check-links.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index 84aa5aa..075cfba 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -20,7 +20,7 @@ for (const f of files) { links: [...main.matchAll(/]*href="([^"]+)"/g)].map((m) => m[1].replace(/&/g, '&')), // GitBook "mention" links ([page.md](page.md "mention")) show the raw file // name here, so link text that looks like a file name or anchor is a bug. - rawText: [...main.matchAll(/]*>([^<]*(?:\.md|^#[\w-]+))<\/a>/g)].map((m) => m[1]), + rawText: [...main.matchAll(/]*>([^<]*\.md|#[\w-]+)<\/a>/g)].map((m) => m[1]), }); }