/**
* @file
* @brief DPL (Dynamic Prompt Language) parser + weighted-layer renderer. See notes/reference/dpl-design.md.
*/
// DPL is the v3 authoring language for dynamic prompts: a Markdown-shaped, data-not-code
// description of "what to maybe say", evaluated as a tree of weighted LAYERS. A file is a
// layer; each section is a layer; each line is a layer. Weights are LOCAL sort keys
// (lower = rendered earlier) — a layer only reorders its own children and never the parent.
// `compileDpl(source, bridge)` returns the same shape as a JS generator module
// (`{ default, full, suggestion_exclude }`) so the existing engine/loader are untouched.
//
// Plain text is valid DPL (every line is just an always-on layer), so the prompt box and the
// generator files share one language. See notes/reference/dpl-design.md for the full spec and
// notes/plans/v3-layers.md for the engine model.
/** Default randomness (Math.random-based, matching the v2 generators' lodash usage). */
const RNG = {
/** Inclusive integer in [a, b]. */
int: (a, b) => a + Math.floor(Math.random() * (b - a + 1)),
/** True with probability p (0..1). */
chance: (p) => Math.random() < p,
/** A uniformly random element of arr. */
pick: (arr) => arr[Math.floor(Math.random() * arr.length)],
};
const AUTO_WEIGHT_START = 1000; // first auto-assigned line weight; +1 per following line
// ---------------------------------------------------------------------------
// Front-matter
// ---------------------------------------------------------------------------
/**
* Split a leading `---` YAML-ish front-matter block from the body.
* @param {string} source The raw `.dpl` text.
* @returns {{meta: object, body: string}} Parsed key/value meta and the remaining body.
*/
function parseFrontMatter(source) {
const meta = {};
const lines = source.replace(/\r/g, "").split("\n");
if (lines[0]?.trim() !== "---") return { meta, body: lines.join("\n") };
let i = 1;
for (; i < lines.length; i++) {
if (lines[i].trim() === "---") {
i++;
break;
}
const m = lines[i].match(/^\s*([\w-]+)\s*:\s*(.*)$/);
if (m) meta[m[1].trim()] = m[2].trim();
}
return { meta, body: lines.slice(i).join("\n") };
}
// ---------------------------------------------------------------------------
// Lexing: strip comments, detect the indent unit, compute each line's depth
// ---------------------------------------------------------------------------
/**
* Turn the body into indentation-tagged raw lines. The indent unit is the first indented
* line's leading whitespace (tab or N spaces), per the spec; depth = indent / unit.
* @param {string} body The DPL body (front-matter removed).
* @returns {Array<{depth: number, text: string, raw: string}>} Non-blank logical lines.
*/
function lexLines(body) {
const rawLines = body.split("\n");
let unit = null;
const out = [];
for (let raw of rawLines) {
// Strip `;` comments (not inside the literal — DPL has no string literals, so simple).
const semi = raw.indexOf(";");
if (semi >= 0) raw = raw.slice(0, semi);
if (raw.trim() === "") continue;
const indentMatch = raw.match(/^[ \t]*/)[0];
if (indentMatch.length > 0 && unit === null) unit = indentMatch; // first indent sets the unit
let depth = 0;
if (indentMatch.length > 0 && unit) depth = Math.round(indentMatch.length / unit.length);
out.push({ depth, text: raw.trim(), raw });
}
return out;
}
// ---------------------------------------------------------------------------
// Parsing: sections (heading + `===` underline ≥3), then a tree by indentation
// ---------------------------------------------------------------------------
/**
* Parse the lexed lines into `{ sectionName: nodeTree }`. A heading is a text line whose
* next line is `={3,}`; `Start` is the entry section. Lines under a heading nest by depth.
* @param {Array} lines Lexed lines from {@link lexLines}.
* @returns {object} Map of section name -> array of child nodes.
*/
function parseSections(lines) {
const sections = {};
let current = null;
const ensure = (name, weightLine) => {
if (!sections[name]) sections[name] = { name, weightLine: weightLine ?? name, lines: [] };
return sections[name];
};
for (let i = 0; i < lines.length; i++) {
const ln = lines[i];
const next = lines[i + 1];
if (next && next.depth === 0 && /^={3,}$/.test(next.text) && ln.depth === 0) {
current = ln.text.replace(/\s*\[.*$/, "").trim() || ln.text.trim(); // name (drop a trailing weight)
ensure(current, ln.text);
i++; // consume the underline
continue;
}
// Content before any heading is the implicit `Start` section — so a `.dpl` that is just a line
// (or a few lines) of content "just works" without writing `Start\n===`. An explicit `Start`
// heading later simply appends to the same section.
if (current === null) ensure("Start").lines.push(ln);
else sections[current].lines.push(ln);
}
// Build a nested tree for each section from its flat (depth-tagged) lines.
const trees = {};
for (const name of Object.keys(sections)) {
trees[name] = buildTree(sections[name].lines, 0).nodes.map(parseNode);
}
return trees;
}
/**
* Recursively group depth-tagged lines into `{ line, children }` nodes.
* @param {Array} lines The (remaining) lexed lines.
* @param {number} depth The depth level being collected.
* @param {number} [start] Index to start from.
* @returns {{nodes: Array, next: number}} Nodes at this depth and the next index.
*/
function buildTree(lines, depth, start = 0) {
const nodes = [];
let i = start;
while (i < lines.length) {
const ln = lines[i];
if (ln.depth < depth) break;
if (ln.depth > depth) {
// Children belong to the previous node.
const sub = buildTree(lines, ln.depth, i);
if (nodes.length) nodes[nodes.length - 1].children = sub.nodes;
i = sub.next;
continue;
}
nodes.push({ line: ln, children: [] });
i++;
}
return { nodes, next: i };
}
// ---------------------------------------------------------------------------
// Per-line parsing: weight, gate, repeat, choice, refs, flow, payload
// ---------------------------------------------------------------------------
/**
* Parse one tree node's text into a typed descriptor (weight/gate/repeat/choice/ref/payload).
* @param {{line: object, children: Array}} node A raw tree node.
* @returns {object} The typed node (with parsed `children`).
*/
function parseNode(node) {
let t = node.line.text;
const out = { children: (node.children || []).map(parseNode) };
// Bullet vs plain.
out.bullet = /^-\s+/.test(t) || t === "-";
if (out.bullet) t = t.replace(/^-\s*/, "");
// Leading weight: [900]
const wm = t.match(/^\[(\d+)\]\s*/);
if (wm) {
out.weight = Number(wm[1]);
t = t.slice(wm[0].length);
}
// Flow: go to / go back
if (/^go\s+back\b/i.test(t)) {
out.flow = { kind: "back" };
return out;
}
const gotoM = t.match(/^go\s+to\s+(.+)$/i);
if (gotoM) {
out.flow = { kind: "goto", target: gotoM[1].trim() };
return out;
}
// Gate: NN% / maybe / NN% chance / otherwise [gate]
const otherwiseM = t.match(/^otherwise\b\s*/i);
if (otherwiseM) {
out.otherwise = true;
t = t.slice(otherwiseM[0].length);
}
const maybeM = t.match(/^maybe\s*:?\s*/i);
const pctChanceM = t.match(/^(\d+(?:\.\d+)?)%\s*chance\s*:?\s*/i);
const pctM = t.match(/^(\d+(?:\.\d+)?)%\s+/);
if (pctChanceM) {
out.gate = Number(pctChanceM[1]) / 100;
t = t.slice(pctChanceM[0].length);
} else if (maybeM) {
out.gate = 0.5;
t = t.slice(maybeM[0].length);
} else if (pctM) {
out.gate = Number(pctM[1]) / 100;
t = t.slice(pctM[0].length);
} else if (out.otherwise) {
out.gate = 1; // bare "otherwise" always runs when the prior gate failed
}
// Choice: "one of" (singular keyword) or "N of" / "A to B of" with digit counts.
// Optional "(NN% nothing)" miss chance.
const missRe = "(?:\\((\\d+(?:\\.\\d+)?)%\\s*nothing\\))?\\s*:?\\s*$";
const oneOfM = t.match(new RegExp(`^one\\s+of\\s*${missRe}`, "i"));
const nOfM = oneOfM
? null
: t.match(new RegExp(`^(\\d+)(?:\\s+to\\s+(\\d+))?\\s+of\\s*${missRe}`, "i"));
if ((oneOfM || nOfM) && out.children.length) {
const min = oneOfM ? 1 : Number(nOfM[1]);
const max = oneOfM ? 1 : nOfM[2] ? Number(nOfM[2]) : min;
const missCap = oneOfM ? oneOfM[1] : nOfM[3];
out.choice = { min, max, miss: missCap ? Number(missCap) / 100 : 0 };
return out;
}
// Repeat: repeat N times / repeat A to B times (then ": payload" or a child block)
const repeatM = t.match(/^repeat\s+(\d+)(?:\s+to\s+(\d+))?\s+times\s*:?\s*/i);
if (repeatM) {
out.repeat = {
min: Number(repeatM[1]),
max: repeatM[2] ? Number(repeatM[2]) : Number(repeatM[1]),
};
t = t.slice(repeatM[0].length);
}
// Reference: insert js: path | insert name | +name
const insertJsM = t.match(/^insert\s+js:\s*(\S+)\s*$/i);
const insertM = t.match(/^insert\s+(\S+)\s*$/i);
const callM = t.match(/^\+(\S+)\s*$/);
if (insertJsM) out.ref = { kind: "js-block", path: insertJsM[1] };
else if (insertM) out.ref = { kind: "insert", name: insertM[1] };
else if (callM) out.ref = { kind: "call", name: callM[1] };
// A trailing ":" with children but no choice/repeat → a plain gated block.
if (/:$/.test(t) && out.children.length && !out.ref) {
out.block = true;
t = t.replace(/:\s*$/, "");
}
out.payload = t.trim();
return out;
}
// ---------------------------------------------------------------------------
// Rendering: run the flow, collect weighted pieces, sort within the layer
// ---------------------------------------------------------------------------
/**
* Weighted-sort a list of `{ weight, text }` pieces (stable; ties keep document order) and
* join the non-empty ones with ", ".
* @param {Array<{weight: number, text: string}>} pieces The collected layer pieces.
* @returns {string} The joined layer text.
*/
function joinPieces(pieces) {
return pieces
.map((p, i) => ({ ...p, i }))
.sort((a, b) => a.weight - b.weight || a.i - b.i)
.map((p) => p.text)
.filter((s) => s && s.trim() !== "")
.join(", ");
}
/**
* Render an array of sibling nodes into one layer string (gates, choices, repeats, refs,
* weighted local sort). `auto` tracks the running auto-weight (starts at 1000).
* @param {Array} nodes Parsed sibling nodes.
* @param {object} ctx The render context (settings, rng, bridge, sections).
* @returns {string} The layer's rendered, weight-sorted text.
*/
function renderNodes(nodes, ctx) {
const pieces = [];
let auto = AUTO_WEIGHT_START;
let prevGateFailed = false;
for (const node of nodes) {
const weight = node.weight ?? auto;
auto = (node.weight ?? auto) + 1;
// Effective gate: an explicit gate (NN%/maybe/NN% chance/otherwise) always wins. A bare
// *simple-clause* bullet (plain text / token / ref) defaults to 50%. Structural bullets
// (one of / repeat / block) and plain (non-bullet) lines are unconditional. `gateBearing`
// (an authored gate, not the default) is what an `otherwise` pairs against.
const gateBearing = node.gate != null || node.otherwise === true;
let gate = node.gate;
if (gate == null && !node.otherwise) {
const structural = node.choice || node.repeat || node.flow || node.block;
if (node.bullet && !structural) gate = 0.5;
}
let run = true;
if (node.otherwise) run = prevGateFailed;
if (run && gate != null) run = ctx.rng.chance(gate);
prevGateFailed = gateBearing ? !run : false;
if (!run) continue;
const text = renderNode(node, ctx);
if (text && text.trim() !== "") pieces.push({ weight, text: text.trim() });
}
return joinPieces(pieces);
}
/**
* Render a single parsed node to a string (without its sibling gate, which the caller applied).
* @param {object} node A parsed node.
* @param {object} ctx The render context.
* @returns {string} The node's text contribution.
*/
function renderNode(node, ctx) {
// Choice: pick 1..N options (weighted by each option's leading %), honoring a miss chance.
if (node.choice) {
if (node.choice.miss && ctx.rng.chance(node.choice.miss)) return "";
const opts = node.children.slice();
if (!opts.length) return "";
const count = ctx.rng.int(node.choice.min, Math.min(node.choice.max, opts.length));
const picked = weightedSampleN(opts, count);
return picked
.map((o) => renderNode(o, ctx))
.filter(Boolean)
.join(", ");
}
// Repeat: loop count times, rendering the body (payload or child block) each time.
if (node.repeat) {
const count = ctx.rng.int(node.repeat.min, node.repeat.max);
const parts = [];
for (let i = 0; i < count; i++) {
let part;
if (node.children.length) part = renderNodes(node.children, ctx);
else part = renderInlineBody(node, ctx);
if (part && part.trim() !== "") parts.push(part.trim());
}
return parts.join(", ");
}
// References.
if (node.ref) return renderRef(node.ref, weightOf(node), ctx);
// Plain gated block (`maybe:` etc. with children, no choice/repeat).
if (node.block || (node.children.length && !node.payload)) {
return renderNodes(node.children, ctx);
}
// Flow (MVP: goto/insert behave like a call+include; back is a no-op terminator).
if (node.flow) {
if (node.flow.kind === "goto") return ctx.section(node.flow.target);
return "";
}
return renderInlineBody(node, ctx);
}
/** Render the payload text of a node, substituting inline `{js:path}` via the bridge. */
function renderInlineBody(node, ctx) {
let t = node.payload || "";
// Inline JS values: {js:path}
t = t.replace(/\{js:([^}]+)\}/g, (_m, p) => ctx.bridge?.resolveJs?.(p.trim(), ctx) ?? "");
// A child block alongside a payload line (rare) — append.
if (node.children.length && !node.choice && !node.repeat) {
const sub = renderNodes(node.children, ctx);
if (sub) t = t ? `${t}, ${sub}` : sub;
}
return t;
}
/** Resolve a reference node (call/insert/js-block) to a string. */
function renderRef(ref, _weight, ctx) {
if (ref.kind === "call" || ref.kind === "insert") {
// A DPL-side section (local) or another generator/list/expansion token (passthrough).
if (ctx.hasSection(ref.name)) return ctx.section(ref.name);
if (/^#/.test(ref.name)) return `{${ref.name}}`; // +#weather -> {#weather}
return `{#${ref.name}}`; // bare name -> dynamic-prompt token, resolved downstream
}
if (ref.kind === "js-block") return ctx.bridge?.resolveJs?.(ref.path, ctx) ?? "";
return "";
}
/** A node's explicit weight, or null (used for refs that carry a weight). */
function weightOf(node) {
return node.weight ?? null;
}
/** Pick `n` distinct options weighted by each option's leading gate %, else uniform. */
function weightedSampleN(opts, n) {
const pool = opts.map((o) => ({ o, w: o.gate != null ? o.gate : 1 }));
const picked = [];
for (let k = 0; k < n && pool.length; k++) {
const total = pool.reduce((s, e) => s + e.w, 0);
let r = Math.random() * total;
let idx = 0;
for (; idx < pool.length; idx++) {
r -= pool[idx].w;
if (r <= 0) break;
}
picked.push(pool[Math.min(idx, pool.length - 1)].o);
pool.splice(Math.min(idx, pool.length - 1), 1);
}
return picked;
}
// ---------------------------------------------------------------------------
// Compile: source -> { default, full, suggestion_exclude }
// ---------------------------------------------------------------------------
/**
* Compile a `.dpl` source into a dynamic-prompt module object (same shape as a JS generator).
* @param {string} source The `.dpl` file text.
* @param {object} [bridge] Optional JS bridge: `{ resolveJs(path, ctx) }` for `{js:}` / `insert js:` / `script`.
* @returns {{default: Function, full: boolean, suggestion_exclude: boolean, meta: object}} The module.
*/
export function compileDpl(source, bridge = null) {
const { meta, body } = parseFrontMatter(source);
const sections = parseSections(lexLines(body));
const full = meta.type === "full";
const suggestion_exclude = meta.suggestions === "off" || meta.suggestions === "false";
function makeCtx(settings, imageSettings, upscaleSettings) {
const ctx = {
settings,
imageSettings,
upscaleSettings,
rng: RNG,
bridge,
hasSection: (name) => Object.prototype.hasOwnProperty.call(sections, name),
section: (name) => (sections[name] ? renderNodes(sections[name], ctx) : ""),
// JS->DPL bridge calls (return rendered strings).
prompt: (name) => bridge?.runPrompt?.(name, ctx) ?? `{#${String(name).replace(/^#/, "")}}`,
list: (name) => bridge?.runList?.(name, ctx) ?? `{${name}}`,
expand: (snippet) => bridge?.expand?.(snippet, ctx) ?? snippet,
};
return ctx;
}
function defaultFn(settings = {}, imageSettings = {}, upscaleSettings = {}) {
const ctx = makeCtx(settings, imageSettings, upscaleSettings);
if (meta.script) return bridge?.resolveJs?.(meta.script, ctx) ?? "";
return ctx.section("Start");
}
// `Auto Begin` / `Auto End`: optional sections a block can declare to contribute framing that the
// app folds into the START / END of the whole prompt (alongside the user wrapper). They render
// exactly like any section (probability, refs, JS), but are NOT part of the block's own `Start`
// body. See notes/plans/v3-layers.md.
const has = (name) => Object.prototype.hasOwnProperty.call(sections, name);
const renderSection =
(name) =>
(settings = {}, imageSettings = {}, upscaleSettings = {}) => {
if (!has(name)) return "";
return makeCtx(settings, imageSettings, upscaleSettings).section(name);
};
return {
default: defaultFn,
full,
suggestion_exclude,
meta,
hasAutoBegin: has("Auto Begin"),
hasAutoEnd: has("Auto End"),
autoBegin: renderSection("Auto Begin"),
autoEnd: renderSection("Auto End"),
};
}
export default compileDpl;