Skip to content

Under developmentMica is experimental and not ready for production. The API and the content format can still change, so read the release notes before you upgrade.Follow the releases.

Docs/Change the model

Build

Change the model

Which model changes are safe, which need a change to the content, and a tested script for each common case.

6 min read

The model is code, and the content is files. When you change the model, some changes also need a change to the files. Mica does not change the content for you. Change the model and the content in one commit: Git is the undo.

Which changes are safe

ChangeSafeWhat to do
Add a field that is not requiredYesNothing. Old entries have no value.
Change a label, a description, listed, group, width or a limitYesNothing. Run npx mica check: a new limit can make an old value wrong.
Add an option to a select()YesNothing.
Remove a fieldYesNothing. Mica keeps the value in the file, and the website does not get it. To delete it, use remove-field.mjs.
Add a required fieldNonpx mica check reports each old entry without a value, and an editor cannot publish a change to it before it has one. Give them a value with fill-field.mjs, or let editors do it.
Rename a fieldNoThe editor and Astro do not see the old key, so the value seems to be lost. It is still in the file. Use rename-field.mjs. For a rich-text field, it renames the .mdoc file.
Change text() or textarea() to richText()NoThe value moves from the JSON file to a .mdoc file. Use text-to-rich-text.mjs.
Add localized to a field that has valuesNoEach value moves into the default locale: "title": "About" becomes "title": { "de": "About" }, and body.mdoc becomes body.de.mdoc. npx mica check lists the entries. Change them by hand, or change one of the scripts. See Localization.
Change a field to another kindNoThe old values do not validate. npx mica check lists them. Change them by hand, or change one of the scripts.
Change the value of a select() optionNoLike a rename of a value. npx mica check lists the entries.
Rename a collectionNoRename its folder with git mv content/old content/new, and change each relation({ collection }) that names it.

The steps

  1. If editors use a self-hosted Mica, publish or discard their pending changes first. A saved change has the old shape.
  2. Change the model in src/mica.ts.
  3. Run npx astro sync. It writes the new model to content/.mica/schema.json first. After a new required field, it then stops with an error about the old entries. That is expected: the next steps fix them.
  4. Run the script for your change.
  5. Run npx mica check. It must show no errors.
  6. Commit the model, .mica/schema.json and the content together.

The scripts

Copy a script into your project, for example to scripts/, and run it with Node. Each script uses only @micacms/core, which your project already has. It reads the model from .mica/schema.json, and it finds each entry in its folder.

The scripts change the top-level fields of the entries of one collection. For a singleton or a field in an object(), change the script. A script stops with a message when the collection does not exist, or when a rename would overwrite a value. Mica's CI runs each script on test content, so the scripts on this page work with the current version.

Rename a field

