@inneropen/marvin-astro 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,209 @@
1
+ # @inneropen/marvin-astro
2
+
3
+ Site integration for [Marvin CMS](https://github.com/inneropen) on Astro: content repositories
4
+ with static fallbacks, site chrome from collections, and payload normalization.
5
+
6
+ Three packages, three concerns:
7
+
8
+ | Package | Concern |
9
+ |---|---|
10
+ | `@inneropen/marvin-sdk` | transport — HTTP client, entries, collections, assets |
11
+ | `@inneropen/marvin-renderers-core` | entry-type → Astro renderer component mapping |
12
+ | **`@inneropen/marvin-astro`** | **site integration — repositories, chrome, normalization** |
13
+
14
+ A new site wires up to a Marvin workspace by installing one package, setting three env vars, and
15
+ writing only its own transform functions.
16
+
17
+ ```bash
18
+ npm install @inneropen/marvin-astro @inneropen/marvin-sdk
19
+ ```
20
+
21
+ ```bash
22
+ MARVIN_API_URL=https://marvin.example.com
23
+ MARVIN_SITE_CLIENT_TOKEN=site_client_…
24
+ MARVIN_WORKSPACE_SLUG=my-workspace
25
+ ```
26
+
27
+ ## Quick start
28
+
29
+ ```ts
30
+ // src/lib/content.ts
31
+ import { createMarvinContent } from '@inneropen/marvin-astro';
32
+ import { site as staticSite, mainNav, footerNav } from '../data/site';
33
+ import { posts as staticPosts } from '../data/posts';
34
+
35
+ export const marvin = createMarvinContent({
36
+ site: { fallback: staticSite },
37
+ chrome: { fallback: { mainNavigation: mainNav, footerNavigation: footerNav } },
38
+ });
39
+
40
+ const CATEGORIES = ['Making', 'Materials', 'Lessons'] as const;
41
+
42
+ export const posts = marvin.repository({
43
+ collections: ['bench-notes', 'journal', 'blog'], // tried in order
44
+ hydrate: true, // list items lack data_json — see below
45
+ href: (slug) => `/bench-notes/${slug}`,
46
+ fallback: () => staticPosts,
47
+ sort: (a, b) => Date.parse(b.date) - Date.parse(a.date),
48
+ transform: async (entry, f) => ({
49
+ slug: entry.slug,
50
+ title: entry.title ?? 'Untitled',
51
+ date: f.string('date') ?? f.publishedAt() ?? '',
52
+ noteNumber: f.string('noteNumber'), // data_json → metadata_json
53
+ category: f.oneOf('category', CATEGORIES, 'Making'),
54
+ order: f.number('order') ?? 0,
55
+ featured: f.bool('featured'),
56
+ bodyHtml: await f.markdown('body'),
57
+ image: f.image({ roles: ['hero', 'featured', 'card'] }),
58
+ icon: f.icon(),
59
+ href: f.href,
60
+ }),
61
+ });
62
+ ```
63
+
64
+ ```astro
65
+ ---
66
+ // src/pages/bench-notes/index.astro
67
+ import { posts, marvin } from '../../lib/content';
68
+
69
+ const all = await posts.all();
70
+ const { site, mainNavigation } = await marvin.getSiteChrome();
71
+ ---
72
+ ```
73
+
74
+ `posts.all()` · `posts.bySlug(slug)` · `posts.featured()` · `posts.allFeatured()` · `posts.reset()`
75
+
76
+ ## Why `hydrate`
77
+
78
+ The collection endpoint returns `PublishedEntryListItem`, which carries core fields and
79
+ `metadata_json` but **not** `data_json`. The single-entry endpoint returns `PublishedEntryRead`,
80
+ which includes it.
81
+
82
+ So if a transform reads any schema-defined field — anything beyond title/slug/summary/metadata —
83
+ `hydrate: true` is required or those fields come back `undefined`. It costs one request per entry.
84
+
85
+ ## Field precedence
86
+
87
+ Every reader on `f` resolves `data_json` first and `metadata_json` second. An empty string counts
88
+ as absent: an entry type that declares a field the author left blank stores `""`, and without the
89
+ fall-through a legacy value that *is* set would never surface.
90
+
91
+ | | |
92
+ |---|---|
93
+ | `f.string(key)` `f.number(key)` `f.bool(key)` `f.list(key)` | scalars; `bool` reads `"true"`/`"1"`/`"yes"` |
94
+ | `f.oneOf(key, allowed, fallback)` | enum guard — replaces per-field `normalizeStatus`-style helpers |
95
+ | `f.raw(key)` `f.data()` `f.metadata()` | untyped escape hatches |
96
+ | `f.markdown(key?, { softBreaks })` | renders to HTML; `undefined` when there is nothing to render |
97
+ | `f.date(key)` `f.publishedAt()` | display date ("Mon DD, YYYY") / raw ISO stamp |
98
+ | `f.image(options)` `f.images(options)` `f.icon(options)` | resolved `{src, alt, focalPoint}` |
99
+ | `f.asset(options)` `f.assetByRole(...roles)` `f.assets()` | raw asset placements |
100
+ | `f.resource(options)` `f.resources(options)` | attached resources → `{name, type, role, href}` |
101
+ | `f.collections()` `f.role(collection)` `f.href` | membership and routing context |
102
+
103
+ `f.image()` checks, in order: a hand-authored `metadata_json.featuredImage`, the exact
104
+ `preferRoles` (for derived variants like a colour-graded hero), a role/usage match over the
105
+ entry's image assets, then the list item's `featuredAsset`.
106
+
107
+ > **Role matching:** `selectEntryAsset` ORs role against usage, and an absent usage criterion is
108
+ > vacuously true — so a role-only query matches the entry's *first* asset. When you mean "the
109
+ > asset whose role is exactly this", use `f.assetByRole()` / `selectAssetByRole()`.
110
+
111
+ ## Site and chrome
112
+
113
+ ```ts
114
+ const site = await marvin.getSite(); // identity, SEO, brand assets — memoized
115
+ const chrome = await marvin.getSiteChrome(); // nav, footer, legal, social, inquiry — memoized
116
+ ```
117
+
118
+ `getSite()` resolves every `site_metadata_json.brand.<name>` asset slug to a URL in one pass, so
119
+ a site adds a shared brand asset with one config line and reads `site.brand.<name>` — no code
120
+ change per asset. `logo`, `favicon` and `seal` are aliased onto the top level.
121
+
122
+ `getSiteChrome()` reads `main-navigation` and `footer-navigation` collections, splits
123
+ `role: 'legal'` entries into `legalLinks`, and groups the rest into footer columns. A nav entry's
124
+ route comes from an explicit `href`/`url`/`path` field if it has one, otherwise from
125
+ `resolveHref`:
126
+
127
+ ```ts
128
+ resolveHref?: (entry, context) => string
129
+ ```
130
+
131
+ The default prefixes the entry's own non-navigation collection: an entry in `workshop-reference`
132
+ becomes `/workshop-reference/<slug>`, an entry in no other collection becomes `/<slug>`. Override
133
+ when routes don't mirror collections.
134
+
135
+ ## The failure latch
136
+
137
+ A static build asks for content once per path. When the backend is down that means N failed
138
+ requests with N timeouts. The latch trips on the first *network* failure — not a 404, which says
139
+ nothing about the next entry — and short-circuits the rest.
140
+
141
+ It expires after `retryAfterMs`, so a dev server recovers on its own when the backend comes back
142
+ instead of serving stale static data until someone restarts it. Defaults: **30s in dev**,
143
+ **`Infinity` in production**, since a build should fail fast and consistently rather than
144
+ half-succeed with some pages live and some static.
145
+
146
+ ```ts
147
+ createMarvinContent({ retryAfterMs: 5_000 });
148
+ marvin.backend.isLatched();
149
+ marvin.backend.clearLatch();
150
+ ```
151
+
152
+ ## SEO head (optional)
153
+
154
+ One component ships, behind its own export path, so the core package stays pure TypeScript and
155
+ Astro stays an optional peer dependency.
156
+
157
+ ```astro
158
+ ---
159
+ import { SeoHead } from '@inneropen/marvin-astro/astro';
160
+ import { marvin } from '../lib/content';
161
+
162
+ const { seo } = await marvin.getSite();
163
+ ---
164
+ <head>
165
+ <SeoHead {seo} pageTitle="Bench Notes" pageType="article" />
166
+ </head>
167
+ ```
168
+
169
+ It emits title, description, robots, canonical, Open Graph, Twitter and search-engine
170
+ verification tags. No styling, no site coupling.
171
+
172
+ ## Exports
173
+
174
+ | Path | Contents |
175
+ |---|---|
176
+ | `@inneropen/marvin-astro` | `createMarvinContent` and every helper below it |
177
+ | `@inneropen/marvin-astro/types` | resolved types only (`ApiSite`, `ApiSeo`, `ApiSiteChrome`, …) |
178
+ | `@inneropen/marvin-astro/astro` | `SeoHead` |
179
+
180
+ Beyond `createMarvinContent`, the pieces are usable on their own: `createBackend`,
181
+ `createFetcher`, `createRepository`, `createSiteLoader`, `createChromeLoader`,
182
+ `createFieldAccessor`, `createMarkdownRenderer`, `formatDisplayDate`, `selectValuesForPage`, and
183
+ the whole `normalize` surface.
184
+
185
+ ## Development
186
+
187
+ ```bash
188
+ npm install
189
+ npm run typecheck
190
+ npm test # vitest, fixture-driven, no network
191
+ npm run build # tsup → dist/ with .d.ts
192
+ ```
193
+
194
+ Fixtures under `tests/fixtures/` are captured from a live workspace rather than hand-written —
195
+ payload-shape drift is the class of bug they exist to catch.
196
+
197
+ Two live checks need a running Marvin (`MARVIN_*` in the environment):
198
+
199
+ ```bash
200
+ # End-to-end wiring: env, auth, chrome, a repository, one entry.
201
+ npx vite-node examples/smoke.ts [collection-slug]
202
+
203
+ # Field-level diff against a site's existing hand-rolled integration.
204
+ npx vite-node -c examples/parity/vite.config.ts examples/parity/mashandburnco.ts
205
+ ```
206
+
207
+ ## License
208
+
209
+ MIT