ArcMoon 1.1.0

Start

Language

Reference

Nothing matches “”.

↑↓ move Enter open Esc close

Reference

JavaScript API

compile(), build(), buildPages() and loadGraph(): ArcMoon from your own code.

When to use it

The arcm command covers most sites. Use ArcMoon from JavaScript when your own code decides what to build: pages made from data, a server that renders on request, a build or watch script of your own, or a tool that reads .arcm files.

npm install arcmoon

Everything ArcMoon exports

  • arcmoon (Node.js): the ArcMoon class with compile() and build(), plus buildPages(), loadGraph() and CompilerError. In a browser, the same import gives the browser build.
  • arcmoon/core (works anywhere, no Node.js or browser APIs): lexer, parser, loadModules, unknownTags, closest, the constants TOKEN_TYPES, NODE_TYPES and COMPONENT, and the errors LexerError, ParserError and ModuleError.
  • arcmoon/reactive (runtime code): signal, computed and effect. See Reactivity.
import ArcMoon, { buildPages, loadGraph, CompilerError } from "arcmoon";
import { lexer, parser, loadModules, unknownTags, closest } from "arcmoon/core";

new ArcMoon(options)

One ArcMoon object is one page. Create it with the page's options, then call compile() or build(). Every option:

new ArcMoon({
  filename: "pages/index.arcm",     // the page file (or src)
  src: "[p]Hi[end]",                // or the page as a string
  props: { user: "Adam" },           // page data, read with ArcMoon.props()
  importAliases: { "@": "./src" },  // short names for folders in [import] paths
  removeComments: true,             // leave # comments out of the HTML
  timeout: 5000,                    // ms the page's ${ }$ code may run
  bundle: ["canvas-confetti"],      // npm packages runtime code may import
  packages: { dayjs: "https://esm.sh/dayjs@1" }, // runtime imports loaded from a URL (1.1)
  cwd: process.cwd(),               // where relative paths start
  root: process.cwd(),              // the project folder, for the protector
  dev: false,                       // true: readable JavaScript with source maps
  onWarning: (w) => {}              // what to do with warnings
});
  • Give filename or src. With both, src is the code and filename its name in messages and the folder its imports start from.
  • The API doesn't read arcmoon.config.js: pass what you need. Defaults match the CLI's.
  • Each option is explained, with its default and an example, on Configuration; the ones only the API has are under Options only in the JavaScript API.

compile()

Builds the page and returns its HTML as one string, with the JavaScript in a <script> and the CSS in a <style>:

const html = await new ArcMoon({ src: "[p]Hello ${ 2 + 3 }$[end]" }).compile();
// "<p>Hello 5</p>"

const page = await new ArcMoon({ filename: "pages/index.arcm" }).compile();
// "<!doctype html><html>…"

Because it returns a string, it fits anywhere HTML is needed. A server that builds the page on each request, with data from the URL:

server.js
import { createServer } from "node:http";
import ArcMoon from "arcmoon";

createServer(async (req, res) => {
  const name = new URL(req.url, "http://localhost").searchParams.get("name") ?? "guest";
  try {
    const html = await new ArcMoon({
      filename: "pages/index.arcm",
      props: { user: name }
    }).compile();
    res.writeHead(200, { "content-type": "text/html" }).end(html);
  } catch (err) {
    res.writeHead(500, { "content-type": "text/plain" }).end(err.message);
  }
}).listen(3000);

// http://localhost:3000/?name=Adam  →  <h1>Hi Adam</h1>

url() files in CSS are left as written, since a string has nowhere to put them. Use build() when the page has such files.

build(options)

Builds the page and also returns the files it needs: JavaScript, CSS and url() files. It returns { html, files }; each file is { path, contents }, with a full path:

const { html, files } = await new ArcMoon({ filename: "pages/index.arcm" }).build({
  externalScripts: true,
  externalStyles: true
});

html;    // … <link rel="stylesheet" href="./assets/index-ECRIFUJ2.css"> …
files;   // [{ path: "/my-site/assets/index-ECRIFUJ2.css", contents: "…" },
         //  { path: "/my-site/assets/index-GINXCLDS.js",  contents: "…" }]

Its options are outDir, assetsDir, base, externalScripts and externalStyles, explained in Configuration. Like buildPages(), externalScripts is true by default here. ArcMoon writes nothing; you write the files:

build-one.js
import ArcMoon from "arcmoon";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

const { html, files } = await new ArcMoon({ filename: "pages/index.arcm" }).build({
  outDir: "dist",
  externalScripts: true,
  externalStyles: true
});

await mkdir("dist", { recursive: true });
await writeFile("dist/index.html", html);
for (const file of files) {
  await mkdir(dirname(file.path), { recursive: true });
  await writeFile(file.path, file.contents);
}

buildPages(pages, options)