node scripts/rename-field.mjs content team title role
// Renames a top-level field in every entry of a collection, also a rich-text field and its .mdoc file.
// Change the model and run `npx astro sync` first. node rename-field.mjs <content root> <collection> <old key> <new key>
import { existsSync } from "node:fs";
import { readFile, rename, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { collectionIds, documentName, entryFolder, INDEX_FILE, MANIFEST_PATH } from "@micacms/core";
import { fileSystemSource } from "@micacms/core/node";

const [root, collection, from, to] = process.argv.slice(2);
const manifest = JSON.parse(await readFile(join(root, MANIFEST_PATH), "utf8"));
const def = manifest.collections[collection];
if (!def) throw new Error(`The model has no collection "${collection}".`);

for (const id of await collectionIds(fileSystemSource(root), collection)) {
  const base = join(root, entryFolder(collection, id));
  if (def.fields[to]?.kind === "richText") {
    // Rich text is a file beside index.json, not a key in it.
    const [old, renamed] = [join(base, documentName([from])), join(base, documentName([to]))];
    if (existsSync(renamed)) throw new Error(`${renamed} exists already.`);
    if (existsSync(old)) await rename(old, renamed);
    continue;
  }
  const file = join(base, INDEX_FILE);
  const data = JSON.parse(await readFile(file, "utf8"));
  if (!(from in data)) continue;
  if (to in data) throw new Error(`${file} has "${to}" already.`);
  // The key keeps its place, so the diff shows one changed line.
  const renamed = Object.fromEntries(Object.entries(data).map(([key, value]) => [key === from ? to : key, value]));
  await writeFile(file, `${JSON.stringify(renamed, null, 2)}\n`);
}

Move text to rich text

node scripts/text-to-rich-text.mjs content news summary
// Moves a text value out of index.json into the field's .mdoc file, after text() became richText().
// Change the model and run `npx astro sync` first. node text-to-rich-text.mjs <content root> <collection> <key>
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { collectionIds, documentName, entryFolder, INDEX_FILE, MANIFEST_PATH } from "@micacms/core";
import { fileSystemSource } from "@micacms/core/node";

const [root, collection, key] = process.argv.slice(2);
const manifest = JSON.parse(await readFile(join(root, MANIFEST_PATH), "utf8"));
const def = manifest.collections[collection];
if (!def) throw new Error(`The model has no collection "${collection}".`);

// Markdoc reads these characters as markup, and a list or a rule at the start of a line; a backslash keeps them as text.
const escape = (line) =>
  line
    .replace(/[\\`*_{}[\]<>#|~]/g, "\\$&")
    .replace(/^([-+])/, "\\$1")
    .replace(/^(\d+)([.)])/, "$1\\$2");

for (const id of await collectionIds(fileSystemSource(root), collection)) {
  const base = join(root, entryFolder(collection, id));
  const file = join(base, INDEX_FILE);
  const data = JSON.parse(await readFile(file, "utf8"));
  const value = data[key];
  if (typeof value !== "string") continue;
  delete data[key];
  // Each line of the old text becomes a paragraph.
  const paragraphs = value.split(/\r?\n/).map((line) => escape(line.trim())).filter(Boolean);
  if (paragraphs.length > 0) await writeFile(join(base, documentName([key])), `${paragraphs.join("\n\n")}\n`);
  await writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
}

Each line of the old text becomes a paragraph. Characters that Markdoc reads as markup, such as * and #, and a list marker or a rule at the start of a line, get a backslash, so they stay text. Look at the result in the editor.

Give a field a value

node scripts/fill-field.mjs content team role '"Consultant"'
// Gives a top-level field a value in every entry of a collection that has none, for example after a new required field.
// Change the model and run `npx astro sync` first. node fill-field.mjs <content root> <collection> <key> <value as JSON>
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { collectionIds, entryDataPath, MANIFEST_PATH } from "@micacms/core";
import { fileSystemSource } from "@micacms/core/node";

const [root, collection, key, json] = process.argv.slice(2);
const value = JSON.parse(json);
const manifest = JSON.parse(await readFile(join(root, MANIFEST_PATH), "utf8"));
const def = manifest.collections[collection];
if (!def) throw new Error(`The model has no collection "${collection}".`);
if (def.fields[key]?.kind === "richText") throw new Error(`"${key}" is rich text, which is a file: write it in the editor.`);

for (const id of await collectionIds(fileSystemSource(root), collection)) {
  const file = join(root, entryDataPath(collection, id));
  const data = JSON.parse(await readFile(file, "utf8"));
  if (data[key] !== undefined && data[key] !== null && data[key] !== "") continue;
  data[key] = value;
  await writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
}

The value is JSON: a text in double quotes, a number, true, or a list. The script does not fill a rich-text field, because rich text is a file: write it in the editor.

Delete a removed field

Mica keeps a value whose field is not in the model: a save does not delete it, because the model and the content can arrive in different commits. To delete the content of a field on purpose, remove the field from the model, run npx astro sync, then:

node scripts/remove-field.mjs content team phone
// Deletes a top-level field and its .mdoc file from every entry of a collection, after it left the model.
// Mica keeps a field that the model does not declare, so this is how its content goes. node remove-field.mjs <content root> <collection> <key>
import { existsSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { collectionIds, documentName, entryFolder, INDEX_FILE, MANIFEST_PATH } from "@micacms/core";
import { fileSystemSource } from "@micacms/core/node";

const [root, collection, key] = process.argv.slice(2);
const manifest = JSON.parse(await readFile(join(root, MANIFEST_PATH), "utf8"));
const def = manifest.collections[collection];
if (!def) throw new Error(`The model has no collection "${collection}".`);
if (def.fields[key]) throw new Error(`"${key}" is still in the model. Remove it there, and run \`npx astro sync\` first.`);

for (const id of await collectionIds(fileSystemSource(root), collection)) {
  const base = join(root, entryFolder(collection, id));
  const file = join(base, INDEX_FILE);
  if (!existsSync(file)) continue;
  const data = JSON.parse(await readFile(file, "utf8"));
  if (key in data) {
    delete data[key];
    await writeFile(file, `${JSON.stringify(data, null, 2)}\n`);
  }
  await rm(join(base, documentName([key])), { force: true });
}

There is no automatic rename

Mica has no renamedFrom option that reads an old key. A rename with the script is one commit, and after it the files and the model agree. See Fields and Command line.