@micacms/astro 0.5.0 → 0.6.0-rc.2

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.
@@ -13,12 +13,15 @@ entries are converted once. The website then reads them through
13
13
  - The Git working tree must be clean (`git status`). If it is not, ask the
14
14
  user to commit or stash first.
15
15
  - Ask the user whether to work on a new branch, and make it if they agree.
16
- - Build the website once, and copy the result to a temporary directory
17
- outside the project. Step 8 compares with it.
16
+ - Build the website once, and copy two results to a temporary directory
17
+ outside the project: the website, which step 8 compares with, and Astro's
18
+ data store, which `mica import` reads in step 6. Astro writes the store
19
+ again when the collections change, so this copy is the only one.
18
20
 
19
21
  ```bash
20
22
  npx astro build
21
23
  cp -r dist <temporary directory>/dist-before
24
+ cp node_modules/.astro/data-store.json <temporary directory>/data-store-before.json
22
25
  ```
23
26
 
24
27
  ## 2. Make an inventory
@@ -64,7 +67,16 @@ Show the inventory as a short table, and ask:
64
67
  - Headings lose their `id` and code blocks lose their highlighting, unless
65
68
  the website adds them. See
66
69
  [reference.md](./reference.md#what-markdoc-renders-differently-from-astros-markdown).
67
- 4. Should editors put a collection in a manual order?
70
+ 4. Should editors put a collection in a manual order? If a collection has a
71
+ number field that only sorts it, such as `order`, give the user the choice
72
+ and the reason:
73
+ - Keep it as a `number()` field. No page changes. Editors type numbers,
74
+ and two entries can get the same one.
75
+ - Make the collection `sortable: true`. Editors drag the entries into
76
+ order, which is easier and cannot give two entries the same place. The
77
+ import turns the numbers into `_order.json` (`--order`), the field goes, and each
78
+ page that sorts by it sorts by `entry.data._mica.position` instead: one
79
+ changed line per page.
68
80
 
69
81
  Wait for the answers.
70
82
 
@@ -79,7 +91,7 @@ Wait for the answers.
79
91
  | `z.date()`, `z.coerce.date()` | `date()` (stored as `"YYYY-MM-DD"`) |
80
92
  | `z.enum([...])` | `select({ options })` |
81
93
  | `z.array(z.enum([...]))` | `select({ multiple: true, options })` |
82
- | `image()` | `media({ accept: "image" })` |
94
+ | `image()` | `media({ accept: "image", alt: "required" })`, or `media({ accept: "image" })` for a decorative image |
83
95
  | `reference("authors")` | `relation({ collection: "authors" })` |
84
96
  | `z.object({...})` | `object({ fields })` |
85
97
  | `z.array(z.object({...}))` | `repeater({ fields })` |
@@ -100,51 +112,49 @@ lowerCamelCase.
100
112
  3. In `src/content.config.ts`, remove the definitions of the collections that
101
113
  Mica now manages, and add `...micaCollections(site)`. Keep the others.
102
114
  4. Run `npx astro sync`. It writes `content/.mica/schema.json`, which the
103
- conversion script reads. The pages can fail now; step 7 fixes them.
115
+ import reads. The pages can fail now; step 7 fixes them.
104
116
 
105
117
  ## 6. Convert the entries
106
118
 
107
- Write a conversion script inside the project, for example
108
- `scripts/convert-to-mica.mjs`. Use `writeEntry` from
109
- [reference.md](./reference.md#write-entries-with-a-script). For each entry,
110
- the script:
111
-
112
- 1. Reads the file and its frontmatter. For YAML, use a parser that the project
113
- can already import (Astro depends on `yaml`), or install one for the
114
- conversion only and remove it in step 9.
115
- 2. Makes the slug from the id: lowercase, folders and other characters turned
116
- into single hyphens. Two ids must not give the same slug.
117
- 3. Builds `data` with every field of the model, in the stored form:
118
- - a date from the text in the frontmatter, as `"YYYY-MM-DD"`. Take an ISO
119
- date as it is written. Parse any other text in UTC, for example
120
- `` new Date(`${text} UTC`).toISOString().slice(0, 10) ``, and compare a few
121
- results with the source. Never format a local-time `Date`: in a time zone
122
- east of UTC, that gives the day before.
123
- - a relation as the slug of the other entry;
124
- - the body as a Markdoc string, changed as the user decided in step 3;
125
- - the slug field equal to the slug.
126
- 4. Copies the image of each `image()` field to
127
- `content/<collection>/<slug>/media/<file>`, and stores `media/<file>` in the
128
- field. The path in the frontmatter is relative to the source entry's file.
129
- Body images go where the user decided in step 3.
130
- 5. Calls `writeEntry(collection, slug, data)`.
131
- 6. Prints a line for each entry, and a list of everything it changed or could
132
- not convert.
133
-
134
- Keep the source files. Do not delete anything in this step.
135
-
136
- Run the script, then:
119
+ `mica import` reads the copy of the data store from step 1, where Astro keeps
120
+ every entry after its own loader and schema. Each field of the Mica model takes
121
+ the value with the same name. The command converts it to the stored form: a
122
+ date to `"YYYY-MM-DD"` in UTC, a reference to a slug, an `image()` path to a
123
+ copy in the entry's `media/` folder, and the Markdown body to the rich-text
124
+ field `body`. It makes each slug from the entry id, and writes the files the
125
+ way the editor does.
126
+
127
+ ```bash
128
+ npx mica import --from <temporary directory>/data-store-before.json \
129
+ --map blog.cover.alt=coverAlt --order team=order
130
+ ```
131
+
132
+ - `--map <collection>.<field>=<key>` takes a field from another key. Use it
133
+ when a name changed, for the alt text of an image (`blog.cover.alt=coverAlt`),
134
+ and for a body that goes to a rich-text field with another name
135
+ (`blog.content=@body`). `--map` does not reach into the rows of a repeater.
136
+ - `--order <collection>=<field>` writes the manual order of a `sortable`
137
+ collection from a number field, if the user chose that in step 3.
138
+ - Names after the options import only those collections.
139
+
140
+ The command lists every key it did not import, every id that gives the same
141
+ slug as another, and every image it did not find. Fix the model or add a
142
+ `--map`, delete the collection's directory in `content/`, and run it again.
143
+ Then make the changes to the bodies that the user decided in step 3 directly
144
+ in the `.mdoc` files, and run:
137
145
 
138
146
  ```bash
139
147
  npx mica check
140
148
  ```
141
149
 
142
- Fix the script, not the output, until `mica check` reports 0 errors. Before
143
- each run, delete the converted collection's directory in `content/` (not
144
- `content/.mica/`), so that no file from an earlier run stays. Read every warning: "Not read" means that an
145
- entry or a file is lost, and a Markdoc warning means that some text does not
146
- render as before. If you cannot fix a warning, list it in the report with the
147
- reason.
150
+ Read every warning: "Not read" means that an entry or a file is lost, and a
151
+ Markdoc warning means that some text does not render as before. If you cannot
152
+ fix a warning, list it in the report with the reason. Keep the source files.
153
+ Do not delete anything in this step.
154
+
155
+ If the import cannot express a conversion, for example a value that the model
156
+ splits into two fields, write a script for that part only, with `writeEntry`
157
+ from [reference.md](./reference.md#write-entries-with-a-script).
148
158
 
149
159
  ## 7. Change the pages
150
160
 
@@ -161,9 +171,12 @@ endpoints such as `rss.xml.js`. See
161
171
  strings gives `NaN`, and the list is then not sorted, with no error.
162
172
  - Where an API needs a `Date`, such as the RSS `pubDate`, pass
163
173
  `new Date(entry.data.pubDate)`.
164
- - An image was `ImageMetadata` and is now a path from the content root. Create
165
- `src/lib/media.ts` from [reference.md](./reference.md#images), and use
166
- `mediaImage()`.
174
+ - An `image()` field is still `ImageMetadata` with `media({ accept: "image" })`,
175
+ so its pages do not change. With `alt`, the value is `{ src, alt }`: use
176
+ `entry.data.cover.src` where the page used `entry.data.cover`, and
177
+ `entry.data.cover.alt` in place of the old alt-text field, or spread it:
178
+ `<Image {...entry.data.cover} />`. See
179
+ [reference.md](./reference.md#images).
167
180
  - A reference was `{ collection, id }` and is now a slug:
168
181
  `getEntry("authors", entry.data.author)`.
169
182
  - `entry.id` is the slug. If ids had folders, update the links, and add
@@ -200,7 +213,8 @@ Ask the user before each of these:
200
213
  such as `@astrojs/mdx` when no MDX is left, and those that only the
201
214
  conversion needed. Change text in the content that describes what you
202
215
  removed.
203
- - Delete the conversion script, or keep it for the record.
216
+ - Delete the temporary directory with `dist-before` and the data store copy,
217
+ and a conversion script if you wrote one.
204
218
 
205
219
  Then run `npx mica check` and `npx astro build` once more.
206
220
 
package/setup/finish.md CHANGED
@@ -1,22 +1,39 @@
1
1
  # Finish the setup
2
2
 
3
- ## 1. Check
3
+ ## 1. Add scripts
4
+
5
+ Add two scripts to `package.json`, so that the user has short commands:
6
+
7
+ ```json
8
+ "mica": "mica start",
9
+ "check": "mica check && astro check"
10
+ ```
11
+
12
+ - If the project has no `@astrojs/check`, use `"check": "mica check"`. Do not
13
+ install it only for this: without it, `astro check` asks to install it, and a
14
+ script cannot answer. Tell the user that nothing checks the types in
15
+ `.astro` files.
16
+ - If the project already has a `mica` script, keep it. If it has a `check`
17
+ script without `mica check`, ask the user whether to add `mica check &&` in
18
+ front of it. If the user says no, use `npx mica check` in place of
19
+ `npm run check` in the steps below and in the notes.
20
+
21
+ ## 2. Check
4
22
 
5
23
  All three must pass:
6
24
 
7
25
  ```bash
8
26
  npx astro sync
9
- npx mica check # 0 errors
27
+ npm run check # mica check: 0 errors; then astro check, if it is there
10
28
  npx astro build
11
29
  ```
12
30
 
13
- If the project has `@astrojs/check`, run `npx astro check` too. Do not install
14
- it only for this. Without it, nothing checks the types in `.astro` files: tell
15
- the user.
31
+ If `astro check` reports errors that were there before you started, tell the
32
+ user. Do not fix them unless the user asks.
16
33
 
17
34
  Explain each warning of `mica check` to the user, or fix it.
18
35
 
19
- ## 2. Add notes for the next agent
36
+ ## 3. Add notes for the next agent
20
37
 
21
38
  Add this section to the project's agent instructions: `AGENTS.md`,
22
39
  `CLAUDE.md` or the file the project already uses. If `CLAUDE.md` is a link to
@@ -27,23 +44,41 @@ paths if the project uses others.
27
44
  ## Content (Mica)
28
45
 
29
46
  - The content model is `src/mica.ts`. The content is JSON and Markdoc in
30
- `content/`; editors change it with `npx mica`.
31
- - After a change to `src/mica.ts`, run `npx astro sync` and `npx mica check`,
47
+ `content/`; editors change it with `npm run mica`.
48
+ - After a change to `src/mica.ts`, run `npx astro sync` and `npm run check`,
32
49
  and commit `content/.mica/schema.json` with the change.
33
50
  - Renaming or removing a field or a collection does not move the stored
34
51
  content. Change the content files in the same commit, or ask first.
35
- - Before a commit that changes `content/`, `npx mica check` must report 0 errors.
52
+ - Before a commit that changes `content/`, `npm run check` must pass.
36
53
  ```
37
54
 
38
- ## 3. Tell the user
55
+ ## 4. Tell the user
39
56
 
40
57
  - What you added and changed, file by file.
41
58
  - The decisions they made, and what was not converted, if anything.
42
- - What to commit: every file that `git status` shows as new or changed,
43
- including `content/.mica/schema.json`, `package.json` and the lock file. Do
44
- not commit for them unless they asked.
45
- - How to edit: `npx mica` opens the editor on this computer. A save writes
46
- files in `content/`; Publish commits them. `@micacms/editor` is a dev
59
+ - How to commit: give the commands, and run them only if the user asks. With
60
+ converted content, use two commits, so that the history keeps the setup
61
+ apart from the content. Name the files of the first commit from
62
+ `git status`:
63
+
64
+ ```bash
65
+ # 1. The setup: packages, model, collections config, agent notes, scripts.
66
+ git add package.json package-lock.json src/mica.ts src/content.config.ts \
67
+ content/.mica/schema.json AGENTS.md
68
+ git commit -m "Add Mica"
69
+ # 2. The content and the page changes: everything else that git status shows.
70
+ git add -A
71
+ git commit -m "Move the content to Mica"
72
+ ```
73
+
74
+ Use the project's lock file (`pnpm-lock.yaml`, `yarn.lock`). For a new
75
+ project without converted content, one commit is enough.
76
+ - How to publish the branch: on a branch that is not on the remote yet,
77
+ `git push -u origin <branch>`. The editor's Publish does this too, the first
78
+ time.
79
+ - How to edit: `npm run mica` opens the editor on this computer. A save writes
80
+ files in `content/`; Publish commits them. `npm run check` checks the content
81
+ (and the types, if `@astrojs/check` is there). `@micacms/editor` is a dev
47
82
  dependency, so on another clone, run `npm install` first.
48
83
  - That a self-hosted editor for other people is described at
49
84
  https://micacms.com/docs/self-hosting/.
@@ -17,8 +17,12 @@ Ask these questions, and wait for the answers:
17
17
  3. Do entries refer to other entries? For example, an article has an author
18
18
  from the team.
19
19
  4. Should editors put a collection in a manual order?
20
- 5. Do images need alt text that editors write? A media field has none, so
21
- each image that carries meaning needs a `text()` field beside it.
20
+ 5. Which images carry meaning, and which are decorative? An image that
21
+ carries meaning gets `media({ accept: "image", alt: "required" })`, so that
22
+ editors write its alt text. A decorative image gets
23
+ `media({ accept: "image" })`. `accept: "image"` makes the field an Astro
24
+ image on the pages; without it, the field is a path, for a PDF or another
25
+ file.
22
26
  6. Do they want example content, so that the pages show something at once?
23
27
 
24
28
  Suggest a model from the answers, as a short list of collections, singletons
@@ -9,7 +9,9 @@ The guides link here. Read the section that a step names.
9
9
  | `npx mica init` | Writes a starter `src/mica.ts` and `src/content.config.ts`. Never overwrites a file |
10
10
  | `npx astro sync` | Runs the Mica loaders, which write the model to `content/.mica/schema.json` |
11
11
  | `npx mica check` | Checks every entry against `content/.mica/schema.json`. Exit code 1 on an error |
12
- | `npx mica` | Opens the editor in the browser. Only for the user; do not run it yourself |
12
+ | `npx mica import` | Moves Astro collections into Mica, from Astro's data store. See [content-collections.md](./content-collections.md#6-convert-the-entries) |
13
+ | `npx mica migrate-layout` | Moves entries into the layout of the model, after the first or the last rich-text or media field of a collection was added or removed |
14
+ | `npx mica start` | Opens the editor in the browser. Only for the user; do not run it yourself |
13
15
 
14
16
  `mica check` reads the model from `content/.mica/schema.json`. After every
15
17
  change to `src/mica.ts`, run `npx astro sync` before `npx mica check`.
@@ -61,7 +63,7 @@ in the entry list), `readonly`, `hidden`, `group`, `width` (`"full"`, `"half"`,
61
63
 
62
64
  | Field | Stored as | Other options |
63
65
  | --- | --- | --- |
64
- | `text()` | string | `minLength`, `maxLength`, `pattern`, `placeholder`, `default` |
66
+ | `text()` | string | `minLength`, `maxLength`, `pattern` (must match the whole value), `placeholder`, `default` |
65
67
  | `textarea()` | string | `minLength`, `maxLength`, `rows`, `placeholder`, `default` |
66
68
  | `richText()` | Markdoc in its own `.mdoc` file | `toolbar`, `maxLength`, `default` |
67
69
  | `number()` | number | `min`, `max`, `step`, `integer`, `unit`, `default` |
@@ -74,7 +76,7 @@ in the entry list), `readonly`, `hidden`, `group`, `width` (`"full"`, `"half"`,
74
76
  | `object()` | object | `fields` (required), `collapsible`, `collapsed` |
75
77
  | `repeater()` | array of objects | `fields` (required), `min`, `max`, `itemLabel`, `addLabel` |
76
78
  | `relation()` | the slug of an entry | `collection` (required), `multiple` |
77
- | `media()` | a file path | `accept: "image" \| "document" \| "any"`, `multiple`, `max` |
79
+ | `media()` | a file path, or `{ path, alt }` with `alt` | `accept: "image" \| "document" \| "any"`, `multiple`, `max`, `alt: "required" \| "optional"` |
78
80
  | `color()` | `"#rrggbb"` | `presets`, `default` |
79
81
  | `coordinates()` | `{ "lat": 47.37, "lng": 8.54 }` | `defaultCenter`, `defaultZoom` |
80
82
 
@@ -152,12 +154,13 @@ export async function writeOrder(collection, slugs) {
152
154
  await writeFile(join(ROOT, collection, "_order.json"), `${JSON.stringify(slugs, null, 2)}\n`);
153
155
  }
154
156
 
155
- // Example: `data` has every field, rich text as a Markdoc string, media as `media/<file>`.
157
+ // Example: `data` has every field, rich text as a Markdoc string, and media as
158
+ // `media/<file>`, or `{ path, alt }` for a `media({ alt })` field.
156
159
  // await writeEntry("news", "hello-world", {
157
160
  // title: "Hello, world",
158
161
  // slug: "hello-world",
159
162
  // publishedOn: "2026-01-15",
160
- // cover: "media/cover.jpg",
163
+ // cover: { path: "media/cover.jpg", alt: "The team at the opening" },
161
164
  // body: "The first article.",
162
165
  // });
163
166
  ```
@@ -206,41 +209,24 @@ const settings = await getEntry("settings", "settings");
206
209
 
207
210
  ### Images
208
211
 
209
- On disk, a media field holds `media/cover.jpg`. In `entry.data`, the loader
210
- gives the path from the content root: `news/hello-world/media/cover.jpg`. Astro can only optimize an image that the
211
- build imports, so collect the images with `import.meta.glob`:
212
-
213
- ```ts
214
- // src/lib/media.ts — the glob path is relative to this file
215
- import type { ImageMetadata } from "astro";
216
-
217
- const images = import.meta.glob<{ default: ImageMetadata }>(
218
- "../../content/**/*.{jpeg,jpg,png,webp,avif,gif}",
219
- { eager: true },
220
- );
221
-
222
- /** The image for a Mica media path, or undefined when the file is not there. */
223
- export function mediaImage(path: string | null | undefined): ImageMetadata | undefined {
224
- return path ? images[`../../content/${path}`]?.default : undefined;
225
- }
226
- ```
212
+ A `media({ accept: "image" })` field reaches Astro as an image that Astro
213
+ optimizes, the same as `image()` in an Astro collection: `ImageMetadata`. With
214
+ `alt`, it is `{ src, alt }`. No helper is needed:
227
215
 
228
216
  ```astro
229
217
  ---
230
218
  import { Image } from "astro:assets";
231
- import { mediaImage } from "../../lib/media";
232
- const cover = mediaImage(article.data.cover);
219
+ const { cover } = article.data; // media({ accept: "image", alt: "required" })
233
220
  ---
234
- {cover && <Image src={cover} alt={article.data.title} />}
221
+ {cover && <Image {...cover} />}
235
222
  ```
236
223
 
237
- A `media({ multiple: true })` field is an array of paths:
238
- `project.data.gallery.map(mediaImage)`.
239
-
240
- A media field has no alt text. Add a `text()` field for it where the image
241
- carries meaning. For documents such as PDF files, use
242
- `import.meta.glob("../../content/**/*.pdf", { query: "?url", import: "default", eager: true })`
243
- and link to the URL.
224
+ - Without `alt`, the field is the image itself: `<Image src={logo} alt="" />`.
225
+ - A `media({ accept: "image", multiple: true })` field is an array of them.
226
+ - On disk, the field still holds `media/cover.jpg`, or `{ path, alt }`.
227
+ - A `media()` without `accept: "image"`, for example a PDF, is a path from the
228
+ content root: `news/hello-world/media/plan.pdf`. To link to it, use
229
+ `import.meta.glob("../../content/**/*.pdf", { query: "?url", import: "default", eager: true })`.
244
230
 
245
231
  ### What Markdoc renders differently from Astro's Markdown
246
232
 
package/src/images.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Image fields reach Astro through its own `image()`, which resolves a path
3
+ * against the entry's file, optimizes the image and gives `ImageMetadata`. A
4
+ * site then needs no helper: `<Image {...entry.data.cover} />`.
5
+ */
6
+
7
+ import { posix } from "node:path";
8
+
9
+ import type { FieldMap } from "@micacms/core";
10
+
11
+ /**
12
+ * Rewrites the paths of `media({ accept: "image" })` fields, which the reader
13
+ * gives from the content root, to paths from the entry's data file.
14
+ */
15
+ export function imagePathsFromEntry(
16
+ fields: FieldMap,
17
+ data: Record<string, unknown>,
18
+ dataFile: string,
19
+ ): Record<string, unknown> {
20
+ const from = posix.dirname(dataFile);
21
+ const relative = (path: unknown): unknown => {
22
+ if (typeof path !== "string" || path === "") return path;
23
+ const result = posix.relative(from, path);
24
+ return result.startsWith(".") ? result : `./${result}`;
25
+ };
26
+ const one = (value: unknown): unknown =>
27
+ isRecord(value) ? { ...value, path: relative(value["path"]) } : relative(value);
28
+
29
+ const visit = (map: FieldMap, record: Record<string, unknown>): Record<string, unknown> => {
30
+ const result = { ...record };
31
+ for (const [key, field] of Object.entries(map)) {
32
+ const value = record[key];
33
+ if (field.kind === "media" && field.accept === "image") {
34
+ result[key] = Array.isArray(value) ? value.map(one) : one(value);
35
+ } else if (field.kind === "object" && isRecord(value)) {
36
+ result[key] = visit(field.fields, value);
37
+ } else if (field.kind === "repeater" && Array.isArray(value)) {
38
+ result[key] = value.map((row) => (isRecord(row) ? visit(field.fields, row) : row));
39
+ }
40
+ }
41
+ return result;
42
+ };
43
+
44
+ return visit(fields, data);
45
+ }
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return value !== null && typeof value === "object" && !Array.isArray(value);
49
+ }