diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b3118d2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,57 @@ +# Changelog + +All notable changes to git-cms are documented in this file. + +## [Unreleased] — git-stunts branch + +### Added + +- **Content Identity Policy (M1.1):** Canonical slug validation with NFKC normalization, reserved word rejection, and `CmsValidationError` contract (`ContentIdentityPolicy.js`) +- **State Machine (M1.2):** Explicit draft/published/unpublished/reverted states with enforced transition rules (`ContentStatePolicy.js`) +- **Admin UI overhaul:** Split/edit/preview markdown editor (via `marked`), autosave, toast notifications, skeleton loading, drag-and-drop file uploads, metadata trailer editor, keyboard shortcuts (`Cmd+S`, `Esc`), dark mode token system +- **DI seam in CmsService:** Optional `graph` constructor param enables `InMemoryGraphAdapter` injection for zero-subprocess tests +- **In-memory test adapter:** Unit tests run in ~11ms instead of hundreds of ms (no `git init`/subprocess forks) +- **E2E test separation:** Real-git smoke tests in `test/git-e2e.test.js`, excluded from default `test:local` runs +- **`test:git-e2e` script:** Run real-git integration tests independently +- **`@git-stunts/alfred` dependency:** Resilience policy library (wired but not yet integrated) +- **`@git-stunts/docker-guard` dependency:** Docker isolation helpers +- **ROADMAP.md:** M0–M6 milestone plan with blocking graph +- **Formal LaTeX ADR** (`docs/adr-tex-2/`) +- **Onboarding scripts:** `setup.sh`, `demo.sh`, `quickstart.sh` with interactive menus +- **Dependency integrity check:** `check-dependency-integrity.mjs` prevents `file:` path regressions + +### Changed + +- CmsService now uses `@git-stunts/git-warp` `GitGraphAdapter` and `@git-stunts/plumbing` `GitRepositoryService` instead of raw plumbing calls +- All `repo.updateRef()` calls routed through `CmsService._updateRef()` for DI/production dual-path +- `listArticles()` supports both plumbing (`for-each-ref`) and in-memory (`graph.listRefs`) paths +- Server endpoints return structured `{ code, field }` errors for validation failures +- Swapped all `file:` dependency paths to versioned npm ranges (PP3) + +### Fixed + +- Symlink traversal hardening in static file serving +- Slug canonicalization enforced at all API ingress points +- Admin UI API calls aligned with server contract (query params, response shapes) +- Server integration test environment stabilized for CI +- **(P1) Stored XSS via markdown preview:** Sanitize `marked.parse()` output with DOMPurify +- **(P1) Unpublish atomicity:** Reorder `unpublishArticle` so draft ref updates before published ref deletion +- **(P2) XSS via slug/badge rendering:** Use `textContent` and DOM APIs instead of `innerHTML` interpolation +- **(P2) SRI hashes:** Add `integrity` + `crossorigin` to marked and DOMPurify CDN script tags +- **(P2) Null guards:** `revertArticle` and `unpublishArticle` throw `no_draft` when draft ref is missing; `_resolveArticleState` throws `article_not_found` when both draft and published refs are missing +- **(P2) uploadAsset DI guard:** Throw `unsupported_in_di_mode` when `cas`/`vault` are null +- **(P2) Trailer key casing:** Use camelCase `updatedAt` in `unpublishArticle` and `revertArticle` (was lowercase `updatedat` which broke `renderBadges` lookups); destructure out decoded lowercase key before spreading to avoid `TrailerInvalidError` +- **(P2) XSS in `escAttr`:** Escape single quotes (`'` → `'`) to prevent injection into single-quoted attributes +- **(P2) Supply-chain hardening:** Vendor Open Props CSS files locally (`public/css/`) instead of `@import` from unpkg, eliminating CDN dependency and SRI gap +- **(P2) Monkey-patch safety:** E2E test restores `plumbing.execute` in `finally` block +- Unknown `draftStatus` in `resolveEffectiveState` now throws `unknown_status` instead of silently falling through to draft +- Removed double-canonicalization in `_resolveArticleState` +- Replaced sequential `readRef` loop with `Promise.all` in `listArticles` DI path +- Admin UI: fixed `removeTrailerRow` redundant positional removal, FileReader error handling, autosave-while-saving guard, Escape key scoped to editor panel, drag-and-drop scoped to drop zone +- Test cleanup: extracted `createTestCms()` helper, converted try/catch assertions to `.rejects.toMatchObject()`, added guard-path tests +- `TRANSITIONS` Sets now `Object.freeze`d to prevent mutation via `.add()`/`.delete()` +- DI-mode `_updateRef` now performs manual CAS check against `oldSha` +- Server tests assert setup call status codes to surface silent failures +- Vitest exclude glob `test/git-e2e*` → `test/git-e2e**` to cover future subdirectories + +[Unreleased]: https://github.com/flyingrobots/git-cms/compare/main...git-stunts diff --git a/README.md b/README.md index 940038c..509da87 100644 --- a/README.md +++ b/README.md @@ -24,10 +24,10 @@ npm run setup ### Try It Out ```bash -# Option 1: See a demo (recommended first time) +# Option 1: Guided walkthrough of key features npm run demo -# Option 2: Interactive menu +# Option 2: Interactive menu (start server, run tests, open shell) npm run quickstart # Option 3: Just start the server diff --git a/bin/git-cms.js b/bin/git-cms.js index 1599234..15de585 100755 --- a/bin/git-cms.js +++ b/bin/git-cms.js @@ -39,6 +39,24 @@ async function main() { console.log(`Published: ${res.sha} (${res.ref})`); break; } + case 'unpublish': { + const [rawSlug] = args; + if (!rawSlug) throw new Error('Usage: git cms unpublish '); + const slug = canonicalizeSlug(rawSlug); + + const res = await cms.unpublishArticle({ slug }); + console.log(`Unpublished: ${res.sha} (${res.ref})`); + break; + } + case 'revert': { + const [rawSlug] = args; + if (!rawSlug) throw new Error('Usage: git cms revert '); + const slug = canonicalizeSlug(rawSlug); + + const res = await cms.revertArticle({ slug }); + console.log(`Reverted: ${res.sha} (${res.ref})`); + break; + } case 'list': { const items = await cms.listArticles(); if (items.length === 0) console.log('No articles found'); @@ -65,7 +83,7 @@ async function main() { break; } default: - console.log('Usage: git cms '); + console.log('Usage: git cms '); process.exit(1); } } catch (err) { diff --git a/package-lock.json b/package-lock.json index 1d6338f..8aee0b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@git-stunts/alfred": "^0.10.3", "@git-stunts/docker-guard": "^0.1.0", "@git-stunts/git-cas": "^3.0.0", - "@git-stunts/git-warp": "^10.4.2", + "@git-stunts/git-warp": "^10.8.0", "@git-stunts/plumbing": "^2.8.0", "@git-stunts/trailer-codec": "^2.1.1", "@git-stunts/vault": "^1.0.0" @@ -590,9 +590,9 @@ } }, "node_modules/@git-stunts/git-warp": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-10.4.2.tgz", - "integrity": "sha512-omSbE7df+j89lJlrtjtBvG0f6rompdGLt4Xq2w+rHqIZpx+TxUlwzzndGII8RgVOzvUydk9T2LDbWTI0dCASMQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@git-stunts/git-warp/-/git-warp-10.8.0.tgz", + "integrity": "sha512-wlwez1Fae4GNxGRIF8HZHJMUFvPA8IHPH0ZWHa8jsYyqzU4563yARaaPYxIQUi079lO8u5HuH0y1EORIZpOLFw==", "license": "Apache-2.0", "dependencies": { "@git-stunts/alfred": "^0.4.0", diff --git a/package.json b/package.json index 45f0695..e3d02f5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "test": "./test/run-docker.sh", "test:setup": "./test/run-setup-tests.sh", "test:local": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "test:git-e2e": "vitest run --config vitest.e2e.config.js test/git-e2e.test.js" }, "author": "James Ross ", "license": "Apache-2.0", @@ -28,7 +29,7 @@ "@git-stunts/alfred": "^0.10.3", "@git-stunts/docker-guard": "^0.1.0", "@git-stunts/git-cas": "^3.0.0", - "@git-stunts/git-warp": "^10.4.2", + "@git-stunts/git-warp": "^10.8.0", "@git-stunts/plumbing": "^2.8.0", "@git-stunts/trailer-codec": "^2.1.1", "@git-stunts/vault": "^1.0.0" diff --git a/public/css/buttons.min.css b/public/css/buttons.min.css new file mode 100644 index 0000000..165a06d --- /dev/null +++ b/public/css/buttons.min.css @@ -0,0 +1 @@ +:where(html){--gray-0-hsl:210 17% 98%;--gray-1-hsl:210 17% 95%;--gray-2-hsl:210 16% 93%;--gray-3-hsl:210 14% 89%;--gray-4-hsl:210 14% 83%;--gray-5-hsl:210 11% 71%;--gray-6-hsl:210 7% 56%;--gray-7-hsl:210 9% 31%;--gray-8-hsl:210 10% 23%;--gray-9-hsl:210 11% 15%;--gray-10-hsl:214 14% 10%;--gray-11-hsl:216 16% 6%;--gray-12-hsl:210 40% 2%}:where(button,input[type=button],.btn){--_accent:initial;--_text:initial;--_size:initial;--_bg-light:#fff;--_bg-dark:var(--surface-3);--_bg:var(--_bg-light);--_border:var(--surface-3);--_highlight-size:0;--_highlight-light:hsl(var(--gray-5-hsl)/25%);--_highlight-dark:hsl(var(--gray-12-hsl)/25%);--_highlight:var(--_highlight-light);--_ink-shadow-light:0 1px 0 var(--gray-3);--_ink-shadow-dark:0 1px 0 var(--surface-1);--_ink-shadow:var(--_ink-shadow-light);--_icon-size:var(--size-relative-7);--_icon-color:var(--_accent,var(--link));-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;align-items:center;background:var(--_bg);border:var(--border-size-2) solid var(--_border);border-radius:var(--radius-2);box-shadow:var(--shadow-2),0 1px var(--surface-3),0 0 0 var(--_highlight-size) var(--_highlight);color:var(--_text);display:inline-flex;font-size:var(--_size);font-weight:var(--font-weight-7);gap:var(--size-2);justify-content:center;padding-block:.75ch;padding-inline:var(--size-relative-6);text-align:center;text-shadow:var(--_ink-shadow);transition:border-color .5s var(--ease-3);user-select:none}:where(button,input[type=submit],.btn){--_accent:initial;--_text:initial;--_size:initial;--_bg-light:#fff;--_bg-dark:var(--surface-3);--_bg:var(--_bg-light);--_border:var(--surface-3);--_highlight-size:0;--_highlight-light:hsl(var(--gray-5-hsl)/25%);--_highlight-dark:hsl(var(--gray-12-hsl)/25%);--_highlight:var(--_highlight-light);--_ink-shadow-light:0 1px 0 var(--gray-3);--_ink-shadow-dark:0 1px 0 var(--surface-1);--_ink-shadow:var(--_ink-shadow-light);--_icon-size:var(--size-relative-7);--_icon-color:var(--_accent,var(--link));-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;align-items:center;background:var(--_bg);border:var(--border-size-2) solid var(--_border);border-radius:var(--radius-2);box-shadow:var(--shadow-2),0 1px var(--surface-3),0 0 0 var(--_highlight-size) var(--_highlight);color:var(--_text);display:inline-flex;font-size:var(--_size);font-weight:var(--font-weight-7);gap:var(--size-2);justify-content:center;padding-block:.75ch;padding-inline:var(--size-relative-6);text-align:center;text-shadow:var(--_ink-shadow);transition:border-color .5s var(--ease-3);user-select:none}:where(button,input[type=reset],.btn){--_accent:initial;--_text:initial;--_size:initial;--_bg-light:#fff;--_bg-dark:var(--surface-3);--_bg:var(--_bg-light);--_border:var(--surface-3);--_highlight-size:0;--_highlight-light:hsl(var(--gray-5-hsl)/25%);--_highlight-dark:hsl(var(--gray-12-hsl)/25%);--_highlight:var(--_highlight-light);--_ink-shadow-light:0 1px 0 var(--gray-3);--_ink-shadow-dark:0 1px 0 var(--surface-1);--_ink-shadow:var(--_ink-shadow-light);--_icon-size:var(--size-relative-7);--_icon-color:var(--_accent,var(--link));-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;align-items:center;background:var(--_bg);border:var(--border-size-2) solid var(--_border);border-radius:var(--radius-2);box-shadow:var(--shadow-2),0 1px var(--surface-3),0 0 0 var(--_highlight-size) var(--_highlight);color:var(--_text);display:inline-flex;font-size:var(--_size);font-weight:var(--font-weight-7);gap:var(--size-2);justify-content:center;padding-block:.75ch;padding-inline:var(--size-relative-6);text-align:center;text-shadow:var(--_ink-shadow);transition:border-color .5s var(--ease-3);user-select:none}:where(input[type=file])::-webkit-file-upload-button,:where(input[type=file])::file-selector-button{--_accent:initial;--_text:initial;--_size:initial;--_bg-light:#fff;--_bg-dark:var(--surface-3);--_bg:var(--_bg-light);--_border:var(--surface-3);--_highlight-size:0;--_highlight-light:hsl(var(--gray-5-hsl)/25%);--_highlight-dark:hsl(var(--gray-12-hsl)/25%);--_highlight:var(--_highlight-light);--_ink-shadow-light:0 1px 0 var(--gray-3);--_ink-shadow-dark:0 1px 0 var(--surface-1);--_ink-shadow:var(--_ink-shadow-light);--_icon-size:var(--size-relative-7);--_icon-color:var(--_accent,var(--link));-webkit-tap-highlight-color:transparent;-webkit-touch-callout:none;align-items:center;background:var(--_bg);border:var(--border-size-2) solid var(--_border);border-radius:var(--radius-2);box-shadow:var(--shadow-2),0 1px var(--surface-3),0 0 0 var(--_highlight-size) var(--_highlight);color:var(--_text);display:inline-flex;font-size:var(--_size);font-weight:var(--font-weight-7);gap:var(--size-2);justify-content:center;padding-block:.75ch;padding-inline:var(--size-relative-6);text-align:center;text-shadow:var(--_ink-shadow);transition:border-color .5s var(--ease-3);user-select:none}@media (prefers-color-scheme:dark){:where(button,input[type=button],.btn){--_highlight:var(--_highlight-dark);--_bg:var(--_bg-dark);--_ink-shadow:var(--_ink-shadow-dark)}:where(button,input[type=submit],.btn){--_highlight:var(--_highlight-dark);--_bg:var(--_bg-dark);--_ink-shadow:var(--_ink-shadow-dark)}:where(button,input[type=reset],.btn){--_highlight:var(--_highlight-dark);--_bg:var(--_bg-dark);--_ink-shadow:var(--_ink-shadow-dark)}:where(input[type=file])::-webkit-file-upload-button,:where(input[type=file])::file-selector-button{--_highlight:var(--_highlight-dark);--_bg:var(--_bg-dark);--_ink-shadow:var(--_ink-shadow-dark)}}@media (prefers-reduced-motion:no-preference){:where(button,input[type=button],.btn){transition:border-color .5s var(--ease-3),box-shadow 145ms var(--ease-4),outline-offset 145ms var(--ease-4)}:where(button,input[type=submit],.btn){transition:border-color .5s var(--ease-3),box-shadow 145ms var(--ease-4),outline-offset 145ms var(--ease-4)}:where(button,input[type=reset],.btn){transition:border-color .5s var(--ease-3),box-shadow 145ms var(--ease-4),outline-offset 145ms var(--ease-4)}:where(input[type=file])::-webkit-file-upload-button,:where(input[type=file])::file-selector-button{transition:border-color .5s var(--ease-3),box-shadow 145ms var(--ease-4),outline-offset 145ms var(--ease-4)}}[disabled]:where(button,input[type=button],.btn){--_bg:none;--_text:var(--gray-6);box-shadow:var(--shadow-1);cursor:not-allowed}[disabled]:where(button,input[type=submit],.btn){--_bg:none;--_text:var(--gray-6);box-shadow:var(--shadow-1);cursor:not-allowed}[disabled]:where(button,input[type=reset],.btn){--_bg:none;--_text:var(--gray-6);box-shadow:var(--shadow-1);cursor:not-allowed}@media (prefers-color-scheme:dark){[disabled]:where(button,input[type=button],.btn){--_text:var(--gray-5)}[disabled]:where(button,input[type=submit],.btn){--_text:var(--gray-5)}[disabled]:where(button,input[type=reset],.btn){--_text:var(--gray-5)}}:where(button,input[type=button],.btn):where(:not(:active):hover){--_highlight-size:var(--size-2);transition-duration:.25s}:where(button,input[type=submit],.btn):where(:not(:active):hover){--_highlight-size:var(--size-2);transition-duration:.25s}:where(button,input[type=reset],.btn):where(:not(:active):hover){--_highlight-size:var(--size-2);transition-duration:.25s}:where(button,input[type=button],.btn)>:where(svg){block-size:var(--_icon-size);filter:drop-shadow(var(--_ink-shadow));flex-shrink:0;inline-size:var(--_icon-size)}:where(button,input[type=submit],.btn)>:where(svg){block-size:var(--_icon-size);filter:drop-shadow(var(--_ink-shadow));flex-shrink:0;inline-size:var(--_icon-size)}:where(button,input[type=reset],.btn)>:where(svg){block-size:var(--_icon-size);filter:drop-shadow(var(--_ink-shadow));flex-shrink:0;inline-size:var(--_icon-size)}:where(button,input[type=button],.btn)>:where(svg>*){stroke:var(--_icon-color);stroke-width:var(--border-size-2)}:where(button,input[type=submit],.btn)>:where(svg>*){stroke:var(--_icon-color);stroke-width:var(--border-size-2)}:where(button,input[type=reset],.btn)>:where(svg>*){stroke:var(--_icon-color);stroke-width:var(--border-size-2)}:where(a.btn){-webkit-text-decoration:none;text-decoration:none}:where([type=submit],form button:not([type],[disabled])){--_text:var(--_accent,var(--link))}:where([type=reset]){--_text:var(--red-6);--_border:var(--red-3)}:where([type=reset]):focus-visible{outline-color:var(--red-6)}@media (prefers-color-scheme:dark){:where([type=reset]){--_text:var(--red-2);--_border:var(--surface-3)}}:where(form button:not([type]),[type=submit],[type=reset]):hover:not([disabled]){--_border:currentColor}:where(form button:not([type]),[type=submit],[type=reset]):focus-visible:not([disabled]){--_border:currentColor}:where(input[type=file]){align-self:flex-start;border:var(--border-size-1) solid var(--surface-2);border-radius:var(--radius-2);box-shadow:var(--inner-shadow-4);color:var(--text-2);cursor:auto;max-inline-size:100%;padding:0}:where(input[type=file])::-webkit-file-upload-button,:where(input[type=file])::file-selector-button{cursor:pointer;margin-inline-end:var(--size-relative-6)}@media (prefers-color-scheme:dark){:where([disabled]),:where([type=reset]),:where([type=submit]),:where(form button:not([type=button])){--_bg:var(--surface-1)}} \ No newline at end of file diff --git a/public/css/normalize.min.css b/public/css/normalize.min.css new file mode 100644 index 0000000..c685c33 --- /dev/null +++ b/public/css/normalize.min.css @@ -0,0 +1 @@ +:where(html){--csstools-color-scheme--light:initial;--link:var(--indigo-7);--link-visited:var(--purple-7);--text-1:var(--gray-12);--text-2:var(--gray-7);--surface-1:var(--gray-0);--surface-2:var(--gray-2);--surface-3:var(--gray-3);--surface-4:var(--gray-4);--scrollthumb-color:var(--gray-7);-webkit-text-size-adjust:none;accent-color:var(--brand,var(--link));background-color:var(--surface-1);block-size:100%;caret-color:var(--brand,var(--link));color:var(--text-1);color-scheme:light;font-family:var(--font-system-ui);line-height:var(--font-lineheight-3);scrollbar-color:var(--scrollthumb-color) transparent}@media (dynamic-range:high) or (color-gamut:p3){@supports (color:color(display-p3 0 0 0)){:where(html){--link:color(display-p3 .1 .39 1);--link-visited:color(display-p3 .6 .2 1)}}}:where(html) :where(dialog){background-color:var(--surface-1)}:where(html) :where(button,.btn){--_highlight:var(--_highlight-light);--_bg:var(--_bg-light);--_ink-shadow:var(--_ink-shadow-light)}:where(html) :where(button,.btn) :where([type=reset]){--_text:var(--red-6);--_border:var(--red-3)}:where(html) [disabled]:where(button,input[type=button],.btn){--_text:var(--gray-6)}:where(html) [disabled]:where(button,input[type=submit],.btn){--_text:var(--gray-6)}:where(html) [disabled]:where(button,input[type=reset],.btn){--_text:var(--gray-6)}:where(html) :where(textarea,select,input:not([type=button],[type=submit],[type=reset])){background-color:var(--surface-2)}@media (prefers-color-scheme:dark){:where(html){--csstools-color-scheme--light: ;--link:var(--indigo-3);--link-visited:var(--purple-3);--text-1:var(--gray-1);--text-2:var(--gray-4);--surface-1:var(--gray-9);--surface-2:var(--gray-8);--surface-3:var(--gray-7);--surface-4:var(--gray-6);--scrollthumb-color:var(--gray-6);--shadow-strength:10%;--shadow-color:220 40% 2%;color-scheme:dark}}:where(a[href]){color:var(--brand,var(--link))}:where(a[href]):where(:visited){color:var(--link-visited)}:focus-visible{outline-color:var(--brand,var(--link))}*,:after,:before{box-sizing:border-box}:where(:not(dialog)){margin:0}:where(:not(fieldset,progress,meter)){background-origin:border-box;background-repeat:no-repeat;border-style:solid;border-width:0}@media (prefers-reduced-motion:no-preference){:where(html){scroll-behavior:smooth}}@media (prefers-reduced-motion:no-preference){:where(:focus-visible){transition:outline-offset 145ms var(--ease-2)}:where(:not(:active):focus-visible){transition-duration:.25s}}:where(:not(:active):focus-visible){outline-offset:5px}:where(body){min-block-size:100%}:where(h1,h2,h3,h4,h5,h6){text-wrap:balance;font-weight:var(--font-weight-9);line-height:var(--font-lineheight-1)}:where(h1){font-size:var(--font-size-8);max-inline-size:var(--size-header-1)}:where(h2){font-size:var(--font-size-6);max-inline-size:var(--size-header-2)}:where(h3){font-size:var(--font-size-5)}:where(h4){font-size:var(--font-size-4)}:where(h5){font-size:var(--font-size-3)}:where(h3,h4,h5,h6,dt){max-inline-size:var(--size-header-3)}:where(p,ul,ol,dl,h6){font-size:var(--font-size-2)}:where(a,u,ins,abbr){text-underline-offset:1px}@supports (-moz-appearance:none){:where(a,u,ins,abbr){text-underline-offset:2px}}:where(a[href],area,button,input:not([type=text],[type=email],[type=number],[type=password],[type=""],[type=tel],[type=url]),label[for],select,summary,[tabindex]:not([tabindex*="-"],pre)){cursor:pointer}:where(a[href],area,button,input,label[for],select,summary,textarea,[tabindex]:not([tabindex*="-"])){-webkit-tap-highlight-color:transparent;touch-action:manipulation}:where(a):where([href]){text-decoration-color:var(--indigo-2)}:where(a):where([href]):where(:visited){text-decoration-color:var(--purple-2)}:where(a):where(:not(:hover)){text-decoration:inherit}:where(img,svg,video,canvas,audio,iframe,embed,object){display:block}:where(img,svg,video){block-size:auto;max-inline-size:100%}:where(input,button,textarea,select),:where(input[type=file])::-webkit-file-upload-button{color:inherit;font:inherit;font-size:inherit;letter-spacing:inherit}::placeholder{color:var(--gray-7);opacity:.75}:where(input:not([type=range]),textarea){padding-block:var(--size-1);padding-inline:var(--size-2)}:where(select){field-sizing:content;padding-block:.75ch;padding-inline:var(--size-relative-4) 0}:where(textarea,select,input:not([type=button],[type=submit],[type=reset])){background-color:var(--surface-2);border-radius:var(--radius-2)}:where(textarea){field-sizing:content;min-block-size:2lh;min-inline-size:var(--size-content-1);resize:vertical}:where(input[type=checkbox],input[type=radio]){block-size:var(--size-3);inline-size:var(--size-3)}:where(svg:not([width])){inline-size:var(--size-10)}:where(code,kbd,samp,pre){font-family:var(--font-monospace-code),monospace}:where(:not(pre)>code,kbd){white-space:nowrap}:where(pre){direction:ltr;max-inline-size:max-content;min-inline-size:0;white-space:pre;writing-mode:lr}:where(:not(pre)>code){background:var(--surface-2);border-radius:var(--radius-2);padding:var(--size-1) var(--size-2);writing-mode:lr}:where(kbd,var){border-color:var(--surface-4);border-radius:var(--radius-2);border-width:var(--border-size-1);padding:var(--size-1) var(--size-2)}:where(mark){border-radius:var(--radius-2);padding-inline:var(--size-1)}:where(ol,ul){padding-inline-start:var(--size-8)}:where(li){padding-inline-start:var(--size-2)}:where(li,dd,figcaption){max-inline-size:var(--size-content-2)}:where(p){text-wrap:pretty;max-inline-size:var(--size-content-3)}:where(dt,summary){font-weight:var(--font-weight-7)}:where(dt:not(:first-of-type)){margin-block-start:var(--size-5)}:where(small){font-size:max(.5em,var(--font-size-0));max-inline-size:var(--size-content-1)}:where(hr){background-color:var(--surface-3);height:var(--border-size-2);margin-block:var(--size-fluid-5)}:where(figure){display:grid;gap:var(--size-2);place-items:center}:where(figure)>:where(figcaption){text-wrap:balance;font-size:var(--font-size-1)}:where(blockquote,:not(blockquote)>cite){border-inline-start-width:var(--border-size-3)}:where(blockquote){display:grid;gap:var(--size-3);max-inline-size:var(--size-content-2);padding-block:var(--size-3);padding-inline:var(--size-4)}:where(:not(blockquote)>cite){padding-inline-start:var(--size-2)}:where(summary){background:var(--surface-3);border-radius:var(--radius-2);margin:calc(var(--size-2)*-1) calc(var(--size-3)*-1);padding:var(--size-2) var(--size-3)}:where(details){background:var(--surface-2);border-radius:var(--radius-2);padding-block:var(--size-2);padding-inline:var(--size-3)}:where(details[open]>summary){border-end-end-radius:0;border-end-start-radius:0;margin-bottom:var(--size-2)}:where(fieldset){border:var(--border-size-1) solid var(--surface-4);border-radius:var(--radius-2)}:where(del){background:var(--red-9);color:var(--red-2)}:where(ins){background:var(--green-9);color:var(--green-1)}:where(abbr){text-decoration-color:var(--blue-5)}:where(dialog){background-color:var(--surface-1);border-radius:var(--radius-3);box-shadow:var(--shadow-6);color:inherit}:where(menu){display:flex;gap:var(--size-3);padding-inline-start:0}:where(sup){font-size:.5em}:where(table){--nice-inner-radius:calc(var(--radius-3) - 2px);background:var(--surface-2);border:1px solid var(--surface-2);border-radius:var(--radius-3);width:fit-content}:where(table:not(:has(tfoot)) tr:last-child td:first-child){border-end-start-radius:var(--nice-inner-radius)}:where(table:not(:has(tfoot)) tr:last-child td:last-child){border-end-end-radius:var(--nice-inner-radius)}:where(table thead tr:first-child th:first-child){border-start-start-radius:var(--nice-inner-radius)}:where(table thead tr:first-child th:last-child){border-start-end-radius:var(--nice-inner-radius)}:where(tfoot tr:last-child th:first-of-type){border-end-start-radius:var(--nice-inner-radius)}:where(tfoot tr:last-child td:first-of-type){border-end-start-radius:var(--nice-inner-radius)}:where(tfoot tr:last-child th:last-of-type){border-end-end-radius:var(--nice-inner-radius)}:where(tfoot tr:last-child td:last-of-type){border-end-end-radius:var(--nice-inner-radius)}:where(th){background-color:var(--surface-2);color:var(--text-1)}:where(table a:not(.does-not-exist):focus-visible){outline-offset:-2px}:where(table button:not(.does-not-exist):focus-visible){outline-offset:-2px}:where(table [contenteditable]:focus-visible){outline-offset:-2px}:where(td){text-wrap:pretty;background:var(--surface-1);max-inline-size:var(--size-content-2)}:where(td,th){padding:var(--size-2);text-align:left}:where(td:not([align])){text-align:center}:where(th:not([align])){text-align:center}:where(thead){border-collapse:collapse}:where(table tr:hover td),:where(tbody tr:nth-child(2n):hover td){background-color:var(--surface-3)}:where(table>caption){margin:var(--size-3)}:where(tfoot button){padding-block:var(--size-1);padding-inline:var(--size-3)}@media (prefers-color-scheme:dark){:where(textarea,select,input:not([type=button],[type=submit],[type=reset])){background-color:#171a1c}:where(dialog){background-color:var(--surface-2)}::placeholder{color:var(--gray-6)}} \ No newline at end of file diff --git a/public/css/open-props.min.css b/public/css/open-props.min.css new file mode 100644 index 0000000..96deab8 --- /dev/null +++ b/public/css/open-props.min.css @@ -0,0 +1 @@ +:where(html){--font-system-ui:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Cantarell,Noto Sans,sans-serif;--font-transitional:Charter,Bitstream Charter,Sitka Text,Cambria,serif;--font-old-style:Iowan Old Style,Palatino Linotype,URW Palladio L,P052,serif;--font-humanist:Seravek,Gill Sans Nova,Ubuntu,Calibri,DejaVu Sans,source-sans-pro,sans-serif;--font-geometric-humanist:Avenir,Montserrat,Corbel,URW Gothic,source-sans-pro,sans-serif;--font-classical-humanist:Optima,Candara,Noto Sans,source-sans-pro,sans-serif;--font-neo-grotesque:Inter,Roboto,Helvetica Neue,Arial Nova,Nimbus Sans,Arial,sans-serif;--font-monospace-slab-serif:Nimbus Mono PS,Courier New,monospace;--font-monospace-code:Dank Mono,Operator Mono,Inconsolata,Fira Mono,ui-monospace,SF Mono,Monaco,Droid Sans Mono,Source Code Pro,Cascadia Code,Menlo,Consolas,DejaVu Sans Mono,monospace;--font-industrial:Bahnschrift,DIN Alternate,Franklin Gothic Medium,Nimbus Sans Narrow,sans-serif-condensed,sans-serif;--font-rounded-sans:ui-rounded,Hiragino Maru Gothic ProN,Quicksand,Comfortaa,Manjari,Arial Rounded MT,Arial Rounded MT Bold,Calibri,source-sans-pro,sans-serif;--font-slab-serif:Rockwell,Rockwell Nova,Roboto Slab,DejaVu Serif,Sitka Small,serif;--font-antique:Superclarendon,Bookman Old Style,URW Bookman,URW Bookman L,Georgia Pro,Georgia,serif;--font-didone:Didot,Bodoni MT,Noto Serif Display,URW Palladio L,P052,Sylfaen,serif;--font-handwritten:Segoe Print,Bradley Hand,Chilanka,TSCu_Comic,casual,cursive;--font-sans:var(--font-system-ui);--font-serif:ui-serif,serif;--font-mono:var(--font-monospace-code);--font-weight-1:100;--font-weight-2:200;--font-weight-3:300;--font-weight-4:400;--font-weight-5:500;--font-weight-6:600;--font-weight-7:700;--font-weight-8:800;--font-weight-9:900;--font-lineheight-00:.95;--font-lineheight-0:1.1;--font-lineheight-1:1.25;--font-lineheight-2:1.375;--font-lineheight-3:1.5;--font-lineheight-4:1.75;--font-lineheight-5:2;--font-letterspacing-0:-.05em;--font-letterspacing-1:.025em;--font-letterspacing-2:.050em;--font-letterspacing-3:.075em;--font-letterspacing-4:.150em;--font-letterspacing-5:.500em;--font-letterspacing-6:.750em;--font-letterspacing-7:1em;--font-size-00:.5rem;--font-size-0:.75rem;--font-size-1:1rem;--font-size-2:1.1rem;--font-size-3:1.25rem;--font-size-4:1.5rem;--font-size-5:2rem;--font-size-6:2.5rem;--font-size-7:3rem;--font-size-8:3.5rem;--font-size-fluid-0:max(.75rem,min(2vw,1rem));--font-size-fluid-1:max(1rem,min(4vw,1.5rem));--font-size-fluid-2:max(1.5rem,min(6vw,2.5rem));--font-size-fluid-3:max(2rem,min(9vw,3.5rem));--size-000:-.5rem;--size-00:-.25rem;--size-1:.25rem;--size-2:.5rem;--size-3:1rem;--size-4:1.25rem;--size-5:1.5rem;--size-6:1.75rem;--size-7:2rem;--size-8:3rem;--size-9:4rem;--size-10:5rem;--size-11:7.5rem;--size-12:10rem;--size-13:15rem;--size-14:20rem;--size-15:30rem;--size-px-000:-8px;--size-px-00:-4px;--size-px-1:4px;--size-px-2:8px;--size-px-3:16px;--size-px-4:20px;--size-px-5:24px;--size-px-6:28px;--size-px-7:32px;--size-px-8:48px;--size-px-9:64px;--size-px-10:80px;--size-px-11:120px;--size-px-12:160px;--size-px-13:240px;--size-px-14:320px;--size-px-15:480px;--size-fluid-1:max(.5rem,min(1vw,1rem));--size-fluid-2:max(1rem,min(2vw,1.5rem));--size-fluid-3:max(1.5rem,min(3vw,2rem));--size-fluid-4:max(2rem,min(4vw,3rem));--size-fluid-5:max(4rem,min(5vw,5rem));--size-fluid-6:max(5rem,min(7vw,7.5rem));--size-fluid-7:max(7.5rem,min(10vw,10rem));--size-fluid-8:max(10rem,min(20vw,15rem));--size-fluid-9:max(15rem,min(30vw,20rem));--size-fluid-10:max(20rem,min(40vw,30rem));--size-content-1:20ch;--size-content-2:45ch;--size-content-3:60ch;--size-header-1:20ch;--size-header-2:25ch;--size-header-3:35ch;--size-xxs:240px;--size-xs:360px;--size-sm:480px;--size-md:768px;--size-lg:1024px;--size-xl:1440px;--size-xxl:1920px;--size-relative-000:-.5ch;--size-relative-00:-.25ch;--size-relative-1:.25ch;--size-relative-2:.5ch;--size-relative-3:1ch;--size-relative-4:1.25ch;--size-relative-5:1.5ch;--size-relative-6:1.75ch;--size-relative-7:2ch;--size-relative-8:3ch;--size-relative-9:4ch;--size-relative-10:5ch;--size-relative-11:7.5ch;--size-relative-12:10ch;--size-relative-13:15ch;--size-relative-14:20ch;--size-relative-15:30ch;--ease-1:cubic-bezier(.25,0,.5,1);--ease-2:cubic-bezier(.25,0,.4,1);--ease-3:cubic-bezier(.25,0,.3,1);--ease-4:cubic-bezier(.25,0,.2,1);--ease-5:cubic-bezier(.25,0,.1,1);--ease-in-1:cubic-bezier(.25,0,1,1);--ease-in-2:cubic-bezier(.50,0,1,1);--ease-in-3:cubic-bezier(.70,0,1,1);--ease-in-4:cubic-bezier(.90,0,1,1);--ease-in-5:cubic-bezier(1,0,1,1);--ease-out-1:cubic-bezier(0,0,.75,1);--ease-out-2:cubic-bezier(0,0,.50,1);--ease-out-3:cubic-bezier(0,0,.3,1);--ease-out-4:cubic-bezier(0,0,.1,1);--ease-out-5:cubic-bezier(0,0,0,1);--ease-in-out-1:cubic-bezier(.1,0,.9,1);--ease-in-out-2:cubic-bezier(.3,0,.7,1);--ease-in-out-3:cubic-bezier(.5,0,.5,1);--ease-in-out-4:cubic-bezier(.7,0,.3,1);--ease-in-out-5:cubic-bezier(.9,0,.1,1);--ease-elastic-out-1:cubic-bezier(.5,.75,.75,1.25);--ease-elastic-out-2:cubic-bezier(.5,1,.75,1.25);--ease-elastic-out-3:cubic-bezier(.5,1.25,.75,1.25);--ease-elastic-out-4:cubic-bezier(.5,1.5,.75,1.25);--ease-elastic-out-5:cubic-bezier(.5,1.75,.75,1.25);--ease-elastic-in-1:cubic-bezier(.5,-0.25,.75,1);--ease-elastic-in-2:cubic-bezier(.5,-0.50,.75,1);--ease-elastic-in-3:cubic-bezier(.5,-0.75,.75,1);--ease-elastic-in-4:cubic-bezier(.5,-1.00,.75,1);--ease-elastic-in-5:cubic-bezier(.5,-1.25,.75,1);--ease-elastic-in-out-1:cubic-bezier(.5,-.1,.1,1.5);--ease-elastic-in-out-2:cubic-bezier(.5,-.3,.1,1.5);--ease-elastic-in-out-3:cubic-bezier(.5,-.5,.1,1.5);--ease-elastic-in-out-4:cubic-bezier(.5,-.7,.1,1.5);--ease-elastic-in-out-5:cubic-bezier(.5,-.9,.1,1.5);--ease-step-1:steps(2);--ease-step-2:steps(3);--ease-step-3:steps(4);--ease-step-4:steps(7);--ease-step-5:steps(10);--ease-elastic-1:var(--ease-elastic-out-1);--ease-elastic-2:var(--ease-elastic-out-2);--ease-elastic-3:var(--ease-elastic-out-3);--ease-elastic-4:var(--ease-elastic-out-4);--ease-elastic-5:var(--ease-elastic-out-5);--ease-squish-1:var(--ease-elastic-in-out-1);--ease-squish-2:var(--ease-elastic-in-out-2);--ease-squish-3:var(--ease-elastic-in-out-3);--ease-squish-4:var(--ease-elastic-in-out-4);--ease-squish-5:var(--ease-elastic-in-out-5);--ease-spring-1:linear(0,0.006,0.025 2.8%,0.101 6.1%,0.539 18.9%,0.721 25.3%,0.849 31.5%,0.937 38.1%,0.968 41.8%,0.991 45.7%,1.006 50.1%,1.015 55%,1.017 63.9%,1.001);--ease-spring-2:linear(0,0.007,0.029 2.2%,0.118 4.7%,0.625 14.4%,0.826 19%,0.902,0.962,1.008 26.1%,1.041 28.7%,1.064 32.1%,1.07 36%,1.061 40.5%,1.015 53.4%,0.999 61.6%,0.995 71.2%,1);--ease-spring-3:linear(0,0.009,0.035 2.1%,0.141 4.4%,0.723 12.9%,0.938 16.7%,1.017,1.077,1.121,1.149 24.3%,1.159,1.163,1.161,1.154 29.9%,1.129 32.8%,1.051 39.6%,1.017 43.1%,0.991,0.977 51%,0.974 53.8%,0.975 57.1%,0.997 69.8%,1.003 76.9%,1);--ease-spring-4:linear(0,0.009,0.037 1.7%,0.153 3.6%,0.776 10.3%,1.001,1.142 16%,1.185,1.209 19%,1.215 19.9% 20.8%,1.199,1.165 25%,1.056 30.3%,1.008 33%,0.973,0.955 39.2%,0.953 41.1%,0.957 43.3%,0.998 53.3%,1.009 59.1% 63.7%,0.998 78.9%,1);--ease-spring-5:linear(0,0.01,0.04 1.6%,0.161 3.3%,0.816 9.4%,1.046,1.189 14.4%,1.231,1.254 17%,1.259,1.257 18.6%,1.236,1.194 22.3%,1.057 27%,0.999 29.4%,0.955 32.1%,0.942,0.935 34.9%,0.933,0.939 38.4%,1 47.3%,1.011,1.017 52.6%,1.016 56.4%,1 65.2%,0.996 70.2%,1.001 87.2%,1);--ease-bounce-1:linear(0,0.004,0.016,0.035,0.063,0.098,0.141,0.191,0.25,0.316,0.391 36.8%,0.563,0.766,1 58.8%,0.946,0.908 69.1%,0.895,0.885,0.879,0.878,0.879,0.885,0.895,0.908 89.7%,0.946,1);--ease-bounce-2:linear(0,0.004,0.016,0.035,0.063,0.098,0.141 15.1%,0.25,0.391,0.562,0.765,1,0.892 45.2%,0.849,0.815,0.788,0.769,0.757,0.753,0.757,0.769,0.788,0.815,0.85,0.892 75.2%,1 80.2%,0.973,0.954,0.943,0.939,0.943,0.954,0.973,1);--ease-bounce-3:linear(0,0.004,0.016,0.035,0.062,0.098,0.141 11.4%,0.25,0.39,0.562,0.764,1 30.3%,0.847 34.8%,0.787,0.737,0.699,0.672,0.655,0.65,0.656,0.672,0.699,0.738,0.787,0.847 61.7%,1 66.2%,0.946,0.908,0.885 74.2%,0.879,0.878,0.879,0.885 79.5%,0.908,0.946,1 87.4%,0.981,0.968,0.96,0.957,0.96,0.968,0.981,1);--ease-bounce-4:linear(0,0.004,0.016 3%,0.062,0.141,0.25,0.391,0.562 18.2%,1 24.3%,0.81,0.676 32.3%,0.629,0.595,0.575,0.568,0.575,0.595,0.629,0.676 48.2%,0.811,1 56.2%,0.918,0.86,0.825,0.814,0.825,0.86,0.918,1 77.2%,0.94 80.6%,0.925,0.92,0.925,0.94 87.5%,1 90.9%,0.974,0.965,0.974,1);--ease-bounce-5:linear(0,0.004,0.016 2.5%,0.063,0.141,0.25 10.1%,0.562,1 20.2%,0.783,0.627,0.534 30.9%,0.511,0.503,0.511,0.534 38%,0.627,0.782,1 48.7%,0.892,0.815,0.769 56.3%,0.757,0.753,0.757,0.769 61.3%,0.815,0.892,1 68.8%,0.908 72.4%,0.885,0.878,0.885,0.908 79.4%,1 83%,0.954 85.5%,0.943,0.939,0.943,0.954 90.5%,1 93%,0.977,0.97,0.977,1);--ease-circ-in:cubic-bezier(.6,.04,.98,.335);--ease-circ-in-out:cubic-bezier(.785,.135,.15,.86);--ease-circ-out:cubic-bezier(.075,.82,.165,1);--ease-cubic-in:cubic-bezier(.55,.055,.675,.19);--ease-cubic-in-out:cubic-bezier(.645,.045,.355,1);--ease-cubic-out:cubic-bezier(.215,.61,.355,1);--ease-expo-in:cubic-bezier(.95,.05,.795,.035);--ease-expo-in-out:cubic-bezier(1,0,0,1);--ease-expo-out:cubic-bezier(.19,1,.22,1);--ease-quad-in:cubic-bezier(.55,.085,.68,.53);--ease-quad-in-out:cubic-bezier(.455,.03,.515,.955);--ease-quad-out:cubic-bezier(.25,.46,.45,.94);--ease-quart-in:cubic-bezier(.895,.03,.685,.22);--ease-quart-in-out:cubic-bezier(.77,0,.175,1);--ease-quart-out:cubic-bezier(.165,.84,.44,1);--ease-quint-in:cubic-bezier(.755,.05,.855,.06);--ease-quint-in-out:cubic-bezier(.86,0,.07,1);--ease-quint-out:cubic-bezier(.23,1,.32,1);--ease-sine-in:cubic-bezier(.47,0,.745,.715);--ease-sine-in-out:cubic-bezier(.445,.05,.55,.95);--ease-sine-out:cubic-bezier(.39,.575,.565,1);--layer-1:1;--layer-2:2;--layer-3:3;--layer-4:4;--layer-5:5;--layer-important:2147483647;--shadow-color:220 3% 15%;--shadow-strength:1%;--shadow-strength-3:calc(var(--shadow-strength) + 2%);--shadow-strength-4:calc(var(--shadow-strength) + 3%);--shadow-strength-5:calc(var(--shadow-strength) + 4%);--shadow-strength-6:calc(var(--shadow-strength) + 5%);--shadow-strength-7:calc(var(--shadow-strength) + 6%);--shadow-strength-8:calc(var(--shadow-strength) + 7%);--shadow-strength-10:calc(var(--shadow-strength) + 9%);--inner-shadow-highlight:inset 0 -.5px 0 0 #fff,inset 0 .5px 0 0 rgba(0,0,0,.067);--shadow-1:0 1px 2px -1px hsl(var(--shadow-color)/var(--shadow-strength-10));--shadow-2:0 3px 5px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 7px 14px -5px hsl(var(--shadow-color)/var(--shadow-strength-6));--shadow-3:0 -1px 3px 0 hsl(var(--shadow-color)/var(--shadow-strength-3)),0 1px 2px -5px hsl(var(--shadow-color)/var(--shadow-strength-3)),0 2px 5px -5px hsl(var(--shadow-color)/var(--shadow-strength-5)),0 4px 12px -5px hsl(var(--shadow-color)/var(--shadow-strength-6)),0 12px 15px -5px hsl(var(--shadow-color)/var(--shadow-strength-8));--shadow-4:0 -2px 5px 0 hsl(var(--shadow-color)/var(--shadow-strength-3)),0 1px 1px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 2px 2px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 5px 5px -2px hsl(var(--shadow-color)/var(--shadow-strength-5)),0 9px 9px -2px hsl(var(--shadow-color)/var(--shadow-strength-6)),0 16px 16px -2px hsl(var(--shadow-color)/var(--shadow-strength-7));--shadow-5:0 -1px 2px 0 hsl(var(--shadow-color)/var(--shadow-strength-3)),0 2px 1px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 5px 5px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 10px 10px -2px hsl(var(--shadow-color)/var(--shadow-strength-5)),0 20px 20px -2px hsl(var(--shadow-color)/var(--shadow-strength-6)),0 40px 40px -2px hsl(var(--shadow-color)/var(--shadow-strength-8));--shadow-6:0 -1px 2px 0 hsl(var(--shadow-color)/var(--shadow-strength-3)),0 3px 2px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 7px 5px -2px hsl(var(--shadow-color)/var(--shadow-strength-4)),0 12px 10px -2px hsl(var(--shadow-color)/var(--shadow-strength-5)),0 22px 18px -2px hsl(var(--shadow-color)/var(--shadow-strength-6)),0 41px 33px -2px hsl(var(--shadow-color)/var(--shadow-strength-7)),0 100px 80px -2px hsl(var(--shadow-color)/var(--shadow-strength-8));--inner-shadow-0:inset 0 0 0 1px hsl(var(--shadow-color)/var(--shadow-strength-10));--inner-shadow-1:inset 0 1px 2px 0 hsl(var(--shadow-color)/var(--shadow-strength-10)),var(--inner-shadow-highlight);--inner-shadow-2:inset 0 1px 4px 0 hsl(var(--shadow-color)/var(--shadow-strength-10)),var(--inner-shadow-highlight);--inner-shadow-3:inset 0 2px 8px 0 hsl(var(--shadow-color)/var(--shadow-strength-10)),var(--inner-shadow-highlight);--inner-shadow-4:inset 0 2px 14px 0 hsl(var(--shadow-color)/var(--shadow-strength-10)),var(--inner-shadow-highlight);--ratio-square:1;--ratio-landscape:4/3;--ratio-portrait:3/4;--ratio-widescreen:16/9;--ratio-ultrawide:18/5;--ratio-golden:1.6180/1;--gray-0:#f8f9fa;--gray-1:#f1f3f5;--gray-2:#e9ecef;--gray-3:#dee2e6;--gray-4:#ced4da;--gray-5:#adb5bd;--gray-6:#868e96;--gray-7:#495057;--gray-8:#343a40;--gray-9:#212529;--gray-10:#16191d;--gray-11:#0d0f12;--gray-12:#030507;--stone-0:#f8fafb;--stone-1:#f2f4f6;--stone-2:#ebedef;--stone-3:#e0e4e5;--stone-4:#d1d6d8;--stone-5:#b1b6b9;--stone-6:#979b9d;--stone-7:#7e8282;--stone-8:#666968;--stone-9:#50514f;--stone-10:#3a3a37;--stone-11:#252521;--stone-12:#121210;--red-0:#fff5f5;--red-1:#ffe3e3;--red-2:#ffc9c9;--red-3:#ffa8a8;--red-4:#ff8787;--red-5:#ff6b6b;--red-6:#fa5252;--red-7:#f03e3e;--red-8:#e03131;--red-9:#c92a2a;--red-10:#b02525;--red-11:#962020;--red-12:#7d1a1a;--pink-0:#fff0f6;--pink-1:#ffdeeb;--pink-2:#fcc2d7;--pink-3:#faa2c1;--pink-4:#f783ac;--pink-5:#f06595;--pink-6:#e64980;--pink-7:#d6336c;--pink-8:#c2255c;--pink-9:#a61e4d;--pink-10:#8c1941;--pink-11:#731536;--pink-12:#59102a;--purple-0:#f8f0fc;--purple-1:#f3d9fa;--purple-2:#eebefa;--purple-3:#e599f7;--purple-4:#da77f2;--purple-5:#cc5de8;--purple-6:#be4bdb;--purple-7:#ae3ec9;--purple-8:#9c36b5;--purple-9:#862e9c;--purple-10:#702682;--purple-11:#5a1e69;--purple-12:#44174f;--violet-0:#f3f0ff;--violet-1:#e5dbff;--violet-2:#d0bfff;--violet-3:#b197fc;--violet-4:#9775fa;--violet-5:#845ef7;--violet-6:#7950f2;--violet-7:#7048e8;--violet-8:#6741d9;--violet-9:#5f3dc4;--violet-10:#5235ab;--violet-11:#462d91;--violet-12:#3a2578;--indigo-0:#edf2ff;--indigo-1:#dbe4ff;--indigo-2:#bac8ff;--indigo-3:#91a7ff;--indigo-4:#748ffc;--indigo-5:#5c7cfa;--indigo-6:#4c6ef5;--indigo-7:#4263eb;--indigo-8:#3b5bdb;--indigo-9:#364fc7;--indigo-10:#2f44ad;--indigo-11:#283a94;--indigo-12:#21307a;--blue-0:#e7f5ff;--blue-1:#d0ebff;--blue-2:#a5d8ff;--blue-3:#74c0fc;--blue-4:#4dabf7;--blue-5:#339af0;--blue-6:#228be6;--blue-7:#1c7ed6;--blue-8:#1971c2;--blue-9:#1864ab;--blue-10:#145591;--blue-11:#114678;--blue-12:#0d375e;--cyan-0:#e3fafc;--cyan-1:#c5f6fa;--cyan-2:#99e9f2;--cyan-3:#66d9e8;--cyan-4:#3bc9db;--cyan-5:#22b8cf;--cyan-6:#15aabf;--cyan-7:#1098ad;--cyan-8:#0c8599;--cyan-9:#0b7285;--cyan-10:#095c6b;--cyan-11:#074652;--cyan-12:#053038;--teal-0:#e6fcf5;--teal-1:#c3fae8;--teal-2:#96f2d7;--teal-3:#63e6be;--teal-4:#38d9a9;--teal-5:#20c997;--teal-6:#12b886;--teal-7:#0ca678;--teal-8:#099268;--teal-9:#087f5b;--teal-10:#066649;--teal-11:#054d37;--teal-12:#033325;--green-0:#ebfbee;--green-1:#d3f9d8;--green-2:#b2f2bb;--green-3:#8ce99a;--green-4:#69db7c;--green-5:#51cf66;--green-6:#40c057;--green-7:#37b24d;--green-8:#2f9e44;--green-9:#2b8a3e;--green-10:#237032;--green-11:#1b5727;--green-12:#133d1b;--lime-0:#f4fce3;--lime-1:#e9fac8;--lime-2:#d8f5a2;--lime-3:#c0eb75;--lime-4:#a9e34b;--lime-5:#94d82d;--lime-6:#82c91e;--lime-7:#74b816;--lime-8:#66a80f;--lime-9:#5c940d;--lime-10:#4c7a0b;--lime-11:#3c6109;--lime-12:#2c4706;--yellow-0:#fff9db;--yellow-1:#fff3bf;--yellow-2:#ffec99;--yellow-3:#ffe066;--yellow-4:#ffd43b;--yellow-5:#fcc419;--yellow-6:#fab005;--yellow-7:#f59f00;--yellow-8:#f08c00;--yellow-9:#e67700;--yellow-10:#b35c00;--yellow-11:#804200;--yellow-12:#663500;--orange-0:#fff4e6;--orange-1:#ffe8cc;--orange-2:#ffd8a8;--orange-3:#ffc078;--orange-4:#ffa94d;--orange-5:#ff922b;--orange-6:#fd7e14;--orange-7:#f76707;--orange-8:#e8590c;--orange-9:#d9480f;--orange-10:#bf400d;--orange-11:#99330b;--orange-12:#802b09;--choco-0:#fff8dc;--choco-1:#fce1bc;--choco-2:#f7ca9e;--choco-3:#f1b280;--choco-4:#e99b62;--choco-5:#df8545;--choco-6:#d46e25;--choco-7:#bd5f1b;--choco-8:#a45117;--choco-9:#8a4513;--choco-10:#703a13;--choco-11:#572f12;--choco-12:#3d210d;--brown-0:#faf4eb;--brown-1:#ede0d1;--brown-2:#e0cab7;--brown-3:#d3b79e;--brown-4:#c5a285;--brown-5:#b78f6d;--brown-6:#a87c56;--brown-7:#956b47;--brown-8:#825b3a;--brown-9:#6f4b2d;--brown-10:#5e3a21;--brown-11:#4e2b15;--brown-12:#422412;--sand-0:#f8fafb;--sand-1:#e6e4dc;--sand-2:#d5cfbd;--sand-3:#c2b9a0;--sand-4:#aea58c;--sand-5:#9a9178;--sand-6:#867c65;--sand-7:#736a53;--sand-8:#5f5746;--sand-9:#4b4639;--sand-10:#38352d;--sand-11:#252521;--sand-12:#121210;--camo-0:#f9fbe7;--camo-1:#e8ed9c;--camo-2:#d2df4e;--camo-3:#c2ce34;--camo-4:#b5bb2e;--camo-5:#a7a827;--camo-6:#999621;--camo-7:#8c851c;--camo-8:#7e7416;--camo-9:#6d6414;--camo-10:#5d5411;--camo-11:#4d460e;--camo-12:#36300a;--jungle-0:#ecfeb0;--jungle-1:#def39a;--jungle-2:#d0e884;--jungle-3:#c2dd6e;--jungle-4:#b5d15b;--jungle-5:#a8c648;--jungle-6:#9bbb36;--jungle-7:#8fb024;--jungle-8:#84a513;--jungle-9:#7a9908;--jungle-10:#658006;--jungle-11:#516605;--jungle-12:#3d4d04;--gradient-space: ;--gradient-1:linear-gradient(to bottom right var(--gradient-space),#1f005c,#5b0060,#870160,#ac255e,#ca485c,#e16b5c,#f39060,#ffb56b);--gradient-2:linear-gradient(to bottom right var(--gradient-space),#48005c,#8300e2,#a269ff);--gradient-3:radial-gradient(circle at top right var(--gradient-space),#0ff,rgba(0,255,255,0)),radial-gradient(circle at bottom left var(--gradient-space),#ff1492,rgba(255,20,146,0));--gradient-4:linear-gradient(to bottom right var(--gradient-space),#00f5a0,#00d9f5);--gradient-5:conic-gradient(from -270deg at 75% 110% var(--gradient-space),#f0f,#fffaf0);--gradient-6:conic-gradient(from -90deg at top left var(--gradient-space),#000,#fff);--gradient-7:linear-gradient(to bottom right var(--gradient-space),#72c6ef,#004e8f);--gradient-8:conic-gradient(from 90deg at 50% 0% var(--gradient-space),#111,50%,#222,#111);--gradient-9:conic-gradient(from .5turn at bottom center var(--gradient-space),#add8e6,#fff);--gradient-10:conic-gradient(from 90deg at 40% -25% var(--gradient-space),gold,#f79d03,#ee6907,#e6390a,#de0d0d,#d61039,#cf1261,#c71585,#cf1261,#d61039,#de0d0d,#ee6907,#f79d03,gold,gold,gold);--gradient-11:conic-gradient(at bottom left var(--gradient-space),#ff1493,cyan);--gradient-12:conic-gradient(from 90deg at 25% -10% var(--gradient-space),#ff4500,#d3f340,#7bee85,#afeeee,#7bee85);--gradient-13:radial-gradient(circle at 50% 200% var(--gradient-space),#000142,#3b0083,#b300c3,#ff059f,#ff4661,#ffad86,#fff3c7);--gradient-14:conic-gradient(at top right var(--gradient-space),lime,cyan);--gradient-15:linear-gradient(to bottom right var(--gradient-space),#c7d2fe,#fecaca,#fef3c7);--gradient-16:radial-gradient(circle at 50% -250% var(--gradient-space),#374151,#111827,#000);--gradient-17:conic-gradient(from -90deg at 50% -25% var(--gradient-space),blue,#8a2be2);--gradient-18:linear-gradient(0deg var(--gradient-space),rgba(255,0,0,.8),rgba(255,0,0,0) 75%),linear-gradient(60deg var(--gradient-space),rgba(255,255,0,.8),rgba(255,255,0,0) 75%),linear-gradient(120deg var(--gradient-space),rgba(0,255,0,.8),rgba(0,255,0,0) 75%),linear-gradient(180deg var(--gradient-space),rgba(0,255,255,.8),rgba(0,255,255,0) 75%),linear-gradient(240deg var(--gradient-space),rgba(0,0,255,.8),rgba(0,0,255,0) 75%),linear-gradient(300deg var(--gradient-space),rgba(255,0,255,.8),rgba(255,0,255,0) 75%);--gradient-19:linear-gradient(to bottom right var(--gradient-space),#ffe259,#ffa751);--gradient-20:conic-gradient(from -135deg at -10% center var(--gradient-space),orange,#ff7715,#ff522a,#ff3f47,#ff5482,#ff69b4);--gradient-21:conic-gradient(from -90deg at 25% 115% var(--gradient-space),red,#f06,#f0c,#c0f,#60f,#00f,#00f,#00f,#00f);--gradient-22:linear-gradient(to bottom right var(--gradient-space),#acb6e5,#86fde8);--gradient-23:linear-gradient(to bottom right var(--gradient-space),#536976,#292e49);--gradient-24:conic-gradient(from .5turn at 0% 0% var(--gradient-space),#00c476,10%,#82b0ff,90%,#00c476);--gradient-25:conic-gradient(at 125% 50% var(--gradient-space),#b78cf7,#ff7c94,#ffcf0d,#ff7c94,#b78cf7);--gradient-26:linear-gradient(to bottom right var(--gradient-space),#9796f0,#fbc7d4);--gradient-27:conic-gradient(from .5turn at bottom left var(--gradient-space),#ff1493,#639);--gradient-28:conic-gradient(from -90deg at 50% 105% var(--gradient-space),#fff,orchid);--gradient-29:radial-gradient(circle at top right var(--gradient-space),#bfb3ff,rgba(191,179,255,0)),radial-gradient(circle at bottom left var(--gradient-space),#86acf9,rgba(134,172,249,0));--gradient-30:radial-gradient(circle at top right var(--gradient-space),#00ff80,rgba(0,255,128,0)),radial-gradient(circle at bottom left var(--gradient-space),#adffd6,rgba(173,255,214,0));--noise-1:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.005' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");--noise-2:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 300 300' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.05' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");--noise-3:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.25' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");--noise-4:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 2056 2056' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.5' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");--noise-5:url("data:image/svg+xml;charset=utf-8,%3Csvg viewBox='0 0 2056 2056' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='a'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.75' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23a)'/%3E%3C/svg%3E");--noise-filter-1:contrast(300%) brightness(100%);--noise-filter-2:contrast(200%) brightness(150%);--noise-filter-3:contrast(200%) brightness(250%);--noise-filter-4:contrast(200%) brightness(500%);--noise-filter-5:contrast(200%) brightness(1000%);--animation-fade-in:fade-in .5s var(--ease-3);--animation-fade-in-bloom:fade-in-bloom 2s var(--ease-3);--animation-fade-out:fade-out .5s var(--ease-3);--animation-fade-out-bloom:fade-out-bloom 2s var(--ease-3);--animation-scale-up:scale-up .5s var(--ease-3);--animation-scale-down:scale-down .5s var(--ease-3);--animation-slide-out-up:slide-out-up .5s var(--ease-3);--animation-slide-out-down:slide-out-down .5s var(--ease-3);--animation-slide-out-right:slide-out-right .5s var(--ease-3);--animation-slide-out-left:slide-out-left .5s var(--ease-3);--animation-slide-in-up:slide-in-up .5s var(--ease-3);--animation-slide-in-down:slide-in-down .5s var(--ease-3);--animation-slide-in-right:slide-in-right .5s var(--ease-3);--animation-slide-in-left:slide-in-left .5s var(--ease-3);--animation-shake-x:shake-x .75s var(--ease-out-5);--animation-shake-y:shake-y .75s var(--ease-out-5);--animation-shake-z:shake-z 1s var(--ease-in-out-3);--animation-spin:spin 2s linear infinite;--animation-ping:ping 5s var(--ease-out-3) infinite;--animation-blink:blink 1s var(--ease-out-3) infinite;--animation-float:float 3s var(--ease-in-out-3) infinite;--animation-bounce:bounce 2s var(--ease-squish-2) infinite;--animation-pulse:pulse 2s var(--ease-out-3) infinite;--border-size-1:1px;--border-size-2:2px;--border-size-3:5px;--border-size-4:10px;--border-size-5:25px;--radius-1:2px;--radius-2:5px;--radius-3:1rem;--radius-4:2rem;--radius-5:4rem;--radius-6:8rem;--radius-drawn-1:255px 15px 225px 15px/15px 225px 15px 255px;--radius-drawn-2:125px 10px 20px 185px/25px 205px 205px 25px;--radius-drawn-3:15px 255px 15px 225px/225px 15px 255px 15px;--radius-drawn-4:15px 25px 155px 25px/225px 150px 25px 115px;--radius-drawn-5:250px 25px 15px 20px/15px 80px 105px 115px;--radius-drawn-6:28px 100px 20px 15px/150px 30px 205px 225px;--radius-round:1e5px;--radius-blob-1:30% 70% 70% 30%/53% 30% 70% 47%;--radius-blob-2:53% 47% 34% 66%/63% 46% 54% 37%;--radius-blob-3:37% 63% 56% 44%/49% 56% 44% 51%;--radius-blob-4:63% 37% 37% 63%/43% 37% 63% 57%;--radius-blob-5:49% 51% 48% 52%/57% 44% 56% 43%;--radius-conditional-1:clamp(0px,calc(100vw - 100%) * 1e5,var(--radius-1));--radius-conditional-2:clamp(0px,calc(100vw - 100%) * 1e5,var(--radius-2));--radius-conditional-3:clamp(0px,calc(100vw - 100%) * 1e5,var(--radius-3));--radius-conditional-4:clamp(0px,calc(100vw - 100%) * 1e5,var(--radius-4));--radius-conditional-5:clamp(0px,calc(100vw - 100%) * 1e5,var(--radius-5));--radius-conditional-6:clamp(0px,calc(100vw - 100%) * 1e5,var(--radius-6));--palette-hue:250;--palette-hue-rotate-by:0;--palette-chroma:0.15;--color-1:oklch(98% calc(var(--palette-chroma)*0.03) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*0));--color-2:oklch(97% calc(var(--palette-chroma)*0.06) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*1));--color-3:oklch(93% calc(var(--palette-chroma)*0.1) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*2));--color-4:oklch(84% calc(var(--palette-chroma)*0.12) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*3));--color-5:oklch(80% calc(var(--palette-chroma)*0.16) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*4));--color-6:oklch(71% calc(var(--palette-chroma)*0.19) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*5));--color-7:oklch(66% calc(var(--palette-chroma)*0.2) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*6));--color-8:oklch(58% calc(var(--palette-chroma)*0.21) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*7));--color-9:oklch(53% calc(var(--palette-chroma)*0.2) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*8));--color-10:oklch(49% calc(var(--palette-chroma)*0.19) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*9));--color-11:oklch(42% calc(var(--palette-chroma)*0.17) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*10));--color-12:oklch(35% calc(var(--palette-chroma)*0.15) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*11));--color-13:oklch(27% calc(var(--palette-chroma)*0.12) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*12));--color-14:oklch(20% calc(var(--palette-chroma)*0.09) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*13));--color-15:oklch(16% calc(var(--palette-chroma)*0.07) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*14));--color-16:oklch(10% calc(var(--palette-chroma)*0.05) calc(var(--palette-hue) + var(--palette-hue-rotate-by)*15))}@media (prefers-color-scheme:dark){:where(html){--shadow-color:220 40% 2%;--shadow-strength:25%;--inner-shadow-highlight:inset 0 -.5px 0 0 hsla(0,0%,100%,.067),inset 0 .5px 0 0 rgba(0,0,0,.467)}}@supports (background:linear-gradient(to right in oklab,#000,#fff)){:where(html){--gradient-space:in oklab}}@keyframes fade-in{to{opacity:1}}@keyframes fade-in-bloom{0%{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(2) blur(10px);opacity:1}to{filter:brightness(1) blur(0);opacity:1}}@keyframes fade-out{to{opacity:0}}@keyframes fade-out-bloom{to{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(2) blur(10px);opacity:1}0%{filter:brightness(1) blur(0);opacity:1}}@keyframes scale-up{to{transform:scale(1.25)}}@keyframes scale-down{to{transform:scale(.75)}}@keyframes slide-out-up{to{transform:translateY(-100%)}}@keyframes slide-out-down{to{transform:translateY(100%)}}@keyframes slide-out-right{to{transform:translateX(100%)}}@keyframes slide-out-left{to{transform:translateX(-100%)}}@keyframes slide-in-up{0%{transform:translateY(100%)}}@keyframes slide-in-down{0%{transform:translateY(-100%)}}@keyframes slide-in-right{0%{transform:translateX(-100%)}}@keyframes slide-in-left{0%{transform:translateX(100%)}}@keyframes shake-x{0%,to{transform:translateX(0)}20%{transform:translateX(-5%)}40%{transform:translateX(5%)}60%{transform:translateX(-5%)}80%{transform:translateX(5%)}}@keyframes shake-y{0%,to{transform:translateY(0)}20%{transform:translateY(-5%)}40%{transform:translateY(5%)}60%{transform:translateY(-5%)}80%{transform:translateY(5%)}}@keyframes shake-z{0%,to{transform:rotate(0deg)}20%{transform:rotate(-2deg)}40%{transform:rotate(2deg)}60%{transform:rotate(-2deg)}80%{transform:rotate(2deg)}}@keyframes spin{to{transform:rotate(1turn)}}@keyframes ping{90%,to{opacity:0;transform:scale(2)}}@keyframes blink{0%,to{opacity:1}50%{opacity:.5}}@keyframes float{50%{transform:translateY(-25%)}}@keyframes bounce{25%{transform:translateY(-20%)}40%{transform:translateY(-3%)}0%,60%,to{transform:translateY(0)}}@keyframes pulse{50%{transform:scale(.9)}}@media (prefers-color-scheme:dark){@keyframes fade-in-bloom{0%{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(.5) blur(10px);opacity:1}to{filter:brightness(1) blur(0);opacity:1}}}@media (prefers-color-scheme:dark){@keyframes fade-out-bloom{to{filter:brightness(1) blur(20px);opacity:0}10%{filter:brightness(.5) blur(10px);opacity:1}0%{filter:brightness(1) blur(0);opacity:1}}} \ No newline at end of file diff --git a/public/index.html b/public/index.html index 8b77250..6cd5884 100644 --- a/public/index.html +++ b/public/index.html @@ -4,17 +4,30 @@ Git CMS Admin + + + + + + -
+ +
- - -
- - + + +
+ + +
+ +
- - -
- - +
+ +
- + +
+ Metadata +
+
+ +
+ +
+ Drop files here or + +
+
+
Ready
+ +
+ +

