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
15 changes: 0 additions & 15 deletions .gitbook.yaml

This file was deleted.

Binary file not shown.
Binary file removed .gitbook/assets/image.png
Binary file not shown.
47 changes: 47 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Deploy docs

# Builds the VitePress site, checks every internal link and anchor, and deploys
# it to the devicecloud-docs Worker on Cloudflare. Pull requests build and check
# only, so a broken link fails the PR instead of reaching the live site.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
# The IP sync commits with GITHUB_TOKEN, and GitHub doesn't let those pushes
# trigger workflows. So redeploy after each sync run instead. (This runs on
# main. If nothing changed, wrangler uploads nothing.)
workflow_run:
workflows: ['Sync IP addresses']
types: [completed]

permissions:
contents: read

concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history, for "last updated" dates on pages

- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm

- run: npm ci
- run: npm run build
- run: npm run check

- name: Deploy to Cloudflare
if: github.ref == 'refs/heads/main'
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: 825e34660eb273af8c29cde6527688f6
2 changes: 1 addition & 1 deletion .github/workflows/sync-ip-addresses.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Sync IP addresses

# Regenerates security/ip-addresses.md from the DeviceCloud IP-addresses API
# (the single source of truth) and commits it if it changed. GitBook republishes
# (the single source of truth) and commits it if it changed. Cloudflare redeploys
# the page on push. Runs daily and on demand — never on push, so a commit made
# here can't re-trigger the workflow.
on:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules/
.vitepress/dist/
.vitepress/cache/
.vitepress/.temp/
.wrangler/
85 changes: 85 additions & 0 deletions .vitepress/config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { appendFileSync } from 'node:fs';
import { join } from 'node:path';
import { defineConfig } from 'vitepress';
import { tabsMarkdownPlugin } from 'vitepress-plugin-tabs';
import { ROOT, readSummary, repoPathRedirects, sourceRelativeLinks } from './summary';

const { sections, rewrites } = readSummary();