Builds many pages together, so code they share is bundled once into chunks/. The first argument is a list of page options (the same as new ArcMoon()); the second applies to all of them. It returns { pages: [{ name, html }], files }.

Every page in a folder, like arcm build pages:

build-folder.js
import { buildPages } from "arcmoon";
import { readdir, mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";

// every .arcm file in pages/, like "arcm build pages"
const names = (await readdir("pages")).filter((f) => f.endsWith(".arcm"));
const { pages, files } = await buildPages(
  names.map((f) => ({ filename: `pages/${f}` })),
  { outDir: "dist", externalScripts: true, externalStyles: true }
);

await mkdir("dist", { recursive: true });
for (const page of pages) await writeFile(`dist/${page.name}.html`, page.html);
for (const file of files) {
  await mkdir(dirname(file.path), { recursive: true });
  await writeFile(file.path, file.contents);
}
console.log(pages.map((p) => p.name).join(", "), "+", files.length, "files");
node build-folder.js
about, index + 4 files

Pages from data: one template, a page per post. name names each page's HTML; without it, the name is the file name without .arcm:

templates/Post.arcm
${ const { title, body } = ArcMoon.props(); }$
[doctype!]
[html = lang: "en"]
  [head][title]${ title }$[end][end]
  [body]
    [h1]${ title }$[end]
    [p]${ body }$[end]
  [end]
[end]
build.js
import { buildPages } from "arcmoon";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";

const posts = [
  { slug: "hello", title: "Hello", body: "The first post." },
  { slug: "loops", title: "Loops", body: "How [for-each] works." }
];

const { pages, files } = await buildPages(
  posts.map((post) => ({ filename: "templates/Post.arcm", name: post.slug, props: post })),
  { outDir: "dist" }
);

for (const page of pages) {
  await mkdir("dist", { recursive: true });
  await writeFile(join("dist", `${page.name}.html`), page.html);
}
for (const file of files) {
  await mkdir(dirname(file.path), { recursive: true });
  await writeFile(file.path, file.contents);
}
console.log("wrote", pages.map((p) => p.name + ".html").join(", "), "and", files.length, "files");
  • Shared options: outDir, assetsDir, base, bundle, packages, cwd, dev, externalScripts, externalStyles.
  • externalScripts is true by default, as in build() (the CLI's default is false): pages with runtime code get JavaScript files and a shared chunk.
  • Props are data: the [for-each] in a post's text stays text.

loadGraph(options)

Reads a page and every component it imports, without running any code. It takes the same options as new ArcMoon() and returns { entry, modules }: the page's path, and a Map from each file's path to its module (id, src, ast, imports):

const graph = await loadGraph({ filename: "pages/index.arcm", importAliases: { "@": "./src" } });

graph.entry;                  // "/my-site/pages/index.arcm"
[...graph.modules.keys()];    // ["/my-site/src/Card.arcm", "/my-site/pages/index.arcm"]

A watch script that rebuilds when the page or any component it uses changes:

watch.js
import { watch } from "node:fs";
import ArcMoon, { loadGraph } from "arcmoon";
import { writeFile } from "node:fs/promises";

const options = { filename: "pages/index.arcm", importAliases: { "@": "./src" } };

const rebuild = async () => {
  await writeFile("dist/index.html", await new ArcMoon(options).compile());
  console.log("rebuilt");
};

// watch the page and every component it imports
const graph = await loadGraph(options);
for (const file of graph.modules.keys()) watch(file, rebuild);
await rebuild();

The CLI uses it for arcm audit and arcm trust.

Page props

props gives a page data, the same way a component gets props:

pages/index.arcm
${ const { user = "guest", joined } = ArcMoon.props(); }$
[h1]Hi ${ user }$[end]
[p]Member since ${ joined.getFullYear() }$[end]
await new ArcMoon({
  filename: "pages/index.arcm",
  props: { user: "Adam", joined: new Date(2026, 0, 1) }
}).compile();
// <h1>Hi Adam</h1> <p>Member since 2026</p>
  • Props must be plain data: text, numbers, booleans, arrays, objects, Date, Map, Set.
  • A function stops the build: page props must be plain data (…): () => 1 could not be cloned.

Warnings

By default each warning prints one line. With onWarning, you get each one as an object, to collect, show or turn into an error:

const warnings = [];
await new ArcMoon({ src: "[dvi]x[end]", onWarning: (w) => warnings.push(w) }).compile();

warnings[0];
// { source: "/my-site/anonymous.arcm",
//   position: { line: 0, character: 0 },
//   message: "[dvi] is not an HTML element or an imported component (did you mean [div]?)" }

// fail a CI build on any warning
if (warnings.length) process.exit(1);

Positions count from 0: line: 0 is the first line. A page from src is called anonymous.arcm.

Errors

An error throws. Every error has name, source, position and a message that starts with file:line:column:

import ArcMoon, { CompilerError } from "arcmoon";
import { LexerError, ParserError, ModuleError } from "arcmoon/core";

try {
  await new ArcMoon({ src: "[p = id: AKZB]x[end]" }).compile();
} catch (err) {
  err.name;                      // "ParserError"
  err instanceof ParserError;    // true
  err.source;                    // "/my-site/anonymous.arcm"
  err.position;                  // { line: 0, character: 9 }
  err.message;                   // "/my-site/anonymous.arcm:1:10  unquoted value AKZB …"
}
  • LexerError, ParserError: the syntax, like an unquoted value or a missing [end].
  • ModuleError: imports, like a file that can't be read or a circular import.
  • EvaluatorError: build-time code that threw, or ran out of time.
  • CompilerError: blocks and props, like [doctype] with props.
  • StyleError: CSS that can't be read in a component's [style].
  • RuntimeError: runtime code and refs.
  • ProtectorError: a template that isn't trusted; the API never asks. See Protector.

Four error classes are exported for instanceof; for the others, check err.name.

arcmoon/core

The parts ArcMoon itself is built from, for tools like editors, linters and highlighters. They run no code and use no Node.js or browser APIs.

lexer(src, filename)

Splits ArcMoon code into tokens: brackets, names, strings, text, code. Each token has a type (one of TOKEN_TYPES), its value, and its place:

import { lexer, TOKEN_TYPES } from "arcmoon/core";

const tokens = lexer('[p = class: "x"]Hi[end]', "tiny.arcm");

tokens.length;   // 15
tokens[0];
// { type: "OPEN_BRACKET", value: "[", source: "tiny.arcm",
//   range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }

// count the strings in a file
tokens.filter((t) => t.type === TOKEN_TYPES.STRING).length;   // 1

It throws LexerError for code it can't split, like a ${ that never closes: a.arcm:1:1 logic block is not closed with }$.

parser(tokens)

Turns tokens into a tree of nodes: blocks with their props and bodies, text, comments, code. Each node has a type from NODE_TYPES: Block, Text, Comment, CommentBlock, StaticLogic, RuntimeLogic, Import, Slot, ForEach:

import { lexer, parser, NODE_TYPES } from "arcmoon/core";

const ast = parser(lexer('[p = class: "x"]Hi[end]', "tiny.arcm"));
ast[0];
// { type: "Block", id: "p", props: { class: "x" }, directives: {},
//   isSelfClosing: false, body: [{ type: "Text", text: "Hi" }] }

// count every block, however deep
const count = (nodes) =>
  nodes.reduce((n, node) => n + (node.type === NODE_TYPES.BLOCK ? 1 : 0) + count(node.body ?? []), 0);

count(parser(lexer("[ul][li]a[end][li]b[end][end]")));   // 3

It throws ParserError for blocks that don't fit together: b.arcm:1:5 [p] opened at 1:1 is missing [end].

loadModules(entry, host, options)

Loads a page and its imports from anywhere: memory, a database, the network. You give it a host with two functions: resolve(path, fromFile) turns an import path into a file id, and readFile(id) returns the file's text. options takes importAliases:

import { loadModules, unknownTags, COMPONENT } from "arcmoon/core";

// files kept in memory: a page and a component
const files = {
  "/page.arcm": '[import = Card: "./Card.arcm" !][Card!][dvi]x[end]',
  "/Card.arcm": "[b]Card[end]"
};

// the host tells loadModules how to find and read files
const host = {
  resolve: (path, from) => new URL(path, "file://" + from).pathname,
  readFile: async (id) => files[id]
};

const graph = await loadModules({ id: "/page.arcm", src: files["/page.arcm"] }, host);

[...graph.modules.keys()];                  // ["/Card.arcm", "/page.arcm"]
const page = graph.modules.get("/page.arcm");
page.imports;                               // Map { "Card" => "/Card.arcm" }
page.ast[1].type === COMPONENT;             // true: [Card!] became a component node
page.ast[1];
// { type: "Component", name: "Card", module: "/Card.arcm", props: {}, … }

unknownTags(graph);
// [{ source: "/page.arcm", position: { line: 0, character: 39 },
//    message: "[dvi] is not an HTML element or an imported component (did you mean [div]?)" }]
  • It returns { entry, modules }, like loadGraph(), which is this function with a host that reads your disk.
  • Blocks named like an import become nodes of type COMPONENT ("Component"), with the component's name and module.
  • It throws ModuleError for a file the host can't read, or a circular import.

unknownTags(graph)

Returns the warnings for a loaded graph: blocks that aren't HTML, SVG, MathML or an imported component, values without a name on elements, and css. names that aren't CSS properties. Each is { source, position, message }, as in the example above. It runs no code.

closest(name, names)

The "did you mean" helper: the name in a list closest to a typo, or null if none is close:

import { closest } from "arcmoon/core";

closest("colr", ["color", "border"]);   // "color"
closest("zzz", ["color"]);              // null