Select an article or create a new one

+
+ + +
+ diff --git a/scripts/README.md b/scripts/README.md index 5f1866b..e5bd2d1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -2,7 +2,7 @@ Helper scripts for working with git-cms safely. -**All scripts are tested!** See `test/setup.bats` for the test suite. +`setup.sh` is tested via `test/setup.bats`. Other scripts are manually verified. ## Available Scripts @@ -88,6 +88,6 @@ npm test ## More Info -- Getting Started Guide: `docs/GETTING_STARTED.md` -- Architecture Decision Record: `docs/ADR.md` -- Main README: `README.md` +- [Getting Started Guide](../docs/GETTING_STARTED.md) +- [Architecture Decision Record](../docs/ADR.md) +- [Main README](../README.md) diff --git a/src/lib/CmsService.js b/src/lib/CmsService.js index e020635..c275c62 100644 --- a/src/lib/CmsService.js +++ b/src/lib/CmsService.js @@ -5,15 +5,22 @@ import ContentAddressableStore from '@git-stunts/git-cas'; import VaultResolver from './VaultResolver.js'; import ShellRunner from '@git-stunts/plumbing/ShellRunner'; import { + CmsValidationError, canonicalizeKind, canonicalizeSlug, resolveContentIdentity, } from './ContentIdentityPolicy.js'; +import { + STATES, + resolveEffectiveState, + validateTransition, +} from './ContentStatePolicy.js'; /** * @typedef {Object} CmsServiceOptions - * @property {string} cwd - The working directory of the git repo. + * @property {string} [cwd] - The working directory of the git repo. * @property {string} refPrefix - The namespace for git refs (e.g. refs/_blog/dev). + * @property {import('@git-stunts/git-warp').GraphPersistencePort} [graph] - Optional injected graph adapter (skips git subprocess setup). */ /** @@ -23,24 +30,54 @@ export default class CmsService { /** * @param {CmsServiceOptions} options */ - constructor({ cwd, refPrefix }) { - this.cwd = cwd; + constructor({ cwd, refPrefix, graph }) { this.refPrefix = refPrefix.replace(/\/$/, ''); - - // Initialize Lego Blocks with ShellRunner as the substrate - this.plumbing = new GitPlumbing({ - runner: ShellRunner.run, - cwd - }); - this.repo = new GitRepositoryService({ plumbing: this.plumbing }); - - this.graph = new GitGraphAdapter({ plumbing: this.plumbing }); + const helpers = createMessageHelpers({ bodyFormatOptions: { keepTrailingNewline: true } }); this.codec = { decode: helpers.decodeMessage, encode: helpers.encodeMessage }; - this.cas = new ContentAddressableStore({ plumbing: this.plumbing }); - this.vault = new VaultResolver(); + + if (graph) { + // DI mode — caller provides a GraphPersistencePort (e.g. InMemoryGraphAdapter) + this.graph = graph; + this.plumbing = null; + this.repo = null; + this.cas = null; + this.vault = null; + } else { + // Production mode — wire up real git subprocess infrastructure + this.cwd = cwd; + this.plumbing = new GitPlumbing({ + runner: ShellRunner.run, + cwd + }); + this.repo = new GitRepositoryService({ plumbing: this.plumbing }); + this.graph = new GitGraphAdapter({ plumbing: this.plumbing }); + this.cas = new ContentAddressableStore({ plumbing: this.plumbing }); + this.vault = new VaultResolver(); + } + } + + /** + * Routes ref updates to the correct backend. + * @private + */ + async _updateRef({ ref, newSha, oldSha }) { + if (this.repo) { + await this.repo.updateRef({ ref, newSha, oldSha }); + } else { + // DI mode — manual CAS: verify current value matches expected oldSha + if (oldSha) { + const current = await this.graph.readRef(ref); + if (current !== oldSha) { + throw new Error( + `CAS conflict on ${ref}: expected ${oldSha}, found ${current}` + ); + } + } + await this.graph.updateRef(ref, newSha); + } } /** @@ -53,24 +90,74 @@ export default class CmsService { return `${this.refPrefix}/${canonicalKind}/${canonicalSlug}`; } + /** + * Resolves the effective content state for an article. + * @private + * @returns {{ effectiveState: string, draftSha: string|null, pubSha: string|null, draftStatus: string }} + */ + async _resolveArticleState(slug) { + const draftRef = this._refFor(slug, 'articles'); + const pubRef = this._refFor(slug, 'published'); + + const draftSha = await this.graph.readRef(draftRef); + const pubSha = await this.graph.readRef(pubRef); + + if (!draftSha && !pubSha) { + throw new CmsValidationError( + `Article not found: "${slug}"`, + { code: 'article_not_found', field: 'slug' } + ); + } + + let draftStatus = STATES.DRAFT; + if (draftSha) { + const message = await this.graph.showNode(draftSha); + const decoded = this.codec.decode(message); + draftStatus = decoded.trailers?.status || STATES.DRAFT; + } + + const effectiveState = resolveEffectiveState({ draftStatus, pubSha }); + return { effectiveState, draftSha, pubSha, draftStatus }; + } + + /** + * Returns the effective state of an article. + */ + async getArticleState({ slug }) { + const canonicalSlug = canonicalizeSlug(slug); + const state = await this._resolveArticleState(canonicalSlug); + return { slug: canonicalSlug, state: state.effectiveState }; + } + /** * Lists all articles of a certain kind. */ async listArticles({ kind = 'articles' } = {}) { const canonicalKind = canonicalizeKind(kind); const ns = `${this.refPrefix}/${canonicalKind}/`; - const out = await this.plumbing.execute({ - args: ['for-each-ref', ns, '--format=%(refname) %(objectname)'], - }); - return out - .split('\n') - .filter(Boolean) - .map((line) => { - const [ref, sha] = line.split(' '); - const slug = ref.replace(ns, ''); - return { ref, sha, slug }; + if (this.plumbing) { + const out = await this.plumbing.execute({ + args: ['for-each-ref', ns, '--format=%(refname) %(objectname)'], }); + + return out + .split('\n') + .filter(Boolean) + .map((line) => { + const [ref, sha] = line.split(' '); + const slug = ref.replace(ns, ''); + return { ref, sha, slug }; + }); + } + + // DI mode — use graph.listRefs + readRef + const refs = await this.graph.listRefs(ns); + return Promise.all(refs.map(async (ref) => { + const sha = await this.graph.readRef(ref); + const slug = ref.replace(ns, ''); + return { ref, sha, slug }; + })); } /** @@ -93,7 +180,13 @@ export default class CmsService { const identity = resolveContentIdentity({ slug, trailers: safeTrailers }); const ref = this._refFor(identity.slug, 'articles'); const parentSha = await this.graph.readRef(ref); - + + // Guard: validate state transition if article already exists + if (parentSha) { + const { effectiveState } = await this._resolveArticleState(identity.slug); + validateTransition(effectiveState, STATES.DRAFT); + } + const finalTrailers = { ...safeTrailers, contentid: identity.contentId, @@ -108,7 +201,7 @@ export default class CmsService { sign: process.env.CMS_SIGN === '1' }); - await this.repo.updateRef({ ref, newSha, oldSha: parentSha }); + await this._updateRef({ ref, newSha, oldSha: parentSha }); return { ref, sha: newSha, parent: parentSha }; } @@ -119,20 +212,121 @@ export default class CmsService { const canonicalSlug = canonicalizeSlug(slug); const draftRef = this._refFor(canonicalSlug, 'articles'); const pubRef = this._refFor(canonicalSlug, 'published'); - + const targetSha = sha || await this.graph.readRef(draftRef); if (!targetSha) throw new Error(`Nothing to publish for ${canonicalSlug}`); - const oldSha = await this.graph.readRef(pubRef); - await this.repo.updateRef({ ref: pubRef, newSha: targetSha, oldSha }); - - return { ref: pubRef, sha: targetSha, prev: oldSha }; + // Guard: validate state transition + const { effectiveState, pubSha } = await this._resolveArticleState(canonicalSlug); + validateTransition(effectiveState, STATES.PUBLISHED); + + // Idempotent: re-publishing the same SHA is a no-op + if (pubSha === targetSha) { + return { ref: pubRef, sha: targetSha, prev: pubSha }; + } + + await this._updateRef({ ref: pubRef, newSha: targetSha, oldSha: pubSha }); + return { ref: pubRef, sha: targetSha, prev: pubSha }; + } + + /** + * Unpublishes an article: deletes the published ref and marks draft as unpublished. + */ + async unpublishArticle({ slug }) { + const canonicalSlug = canonicalizeSlug(slug); + const draftRef = this._refFor(canonicalSlug, 'articles'); + const pubRef = this._refFor(canonicalSlug, 'published'); + + const { effectiveState, draftSha } = await this._resolveArticleState(canonicalSlug); + validateTransition(effectiveState, STATES.UNPUBLISHED); + + if (!draftSha) { + throw new CmsValidationError( + `Cannot unpublish "${canonicalSlug}": no draft ref exists`, + { code: 'no_draft', field: 'slug' } + ); + } + + // Read current draft content and re-commit with status: unpublished + const message = await this.graph.showNode(draftSha); + const decoded = this.codec.decode(message); + const { updatedat: _, ...restTrailers } = decoded.trailers; + const newMessage = this.codec.encode({ + title: decoded.title, + body: decoded.body, + trailers: { ...restTrailers, status: STATES.UNPUBLISHED, updatedAt: new Date().toISOString() }, + }); + + const newSha = await this.graph.commitNode({ + message: newMessage, + parents: [draftSha], + sign: process.env.CMS_SIGN === '1', + }); + + // Update draft ref FIRST, delete published ref LAST for atomicity. + // If deleteRef fails, the published ref survives (safe). + await this._updateRef({ ref: draftRef, newSha, oldSha: draftSha }); + await this.graph.deleteRef(pubRef); + + return { ref: draftRef, sha: newSha, prev: draftSha }; + } + + /** + * Reverts an article to its parent's content, preserving full history. + */ + async revertArticle({ slug }) { + const canonicalSlug = canonicalizeSlug(slug); + const draftRef = this._refFor(canonicalSlug, 'articles'); + + const { effectiveState, draftSha } = await this._resolveArticleState(canonicalSlug); + validateTransition(effectiveState, STATES.REVERTED); + + if (!draftSha) { + throw new CmsValidationError( + `Cannot revert "${canonicalSlug}": no draft ref exists`, + { code: 'no_draft', field: 'slug' } + ); + } + + const info = await this.graph.getNodeInfo(draftSha); + if (!info.parents || info.parents.length === 0 || !info.parents[0]) { + throw new CmsValidationError( + `Cannot revert "${canonicalSlug}": no parent commit exists`, + { code: 'revert_no_parent', field: 'slug' } + ); + } + + const parentCommitSha = info.parents[0]; + const parentMessage = await this.graph.showNode(parentCommitSha); + const parentDecoded = this.codec.decode(parentMessage); + const { updatedat: _u, ...restParentTrailers } = parentDecoded.trailers; + + const newMessage = this.codec.encode({ + title: parentDecoded.title, + body: parentDecoded.body, + trailers: { ...restParentTrailers, status: STATES.REVERTED, updatedAt: new Date().toISOString() }, + }); + + const newSha = await this.graph.commitNode({ + message: newMessage, + parents: [draftSha], + sign: process.env.CMS_SIGN === '1', + }); + + await this._updateRef({ ref: draftRef, newSha, oldSha: draftSha }); + return { ref: draftRef, sha: newSha, prev: draftSha }; } /** * Uploads an asset and returns its manifest and CAS info. */ async uploadAsset({ slug, filePath, filename }) { + if (!this.cas || !this.vault) { + throw new CmsValidationError( + 'uploadAsset is not supported in DI mode', + { code: 'unsupported_in_di_mode' } + ); + } const canonicalSlug = canonicalizeSlug(slug); const ENV = (process.env.GIT_CMS_ENV || 'dev').toLowerCase(); const encryptionKeyRaw = await this.vault.resolveSecret({ @@ -156,7 +350,7 @@ export default class CmsService { message: `asset:${filename}\n\nmanifest: ${treeOid}`, }); - await this.repo.updateRef({ ref, newSha: commitSha }); + await this._updateRef({ ref, newSha: commitSha }); return { manifest, treeOid, commitSha }; } diff --git a/src/lib/ContentStatePolicy.js b/src/lib/ContentStatePolicy.js new file mode 100644 index 0000000..967c459 --- /dev/null +++ b/src/lib/ContentStatePolicy.js @@ -0,0 +1,68 @@ +/** + * Content state machine policy for git-cms. + * + * Defines the four editorial states and enforces valid transitions. + * + * | Logical State | `status` trailer | `published/` ref | + * |---------------|------------------|------------------------| + * | draft | draft | absent | + * | published | draft | present | + * | unpublished | unpublished | absent | + * | reverted | reverted | absent | + */ + +import { CmsValidationError } from './ContentIdentityPolicy.js'; + +export const CONTENT_STATE_POLICY_VERSION = '1.0.0'; + +export const STATES = Object.freeze({ + DRAFT: 'draft', + PUBLISHED: 'published', + UNPUBLISHED: 'unpublished', + REVERTED: 'reverted', +}); + +/** + * Allowed transitions: from → Set of valid targets. + */ +export const TRANSITIONS = Object.freeze({ + [STATES.DRAFT]: Object.freeze(new Set([STATES.DRAFT, STATES.PUBLISHED, STATES.REVERTED])), + [STATES.PUBLISHED]: Object.freeze(new Set([STATES.UNPUBLISHED, STATES.PUBLISHED])), + [STATES.UNPUBLISHED]: Object.freeze(new Set([STATES.DRAFT, STATES.PUBLISHED])), + [STATES.REVERTED]: Object.freeze(new Set([STATES.DRAFT])), +}); + +/** + * Derives the effective state from the draft status trailer and the + * presence/absence of a published ref. + * + * @param {{ draftStatus: string, pubSha: string|null }} params + * @returns {string} One of STATES values. + */ +export function resolveEffectiveState({ draftStatus, pubSha }) { + if (pubSha) return STATES.PUBLISHED; + if (draftStatus === STATES.UNPUBLISHED) return STATES.UNPUBLISHED; + if (draftStatus === STATES.REVERTED) return STATES.REVERTED; + if (draftStatus === STATES.DRAFT) return STATES.DRAFT; + throw new CmsValidationError( + `Unrecognized draft status: "${draftStatus}"`, + { code: 'unknown_status', field: 'status' } + ); +} + +/** + * Guards a state transition. Throws if the transition is not allowed. + * + * @param {string} from - Current effective state. + * @param {string} to - Desired target state. + * @throws {CmsValidationError} with code `invalid_state_transition` + */ +export function validateTransition(from, to) { + const allowed = TRANSITIONS[from]; + if (!allowed || !allowed.has(to)) { + throw new CmsValidationError( + `Cannot transition from "${from}" to "${to}"`, + { code: 'invalid_state_transition', field: 'status' } + ); + } +} diff --git a/src/server/index.js b/src/server/index.js index 4b84159..5c70c44 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -163,6 +163,44 @@ async function handler(req, res) { return; } + // POST /api/cms/unpublish + if (req.method === 'POST' && pathname === '/api/cms/unpublish') { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', async () => { + try { + const { slug: rawSlug } = JSON.parse(body || '{}'); + if (!rawSlug) return send(res, 400, { error: 'slug required' }); + const slug = canonicalizeSlug(rawSlug); + const result = await cms.unpublishArticle({ slug }); + return send(res, 200, result); + } catch (err) { + logError(err); + return sendError(res, err); + } + }); + return; + } + + // POST /api/cms/revert + if (req.method === 'POST' && pathname === '/api/cms/revert') { + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', async () => { + try { + const { slug: rawSlug } = JSON.parse(body || '{}'); + if (!rawSlug) return send(res, 400, { error: 'slug required' }); + const slug = canonicalizeSlug(rawSlug); + const result = await cms.revertArticle({ slug }); + return send(res, 200, result); + } catch (err) { + logError(err); + return sendError(res, err); + } + }); + return; + } + // POST /api/cms/upload if (req.method === 'POST' && pathname === '/api/cms/upload') { let body = ''; diff --git a/test/git-e2e.test.js b/test/git-e2e.test.js new file mode 100644 index 0000000..6856a8c --- /dev/null +++ b/test/git-e2e.test.js @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execFileSync } from 'node:child_process'; +import CmsService from '../src/lib/CmsService.js'; + +describe('CmsService (E2E — real git)', () => { + let cwd; + let cms; + const refPrefix = 'refs/cms'; + + beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'git-cms-e2e-')); + execFileSync('git', ['init'], { cwd }); + execFileSync('git', ['config', 'user.name', 'Test'], { cwd }); + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd }); + cms = new CmsService({ cwd, refPrefix }); + }); + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); + }); + + it('save → read → publish round-trip against real repo', async () => { + const slug = 'e2e-smoke'; + const title = 'E2E Title'; + const body = 'E2E body content'; + + const saved = await cms.saveSnapshot({ slug, title, body }); + expect(saved.sha).toHaveLength(40); + + const article = await cms.readArticle({ slug }); + expect(article.title).toBe(title); + expect(article.body).toBe(body + '\n'); + expect(article.trailers.status).toBe('draft'); + + await cms.publishArticle({ slug, sha: saved.sha }); + const pub = await cms.readArticle({ slug, kind: 'published' }); + expect(pub.sha).toBe(saved.sha); + }); + + it('propagates underlying git errors while listing', async () => { + const originalExecute = cms.plumbing.execute; + cms.plumbing.execute = async () => { + throw new Error('fatal: permission denied'); + }; + + try { + await expect(cms.listArticles()).rejects.toThrow('fatal: permission denied'); + } finally { + cms.plumbing.execute = originalExecute; + } + }); +}); diff --git a/test/git.test.js b/test/git.test.js index e509dfe..6924e64 100644 --- a/test/git.test.js +++ b/test/git.test.js @@ -1,37 +1,30 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; -import { execFileSync } from 'node:child_process'; +import { describe, it, expect, beforeEach } from 'vitest'; +import InMemoryGraphAdapter from '#test/InMemoryGraphAdapter'; import CmsService from '../src/lib/CmsService.js'; +import { CmsValidationError } from '../src/lib/ContentIdentityPolicy.js'; +import { resolveEffectiveState } from '../src/lib/ContentStatePolicy.js'; + +function createTestCms() { + const graph = new InMemoryGraphAdapter(); + return new CmsService({ refPrefix: 'refs/cms', graph }); +} describe('CmsService (Integration)', () => { - let cwd; let cms; - const refPrefix = 'refs/cms'; beforeEach(() => { - cwd = mkdtempSync(path.join(os.tmpdir(), 'git-cms-service-test-')); - execFileSync('git', ['init'], { cwd }); - execFileSync('git', ['config', 'user.name', 'Test'], { cwd }); - execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd }); - - cms = new CmsService({ cwd, refPrefix }); - }); - - afterEach(() => { - rmSync(cwd, { recursive: true, force: true }); + cms = createTestCms(); }); it('saves a snapshot and reads it back', async () => { const slug = 'hello-world'; const title = 'Title'; const body = 'Body content'; - + const res = await cms.saveSnapshot({ slug, title, body }); - + expect(res.sha).toHaveLength(40); - + const article = await cms.readArticle({ slug }); expect(article.title).toBe(title); expect(article.body).toBe(body + '\n'); @@ -40,12 +33,12 @@ describe('CmsService (Integration)', () => { it('updates an existing article (history)', async () => { const slug = 'history-test'; - + const v1 = await cms.saveSnapshot({ slug, title: 'v1', body: 'b1' }); const v2 = await cms.saveSnapshot({ slug, title: 'v2', body: 'b2' }); - + expect(v2.parent).toBe(v1.sha); - + const article = await cms.readArticle({ slug }); expect(article.title).toBe('v2'); }); @@ -53,7 +46,7 @@ describe('CmsService (Integration)', () => { it('lists articles', async () => { await cms.saveSnapshot({ slug: 'a', title: 'A', body: 'A' }); await cms.saveSnapshot({ slug: 'b', title: 'B', body: 'B' }); - + const list = await cms.listArticles(); expect(list).toHaveLength(2); expect(list.map(i => i.slug).sort()).toEqual(['a', 'b']); @@ -62,9 +55,9 @@ describe('CmsService (Integration)', () => { it('publishes an article', async () => { const slug = 'pub-test'; const { sha } = await cms.saveSnapshot({ slug, title: 'ready', body: '...' }); - + await cms.publishArticle({ slug, sha }); - + const pubArticle = await cms.readArticle({ slug, kind: 'published' }); expect(pubArticle.sha).toBe(sha); }); @@ -99,13 +92,185 @@ describe('CmsService (Integration)', () => { ).rejects.toThrow(/must match canonical slug/); }); - it('propagates underlying git errors while listing', async () => { - const originalExecute = cms.plumbing.execute; - cms.plumbing.execute = async () => { - throw new Error('fatal: permission denied'); - }; + it('uploadAsset in DI mode throws unsupported_in_di_mode', async () => { + await expect( + cms.uploadAsset({ slug: 'test', filePath: '/tmp/f', filename: 'f.png' }) + ).rejects.toMatchObject({ name: 'CmsValidationError', code: 'unsupported_in_di_mode' }); + }); +}); + +describe('State Machine', () => { + let cms; + + beforeEach(() => { + cms = createTestCms(); + }); + + it('draft → draft (re-save) keeps status draft', async () => { + await cms.saveSnapshot({ slug: 'sm-test', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-test', title: 'v2', body: 'b2' }); + const { state } = await cms.getArticleState({ slug: 'sm-test' }); + expect(state).toBe('draft'); + }); + + it('draft → published sets effective state to published', async () => { + await cms.saveSnapshot({ slug: 'sm-pub', title: 'Title', body: 'Body' }); + await cms.publishArticle({ slug: 'sm-pub' }); + const { state } = await cms.getArticleState({ slug: 'sm-pub' }); + expect(state).toBe('published'); + }); + + it('published → unpublished deletes published ref and sets status', async () => { + await cms.saveSnapshot({ slug: 'sm-unpub', title: 'Title', body: 'Body' }); + await cms.publishArticle({ slug: 'sm-unpub' }); + await cms.unpublishArticle({ slug: 'sm-unpub' }); + + const { state } = await cms.getArticleState({ slug: 'sm-unpub' }); + expect(state).toBe('unpublished'); + + // Published ref should be gone + const pubList = await cms.listArticles({ kind: 'published' }); + expect(pubList.find(a => a.slug === 'sm-unpub')).toBeUndefined(); + }); + + it('unpublished → draft via re-save', async () => { + await cms.saveSnapshot({ slug: 'sm-resave', title: 'v1', body: 'b1' }); + await cms.publishArticle({ slug: 'sm-resave' }); + await cms.unpublishArticle({ slug: 'sm-resave' }); + await cms.saveSnapshot({ slug: 'sm-resave', title: 'v2', body: 'b2' }); + + const { state } = await cms.getArticleState({ slug: 'sm-resave' }); + expect(state).toBe('draft'); + }); + + it('unpublished → published via re-publish', async () => { + await cms.saveSnapshot({ slug: 'sm-repub', title: 'v1', body: 'b1' }); + await cms.publishArticle({ slug: 'sm-repub' }); + await cms.unpublishArticle({ slug: 'sm-repub' }); + await cms.publishArticle({ slug: 'sm-repub' }); + + const { state } = await cms.getArticleState({ slug: 'sm-repub' }); + expect(state).toBe('published'); + }); + + it('reverted → draft via re-save', async () => { + await cms.saveSnapshot({ slug: 'sm-rev-resave', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-rev-resave', title: 'v2', body: 'b2' }); + await cms.revertArticle({ slug: 'sm-rev-resave' }); + + const { state: revertedState } = await cms.getArticleState({ slug: 'sm-rev-resave' }); + expect(revertedState).toBe('reverted'); + + await cms.saveSnapshot({ slug: 'sm-rev-resave', title: 'v3', body: 'b3' }); + const { state } = await cms.getArticleState({ slug: 'sm-rev-resave' }); + expect(state).toBe('draft'); + }); + + it('revert creates new commit with parent content, preserves history', async () => { + const v1 = await cms.saveSnapshot({ slug: 'sm-rev-content', title: 'Original', body: 'original body' }); + await cms.saveSnapshot({ slug: 'sm-rev-content', title: 'Edited', body: 'edited body' }); + const reverted = await cms.revertArticle({ slug: 'sm-rev-content' }); + + // New SHA, not the same as v1 + expect(reverted.sha).not.toBe(v1.sha); + + const article = await cms.readArticle({ slug: 'sm-rev-content' }); + expect(article.title).toBe('Original'); + expect(article.body).toContain('original body'); + expect(article.trailers.status).toBe('reverted'); + }); + + it('revert with no parent throws revert_no_parent', async () => { + await cms.saveSnapshot({ slug: 'sm-no-parent', title: 'First', body: 'only' }); + + await expect(cms.revertArticle({ slug: 'sm-no-parent' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'revert_no_parent', + }); + }); + + it('revert with nonexistent slug throws article_not_found', async () => { + await expect(cms.revertArticle({ slug: 'no-such-slug' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'article_not_found', + }); + }); + + it('cannot unpublish a draft', async () => { + await cms.saveSnapshot({ slug: 'sm-bad-unpub', title: 'T', body: 'B' }); + + await expect(cms.unpublishArticle({ slug: 'sm-bad-unpub' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'invalid_state_transition', + }); + }); + + it('cannot revert a published article', async () => { + await cms.saveSnapshot({ slug: 'sm-bad-rev', title: 'T', body: 'B' }); + await cms.publishArticle({ slug: 'sm-bad-rev' }); + + await expect(cms.revertArticle({ slug: 'sm-bad-rev' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'invalid_state_transition', + }); + }); + + it('cannot publish a reverted article', async () => { + await cms.saveSnapshot({ slug: 'sm-bad-pub-rev', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-bad-pub-rev', title: 'v2', body: 'b2' }); + await cms.revertArticle({ slug: 'sm-bad-pub-rev' }); + + await expect(cms.publishArticle({ slug: 'sm-bad-pub-rev' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'invalid_state_transition', + }); + }); + + it('cannot unpublish a reverted article', async () => { + await cms.saveSnapshot({ slug: 'sm-rev-unpub', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-rev-unpub', title: 'v2', body: 'b2' }); + await cms.revertArticle({ slug: 'sm-rev-unpub' }); + + await expect(cms.unpublishArticle({ slug: 'sm-rev-unpub' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'invalid_state_transition', + }); + }); + + it('cannot revert a reverted article (double revert)', async () => { + await cms.saveSnapshot({ slug: 'sm-dbl-rev', title: 'v1', body: 'b1' }); + await cms.saveSnapshot({ slug: 'sm-dbl-rev', title: 'v2', body: 'b2' }); + await cms.revertArticle({ slug: 'sm-dbl-rev' }); + + await expect(cms.revertArticle({ slug: 'sm-dbl-rev' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'invalid_state_transition', + }); + }); + + it('cannot revert an unpublished article', async () => { + await cms.saveSnapshot({ slug: 'sm-unpub-rev', title: 'v1', body: 'b1' }); + await cms.publishArticle({ slug: 'sm-unpub-rev' }); + await cms.unpublishArticle({ slug: 'sm-unpub-rev' }); + + await expect(cms.revertArticle({ slug: 'sm-unpub-rev' })).rejects.toMatchObject({ + name: 'CmsValidationError', + code: 'invalid_state_transition', + }); + }); + + it('publish is idempotent (same SHA)', async () => { + await cms.saveSnapshot({ slug: 'sm-idem', title: 'T', body: 'B' }); + const first = await cms.publishArticle({ slug: 'sm-idem' }); + const second = await cms.publishArticle({ slug: 'sm-idem' }); + + expect(second.sha).toBe(first.sha); + expect(second.prev).toBe(first.sha); + }); - await expect(cms.listArticles()).rejects.toThrow('fatal: permission denied'); - cms.plumbing.execute = originalExecute; + it('resolveEffectiveState throws on unknown status', () => { + expect(() => resolveEffectiveState({ draftStatus: 'bogus', pubSha: null })).toThrow( + /Unrecognized draft status/ + ); }); }); diff --git a/test/server.test.js b/test/server.test.js index 1981909..88bdf75 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -163,4 +163,73 @@ describe('Server API (Integration)', () => { const res = await fetch(`${baseUrl}/_test-secret-link.txt`); expect(res.status).toBe(404); }); + + it('unpublishes an article via POST /api/cms/unpublish', async () => { + // Create and publish first + const snapRes = await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-unpub', title: 'T', body: 'B' }), + }); + expect(snapRes.status).toBe(200); + const pubRes = await fetch(`${baseUrl}/api/cms/publish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-unpub' }), + }); + expect(pubRes.status).toBe(200); + + const res = await fetch(`${baseUrl}/api/cms/unpublish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-unpub' }), + }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.sha).toBeDefined(); + }); + + it('reverts an article via POST /api/cms/revert', async () => { + // Create two versions so there is a parent + const v1Res = await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-revert', title: 'v1', body: 'b1' }), + }); + expect(v1Res.status).toBe(200); + const v2Res = await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-revert', title: 'v2', body: 'b2' }), + }); + expect(v2Res.status).toBe(200); + + const res = await fetch(`${baseUrl}/api/cms/revert`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-revert' }), + }); + const data = await res.json(); + expect(res.status).toBe(200); + expect(data.sha).toBeDefined(); + }); + + it('returns 400 with invalid_state_transition for bad transitions', async () => { + // Create a draft, then try to unpublish it (invalid: draft → unpublished) + const setupRes = await fetch(`${baseUrl}/api/cms/snapshot`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-bad-trans', title: 'T', body: 'B' }), + }); + expect(setupRes.status).toBe(200); + + const res = await fetch(`${baseUrl}/api/cms/unpublish`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ slug: 'srv-bad-trans' }), + }); + const data = await res.json(); + expect(res.status).toBe(400); + expect(data.code).toBe('invalid_state_transition'); + }); }); diff --git a/vitest.config.js b/vitest.config.js index b5087aa..4cfc435 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -1,7 +1,15 @@ import { defineConfig, defaultExclude } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + // TODO: remove once @git-stunts/git-warp exports InMemoryGraphAdapter publicly + // (not in the package's "exports" map as of v10.8.0) + '#test/InMemoryGraphAdapter': + new URL('node_modules/@git-stunts/git-warp/src/infrastructure/adapters/InMemoryGraphAdapter.js', import.meta.url).pathname, + }, + }, test: { - exclude: [...defaultExclude, 'test/e2e/**'], + exclude: [...defaultExclude, 'test/e2e/**', 'test/git-e2e**'], }, }); diff --git a/vitest.e2e.config.js b/vitest.e2e.config.js new file mode 100644 index 0000000..b5087aa --- /dev/null +++ b/vitest.e2e.config.js @@ -0,0 +1,7 @@ +import { defineConfig, defaultExclude } from 'vitest/config'; + +export default defineConfig({ + test: { + exclude: [...defaultExclude, 'test/e2e/**'], + }, +});