From 5a3a1cc4856d8cccd535344dc49ce33f9090cdac Mon Sep 17 00:00:00 2001 From: callumalpass Date: Thu, 20 Aug 2026 21:38:04 +1000 Subject: [PATCH 1/2] Add applications page and development deployment --- .github/workflows/ci.yml | 2 +- README.md | 19 +++ astro.config.mjs | 5 +- package.json | 5 +- pnpm-workspace.yaml | 2 + scripts/deploy-pages-dev.mjs | 192 +++++++++++++++++++++++++++ scripts/deploy-pages-dev.test.mjs | 62 +++++++++ scripts/import-spec.mjs | 7 +- src/components/SiteFooter.astro | 1 + src/components/SiteHeader.astro | 5 +- src/layouts/BaseLayout.astro | 2 +- src/pages/apps/index.astro | 210 ++++++++++++++++++++++++++++++ src/styles/apps.css | 189 +++++++++++++++++++++++++++ 13 files changed, 691 insertions(+), 10 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 scripts/deploy-pages-dev.mjs create mode 100644 scripts/deploy-pages-dev.test.mjs create mode 100644 src/pages/apps/index.astro create mode 100644 src/styles/apps.css 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..8683e5f 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,19 @@ "version": "0.1.0", "private": true, "type": "module", + "packageManager": "pnpm@11.15.1", "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

+
+
+ +
+ { + applications.map((app) => ( +
+
+

{app.name}

+
+
+

{app.description}

+
+ {app.links.map((link) => {link.label})} +
+
+
+
+
Status
+
+ + {app.status} + +
+
+
Runs on
{app.runsOn}
+
Collections
{app.collections}
+
+
+ )) + } +
+
+ +
+
+
+

Work with a collection

+

Collection tools

+
+

+ These tools connect mdbase collections to an existing workspace or client. +

+
+ +
+ { + 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); + } +} From 5480cd4ad861d619991040eb73991fd48cce9700 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Thu, 20 Aug 2026 21:39:18 +1000 Subject: [PATCH 2/2] Use repository pnpm version in CI --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 8683e5f..71d3082 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "0.1.0", "private": true, "type": "module", - "packageManager": "pnpm@11.15.1", "scripts": { "dev": "astro dev", "build": "astro build",