diff --git a/.github/workflows/guide-registry.yml b/.github/workflows/guide-registry.yml new file mode 100644 index 0000000..9a15c59 --- /dev/null +++ b/.github/workflows/guide-registry.yml @@ -0,0 +1,22 @@ +name: Guide Registry + +on: + pull_request: {} + +permissions: + contents: read + +jobs: + guide-registry: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - name: Check guide registry + run: node scripts/check-registry.mjs diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000..0efccc4 --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -0,0 +1,29 @@ +name: PR Build + +on: + pull_request: {} + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + + - name: Install + run: npm ci + + - name: Build + env: + # Prefer committed snapshot on PRs so builds do not depend on live S3. + CATALOGUE_URL: "" + run: npm run build diff --git a/.github/workflows/walkthrough-smoke.yml b/.github/workflows/walkthrough-smoke.yml new file mode 100644 index 0000000..056513e --- /dev/null +++ b/.github/workflows/walkthrough-smoke.yml @@ -0,0 +1,31 @@ +name: Walkthrough Smoke + +on: + schedule: + - cron: "0 6 * * *" # 06:00 UTC daily + workflow_dispatch: {} + +permissions: + contents: read + +concurrency: + group: walkthrough-smoke + cancel-in-progress: true + +jobs: + smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - name: Smoke walkthrough URLs + env: + GA_MEASUREMENT_ID: G-6GP64SX615 + SMOKE_CONCURRENCY: "4" + run: node scripts/smoke-walkthroughs.mjs diff --git a/data/catalogue.json b/data/catalogue.json index bb76998..a6eeb9a 100644 --- a/data/catalogue.json +++ b/data/catalogue.json @@ -1,8 +1,26 @@ { "schemaVersion": 1, - "generated": "2026-08-29T00:39:46Z", - "source": "bootstrap: guides frontmatter", + "generated": "2026-08-31T18:45:22Z", + "source": "guides frontmatter", "items": [ + { + "slug": "2026-08-30-amplify-astro-cicd-walkthrough", + "title": "Amplify Astro CI/CD", + "date": "2026-08-30", + "type": "walkthrough", + "category": "tooling", + "tags": [ + "amplify", + "astro", + "github-actions", + "cicd", + "hosting", + "previews" + ], + "hook": "Ship a static Astro site to Amplify Hosting with staging, PR previews, GitHub CI gates, and teardown.", + "url": "https://amplify-astro-cicd-walkthrough.johna.kiwi/", + "repo": "https://github.com/jajera/amplify-astro-cicd" + }, { "slug": "2026-08-23-buildpacks-eks-walkthrough", "title": "Buildpacks on EKS", @@ -18,7 +36,7 @@ "codecommit", "gitops" ], - "hook": "Prove buildpacks on EKS - platform-owned image builds, app teams push code only.", + "hook": "Prove buildpacks on EKS — platform-owned image builds, app teams push code only.", "url": "https://buildpacks-eks-walkthrough.johna.kiwi/", "repo": "" }, @@ -36,7 +54,7 @@ "auto-mode", "gitops" ], - "hook": "Deploy Kueue on Amazon EKS Auto Mode with eksctl and GitOps - cap concurrent batch jobs and share quota across projects.", + "hook": "Deploy Kueue on Amazon EKS Auto Mode with eksctl and GitOps — cap concurrent batch jobs and share quota across projects.", "url": "https://kueue-eks-walkthrough.johna.kiwi/", "repo": "" }, @@ -53,7 +71,7 @@ "hpa", "kube-apiserver" ], - "hook": "What EKS control plane UpdateClusterConfig changes - and what stays the same on clusters created before the feature.", + "hook": "Amazon EKS control plane configuration parameters walkthrough.", "url": "https://eks-control-plane-config.johna.kiwi/", "repo": "" }, @@ -76,24 +94,6 @@ "url": "https://seiscomp-ecs-lab.johna.kiwi/", "repo": "https://github.com/jajera/seiscomp-ecs-lab" }, - { - "slug": "2026-08-15-seiscomp-containers-lab", - "title": "SeisComP containers lab", - "date": "2026-08-15", - "type": "walkthrough", - "category": "containers", - "tags": [ - "seiscomp", - "docker", - "compose", - "seismology", - "ec2", - "gsm" - ], - "hook": "Unofficial LEARN SeisComP lab as one Docker Compose service per process using gsm.", - "url": "https://seiscomp-containers-lab.johna.kiwi/", - "repo": "https://github.com/jajera/seiscomp-containers-lab" - }, { "slug": "2026-08-15-seiscomp-lab", "title": "SeisComP lab", @@ -112,6 +112,24 @@ "url": "https://seiscomp-lab.johna.kiwi/", "repo": "https://github.com/jajera/seiscomp-lab" }, + { + "slug": "2026-08-15-seiscomp-containers-lab", + "title": "SeisComP containers lab", + "date": "2026-08-15", + "type": "walkthrough", + "category": "containers", + "tags": [ + "seiscomp", + "docker", + "compose", + "seismology", + "ec2", + "gsm" + ], + "hook": "Unofficial LEARN SeisComP lab as one Docker Compose service per process using gsm.", + "url": "https://seiscomp-containers-lab.johna.kiwi/", + "repo": "https://github.com/jajera/seiscomp-containers-lab" + }, { "slug": "2026-08-12-privatelink-conduit", "title": "PrivateLink path between producer and consumer VPCs", @@ -126,7 +144,7 @@ "terraform", "multi-account" ], - "hook": "Cross-account PrivateLink into a private 3-tier API - same-Region Sydney, cross-Region Melbourne, and why New Zealand cannot join the cross-Region path today.", + "hook": "Cross-account PrivateLink into a private 3-tier API — same-Region Sydney, cross-Region Melbourne, and why New Zealand cannot join the cross-Region path today.", "url": "https://privatelink-conduit.johna.kiwi/", "repo": "https://github.com/jajera/privatelink-conduit" }, @@ -178,8 +196,8 @@ "docs-sync", "aws-builder-center" ], - "hook": "Kiro Birthday Week Day 1 - a docs sync check hook, with Builder Center write-up, demo video, and LinkedIn share.", - "url": "", + "hook": "Kiro Birthday Week Day 1 — a docs sync check hook, with Builder Center write-up, demo video, and LinkedIn share.", + "url": "https://builder.aws.com/content/3GTOz2yP0In7OD0EpIntARL4lgJ/kiro-birthday-2026-challenge-day-1-hook", "repo": "" }, { @@ -196,7 +214,7 @@ "ci", "productivity" ], - "hook": "AI coach that checks branch readiness before you open a pull request - local CLI, AWS API, GitHub Actions comments, Kiro hooks, and an owner UI.", + "hook": "AI coach that checks branch readiness before you open a pull request — local CLI, AWS API, GitHub Actions comments, Kiro hooks, and an owner UI.", "url": "https://pr-readiness-coach-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/pr-readiness-coach" }, @@ -214,7 +232,7 @@ "cloud-wan", "multi-account" ], - "hook": "Deploy and compare five private cross-account patterns - VPC Peering, PrivateLink, VPC Lattice, Transit Gateway, and Cloud WAN.", + "hook": "Deploy and compare five private cross-account patterns — VPC Peering, PrivateLink, VPC Lattice, Transit Gateway, and Cloud WAN.", "url": "https://aws-private-connectivity-patterns-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/aws-private-connectivity-patterns-walkthrough" }, @@ -232,7 +250,7 @@ "amplify", "terraform" ], - "hook": "Vector search without a vector database - Amazon S3 Vectors, Bedrock embeddings, and a serverless RAG app.", + "hook": "Vector search without a vector database — Amazon S3 Vectors, Bedrock embeddings, and a serverless RAG app.", "url": "https://s3-vectors-rag-workload.johna.kiwi/", "repo": "https://github.com/jajera/s3-vectors-rag-workload" }, @@ -249,7 +267,7 @@ "amplify", "terraform" ], - "hook": "Attach structured JSON metadata to S3 objects - demonstrated on GeoNet volcano camera imagery from Te Kaha.", + "hook": "Attach structured JSON metadata to S3 objects — demonstrated on GeoNet volcano camera imagery from Te Kaha.", "url": "https://s3-annotations-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/terraform-aws-s3-annotations-demo" }, @@ -265,7 +283,7 @@ "private-dns", "terraform" ], - "hook": "Share one private hosted zone across three accounts and two regions - classic VPC association authorization, no Profiles or RAM.", + "hook": "Share one private hosted zone across three accounts and two regions — classic VPC association authorization, no Profiles or RAM.", "url": "https://route53-classic-multi-account-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/route53-classic-multi-account-walkthrough" }, @@ -288,38 +306,22 @@ "repo": "https://github.com/jajera/aws-event-driven-serverless-walkthrough" }, { - "slug": "2026-06-20-aws-gcp-interconnect", - "title": "AWS\u2013GCP Interconnect", - "date": "2026-06-20", - "type": "walkthrough", - "category": "networking", - "tags": [ - "interconnect", - "gcp", - "hybrid", - "terraform", - "private-connectivity" - ], - "hook": "Private connectivity between AWS and GCP - no VPN, no colocation, no public internet.", - "url": "https://aws-gcp-interconnect-walkthrough.johna.kiwi/", - "repo": "https://github.com/jajera/aws-gcp-interconnect-walkthrough" - }, - { - "slug": "2026-06-20-ecs-express-mode", - "title": "ECS Express Mode", + "slug": "2026-06-20-s3-files-workloads", + "title": "S3 Files workloads", "date": "2026-06-20", "type": "walkthrough", - "category": "containers", + "category": "storage", "tags": [ + "s3-files", + "efs", + "ec2", + "lambda", "ecs", - "express-mode", - "alb", - "terraform", - "fargate" + "terraform" ], - "hook": "Deploy HTTP/HTTPS containers on ECS with a single Terraform resource - managed ALB, auto scaling, and HTTPS URL included.", - "url": "https://ecs-express-mode-walkthrough.johna.kiwi/", - "repo": "https://github.com/jajera/ecs-express-mode-walkthrough" + "hook": "Mount an S3 bucket as an NFS file system on EC2, ECS Fargate, and Lambda — CLI and Terraform paths.", + "url": "https://s3-files-workloads.johna.kiwi/", + "repo": "https://github.com/jajera/s3-files-workloads" }, { "slug": "2026-06-20-lambda-managed-instances", @@ -333,27 +335,43 @@ "capacity-provider", "iam" ], - "hook": "Theory-first guide to Lambda Managed Instances - fundamentals, quotas, and hands-on walkthrough pages as they publish.", + "hook": "Theory-first guide to Lambda Managed Instances — fundamentals, quotas, and hands-on walkthrough pages as they publish.", "url": "https://aws-lambda-managed-instance-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/aws-lambda-managed-instance-walkthrough" }, { - "slug": "2026-06-20-s3-files-workloads", - "title": "S3 Files workloads", + "slug": "2026-06-20-ecs-express-mode", + "title": "ECS Express Mode", "date": "2026-06-20", "type": "walkthrough", - "category": "storage", + "category": "containers", "tags": [ - "s3-files", - "efs", - "ec2", - "lambda", "ecs", - "terraform" + "express-mode", + "alb", + "terraform", + "fargate" ], - "hook": "Mount an S3 bucket as an NFS file system on EC2, ECS Fargate, and Lambda - CLI and Terraform paths.", - "url": "https://s3-files-workloads.johna.kiwi/", - "repo": "https://github.com/jajera/s3-files-workloads" + "hook": "Deploy HTTP/HTTPS containers on ECS with a single Terraform resource — managed ALB, auto scaling, and HTTPS URL included.", + "url": "https://ecs-express-mode-walkthrough.johna.kiwi/", + "repo": "https://github.com/jajera/ecs-express-mode-walkthrough" + }, + { + "slug": "2026-06-20-aws-gcp-interconnect", + "title": "AWS–GCP Interconnect", + "date": "2026-06-20", + "type": "walkthrough", + "category": "networking", + "tags": [ + "interconnect", + "gcp", + "hybrid", + "terraform", + "private-connectivity" + ], + "hook": "Private connectivity between AWS and GCP — no VPN, no colocation, no public internet.", + "url": "https://aws-gcp-interconnect-walkthrough.johna.kiwi/", + "repo": "https://github.com/jajera/aws-gcp-interconnect-walkthrough" }, { "slug": "2026-06-19-amazon-vpc-ipam-multi-account", @@ -368,7 +386,7 @@ "terraform", "vpc" ], - "hook": "Org-wide VPC IPAM across four accounts and two regions - delegated admin, RAM pool sharing, and pool-backed workload VPCs.", + "hook": "Org-wide VPC IPAM across four accounts and two regions — delegated admin, RAM pool sharing, and pool-backed workload VPCs.", "url": "https://amazon-vpc-ipam-multi-account-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/amazon-vpc-ipam-multi-account-walkthrough" }, @@ -386,13 +404,13 @@ "amplify", "lambda" ], - "hook": "Connect ESP32-S3 to AWS IoT Core - pretest flash and serial, then Terraform for rules, Lambda, DynamoDB, API, and Amplify dashboard.", + "hook": "Connect ESP32-S3 to AWS IoT Core — pretest flash and serial, then Terraform for rules, Lambda, DynamoDB, API, and Amplify dashboard.", "url": "https://aws-iot-walkthrough.johna.kiwi/", "repo": "https://github.com/jajera/aws-iot-walkthrough" }, { "slug": "2026-04-07-greenfield-eks-standard-vs-auto-mode", - "title": "Greenfield EKS - Standard vs Auto Mode", + "title": "Greenfield EKS — Standard vs Auto Mode", "date": "2026-04-07", "type": "article", "category": "containers", @@ -403,8 +421,8 @@ "aws", "decision-guide" ], - "hook": "Practical greenfield guide to choosing Standard EKS versus EKS Auto Mode - cost, who runs what, and a decision rubric without legacy baggage.", - "url": "", + "hook": "Practical greenfield guide to choosing Standard EKS versus EKS Auto Mode — cost, who runs what, and a decision rubric without legacy baggage.", + "url": "https://dev.to/jajera/greenfield-eks-choosing-standard-eks-vs-eks-auto-mode-without-legacy-baggage-39f3", "repo": "" }, { @@ -421,8 +439,8 @@ "hackathon", "bedrock" ], - "hook": "Serverless IAM guard that detects dangerous policy changes and remediates them in seconds - Lambda Hackathon 2025.", - "url": "", + "hook": "Serverless IAM guard that detects dangerous policy changes and remediates them in seconds — Lambda Hackathon 2025.", + "url": "https://jajera.github.io/hackathon-2025-iam-policy-guard/", "repo": "https://github.com/jajera/hackathon-2025-iam-policy-guard" }, { @@ -437,8 +455,8 @@ "talk", "service-networking" ], - "hook": "Talk resources for modern service networking with VPC Lattice - agenda, links, and a Terraform example.", - "url": "", + "hook": "Talk resources for modern service networking with VPC Lattice — agenda, links, and a Terraform example.", + "url": "https://jajera.github.io/aws-community-day-aotearoa-2025-vpclattice-talk/", "repo": "https://github.com/jajera/aws-community-day-aotearoa-2025-vpclattice-talk" }, { @@ -453,8 +471,8 @@ "developer-tools", "slides" ], - "hook": "Slide deck on Amazon Q for development - history, install, CLI habits, and demo projects scaffolded with q chat.", - "url": "", + "hook": "Slide deck on Amazon Q for development — history, install, CLI habits, and demo projects scaffolded with q chat.", + "url": "https://jajera.github.io/amazon-q-cli-slides/", "repo": "https://github.com/jajera/amazon-q-cli-slides" }, { @@ -469,8 +487,8 @@ "automation", "paths" ], - "hook": "Where GitHub Actions stores files during a run - default workspace, working-directory scopes, action paths, and path-related environment variables.", - "url": "", + "hook": "Where GitHub Actions stores files during a run — default workspace, working-directory scopes, action paths, and path-related environment variables.", + "url": "https://dev.to/jajera/understanding-github-actions-working-directory-550o", "repo": "" } ] diff --git a/package.json b/package.json index 34c24a4..0d20ff2 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "scripts": { "dev": "astro dev", "build": "astro build", - "preview": "astro preview" + "preview": "astro preview", + "check:registry": "node scripts/check-registry.mjs", + "smoke:walkthroughs": "node scripts/smoke-walkthroughs.mjs", + "export:registry": "node scripts/export-registry.mjs" }, "dependencies": { "@astrojs/rss": "^4.0.19", diff --git a/scripts/check-registry.mjs b/scripts/check-registry.mjs new file mode 100644 index 0000000..0e3d6ae --- /dev/null +++ b/scripts/check-registry.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * Validate guide frontmatter and data/catalogue.json consistency. + * Usage: node scripts/check-registry.mjs + */ + +import { readFile } from "node:fs/promises"; +import { + ALLOWED_TYPES, + CATALOGUE_PATH, + JOHNA_KIWI_HOST, + REQUIRED_FIELDS, + loadGuides, + normalizeUrl, + pickUrl, +} from "./lib/guides-md.mjs"; + +const errors = []; + +function fail(msg) { + errors.push(msg); +} + +async function main() { + const guides = await loadGuides(); + const published = []; + + for (const { slug, file, fm } of guides) { + if (!fm) { + fail(`${file}: missing or invalid frontmatter`); + continue; + } + + for (const key of REQUIRED_FIELDS) { + const value = fm[key]; + if (value === undefined || value === null || String(value).trim() === "") { + fail(`${slug}: missing required field '${key}'`); + } + } + + const type = String(fm.type || "").trim(); + if (type && !ALLOWED_TYPES.has(type)) { + fail(`${slug}: type must be walkthrough|article (got '${type}')`); + } + + if (type === "walkthrough") { + const url = String(fm.walkthrough_url || "").trim(); + if (!url) { + fail(`${slug}: walkthrough requires walkthrough_url`); + } else if (!JOHNA_KIWI_HOST.test(url)) { + fail( + `${slug}: walkthrough_url must be https://.johna.kiwi/ (got '${url}')`, + ); + } + } + + if (type === "article") { + const article = String(fm.article_url || "").trim(); + const video = String(fm.video_url || "").trim(); + if (!article && !video) { + fail(`${slug}: article requires article_url or video_url`); + } + } + + if (fm.draft === true) { + continue; + } + + const url = pickUrl(fm); + if (!url) { + fail(`${slug}: published guide needs walkthrough_url, article_url, or video_url`); + continue; + } + + published.push({ + slug, + title: String(fm.title || "").trim(), + type, + summary: String(fm.summary || "").trim(), + url: normalizeUrl(url), + }); + } + + let catalogue; + try { + catalogue = JSON.parse(await readFile(CATALOGUE_PATH, "utf8")); + } catch (err) { + fail(`cannot read data/catalogue.json: ${err.message}`); + report(); + return; + } + + if (!catalogue || !Array.isArray(catalogue.items)) { + fail("data/catalogue.json: missing items array"); + report(); + return; + } + + const bySlug = new Map(published.map((g) => [g.slug, g])); + const catSlugs = new Set(); + + for (const item of catalogue.items) { + const slug = String(item.slug || "").trim(); + if (!slug) { + fail("catalogue item missing slug"); + continue; + } + catSlugs.add(slug); + + const guide = bySlug.get(slug); + if (!guide) { + fail(`catalogue has '${slug}' but no matching published guide`); + continue; + } + + if (String(item.type || "").trim() !== guide.type) { + fail( + `${slug}: catalogue type '${item.type}' != frontmatter '${guide.type}'`, + ); + } + if (String(item.title || "").trim() !== guide.title) { + fail( + `${slug}: catalogue title diverges from frontmatter\n catalogue: ${item.title}\n guide: ${guide.title}`, + ); + } + if (normalizeUrl(item.url) !== guide.url) { + fail( + `${slug}: catalogue url diverges from frontmatter\n catalogue: ${item.url}\n guide: ${guide.url}`, + ); + } + if (String(item.hook || "").trim() !== guide.summary) { + fail( + `${slug}: catalogue hook diverges from frontmatter summary\n catalogue: ${item.hook}\n guide: ${guide.summary}`, + ); + } + } + + for (const guide of published) { + if (!catSlugs.has(guide.slug)) { + fail( + `${guide.slug}: published guide missing from data/catalogue.json (update snapshot)`, + ); + } + } + + report(); +} + +function report() { + if (errors.length) { + console.error(`guide registry check failed (${errors.length}):\n`); + for (const msg of errors) { + console.error(`- ${msg}`); + } + process.exit(1); + } + console.log("guide registry check passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/export-registry.mjs b/scripts/export-registry.mjs index 122cf65..eba0ca3 100644 --- a/scripts/export-registry.mjs +++ b/scripts/export-registry.mjs @@ -4,93 +4,19 @@ * Usage: node scripts/export-registry.mjs > payload.json */ -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); -const GUIDES_DIR = path.join(ROOT, "src", "content", "guides"); - -function parseFrontmatter(raw) { - if (!raw.startsWith("---")) { - return null; - } - const end = raw.indexOf("\n---", 3); - if (end === -1) { - return null; - } - const block = raw.slice(3, end).replace(/^\r?\n/, ""); - const data = {}; - for (const line of block.split(/\r?\n/)) { - if (!line.trim() || line.trimStart().startsWith("#")) { - continue; - } - const colon = line.indexOf(":"); - if (colon === -1) { - continue; - } - const key = line.slice(0, colon).trim(); - let value = line.slice(colon + 1).trim(); - if ( - (value.startsWith('"') && value.endsWith('"')) || - (value.startsWith("'") && value.endsWith("'")) - ) { - value = value.slice(1, -1); - } - if (value === "true") { - data[key] = true; - } else if (value === "false") { - data[key] = false; - } else if (value.startsWith("[") && value.endsWith("]")) { - const inner = value.slice(1, -1).trim(); - data[key] = inner - ? inner.split(",").map((part) => { - let item = part.trim(); - if ( - (item.startsWith('"') && item.endsWith('"')) || - (item.startsWith("'") && item.endsWith("'")) - ) { - item = item.slice(1, -1); - } - return item; - }) - : []; - } else { - data[key] = value; - } - } - return data; -} - -function pickUrl(fm) { - return ( - String(fm.walkthrough_url || "").trim() || - String(fm.article_url || "").trim() || - String(fm.video_url || "").trim() || - "" - ); -} - -function pickRepo(fm) { - const demo = String(fm.demo_url || "").trim(); - if (/^https?:\/\/(www\.)?github\.com\//i.test(demo)) { - return demo; - } - return ""; -} +import { + loadGuides, + pickRepo, + pickUrl, +} from "./lib/guides-md.mjs"; async function main() { - const names = (await readdir(GUIDES_DIR)) - .filter((name) => name.endsWith(".md")) - .sort(); - + const guides = await loadGuides(); const items = []; - for (const name of names) { - const slug = name.replace(/\.md$/i, ""); - const raw = await readFile(path.join(GUIDES_DIR, name), "utf8"); - const fm = parseFrontmatter(raw); + + for (const { slug, file, fm } of guides) { if (!fm) { - console.error(`skip ${name}: missing frontmatter`); + console.error(`skip ${file}: missing frontmatter`); continue; } if (fm.draft === true) { diff --git a/scripts/lib/guides-md.mjs b/scripts/lib/guides-md.mjs new file mode 100644 index 0000000..092ffa2 --- /dev/null +++ b/scripts/lib/guides-md.mjs @@ -0,0 +1,113 @@ +/** + * Shared Markdown frontmatter helpers for guides maintenance scripts. + */ + +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +export const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); +export const GUIDES_DIR = path.join(ROOT, "src", "content", "guides"); +export const CATALOGUE_PATH = path.join(ROOT, "data", "catalogue.json"); + +/** Walkthrough hosts must use the custom domain, not github.io. */ +export const JOHNA_KIWI_HOST = + /^https:\/\/[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.johna\.kiwi(?:\/.*)?$/i; + +export const REQUIRED_FIELDS = ["title", "date", "type", "category", "summary"]; +export const ALLOWED_TYPES = new Set(["walkthrough", "article"]); + +export function parseFrontmatter(raw) { + if (!raw.startsWith("---")) { + return null; + } + const end = raw.indexOf("\n---", 3); + if (end === -1) { + return null; + } + const block = raw.slice(3, end).replace(/^\r?\n/, ""); + const data = {}; + for (const line of block.split(/\r?\n/)) { + if (!line.trim() || line.trimStart().startsWith("#")) { + continue; + } + const colon = line.indexOf(":"); + if (colon === -1) { + continue; + } + const key = line.slice(0, colon).trim(); + let value = line.slice(colon + 1).trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (value === "true") { + data[key] = true; + } else if (value === "false") { + data[key] = false; + } else if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + data[key] = inner + ? inner.split(",").map((part) => { + let item = part.trim(); + if ( + (item.startsWith('"') && item.endsWith('"')) || + (item.startsWith("'") && item.endsWith("'")) + ) { + item = item.slice(1, -1); + } + return item; + }) + : []; + } else { + data[key] = value; + } + } + return data; +} + +export function pickUrl(fm) { + return ( + String(fm.walkthrough_url || "").trim() || + String(fm.article_url || "").trim() || + String(fm.video_url || "").trim() || + "" + ); +} + +export function normalizeUrl(url) { + const s = String(url || "").trim(); + if (!s) return ""; + return s.endsWith("/") ? s : `${s}/`; +} + +export function pickRepo(fm) { + const demo = String(fm.demo_url || "").trim(); + if (/^https?:\/\/(www\.)?github\.com\//i.test(demo)) { + return demo; + } + return ""; +} + +/** + * @returns {Promise, raw: string }>>} + */ +export async function loadGuides() { + const names = (await readdir(GUIDES_DIR)) + .filter((name) => name.endsWith(".md")) + .sort(); + + const entries = []; + for (const name of names) { + const slug = name.replace(/\.md$/i, ""); + const raw = await readFile(path.join(GUIDES_DIR, name), "utf8"); + const fm = parseFrontmatter(raw); + entries.push({ slug, file: name, fm, raw }); + } + return entries; +} diff --git a/scripts/smoke-walkthroughs.mjs b/scripts/smoke-walkthroughs.mjs new file mode 100644 index 0000000..28c0125 --- /dev/null +++ b/scripts/smoke-walkthroughs.mjs @@ -0,0 +1,124 @@ +#!/usr/bin/env node +/** + * HTTP smoke: each published walkthrough_url returns 200 and embeds GA. + * Usage: node scripts/smoke-walkthroughs.mjs + * + * Env: + * GA_MEASUREMENT_ID default G-6GP64SX615 + * SMOKE_CONCURRENCY default 4 + * SMOKE_TIMEOUT_MS default 20000 + */ + +import { + JOHNA_KIWI_HOST, + loadGuides, + normalizeUrl, +} from "./lib/guides-md.mjs"; + +const GA_ID = process.env.GA_MEASUREMENT_ID || "G-6GP64SX615"; +const CONCURRENCY = Math.max(1, Number(process.env.SMOKE_CONCURRENCY || 4)); +const TIMEOUT_MS = Math.max(1000, Number(process.env.SMOKE_TIMEOUT_MS || 20000)); + +async function fetchText(url) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + const res = await fetch(url, { + redirect: "follow", + signal: controller.signal, + headers: { + "user-agent": "guides-walkthrough-smoke/1.0", + accept: "text/html,application/xhtml+xml", + }, + }); + const text = await res.text(); + return { status: res.status, finalUrl: res.url, text }; + } finally { + clearTimeout(timer); + } +} + +async function mapPool(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + + async function worker() { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + } + + await Promise.all( + Array.from({ length: Math.min(limit, items.length) }, () => worker()), + ); + return results; +} + +async function main() { + const guides = await loadGuides(); + const targets = []; + + for (const { slug, fm } of guides) { + if (!fm || fm.draft === true) continue; + if (String(fm.type || "").trim() !== "walkthrough") continue; + const url = normalizeUrl(fm.walkthrough_url); + if (!url) { + console.error(`${slug}: missing walkthrough_url`); + process.exitCode = 1; + continue; + } + if (!JOHNA_KIWI_HOST.test(url)) { + console.error(`${slug}: skip non-johna.kiwi url ${url}`); + process.exitCode = 1; + continue; + } + targets.push({ slug, url }); + } + + if (!targets.length) { + console.error("no walkthrough targets"); + process.exit(1); + } + + console.log( + `smoking ${targets.length} walkthroughs (ga=${GA_ID}, concurrency=${CONCURRENCY})`, + ); + + const failures = []; + + await mapPool(targets, CONCURRENCY, async ({ slug, url }) => { + try { + const { status, finalUrl, text } = await fetchText(url); + if (status < 200 || status >= 300) { + failures.push(`${slug}: HTTP ${status} for ${url} (final ${finalUrl})`); + console.log(`FAIL ${slug} ${status}`); + return; + } + if (!text.includes(GA_ID)) { + failures.push(`${slug}: missing GA id ${GA_ID} at ${url}`); + console.log(`FAIL ${slug} missing GA`); + return; + } + console.log(`ok ${slug}`); + } catch (err) { + failures.push(`${slug}: ${err.name || "Error"} ${err.message} (${url})`); + console.log(`FAIL ${slug} ${err.message}`); + } + }); + + if (failures.length) { + console.error(`\nwalkthrough smoke failed (${failures.length}):\n`); + for (const msg of failures) { + console.error(`- ${msg}`); + } + process.exit(1); + } + + console.log("\nwalkthrough smoke passed"); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});