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 arcmoonEverything ArcMoon exports
arcmoon(Node.js): theArcMoonclass withcompile()andbuild(), plusbuildPages(),loadGraph()andCompilerError. 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 constantsTOKEN_TYPES,NODE_TYPESandCOMPONENT, and the errorsLexerError,ParserErrorandModuleError.arcmoon/reactive(runtime code):signal,computedandeffect. 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
filenameorsrc. With both,srcis the code andfilenameits 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:
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:
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:
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 filesPages from data: one template, a page per post. name names each page's HTML; without it, the name is the file name without .arcm:
${ const { title, body } = ArcMoon.props(); }$
[doctype!]
[html = lang: "en"]
[head][title]${ title }$[end][end]
[body]
[h1]${ title }$[end]
[p]${ body }$[end]
[end]
[end]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. externalScriptsistrueby default, as inbuild()(the CLI's default isfalse): 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:
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:
${ 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; // 1It 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]"))); // 3It 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 }, likeloadGraph(), 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'snameandmodule. - It throws
ModuleErrorfor 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