@inneropen/marvin-astro 1.0.0-next.1
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 +209 -0
- package/dist/index.d.ts +576 -0
- package/dist/index.js +1061 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/package.json +66 -0
- package/src/astro/SeoHead.astro +73 -0
- package/src/astro/index.ts +8 -0
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
|