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
31 changes: 28 additions & 3 deletions .vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }],
Expand All @@ -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/**'],
Expand All @@ -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: {
Expand Down
145 changes: 145 additions & 0 deletions .vitepress/llms.ts
Original file line number Diff line number Diff line change
@@ -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
* /<page>.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<string, string> = { 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<string, string>;
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) => `<!-- Source: ${mdUrl(p.file)} -->\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<string, string>,
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<string, string>,
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.<hash>.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 : ''})`;
});
}
100 changes: 100 additions & 0 deletions .vitepress/seo.ts
Original file line number Diff line number Diff line change
@@ -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(/<!--[\s\S]*?-->/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[])
: []),
];
}
19 changes: 19 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 4 additions & 0 deletions api/results.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
10 changes: 5 additions & 5 deletions billing/subscriptions.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,27 @@ 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).\
\
This plan supports unlimited users and unlimited apps. Results are retained for 6 months.

### 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.

Expand Down
2 changes: 1 addition & 1 deletion configuration/maestro-versions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions configuration/runner-type.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading
Loading