export default defineConfig({
title: 'DeviceCloud',
description: 'Documentation for DeviceCloud, the fast and affordable Maestro Cloud alternative.',
lang: 'en',
cleanUrls: true,
lastUpdated: true,
sitemap: { hostname: 'https://docs.devicecloud.dev' },

head: [
['link', { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
['link', { rel: 'icon', type: 'image/png', sizes: '96x96', href: '/favicon-96x96.png' }],
['link', { rel: 'shortcut icon', href: '/favicon.ico' }],
['link', { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' }],
['meta', { name: 'theme-color', content: '#121419' }],
// Same fonts as devicecloud.dev. Inter ships with VitePress; this adds the mono.
['link', { rel: 'preconnect', href: 'https://fonts.googleapis.com' }],
['link', { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }],
['link', { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Fragment+Mono&display=swap' }],
['meta', { property: 'og:type', content: 'website' }],
['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' }],
],

// 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/**'],
rewrites,

// GitBook also answered at each page's repo path (/ci-cd/github-actions), so
// 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));
},

markdown: {
config(md) {
md.use(tabsMarkdownPlugin);
md.use(sourceRelativeLinks(rewrites, ROOT));
// Inline code like `${{ github.sha }}` must render literally, not as a
// Vue template expression. (Fenced code blocks are already protected.)
const codeInline = md.renderer.rules.code_inline!;
md.renderer.rules.code_inline = (tokens, idx, ...rest) => {
tokens[idx].attrSet('v-pre', '');
return codeInline(tokens, idx, ...rest);
};
},
},

themeConfig: {
// The wordmark already says "DeviceCloud". The top bar is always dark, like
// devicecloud.dev, so the white logo is used in both colour modes.
logo: '/logo-dark.png',
siteTitle: 'Docs',
sidebar: sections.map((s) => ({
text: s.text,
collapsed: false,
items: s.items.map((i) => ({ text: i.text, link: i.url })),
})),
outline: [2, 3],
search: { provider: 'local' },
editLink: {
pattern: 'https://github.com/devicecloud-dev/docs/edit/main/:path',
text: 'Edit this page on GitHub',
},
nav: [
{ text: 'Blog', link: 'https://devicecloud.dev/blog/' },
{ text: 'Changelog', link: 'https://devicecloud.dev/changelog/' },
{ text: 'Status', link: 'https://status.devicecloud.dev' },
{ text: 'Go to Console', link: 'https://console.devicecloud.dev' },
],
socialLinks: [
{ icon: 'discord', link: 'https://discord.gg/gm3mJwcNw8' },
{ icon: 'github', link: 'https://github.com/devicecloud-dev' },
],
},
});
83 changes: 83 additions & 0 deletions .vitepress/summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Reads SUMMARY.md, the single source of truth for page order and the sidebar.
*
* Each page is served at /<section heading slug>/<page title slug>, the same
* URL scheme GitBook used, so every link shared before the move keeps working.
* Renaming a heading or page title in SUMMARY.md therefore CHANGES its URL:
* add a redirect in public/_redirects when you do.
*/
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { posix, sep } from 'node:path';

export const ROOT = fileURLToPath(new URL('..', import.meta.url)).split(sep).join('/');
const SUMMARY = ROOT + 'SUMMARY.md';

export const slugify = (s: string) =>
s.toLowerCase().replace(/&/g, 'and').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');

export interface Section {
text: string;
items: { text: string; file: string; url: string }[];
}

export function readSummary() {
const sections: Section[] = [];
const rewrites: Record<string, string> = {};
let current: Section | undefined;

for (const line of readFileSync(SUMMARY, 'utf8').split('\n')) {
const heading = line.match(/^## (.+)/);
const page = line.match(/^\* \[(.+?)\]\((.+?\.md)\)/);
if (heading) {
current = { text: heading[1], items: [] };
sections.push(current);
} else if (page) {
const [, text, file] = page;
if (file === 'README.md') {
rewrites[file] = 'index.md';
continue;
}
if (!current) throw new Error(`SUMMARY.md: page "${text}" is not under a "## " section`);
const url = `${slugify(current.text)}/${slugify(text)}`;
rewrites[file] = `${url}.md`;
current.items.push({ text, file, url: `/${url}` });
}
}
return { sections, rewrites };
}

/**
* markdown-it plugin: resolves relative `.md` links against the SOURCE file
* and points them at the page's public URL. VitePress alone would resolve them
* against the rewritten URL, which breaks `./sibling.md` links.
*/
export function sourceRelativeLinks(rewrites: Record<string, string>, srcDir: string) {
const toUrl = (target: string) => '/' + target.replace(/(^|\/)index\.md$/, '$1').replace(/\.md$/, '');
return (md: any) => {
const next = md.renderer.rules.link_open ?? ((t: any, i: number, o: any, _e: any, self: any) => self.renderToken(t, i, o));
md.renderer.rules.link_open = (tokens: any[], idx: number, options: any, env: any, self: any) => {
const href: string = tokens[idx].attrGet('href') ?? '';
const m = href.match(/^([^:#?]+\.md)(#.*)?$/);
if (m && !href.startsWith('/') && env.realPath) {
const from = posix.relative(srcDir, env.realPath.split(sep).join('/'));
const file = posix.normalize(posix.join(posix.dirname(from), decodeURI(m[1])));
const target = rewrites[file];
if (!target) throw new Error(`${from}: link to ${href} points at ${file}, which is not in SUMMARY.md`);
tokens[idx].attrSet('href', toUrl(target) + (m[2] ?? ''));
}
return next(tokens, idx, options, env, self);
};
};
}

/** `_redirects` lines from each page's repo path to its public URL. */
export function repoPathRedirects(rewrites: Record<string, string>) {
const url = (f: string) => '/' + f.replace(/\.md$/, '').replace(/^index$/, '');
const live = new Set(Object.values(rewrites).map(url));
return Object.entries(rewrites)
.map(([from, to]) => [url(from), url(to)])
.filter(([from, to]) => from !== to && !live.has(from))
.map(([from, to]) => `${from} ${to} 301\n`)
.join('');
}
111 changes: 111 additions & 0 deletions .vitepress/theme/brand.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* DeviceCloud brand, taken from devicecloud.dev:
* primary #0A506F (Tailwind "primary") primary-800 #073851
* primary-300 #4D9AB6 primary-200 #80B7CB primary-50 #E6F1F5
* header #121419 (dark top bar)
* fonts Inter, Fragment Mono
*/
:root {
--dc-primary: #0a506f;
--dc-primary-800: #073851;
--dc-primary-300: #4d9ab6;
--dc-primary-200: #80b7cb;
--dc-primary-50: #e6f1f5;
--dc-ink: #121419;

--vp-c-brand-1: var(--dc-primary);
--vp-c-brand-2: #0d6890;
--vp-c-brand-3: var(--dc-primary);
--vp-c-brand-soft: rgba(10, 80, 111, 0.12);

--vp-nav-bg-color: var(--dc-ink);
--vp-nav-screen-bg-color: var(--dc-ink);

--vp-font-family-mono: 'Fragment Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

--vp-button-brand-bg: var(--dc-primary);
--vp-button-brand-hover-bg: var(--dc-primary-800);
--vp-button-brand-active-bg: var(--dc-primary-800);
}

.dark {
--vp-nav-bg-color: var(--dc-ink);
--vp-c-bg: var(--dc-ink);
--vp-c-bg-alt: #0c0e11;
--vp-c-bg-elv: #1a1d23;
--vp-c-bg-soft: #1a1d23;

--vp-c-brand-1: var(--dc-primary-200);
--vp-c-brand-2: var(--dc-primary-300);
--vp-c-brand-3: var(--dc-primary);
--vp-c-brand-soft: rgba(77, 154, 182, 0.16);
}

/* ---- Dark top bar in both modes, like the marketing site ---- */
.VPNavBar,
.VPNavBar.has-sidebar .content-body,
.VPNavBar:not(.home).top .content-body,
.VPNavBar .divider-line,
.VPNavScreen {
--vp-c-text-1: #ffffff;
--vp-c-text-2: #d1d5db;
--vp-c-brand-1: #ffffff;
--vp-c-gutter: #2a2e36;
--vp-c-divider: #2a2e36;
--vp-c-default-soft: #23272e;
--vp-c-bg-alt: #1a1d23;
}
.VPNavBar,
.VPNavBar .content-body,
.VPNavBar .title,
.VPNavBar .divider {
background-color: var(--dc-ink) !important;
}
.VPNavBar .divider-line {
background-color: #2a2e36 !important;
}
.VPNavBar .title {
border-bottom-color: #2a2e36 !important;
}
.VPNavBar .logo {
height: 22px;
}
.VPNavBar .VPNavBarTitle .title span {
font-weight: 500;
color: #9ca3af;
border-left: 1px solid #374151;
padding-left: 10px;
margin-left: 2px;
}
.VPNavScreen {
background-color: var(--dc-ink);
}

/* "Go to Console" styled like the outlined button on devicecloud.dev */
.VPNavBarMenu .VPNavBarMenuLink[href^='https://console.devicecloud.dev'] {
align-self: center;
margin-left: 8px;
padding: 0 14px;
height: 34px;
line-height: 32px;
border: 1px solid #4b5563;
border-radius: 6px;
}
.VPNavBarMenu .VPNavBarMenuLink[href^='https://console.devicecloud.dev']:hover {
background: #1f2937;
border-color: #6b7280;
}

/* Callouts in brand colours */
.custom-block.info {
--vp-custom-block-info-bg: var(--dc-primary-50);
--vp-custom-block-info-border: transparent;
border-left: 3px solid var(--dc-primary);
}
.dark .custom-block.info {
--vp-custom-block-info-bg: rgba(77, 154, 182, 0.1);
border-left-color: var(--dc-primary-300);
}
.custom-block.warning {
border-left: 3px solid var(--vp-c-warning-1);
}
11 changes: 11 additions & 0 deletions .vitepress/theme/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import DefaultTheme from 'vitepress/theme';
import type { Theme } from 'vitepress';
import { enhanceAppWithTabs } from 'vitepress-plugin-tabs/client';
import './brand.css';

export default {
extends: DefaultTheme,
enhanceApp({ app }) {
enhanceAppWithTabs(app);
},
} satisfies Theme;
Loading
Loading