Skip to content

Under developmentMica is experimental at v0.10.0 and not ready for production.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.
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 { collectionSlugs, 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 slug of await collectionSlugs(fileSystemSource(root), collection)) {
  const base = join(root, entryFolder(collection, slug));
  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 { collectionSlugs, 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 slug of await collectionSlugs(fileSystemSource(root), collection)) {
  const base = join(root, entryFolder(collection, slug));
  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 { collectionSlugs, 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 slug of await collectionSlugs(fileSystemSource(root), collection)) {
  const file = join(root, entryDataPath(collection, slug));
  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 { collectionSlugs, 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 slug of await collectionSlugs(fileSystemSource(root), collection)) {
  const base = join(root, entryFolder(collection, slug));
  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.