diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f3ce1f4..1f7cd1c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -93,7 +93,7 @@ jobs:
MDBASE_TS_DIR: .sources/mdbase
- name: Type and content checks
- run: pnpm check
+ run: pnpm test:deploy:dev && pnpm check
- name: Build website
run: pnpm build
diff --git a/README.md b/README.md
index d9253f2..1e3503e 100644
--- a/README.md
+++ b/README.md
@@ -35,3 +35,22 @@ remain traceable to the release artifacts they document.
The first-party contract catalog is built from the commit pinned in
`site-sources.json`, then published as static files under `/contracts/`.
+
+## Development deployment
+
+```sh
+pnpm dlx wrangler@4.120.0 login # first use only
+pnpm deploy:dev
+```
+
+The command builds the current working tree for
+`https://mdbase-dev.pages.dev`, imports the specification, checks local links,
+and deploys the `main` branch of the standalone `mdbase-dev` Cloudflare Pages
+project. It verifies the live homepage, specification, canonical URLs, and
+indexing controls after deployment. The production GitHub Pages deployment and
+`mdbase.dev` domain are unchanged.
+
+The command uses the existing `mdbase-dev` Cloudflare Pages project, whose
+production branch remains `main`. The sibling `mdbase-spec/site/dist` build is
+required, as it is for a local production build; `MDBASE_SPEC_DIR` and
+`MDBASE_SPEC_SITE_DIST` can point to another prepared checkout or artifact.
diff --git a/astro.config.mjs b/astro.config.mjs
index 1e1e68e..5129a0d 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,12 +1,13 @@
import { defineConfig } from "astro/config";
import sitemap from "@astrojs/sitemap";
+const site = process.env.MDBASE_SITE_ORIGIN ?? "https://mdbase.dev";
+
export default defineConfig({
- site: "https://mdbase.dev",
+ site,
trailingSlash: "always",
integrations: [sitemap()],
build: {
format: "directory"
}
});
-
diff --git a/package.json b/package.json
index b9870c6..71d3082 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,15 @@
"scripts": {
"dev": "astro dev",
"build": "astro build",
+ "deploy:dev": "node scripts/deploy-pages-dev.mjs",
"check": "astro check",
"preview": "astro preview",
"sync:sources": "node scripts/sync-sources.mjs && node scripts/sync-contracts.mjs",
"sync:contracts": "node scripts/sync-contracts.mjs",
"import:spec": "node scripts/import-spec.mjs",
"check:links": "node scripts/check-links.mjs",
- "test": "pnpm check && pnpm build && pnpm import:spec && pnpm check:links"
+ "test:deploy:dev": "node --test scripts/deploy-pages-dev.test.mjs",
+ "test": "pnpm test:deploy:dev && pnpm check && pnpm build && pnpm import:spec && pnpm check:links"
},
"dependencies": {
"@astrojs/sitemap": "^3.7.3",
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..5ed0b5a
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ esbuild: true
diff --git a/scripts/deploy-pages-dev.mjs b/scripts/deploy-pages-dev.mjs
new file mode 100644
index 0000000..3e95b63
--- /dev/null
+++ b/scripts/deploy-pages-dev.mjs
@@ -0,0 +1,192 @@
+import { spawn } from "node:child_process";
+import { readFile, writeFile } from "node:fs/promises";
+import { resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export const developmentDeployment = Object.freeze({
+ siteOrigin: "https://mdbase-dev.pages.dev",
+ project: "mdbase-dev",
+ branch: "main",
+ wranglerVersion: "4.120.0",
+});
+
+const projectRoot = resolve(import.meta.dirname, "..");
+const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
+
+if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+ await deployDevelopmentSite(process.env);
+}
+
+export function developmentDeploymentEnvironment(environment) {
+ return {
+ ...environment,
+ MDBASE_SITE_ORIGIN: developmentDeployment.siteOrigin,
+ };
+}
+
+export async function deployDevelopmentSite(
+ environment,
+ dependencies = {
+ run: runCommand,
+ prepareBuild: prepareDevelopmentBuild,
+ verifyBuild: verifyDevelopmentBuild,
+ verifyDeployment: verifyLiveDeployment,
+ },
+) {
+ const deploymentEnvironment = developmentDeploymentEnvironment(environment);
+
+ await dependencies.run(pnpm, ["build"], deploymentEnvironment);
+ await dependencies.run(pnpm, ["import:spec"], deploymentEnvironment);
+ await dependencies.prepareBuild();
+ await dependencies.run(pnpm, ["check:links"], deploymentEnvironment);
+ await dependencies.verifyBuild();
+
+ await dependencies.run(
+ pnpm,
+ [
+ "dlx",
+ `wrangler@${developmentDeployment.wranglerVersion}`,
+ "pages",
+ "deploy",
+ "dist",
+ `--project-name=${developmentDeployment.project}`,
+ `--branch=${developmentDeployment.branch}`,
+ "--commit-dirty=true",
+ ],
+ deploymentEnvironment,
+ );
+ await dependencies.verifyDeployment();
+
+ console.log(
+ `Development mdbase.dev deployed: ${developmentDeployment.siteOrigin}/`,
+ );
+}
+
+export async function prepareDevelopmentBuild() {
+ await Promise.all([
+ writeFile(
+ resolve(projectRoot, "dist", "robots.txt"),
+ "User-agent: *\nDisallow: /\n",
+ ),
+ writeFile(
+ resolve(projectRoot, "dist", "_headers"),
+ "/*\n X-Robots-Tag: noindex, nofollow\n",
+ ),
+ ]);
+}
+
+export async function verifyDevelopmentBuild() {
+ const [homepage, specification, sitemap, robots, headers] = await Promise.all(
+ [
+ readFile(resolve(projectRoot, "dist", "index.html"), "utf8"),
+ readFile(resolve(projectRoot, "dist", "spec", "index.html"), "utf8"),
+ readFile(resolve(projectRoot, "dist", "sitemap-0.xml"), "utf8"),
+ readFile(resolve(projectRoot, "dist", "robots.txt"), "utf8"),
+ readFile(resolve(projectRoot, "dist", "_headers"), "utf8"),
+ ],
+ );
+
+ verifyCanonical(homepage, `${developmentDeployment.siteOrigin}/`, "homepage");
+ verifyCanonical(
+ specification,
+ `${developmentDeployment.siteOrigin}/spec/`,
+ "specification",
+ );
+ if (
+ !sitemap.includes(`${developmentDeployment.siteOrigin}/`) ||
+ !sitemap.includes(`${developmentDeployment.siteOrigin}/spec/`)
+ ) {
+ throw new Error("Development sitemap does not declare the staging origin.");
+ }
+ if (
+ !robots.includes("Disallow: /") ||
+ !headers.includes("X-Robots-Tag: noindex")
+ ) {
+ throw new Error("Development deployment is missing its indexing controls.");
+ }
+}
+
+async function verifyLiveDeployment() {
+ let lastError;
+ for (let attempt = 1; attempt <= 12; attempt += 1) {
+ try {
+ const [homepageResponse, specificationResponse, robotsResponse] =
+ await Promise.all([
+ fetch(`${developmentDeployment.siteOrigin}/?attempt=${attempt}`, {
+ cache: "no-store",
+ }),
+ fetch(
+ `${developmentDeployment.siteOrigin}/spec/?attempt=${attempt}`,
+ {
+ cache: "no-store",
+ },
+ ),
+ fetch(
+ `${developmentDeployment.siteOrigin}/robots.txt?attempt=${attempt}`,
+ {
+ cache: "no-store",
+ },
+ ),
+ ]);
+ for (const [label, response] of [
+ ["homepage", homepageResponse],
+ ["specification", specificationResponse],
+ ["robots.txt", robotsResponse],
+ ]) {
+ if (!response.ok)
+ throw new Error(`${label} returned HTTP ${response.status}`);
+ }
+
+ verifyCanonical(
+ await homepageResponse.text(),
+ `${developmentDeployment.siteOrigin}/`,
+ "homepage",
+ );
+ verifyCanonical(
+ await specificationResponse.text(),
+ `${developmentDeployment.siteOrigin}/spec/`,
+ "specification",
+ );
+ if (!(await robotsResponse.text()).includes("Disallow: /")) {
+ throw new Error("robots.txt does not disallow indexing.");
+ }
+ return;
+ } catch (error) {
+ lastError = error;
+ if (attempt < 12) await delay(5_000);
+ }
+ }
+ throw new Error(
+ `mdbase.dev development deployment verification failed: ${String(lastError)}`,
+ );
+}
+
+function verifyCanonical(document, expected, label) {
+ if (!document.includes(``)) {
+ throw new Error(`Development ${label} does not declare ${expected}.`);
+ }
+}
+
+async function runCommand(command, arguments_, environment) {
+ const child = spawn(command, arguments_, {
+ cwd: projectRoot,
+ env: environment,
+ stdio: "inherit",
+ });
+ const exitCode = await new Promise((resolveExit, rejectExit) => {
+ child.once("error", rejectExit);
+ child.once("exit", (code, signal) => {
+ if (signal) rejectExit(new Error(`${command} was stopped by ${signal}.`));
+ else resolveExit(code);
+ });
+ });
+ if (exitCode !== 0) {
+ throw new Error(
+ `${command} ${arguments_.join(" ")} exited with code ${exitCode}.`,
+ );
+ }
+}
+
+function delay(milliseconds) {
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds));
+}
diff --git a/scripts/deploy-pages-dev.test.mjs b/scripts/deploy-pages-dev.test.mjs
new file mode 100644
index 0000000..b6782e0
--- /dev/null
+++ b/scripts/deploy-pages-dev.test.mjs
@@ -0,0 +1,62 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ deployDevelopmentSite,
+ developmentDeployment,
+ developmentDeploymentEnvironment,
+} from "./deploy-pages-dev.mjs";
+
+test("uses the permanent development Pages origin", () => {
+ assert.deepEqual(developmentDeploymentEnvironment({ EXISTING: "kept" }), {
+ EXISTING: "kept",
+ MDBASE_SITE_ORIGIN: "https://mdbase-dev.pages.dev",
+ });
+});
+
+test("builds, checks, and deploys only the Pages production branch", async () => {
+ const calls = [];
+ let prepared = false;
+ let buildVerified = false;
+ let deploymentVerified = false;
+
+ await deployDevelopmentSite(
+ {},
+ {
+ run: async (command, arguments_, environment) => {
+ calls.push({ command, arguments_, environment });
+ },
+ prepareBuild: async () => {
+ prepared = true;
+ },
+ verifyBuild: async () => {
+ buildVerified = true;
+ },
+ verifyDeployment: async () => {
+ deploymentVerified = true;
+ },
+ },
+ );
+
+ assert.equal(prepared, true);
+ assert.equal(buildVerified, true);
+ assert.equal(deploymentVerified, true);
+ assert.deepEqual(
+ calls.slice(0, 3).map(({ arguments_ }) => arguments_),
+ [["build"], ["import:spec"], ["check:links"]],
+ );
+ assert.ok(
+ calls[3].arguments_.includes(
+ `wrangler@${developmentDeployment.wranglerVersion}`,
+ ),
+ );
+ assert.ok(calls[3].arguments_.includes("--project-name=mdbase-dev"));
+ assert.ok(calls[3].arguments_.includes("--branch=main"));
+ assert.equal(
+ calls.every(
+ ({ environment }) =>
+ environment.MDBASE_SITE_ORIGIN === developmentDeployment.siteOrigin,
+ ),
+ true,
+ );
+});
diff --git a/scripts/import-spec.mjs b/scripts/import-spec.mjs
index e948dfc..f0b7139 100644
--- a/scripts/import-spec.mjs
+++ b/scripts/import-spec.mjs
@@ -12,6 +12,7 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const specDir = resolve(process.env.MDBASE_SPEC_DIR ?? join(root, "..", "mdbase-spec"));
const source = resolve(process.env.MDBASE_SPEC_SITE_DIST ?? join(specDir, "site", "dist"));
const destination = join(root, "dist", "spec");
+const siteOrigin = new URL(process.env.MDBASE_SITE_ORIGIN ?? "https://mdbase.dev").origin;
required(join(root, "dist", "index.html"), "Build mdbase.dev before importing the specification");
required(join(source, "spec.html"), "Build mdbase-spec/site before importing it");
@@ -43,8 +44,8 @@ console.log(`Imported specification pages from ${source}`);
function rewrite(html, archive) {
const pageUrl = archive
- ? "https://mdbase.dev/spec/v0.2/"
- : "https://mdbase.dev/spec/";
+ ? `${siteOrigin}/spec/v0.2/`
+ : `${siteOrigin}/spec/`;
const pageTitle = archive
? "mdbase specification v0.2 archive"
: "mdbase specification v0.3";
@@ -95,7 +96,7 @@ function addSitemapRoutes() {
required(path, "Build the Astro sitemap before importing the specification");
let sitemap = readFileSync(path, "utf8");
for (const route of ["/spec/", "/spec/v0.2/"]) {
- const entry = `https://mdbase.dev${route}`;
+ const entry = `${siteOrigin}${route}`;
if (!sitemap.includes(entry)) {
sitemap = sitemap.replace("", `${entry}`);
}
diff --git a/src/components/SiteFooter.astro b/src/components/SiteFooter.astro
index aea3292..b2ce0ec 100644
--- a/src/components/SiteFooter.astro
+++ b/src/components/SiteFooter.astro
@@ -8,6 +8,7 @@
Product
Overview
+
Applications
Downloads
Connect
Connect beta waitlist
diff --git a/src/components/SiteHeader.astro b/src/components/SiteHeader.astro
index 749e354..2ad33b1 100644
--- a/src/components/SiteHeader.astro
+++ b/src/components/SiteHeader.astro
@@ -2,14 +2,15 @@
import BrandMark from "./BrandMark.astro";
interface Props {
- current?: "home" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
+ current?: "home" | "apps" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
}
const { current } = Astro.props;
const links = [
{ href: "/", label: "Overview", key: "home" },
- { href: "/downloads/", label: "Download", key: "downloads" },
{ href: "/connect/", label: "Connect", key: "connect" },
+ { href: "/apps/", label: "Applications", key: "apps" },
+ { href: "/downloads/", label: "Download", key: "downloads" },
{ href: "/sdk/", label: "Developers", key: "sdk" },
{ href: "/spec/", label: "Specification", key: "spec" }
] as const;
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index f345912..37fdfb9 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -12,7 +12,7 @@ import ThemeControlScript from "../components/ThemeControlScript.astro";
interface Props {
title: string;
description: string;
- current?: "home" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
+ current?: "home" | "apps" | "downloads" | "connect" | "sdk" | "runtime" | "spec";
noIndex?: boolean;
}
diff --git a/src/pages/apps/index.astro b/src/pages/apps/index.astro
new file mode 100644
index 0000000..9180dfd
--- /dev/null
+++ b/src/pages/apps/index.astro
@@ -0,0 +1,210 @@
+---
+import BaseLayout from "../../layouts/BaseLayout.astro";
+import "../../styles/apps.css";
+
+type CatalogueEntry = {
+ name: string;
+ description: string;
+ status: "Public beta" | "Development build";
+ runsOn: string;
+ collections: string;
+ links: Array<{ label: string; href: string }>;
+};
+
+const applications: CatalogueEntry[] = [
+ {
+ name: "TaskNotes",
+ description:
+ "Task manager with lists, boards, calendars, recurrence, reminders, attachments, and time tracking.",
+ status: "Public beta",
+ runsOn: "Web, iOS, Android",
+ collections: "Hosted or connected computer",
+ links: [
+ { label: "Open TaskNotes", href: "https://app.tasknotes.dev/" },
+ { label: "Try the demo", href: "https://app.tasknotes.dev/?demo=30" },
+ { label: "Source", href: "https://github.com/callumalpass/tasknotes-app" }
+ ]
+ },
+ {
+ name: "mdbase Editor",
+ description:
+ "General collection editor for Markdown records, frontmatter, links, backlinks, and type definitions.",
+ status: "Public beta",
+ runsOn: "Web",
+ collections: "Hosted or connected computer",
+ links: [
+ { label: "Open Editor", href: "https://editor.mdbase.dev/" },
+ {
+ label: "Source",
+ href: "https://github.com/mdbase-dev/mdbase-connect/tree/main/apps/editor"
+ }
+ ]
+ }
+];
+
+const collectionTools: CatalogueEntry[] = [
+ {
+ name: "mdbase for Obsidian",
+ description:
+ "Obsidian plugin for collection setup, type editing, v0.2 migration, validation, and hosted collection mirrors.",
+ status: "Development build",
+ runsOn: "Obsidian desktop and mobile",
+ collections: "Local; hosted mirror",
+ links: [
+ { label: "Source", href: "https://github.com/callumalpass/mdbase-obsidian" }
+ ]
+ },
+ {
+ name: "mdbase MCP",
+ description:
+ "Remote MCP endpoint for reading and changing approved collections from compatible AI clients.",
+ status: "Public beta",
+ runsOn: "OAuth-capable MCP clients",
+ collections: "Hosted or connected computer",
+ links: [
+ {
+ label: "Setup and tools",
+ href: "https://github.com/mdbase-dev/mdbase-connect/blob/main/docs/mcp-gateway.md"
+ },
+ { label: "Endpoint", href: "https://mcp.mdbase.dev/mcp" }
+ ]
+ }
+];
+---
+
+
+
+
+
+
Applications
+
Applications for mdbase collections
+
+
+
+
- Collection
+ - The Markdown records and type definitions remain the shared data.
+
+
+
- Access
+ - Connect grants each application access to one selected collection.
+
+
+
- Status
+ - Public beta and development builds are identified below.
+
+
+
+
+
+
+
+
+
+ {
+ applications.map((app) => (
+
+
+
{app.name}
+
+
+
{app.description}
+
+ {app.links.map((link) =>
{link.label})}
+
+
+
+
+
- Status
+ -
+
+ {app.status}
+
+
+
+ - Runs on
- {app.runsOn}
+ - Collections
- {app.collections}
+
+
+ ))
+ }
+
+
+
+
+
+
+
+ {
+ collectionTools.map((app) => (
+
+
+
{app.name}
+
+
+
{app.description}
+
+ {app.links.map((link) =>
{link.label})}
+
+
+
+
+
- Status
+ -
+
+ {app.status}
+
+
+
+ - Runs on
- {app.runsOn}
+ - Collections
- {app.collections}
+
+
+ ))
+ }
+
+
+
+
+
+
Developers
+
Build an application
+
+
+
+ The Connect SDK handles authorization, collection selection, operations,
+ and change feeds. Application manifests declare required contracts and
+ permissions.
+
+
+
+
+
diff --git a/src/styles/apps.css b/src/styles/apps.css
new file mode 100644
index 0000000..1b05d50
--- /dev/null
+++ b/src/styles/apps.css
@@ -0,0 +1,189 @@
+.apps-hero__summary {
+ display: grid;
+ border-top: 1px solid var(--line-strong);
+}
+
+.apps-hero__summary > div {
+ display: grid;
+ padding-block: 0.7rem;
+ grid-template-columns: 7rem minmax(0, 1fr);
+ gap: 1rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.apps-hero__summary dt,
+.app-meta dt,
+.catalogue-label {
+ color: var(--ink-muted);
+ font: 500 0.62rem/1.45 var(--mono);
+}
+
+.apps-hero__summary dd {
+ color: var(--ink-soft);
+ font-size: 0.82rem;
+}
+
+.catalogue-section {
+ width: min(100% - 2rem, var(--page));
+ margin-inline: auto;
+ padding-block: clamp(3.5rem, 6vw, 5.5rem);
+ border-bottom: 1px solid var(--line);
+}
+
+.catalogue-section__header {
+ display: grid;
+ align-items: end;
+ grid-template-columns: minmax(12rem, 0.34fr) minmax(0, 1fr);
+ gap: 1.5rem;
+}
+
+.catalogue-section__header h2 {
+ font-size: clamp(1.45rem, 2.2vw, 1.9rem);
+}
+
+.catalogue-section__header p {
+ max-width: 56ch;
+ color: var(--ink-soft);
+ font-size: 0.88rem;
+}
+
+.app-list {
+ margin-top: 2rem;
+ border-top: 1px solid var(--line-strong);
+}
+
+.app-entry {
+ display: grid;
+ max-width: none;
+ padding-block: 1.5rem;
+ grid-template-columns: minmax(12rem, 0.34fr) minmax(17rem, 0.78fr) minmax(
+ 15rem,
+ 0.62fr
+ );
+ gap: 1.5rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.app-entry__identity {
+ min-width: 0;
+}
+
+.app-entry h3 {
+ max-width: none;
+ font-size: 1rem;
+}
+
+.app-entry__description p {
+ color: var(--ink-soft);
+ font-size: 0.88rem;
+}
+
+.app-entry__links {
+ display: flex;
+ margin-top: 0.8rem;
+ flex-wrap: wrap;
+ gap: 0.4rem 1rem;
+}
+
+.app-entry__links a {
+ font-size: 0.78rem;
+ font-weight: 700;
+}
+
+.app-meta {
+ display: grid;
+ align-content: start;
+ border-top: 1px solid var(--line);
+}
+
+.app-meta > div {
+ display: grid;
+ padding-block: 0.45rem;
+ grid-template-columns: 5.25rem minmax(0, 1fr);
+ gap: 0.75rem;
+ border-bottom: 1px solid var(--line);
+}
+
+.app-meta dd {
+ color: var(--ink-soft);
+ font-size: 0.75rem;
+}
+
+.catalogue-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.catalogue-status::before {
+ width: 0.38rem;
+ height: 0.38rem;
+ flex: 0 0 auto;
+ content: "";
+ background: var(--signal);
+ border-radius: 50%;
+}
+
+.catalogue-status--development::before {
+ background: var(--warning);
+}
+
+.build-section {
+ display: grid;
+ width: min(100% - 2rem, var(--page));
+ margin-inline: auto;
+ padding-block: clamp(3.5rem, 6vw, 5.5rem);
+ align-items: start;
+ grid-template-columns: minmax(12rem, 0.34fr) minmax(0, 1fr);
+ gap: 1.5rem;
+}
+
+.build-section h2 {
+ font-size: clamp(1.45rem, 2.2vw, 1.9rem);
+}
+
+.build-section__body p {
+ color: var(--ink-soft);
+ font-size: 0.9rem;
+}
+
+.build-section__body .button-row {
+ margin-top: 1.2rem;
+}
+
+@media (max-width: 68rem) {
+ .app-entry {
+ grid-template-columns: minmax(11rem, 0.38fr) minmax(0, 1fr);
+ }
+
+ .app-meta {
+ grid-column: 2;
+ }
+}
+
+@media (max-width: 48rem) {
+ .catalogue-section__header,
+ .app-entry,
+ .build-section {
+ grid-template-columns: 1fr;
+ }
+
+ .catalogue-section__header {
+ align-items: start;
+ }
+
+ .app-meta {
+ grid-column: auto;
+ }
+}
+
+@media (max-width: 42rem) {
+ .catalogue-section,
+ .build-section {
+ width: min(100% - 1.25rem, var(--page));
+ }
+
+ .apps-hero__summary > div {
+ grid-template-columns: 5.5rem minmax(0, 1fr);
+ }
+}