@timholtweb/cms-core
v2.9.0
Published
Shared Google Sheets + Google Docs CMS platform for Astro content collections -- reconciled from astro-composer-starter-v2.0 and timholtmusic.com.
Readme
@timholtweb/cms-core
A reusable Google Sheets/Docs → Markdown content-management engine for static site projects. It syncs structured rows from a Google Sheet (and, optionally, long-form body content from linked Google Docs) into real, git-tracked Markdown files, tracks identity so repeated syncs update rather than duplicate content, and refuses to publish a change that looks like it would silently destroy existing content.
What cms-core is responsible for
- Reading rows from a Google Sheet and normalizing them via a project's own adapters
- Generating Markdown frontmatter (and, optionally, resolving a linked Google Doc as the entry's body) from each normalized row
- Tracking content identity across syncs (
lib/identity-map.mjs), so a second sync of the same row updates the same file instead of creating a duplicate - Verifying a proposed change against the current, real content
before writing it, and refusing to publish automatically if the
change looks like it would remove existing content
(
lib/verify.mjs, the information-loss safeguard inlib/sync.mjs) - Validating a project's real readiness to publish --
cms doctor-- including Sheet/adapter column agreement, a project-identity guardrail, and a deterministic, fail-safe scan of whether an adapter's declaredoutputFieldsactually cover what a real Astrocontent.config.tsschema requires (lib/schema-scanner.mjs) - Optionally writing a real, plain-language sync result directly
into the same Google Sheet row that was processed
(
lib/row-status.mjs), so a nontechnical editor can see the outcome without checking a terminal or GitHub Actions - A CLI (
cms) tying all of the above together:sync,verify,check,doctor,publish,audit,adopt
Drive-managed images (imageSources)
An adapter can optionally declare one or more imageSources entries to
let a client associate a Google Drive image with a row through the
Sheet, instead of (or alongside) manually-authored image entries. This
section documents the actual, current implementation -- not an
aspirational future API.
Current scope: array fields only. imageSources resolves into a
list-shaped frontmatter field (e.g. images: [...], or any other
array field an adapter's schema defines). It does not currently
support a single-image field (a scalar object like cover or
headshot) -- that would be a materially different capability, not
yet built.
Declaring an image source
// A generic adapter, not tied to any specific collection name:
export const someAdapter = {
// ...
imageSources: [
{
fieldKey: "images", // the frontmatter array field this populates
sheetLinksColumn: "Image Uploads",
sheetAltColumn: "Image Uploads Alt",
sheetCaptionColumn: "Image Uploads Caption", // optional
},
],
};fieldKey can be any name the adapter's own schema uses -- images,
photos, or anything else. Nothing in cms-core hardcodes a field
name; the adapter's declaration is the sole source of truth,
including for the managed-removal safety exemption described below.
Identity and provenance
- Drive file ID is the canonical external identity -- stable across a Drive-side rename, never reused for a different file.
- Drive filename is editorial/human metadata -- validated (descriptive, lowercase, hyphen-separated, letters/numbers/hyphens, a real image extension), but never invented or auto-sequenced by the CMS. An invalid filename fails the row with an actionable message; the CMS never silently renames or suffixes it.
- Local asset path is the stable production representation, derived once, at first import, directly from the validated Drive filename. It never changes on a later Drive-side rename.
- Provenance (
.cms/identity-map.json'smediamap, keyed by Drive file ID) records the relationship between the Drive identity and the local asset -- collection, field, filename, local path, and a content hash of the current bytes. - Duplicate-path safety: two distinct Drive files may never claim the same local path. A real collision fails the row rather than silently suffixing a filename.
Sync behavior
- First import: downloads the file, validates the filename,
writes it to
src/assets/images/<collection>/<filename>, and records provenance. - Subsequent sync, unchanged file: reuses the existing local path and skips re-downloading.
- Drive-side rename: does not move, rename, or duplicate the local
asset. The local path is permanent once established; only the
informational
driveFilenamemetadata updates. - Drive-side byte replacement (same file, same name): detected via a content-hash fingerprint independent of any visible frontmatter field, so a pure content swap still registers as a real change and triggers a real re-download -- not a false no-op.
- Reorder / add / remove within a list: existing entries keep their identity; reordering never renames or re-downloads anything.
Removal semantics (a platform invariant)
The Sheet controls whether an image is currently referenced and published. Drive remains the source asset. Clearing a Sheet association removes the image's reference from the published content, but never automatically deletes the corresponding local asset from the repository.
Re-adding the same Drive file afterward reuses its existing provenance and local asset -- it is not treated as a new import.
The information-loss safety net and the managed-removal exemption
cms-core's existing, generic information-loss protection
(lib/verify.mjs) blocks any sync where a field would go from
populated to empty, regardless of collection or field name -- proven
elsewhere on this platform to catch real accidental data loss (e.g. a
cleared date field). Deliberately clearing a Drive-managed image
association produces exactly that shape of diff, so a second,
narrowly-scoped mechanism (lib/managed-removal.mjs) tells the two
cases apart:
- A wipeout is exempted from information-loss only when every
single vanished entry is provably Drive-managed, for that exact
field (as declared in the adapter's own
imageSources), for that exact collection. - If even one vanished entry is a manually-authored image the CMS has no provenance record for, the removal stays blocked -- a manual-only removal, or a mixed manual-and-Drive removal, is still treated as a real risk requiring human review.
- Collections with no
imageSourcesdeclared receive no exemption at all -- the original, strict behavior is completely unchanged for them.
Transactionality
Image resolution happens before markdown generation, inside the same
staged, all-or-nothing sync sync.mjs already performs for content:
images are written to a staging mirror, promoted alongside markdown
only after validation/build succeeds, and rolled back entirely on
failure or --dry-run. A failed sync never leaves a partial mix of
promoted images and un-promoted content, or vice versa.
Manual and Drive-managed images coexist
An adapter's existing, manually-authored image entries (however that adapter already parses them from its own Sheet column) are never replaced -- Drive-resolved entries are appended alongside them. Both kinds of entries share the exact same frontmatter shape; nothing downstream needs to know which kind an entry is.
What this capability deliberately does not do
To avoid the wrong assumption that these are missing bugs: as of this
writing, imageSources does not provide inline body-image authoring,
a generalized gallery UI beyond a plain list, automatic filename
generation, Drive itself as a production asset host (Drive is only
ever a one-time source; the committed local file is what the site
actually serves), automatic local-asset deletion, or a universal
caption/credit content model (caption/credit are optional,
adapter-declared fields, not guaranteed across every collection). A
single-image field (a scalar, not a list) is also not yet supported --
see the scope note above.
What cms-core is NOT responsible for
cms-core has no knowledge of Astro, page routing, JSON-LD, taxonomy
presentation, theme tokens, or site templates. That's the
Astro Portfolio Starter Kit's
job. A project's real Astro schema (content.config.ts) and cms-core's
adapters must agree with each other (the schema scanner above helps
catch when they don't), but cms-core never generates or owns that
schema itself.
Relationship to the Astro Portfolio Starter Kit
The Starter Kit is a real, working Astro site template that consumes
cms-core as a dependency. It owns everything cms-core deliberately
doesn't: page routing, site presentation, JSON-LD builders, taxonomy/
browse pages, theme configuration, and the actual rendered site. A
project built on the Starter Kit declares its own adapters (one
per content collection) and registers them in a real cms.config.mjs
at the project root -- cms-core reads that file and the adapters it
points to; it has no built-in knowledge of any specific project's
collections.
Installation
Consuming projects currently install this as a local, packed dependency:
{
"dependencies": {
"@timholtweb/cms-core": "file:vendor/timholtweb-cms-core-2.6.0.tgz"
}
}Basic CLI usage
Run via npx cms <command> from a project that has a real
cms.config.mjs at its root:
npx cms doctor # is this project ready to publish?
npx cms doctor --key <path> --sheet-id <id> # same, including live Sheet checks
npx cms publish <collection> --live --key <path> --sheet-id <id> --dry-run
npx cms publish <collection> --live --key <path> --sheet-id <id>
npx cms check --generate # (re)generate the integrity manifest
npx cms adopt <collection> --live --key <path> --sheet-id <id>
npx cms audit <collection>See a real project's own PLATFORM-ARCHITECTURE.md (in the Starter
Kit) for the complete adapter contract and setup walkthrough --
cms-core itself has no project-specific setup docs, since it has no
opinion about what a project's collections should be.
Testing
CI workflow template
.github-workflow-cms-publish.yml is a template, meant to be
copied into a consuming project as .github/workflows/cms-publish.yml
-- it is not this package's own CI configuration. It defines the real
GitHub Action a project uses to run cms publish against a real
Sheet on demand (e.g. triggered from an Apps Script menu). Set the
real GDRIVE_SERVICE_ACCOUNT_KEY secret in the consuming repository
before using it; the git identity it commits as is a neutral
placeholder ([email protected]) meant to be changed per
project, not a real, shared account.
npm testRuns the real, permanent regression suite (node --test test/*.test.mjs)
-- currently 22 tests covering row-level status feedback, the schema
scanner, and the manual-promotion identity round trip.
Versioning and release philosophy
Every real version bump in this package corresponds to a real,
evidenced need found while building an actual project on top of it --
not speculative hardening. See CHANGELOG.md for the full, honest
history of what each version fixed and why, including several entries
that describe a real bug found on a real project before being fixed
here.
Current version
2.6.0
