initial scaffold: emdash catalog, helm chart, woodpecker pipeline, ddev
- app/: Emdash scaffold (Astro 6, node target) with cmses/plugins/pages collections - app/seed/seed.json: WordPress→Emdash parity for kotkanagrilli.fi (~30 entries) - Dockerfile + docker/entrypoint.sh: multi-stage build, single PVC at /app/state - deploy/helm/: chart mirroring emdash-kotkanagrilli (single-replica, sqlite, kotkan) - deploy/fleet-overlay/: HelmRelease/source/image-automation templates for anton-helm-workloads (staging + production) - .woodpecker/container.yaml: arm64 build, three OCI tags per push (immutable 0.1.<pipeline> + floating <branch> + <branch>-latest) - .ddev/: local dev with nginx proxy to emdash on :4321 - README/DEPLOYMENT/ARCHITECTURE/CLAUDE: docs covering the three-repo pipeline (cms-plugins + anton-helm-workloads + Gitea OCI registry)
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import node from "@astrojs/node";
|
||||
import react from "@astrojs/react";
|
||||
import { defineConfig, memoryCache } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
// Persistent state directory. Defaults to the working directory for dev/DDEV
|
||||
// (so data.db + uploads/ stay where you'd expect them). The k8s deploy sets
|
||||
// STATE_DIR=/app/state and mounts a PVC there so SQLite + uploads survive
|
||||
// pod replacement.
|
||||
const stateDir = process.env.STATE_DIR ?? ".";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
trailingSlash: "ignore",
|
||||
server: { host: true, port: 4321 },
|
||||
vite: {
|
||||
server: {
|
||||
// Dev runs behind DDEV's nginx (https://cms-plugins.ddev.site/).
|
||||
// Vite's host check must allow the public hostname.
|
||||
allowedHosts: ["cms-plugins.ddev.site", ".ddev.site"],
|
||||
},
|
||||
},
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: sqlite({ url: `file:${stateDir}/data.db` }),
|
||||
storage: local({
|
||||
directory: `${stateDir}/uploads`,
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
experimental: {
|
||||
cache: { provider: memoryCache() },
|
||||
},
|
||||
devToolbar: { enabled: false },
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "cms-plugins-app",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"emdash": {
|
||||
"label": "CMS plugins catalog",
|
||||
"seed": "seed/seed.json"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"start": "node ./dist/server/entry.mjs",
|
||||
"bootstrap": "emdash init",
|
||||
"typecheck": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/node": "^10.0.5",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"astro": "~6.2.2",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"emdash": "^0.10.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.7"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "CMS plugins catalog",
|
||||
"description": "WordPress → Emdash plugin parity catalog, seeded with kotkanagrilli.fi entries",
|
||||
"author": "oleks"
|
||||
},
|
||||
"settings": {
|
||||
"title": "CMS plugins catalog",
|
||||
"tagline": "WordPress → Emdash plugin parity, one entry at a time"
|
||||
},
|
||||
"collections": [
|
||||
{
|
||||
"slug": "cmses",
|
||||
"label": "CMS platforms",
|
||||
"labelSingular": "CMS platform",
|
||||
"urlPattern": "/cms/{slug}",
|
||||
"supports": ["search", "seo"],
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Name", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "website", "label": "Website URL", "type": "string" },
|
||||
{ "slug": "description", "label": "Description", "type": "text", "searchable": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "plugins",
|
||||
"label": "Plugins",
|
||||
"labelSingular": "Plugin",
|
||||
"urlPattern": "/plugins/{slug}",
|
||||
"supports": ["drafts", "search", "seo"],
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Plugin name", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "purpose", "label": "Purpose", "type": "text", "searchable": true,
|
||||
"description": "One-line description of what the plugin does." },
|
||||
{ "slug": "source_cms", "label": "Source CMS", "type": "string", "required": true, "searchable": true,
|
||||
"description": "Name of the CMS this plugin runs on today (e.g. WordPress)." },
|
||||
{ "slug": "target_cms", "label": "Target CMS", "type": "string", "searchable": true,
|
||||
"description": "Name of the CMS we're porting to, if applicable." },
|
||||
{ "slug": "category", "label": "Category", "type": "string",
|
||||
"description": "e-commerce, SEO, content, performance, etc." },
|
||||
{
|
||||
"slug": "status",
|
||||
"label": "Migration status",
|
||||
"type": "select",
|
||||
"required": true,
|
||||
"defaultValue": "proposed",
|
||||
"searchable": true,
|
||||
"options": [
|
||||
{ "value": "port", "label": "Port — reimplement on target" },
|
||||
{ "value": "built-in", "label": "Built-in — covered by target core" },
|
||||
{ "value": "saas", "label": "SaaS — replaced by external service" },
|
||||
{ "value": "drop", "label": "Drop — not needed" },
|
||||
{ "value": "gated", "label": "Gated — pending decision" },
|
||||
{ "value": "done", "label": "Done — ported and shipped" },
|
||||
{ "value": "proposed", "label": "Proposed — newly submitted" }
|
||||
]
|
||||
},
|
||||
{ "slug": "source_repo_url", "label": "Source repo URL", "type": "string" },
|
||||
{ "slug": "target_repo_url", "label": "Target repo URL", "type": "string" },
|
||||
{ "slug": "notes", "label": "Migration notes", "type": "portableText", "searchable": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"urlPattern": "/{slug}",
|
||||
"supports": ["drafts", "revisions", "search", "seo"],
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
|
||||
{ "slug": "excerpt", "label": "Excerpt", "type": "text" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"content": {
|
||||
"cmses": [
|
||||
{
|
||||
"slug": "wordpress",
|
||||
"data": {
|
||||
"title": "WordPress",
|
||||
"website": "https://wordpress.org",
|
||||
"description": "PHP/MariaDB CMS with the largest plugin ecosystem. Source platform for the kotkanagrilli.fi migration."
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "emdash",
|
||||
"data": {
|
||||
"title": "Emdash",
|
||||
"website": "https://github.com/emdash-cms/emdash",
|
||||
"description": "TypeScript/Astro/SQLite CMS positioned as a spiritual successor to WordPress. Target platform for the kotkanagrilli.fi migration."
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"slug": "about",
|
||||
"data": {
|
||||
"title": "About this catalog",
|
||||
"excerpt": "What this catalog is and how it's organized.",
|
||||
"content": [
|
||||
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "This catalog tracks plugins from one CMS and how they map to another. The seeded entries come from the kotkanagrilli.fi WordPress → Emdash migration, where ~30 third-party and custom plugins needed to be classified before the rebuild." }] },
|
||||
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Each entry has a migration status (port, built-in, saas, drop, gated, done, proposed) and free-form notes. New entries can be added through the Emdash admin at /_emdash/admin." }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"slug": "woocommerce",
|
||||
"data": {
|
||||
"title": "WooCommerce",
|
||||
"purpose": "E-commerce / order management for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated",
|
||||
"source_repo_url": "https://wordpress.org/plugins/woocommerce/",
|
||||
"notes": [
|
||||
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Fate depends on the kotkanagrilli WooCommerce decision gate: (a) keep WP on a subdomain for ordering, (b) build an Emdash orders+SumUp plugin, (c) outsource to an ordering SaaS." }] }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "woocommerce-payments",
|
||||
"data": {
|
||||
"title": "WooCommerce Payments",
|
||||
"purpose": "WC native payment gateway.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Tied to the WooCommerce decision." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "sumup-payment-gateway-for-woocommerce",
|
||||
"data": {
|
||||
"title": "SumUp Payment Gateway for WooCommerce",
|
||||
"purpose": "SumUp checkout integration for WC.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Tied to WC decision. SumUp REST API is callable directly from a custom Emdash plugin if option (b) is chosen." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "fluid-checkout",
|
||||
"data": {
|
||||
"title": "Fluid Checkout",
|
||||
"purpose": "Improved WooCommerce checkout UX.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "woo-checkout-field-editor-pro",
|
||||
"data": {
|
||||
"title": "Woo Checkout Field Editor Pro",
|
||||
"purpose": "Custom checkout fields for WooCommerce.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "woocommerce-customizer",
|
||||
"data": {
|
||||
"title": "WooCommerce Customizer",
|
||||
"purpose": "Miscellaneous WooCommerce tweaks.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "woc-open-close",
|
||||
"data": {
|
||||
"title": "WooCommerce Open/Close",
|
||||
"purpose": "Show/hide the store as open or closed.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Reimplement as a small Emdash plugin or Astro middleware reading a business-hours config. Not actually WC-coupled despite the name." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "google-listings-and-ads",
|
||||
"data": {
|
||||
"title": "Google Listings & Ads",
|
||||
"purpose": "Merchant Center feed for WooCommerce.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "marketing",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "polylang",
|
||||
"data": {
|
||||
"title": "Polylang",
|
||||
"purpose": "FI/EN translation for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "i18n",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Native Emdash collections + locale routing in Astro (src/pages/[lang]/...) replaces Polylang." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "connect-polylang-elementor",
|
||||
"data": {
|
||||
"title": "Connect Polylang for Elementor",
|
||||
"purpose": "Glue between Polylang and Elementor.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "i18n",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with Elementor." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "elementor",
|
||||
"data": {
|
||||
"title": "Elementor",
|
||||
"purpose": "Visual page builder for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "page-builder",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Rebuild pages as Astro components / Emdash portable-text blocks." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "cafe-eatery",
|
||||
"data": {
|
||||
"title": "Cafe Eatery",
|
||||
"purpose": "WordPress block theme (parent of the kotkanagrilli child theme).",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "theme",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Rebuild as an Astro theme (Phase 2)." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "autoptimize",
|
||||
"data": {
|
||||
"title": "Autoptimize",
|
||||
"purpose": "Asset minification + concatenation.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "performance",
|
||||
"status": "built-in",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro/Vite handles bundling natively." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "jetpack-boost",
|
||||
"data": {
|
||||
"title": "Jetpack Boost",
|
||||
"purpose": "Performance hints for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "performance",
|
||||
"status": "built-in",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro + Cloudflare handle this." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "ewww-image-optimizer",
|
||||
"data": {
|
||||
"title": "EWWW Image Optimizer",
|
||||
"purpose": "Image optimization for WordPress media.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "performance",
|
||||
"status": "built-in",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro Image integration + responsive styles already wired in astro.config.mjs." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "jetpack",
|
||||
"data": {
|
||||
"title": "Jetpack",
|
||||
"purpose": "Stats, hardening, related-posts, and more.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "ops",
|
||||
"status": "saas",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Replaced by Cloudflare analytics + WAF." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "redis-cache",
|
||||
"data": {
|
||||
"title": "Redis Object Cache",
|
||||
"purpose": "PHP object cache for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "performance",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "No PHP, no need; Astro + Emdash memoryCache provider already wired." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "wp-mail-smtp",
|
||||
"data": {
|
||||
"title": "WP Mail SMTP",
|
||||
"purpose": "Outbound mail via external SMTP.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "mail",
|
||||
"status": "saas",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "External SMTP configured at the platform layer, or via an Emdash mailer plugin." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "cb-change-mail-sender",
|
||||
"data": {
|
||||
"title": "CB Change Mail Sender",
|
||||
"purpose": "Override the sender address on outgoing mail.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "mail",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Handled by the same Emdash mailer plugin that replaces wp-mail-smtp." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "wpforms-lite",
|
||||
"data": {
|
||||
"title": "WPForms Lite",
|
||||
"purpose": "Contact form builder.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "forms",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Small Astro form action posting to a server endpoint, or a Cloudflare Worker on the CF target." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "wp-google-maps",
|
||||
"data": {
|
||||
"title": "WP Google Maps",
|
||||
"purpose": "Map embed for posts/pages.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "content",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static iframe embed in the theme — trivial port." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "sticky-chat-widget",
|
||||
"data": {
|
||||
"title": "Sticky Chat Widget",
|
||||
"purpose": "Floating WhatsApp/chat button.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "content",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static component in the theme — trivial port." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "shortcodes-ultimate",
|
||||
"data": {
|
||||
"title": "Shortcodes Ultimate",
|
||||
"purpose": "Shortcode library for the WP editor.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "content",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Astro components / portable-text block types replace it." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "tinymce-advanced",
|
||||
"data": {
|
||||
"title": "TinyMCE Advanced",
|
||||
"purpose": "Editor enhancement for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "editor",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Emdash has its own editor." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "lara-google-analytics",
|
||||
"data": {
|
||||
"title": "Lara Google Analytics",
|
||||
"purpose": "GA4 tag for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "analytics",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Static <script> in the theme head — trivial port." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "social-login",
|
||||
"data": {
|
||||
"title": "Social Login",
|
||||
"purpose": "OAuth login for WordPress.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "auth",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Drop unless actually needed; Emdash uses passkeys by default." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "twentytwentyfive",
|
||||
"data": {
|
||||
"title": "Twenty Twenty-Five",
|
||||
"purpose": "Default WordPress fallback theme.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "theme",
|
||||
"status": "drop"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "store-closed-button",
|
||||
"data": {
|
||||
"title": "store-closed-button (mu-plugin)",
|
||||
"purpose": "Toggle the store between open and closed.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "ops",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Becomes an Emdash plugin: business-hours config + an admin badge. Replaces store.py CLI." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "store-override-admin-bar",
|
||||
"data": {
|
||||
"title": "store-override-admin-bar (mu-plugin)",
|
||||
"purpose": "Admin-bar indicator for the open/close override.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "ops",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Folded into the same business-hours Emdash plugin." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "nginx-cache-indicator",
|
||||
"data": {
|
||||
"title": "nginx-cache-indicator (mu-plugin)",
|
||||
"purpose": "Visual UX hint for nginx cache hits.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "ops",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Folded into the same admin badge that store-closed-button becomes." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "out-of-stock-display",
|
||||
"data": {
|
||||
"title": "out-of-stock-display (mu-plugin)",
|
||||
"purpose": "WC UX fix for out-of-stock items.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "cart-fragment-cache-fix",
|
||||
"data": {
|
||||
"title": "cart-fragment-cache-fix (mu-plugin)",
|
||||
"purpose": "Fix for WC cart fragment caching.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "checkout-button-fix",
|
||||
"data": {
|
||||
"title": "checkout-button-fix (mu-plugin)",
|
||||
"purpose": "WC checkout button UX fix.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "checkout-local-pickup",
|
||||
"data": {
|
||||
"title": "checkout-local-pickup (mu-plugin)",
|
||||
"purpose": "Local-pickup behavior at checkout.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "sumup-payment-verify",
|
||||
"data": {
|
||||
"title": "sumup-payment-verify (mu-plugin)",
|
||||
"purpose": "Manual SumUp payment verification step.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "sumup-webhook-fix",
|
||||
"data": {
|
||||
"title": "sumup-webhook-fix (mu-plugin)",
|
||||
"purpose": "Patch for the SumUp webhook handler.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "e-commerce",
|
||||
"status": "gated"
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "load-elementor-fonts",
|
||||
"data": {
|
||||
"title": "load-elementor-fonts (mu-plugin)",
|
||||
"purpose": "Force-load Elementor fonts.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "theme",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with Elementor." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "remove-theme-ads",
|
||||
"data": {
|
||||
"title": "remove-theme-ads (mu-plugin)",
|
||||
"purpose": "Strip upsell ads from the Cafe Eatery theme.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "theme",
|
||||
"status": "drop",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Dropped together with the Cafe Eatery theme." }] }]
|
||||
}
|
||||
},
|
||||
{
|
||||
"slug": "security-hardening",
|
||||
"data": {
|
||||
"title": "security-hardening (mu-plugin)",
|
||||
"purpose": "Response headers + login lockdown.",
|
||||
"source_cms": "WordPress",
|
||||
"target_cms": "Emdash",
|
||||
"category": "security",
|
||||
"status": "port",
|
||||
"notes": [{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Cloudflare WAF rules cover the WAF half; Astro middleware sets response headers for the rest." }] }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
import StatusBadge from "./StatusBadge.astro";
|
||||
|
||||
interface Props {
|
||||
entry: {
|
||||
id: string;
|
||||
data: {
|
||||
title: string;
|
||||
purpose?: string | null;
|
||||
status?: string | null;
|
||||
source_cms?: string | null;
|
||||
target_cms?: string | null;
|
||||
};
|
||||
};
|
||||
}
|
||||
const { entry } = Astro.props;
|
||||
const d = entry.data;
|
||||
---
|
||||
<li class="plugin-card">
|
||||
<h3><a href={`/plugins/${entry.id}`}>{d.title}</a></h3>
|
||||
<div class="meta">
|
||||
<StatusBadge status={d.status} />
|
||||
{d.source_cms && <span>{d.source_cms} → {d.target_cms ?? "—"}</span>}
|
||||
</div>
|
||||
{d.purpose && <p>{d.purpose}</p>}
|
||||
</li>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
interface Props {
|
||||
status: string | null | undefined;
|
||||
}
|
||||
const { status } = Astro.props;
|
||||
const value = (status ?? "proposed").toLowerCase();
|
||||
const labels: Record<string, string> = {
|
||||
port: "Port",
|
||||
"built-in": "Built-in",
|
||||
drop: "Drop",
|
||||
saas: "SaaS",
|
||||
gated: "Gated",
|
||||
done: "Done",
|
||||
proposed: "Proposed",
|
||||
};
|
||||
const label = labels[value] ?? value;
|
||||
---
|
||||
<span class={`badge badge--${value}`}>{label}</span>
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
import StatusBadge from "./StatusBadge.astro";
|
||||
const items = [
|
||||
{ status: "port", desc: "must be reimplemented on the target CMS" },
|
||||
{ status: "built-in", desc: "covered by target CMS core / framework" },
|
||||
{ status: "saas", desc: "replaced by an external service" },
|
||||
{ status: "drop", desc: "not needed on the target CMS" },
|
||||
{ status: "gated", desc: "fate depends on an unresolved decision" },
|
||||
{ status: "done", desc: "ported and shipped" },
|
||||
{ status: "proposed", desc: "submitted for cataloging" },
|
||||
];
|
||||
---
|
||||
<div class="legend">
|
||||
{items.map((i) => (
|
||||
<span class="item"><StatusBadge status={i.status} /> {i.desc}</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
import { getSiteSettings } from "emdash";
|
||||
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
|
||||
import { createPublicPageContext } from "emdash/page";
|
||||
import "../styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
content?: { collection: string; id: string; slug?: string | null };
|
||||
}
|
||||
|
||||
const { title, description, content } = Astro.props;
|
||||
const settings = await getSiteSettings();
|
||||
const siteTitle = settings?.title ?? "CMS plugins catalog";
|
||||
const siteTagline = settings?.tagline ?? "WordPress → Emdash plugin parity catalog";
|
||||
|
||||
const fullTitle = title === siteTitle ? siteTitle : `${title} — ${siteTitle}`;
|
||||
const pageDescription = description ?? siteTagline;
|
||||
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: content ? "content" : "custom",
|
||||
pageType: "website",
|
||||
title: fullTitle,
|
||||
pageTitle: title,
|
||||
description: pageDescription,
|
||||
content,
|
||||
siteName: siteTitle,
|
||||
});
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={pageDescription} />
|
||||
<EmDashHead page={pageCtx} />
|
||||
</head>
|
||||
<body>
|
||||
<EmDashBodyStart page={pageCtx} />
|
||||
<header class="site">
|
||||
<div class="container row">
|
||||
<a href="/" class="brand">{siteTitle}</a>
|
||||
<nav>
|
||||
<a href="/">Plugins</a>
|
||||
<a href="/cms">By CMS</a>
|
||||
<a href="/about">About</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="site">
|
||||
<div class="container">
|
||||
<p>{siteTagline}</p>
|
||||
<p><a href="/_emdash/admin">Admin</a></p>
|
||||
</div>
|
||||
</footer>
|
||||
<EmDashBodyEnd page={pageCtx} />
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
<Base title="Not found">
|
||||
<h1>Not found</h1>
|
||||
<p>The page you're looking for doesn't exist. <a href="/">Back to the catalog.</a></p>
|
||||
</Base>
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
import { getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const slug = decodeSlug(Astro.params.slug);
|
||||
if (!slug) return Astro.redirect("/404");
|
||||
|
||||
const { entry, cacheHint } = await getEmDashEntry("pages", slug);
|
||||
if (!entry) return Astro.redirect("/404");
|
||||
Astro.cache.set(cacheHint);
|
||||
---
|
||||
<Base title={entry.data.title} description={entry.data.excerpt ?? undefined} content={{ collection: "pages", id: entry.data.id, slug: entry.id }}>
|
||||
<article class="detail">
|
||||
<h1>{entry.data.title}</h1>
|
||||
{entry.data.content && <div class="notes"><PortableText value={entry.data.content} /></div>}
|
||||
</article>
|
||||
</Base>
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry, decodeSlug } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import PluginCard from "../../components/PluginCard.astro";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const slug = decodeSlug(Astro.params.slug);
|
||||
if (!slug) return Astro.redirect("/404");
|
||||
|
||||
const { entry: cms, cacheHint } = await getEmDashEntry("cmses", slug);
|
||||
if (!cms) return Astro.redirect("/404");
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const { entries: all } = await getEmDashCollection("plugins", {
|
||||
orderBy: { title: "asc" },
|
||||
limit: 9999,
|
||||
});
|
||||
const cmsName = cms.data.title as string;
|
||||
const plugins = all.filter((p) => p.data.source_cms === cmsName);
|
||||
---
|
||||
<Base title={cmsName} description={cms.data.description ?? undefined}>
|
||||
<p class="crumbs"><a href="/cms">By CMS</a></p>
|
||||
<h1>{cmsName}</h1>
|
||||
{cms.data.website && <p><a href={cms.data.website}>{cms.data.website}</a></p>}
|
||||
{cms.data.description && <p>{cms.data.description}</p>}
|
||||
|
||||
<h2 style="margin-top:2rem">Plugins ({plugins.length})</h2>
|
||||
{plugins.length === 0 ? (
|
||||
<p class="empty">No plugins cataloged for this CMS yet.</p>
|
||||
) : (
|
||||
<ul class="plugin-grid">
|
||||
{plugins.map((entry) => <PluginCard entry={entry} />)}
|
||||
</ul>
|
||||
)}
|
||||
</Base>
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { entries: cmses, cacheHint } = await getEmDashCollection("cmses", {
|
||||
orderBy: { title: "asc" },
|
||||
});
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const { entries: plugins } = await getEmDashCollection("plugins", { limit: 9999 });
|
||||
const countBySource = new Map<string, number>();
|
||||
for (const p of plugins) {
|
||||
const s = p.data.source_cms;
|
||||
if (s) countBySource.set(s, (countBySource.get(s) ?? 0) + 1);
|
||||
}
|
||||
---
|
||||
<Base title="By CMS" description="Browse plugins grouped by source CMS.">
|
||||
<h1>By CMS</h1>
|
||||
<ul class="plugin-grid">
|
||||
{cmses.map((c) => (
|
||||
<li class="plugin-card">
|
||||
<h3><a href={`/cms/${c.id}`}>{c.data.title}</a></h3>
|
||||
<p class="meta">{countBySource.get(c.data.title) ?? 0} plugins</p>
|
||||
{c.data.description && <p>{c.data.description}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Base>
|
||||
@@ -0,0 +1,61 @@
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
import PluginCard from "../components/PluginCard.astro";
|
||||
import StatusLegend from "../components/StatusLegend.astro";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const { entries: plugins, cacheHint } = await getEmDashCollection("plugins", {
|
||||
orderBy: { title: "asc" },
|
||||
});
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
const q = (url.searchParams.get("q") ?? "").toLowerCase().trim();
|
||||
const statusFilter = url.searchParams.get("status") ?? "";
|
||||
const sourceFilter = url.searchParams.get("source") ?? "";
|
||||
|
||||
const sources = Array.from(new Set(plugins.map((p) => p.data.source_cms).filter(Boolean))).sort();
|
||||
const statuses = ["port", "built-in", "saas", "drop", "gated", "done", "proposed"];
|
||||
|
||||
const filtered = plugins.filter((p) => {
|
||||
const d = p.data;
|
||||
if (q && !(`${d.title} ${d.purpose ?? ""}`.toLowerCase().includes(q))) return false;
|
||||
if (statusFilter && d.status !== statusFilter) return false;
|
||||
if (sourceFilter && d.source_cms !== sourceFilter) return false;
|
||||
return true;
|
||||
});
|
||||
---
|
||||
<Base title="Plugins" description="Browse cataloged CMS plugins and their migration status.">
|
||||
<h1>Plugins</h1>
|
||||
<p>{plugins.length} entries cataloged. Filter by status, source CMS, or search by name.</p>
|
||||
|
||||
<StatusLegend />
|
||||
|
||||
<form class="toolbar" method="get">
|
||||
<input type="search" name="q" placeholder="Search…" value={q} />
|
||||
<select name="source">
|
||||
<option value="">All source CMSes</option>
|
||||
{sources.map((s) => (
|
||||
<option value={s} selected={s === sourceFilter}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="status">
|
||||
<option value="">All statuses</option>
|
||||
{statuses.map((s) => (
|
||||
<option value={s} selected={s === statusFilter}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit">Apply</button>
|
||||
{(q || statusFilter || sourceFilter) && <a href="/">Reset</a>}
|
||||
</form>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p class="empty">No plugins match the current filters.</p>
|
||||
) : (
|
||||
<ul class="plugin-grid">
|
||||
{filtered.map((entry) => <PluginCard entry={entry} />)}
|
||||
</ul>
|
||||
)}
|
||||
</Base>
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
import { getEmDashEntry, decodeSlug } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import StatusBadge from "../../components/StatusBadge.astro";
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
const slug = decodeSlug(Astro.params.slug);
|
||||
if (!slug) return Astro.redirect("/404");
|
||||
|
||||
const { entry, cacheHint } = await getEmDashEntry("plugins", slug);
|
||||
if (!entry) return Astro.redirect("/404");
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const d = entry.data;
|
||||
---
|
||||
<Base title={d.title} description={d.purpose ?? undefined} content={{ collection: "plugins", id: entry.data.id, slug: entry.id }}>
|
||||
<article class="detail">
|
||||
<header>
|
||||
<p class="crumbs">
|
||||
<a href="/">Plugins</a>
|
||||
{d.source_cms && (<> · <a href={`/cms/${d.source_cms.toLowerCase()}`}>{d.source_cms}</a></>)}
|
||||
</p>
|
||||
<h1>{d.title}</h1>
|
||||
<p>
|
||||
<StatusBadge status={d.status} />
|
||||
{d.source_cms && <span style="margin-left:.75rem;color:var(--c-muted)">{d.source_cms}{d.target_cms ? ` → ${d.target_cms}` : ""}</span>}
|
||||
</p>
|
||||
{d.purpose && <p style="font-size:1.05rem">{d.purpose}</p>}
|
||||
</header>
|
||||
|
||||
<dl>
|
||||
{d.category && (<><dt>Category</dt><dd>{d.category}</dd></>)}
|
||||
{d.source_cms && (<><dt>Source CMS</dt><dd>{d.source_cms}</dd></>)}
|
||||
{d.target_cms && (<><dt>Target CMS</dt><dd>{d.target_cms}</dd></>)}
|
||||
{d.source_repo_url && (<><dt>Source repo</dt><dd><a href={d.source_repo_url}>{d.source_repo_url}</a></dd></>)}
|
||||
{d.target_repo_url && (<><dt>Target repo</dt><dd><a href={d.target_repo_url}>{d.target_repo_url}</a></dd></>)}
|
||||
</dl>
|
||||
|
||||
{d.notes && (
|
||||
<section class="notes">
|
||||
<h2>Migration notes</h2>
|
||||
<PortableText value={d.notes} />
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
</Base>
|
||||
@@ -0,0 +1,146 @@
|
||||
:root {
|
||||
--c-bg: #ffffff;
|
||||
--c-bg-alt: #f6f7f9;
|
||||
--c-border: #e2e4e9;
|
||||
--c-heading: #0b1320;
|
||||
--c-body: #2a2f3a;
|
||||
--c-muted: #5b6371;
|
||||
--c-link: #2b5cff;
|
||||
--c-accent: #6b21a8;
|
||||
|
||||
/* status palette */
|
||||
--c-status-port: #2563eb;
|
||||
--c-status-builtin: #16a34a;
|
||||
--c-status-drop: #6b7280;
|
||||
--c-status-saas: #d97706;
|
||||
--c-status-gated: #b91c1c;
|
||||
--c-status-done: #047857;
|
||||
--c-status-proposed: #7c3aed;
|
||||
|
||||
--fs-base: 1rem;
|
||||
--fs-sm: 0.875rem;
|
||||
--fs-h1: clamp(1.75rem, 4vw, 2.5rem);
|
||||
--fs-h2: clamp(1.4rem, 3vw, 1.9rem);
|
||||
--fs-h3: 1.25rem;
|
||||
--fs-h4: 1.1rem;
|
||||
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html { font-size: 16px; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
color: var(--c-body);
|
||||
background: var(--c-bg);
|
||||
line-height: 1.55;
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 { color: var(--c-heading); margin: 0 0 0.5rem; line-height: 1.25; }
|
||||
h1 { font-size: var(--fs-h1); }
|
||||
h2 { font-size: var(--fs-h2); }
|
||||
h3 { font-size: var(--fs-h3); }
|
||||
h4 { font-size: var(--fs-h4); }
|
||||
|
||||
a { color: var(--c-link); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.container {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 0 clamp(1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
header.site {
|
||||
border-bottom: 1px solid var(--c-border);
|
||||
padding: 1rem 0;
|
||||
background: var(--c-bg);
|
||||
}
|
||||
header.site .row {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 1.5rem;
|
||||
}
|
||||
header.site .brand { font-weight: 700; color: var(--c-heading); }
|
||||
header.site .brand:hover { text-decoration: none; }
|
||||
header.site nav a { color: var(--c-muted); margin-left: 1.25rem; font-size: var(--fs-sm); }
|
||||
header.site nav a:hover { color: var(--c-heading); }
|
||||
|
||||
main { padding: clamp(1.5rem, 4vw, 3rem) 0; min-height: 60vh; }
|
||||
|
||||
footer.site {
|
||||
border-top: 1px solid var(--c-border);
|
||||
padding: 2rem 0;
|
||||
color: var(--c-muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
/* status badge */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
background: var(--c-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge--port { background: var(--c-status-port); }
|
||||
.badge--built-in { background: var(--c-status-builtin); }
|
||||
.badge--drop { background: var(--c-status-drop); }
|
||||
.badge--saas { background: var(--c-status-saas); }
|
||||
.badge--gated { background: var(--c-status-gated); }
|
||||
.badge--done { background: var(--c-status-done); }
|
||||
.badge--proposed { background: var(--c-status-proposed); }
|
||||
|
||||
/* plugin grid + cards */
|
||||
.toolbar {
|
||||
display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center;
|
||||
padding: 0.75rem 0 1.5rem;
|
||||
}
|
||||
.toolbar input[type="search"], .toolbar select {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
background: white;
|
||||
}
|
||||
.toolbar input[type="search"] { min-width: 240px; }
|
||||
|
||||
.plugin-grid {
|
||||
list-style: none; padding: 0; margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.plugin-card {
|
||||
border: 1px solid var(--c-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--c-bg);
|
||||
display: flex; flex-direction: column; gap: 0.5rem;
|
||||
}
|
||||
.plugin-card h3 { margin: 0; font-size: 1.05rem; }
|
||||
.plugin-card h3 a { color: var(--c-heading); }
|
||||
.plugin-card .meta { font-size: 0.78rem; color: var(--c-muted); display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.plugin-card p { margin: 0; font-size: 0.92rem; color: var(--c-body); }
|
||||
|
||||
/* detail page */
|
||||
.detail header { border-bottom: 1px solid var(--c-border); padding-bottom: 1rem; margin-bottom: 1.5rem; }
|
||||
.detail .crumbs { color: var(--c-muted); font-size: var(--fs-sm); margin-bottom: 0.25rem; }
|
||||
.detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1rem; margin: 0 0 1.5rem; }
|
||||
.detail dt { color: var(--c-muted); font-size: var(--fs-sm); }
|
||||
.detail dd { margin: 0; }
|
||||
.detail .notes { line-height: 1.65; }
|
||||
.detail .notes pre { background: var(--c-bg-alt); padding: 0.6rem 0.8rem; border-radius: var(--radius); overflow-x: auto; }
|
||||
|
||||
/* status legend */
|
||||
.legend { display: flex; flex-wrap: wrap; gap: 0.5rem; font-size: var(--fs-sm); color: var(--c-muted); padding: 0 0 1rem; }
|
||||
.legend .item { display: inline-flex; gap: 0.4rem; align-items: center; }
|
||||
|
||||
.empty { padding: 2rem; text-align: center; color: var(--c-muted); }
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user