pagez

${…} in prose

Prose refers to values, and elements compute (pages). So a ${…} in a page is a path into page, the page's metadata, such as ${page.title}, and nothing else: no call, no operator and no other name. Anything more fails, at the expression.

  • A missing value fails, since a page that prints nothing where it meant a subtitle is a typo no one sees.
  • A dotted key is reached. Metadata keys are flat, so author.name: Ada is one key, as in YAML, and ${page.author.name} reads it. At each step the longest key that matches wins.
  • Prose prints a string or a number. A list, a boolean and an empty value have no one way to read as text, so each fails, and an element writes it. An attribute takes any value, since it goes to a template.
import type { Problem } from "./elements/errors.ts";
import type { Metadata } from "./head.ts";

/** What a path names in `page`, or what's wrong with it. */
export type Found = { value: unknown } | Problem;

const PATH = /^[A-Za-z_$][\w$]*(?:\.[\w$]+)*$/;

/** The value `code`, what a `${…}` holds, names in `page`. */
export function evaluate(code: string, page: Metadata): Found {
  const path = code.trim();
  const written = `\${${path}}`;
  if (!PATH.test(path)) {
    return {
      id: "error-expression-call",
      problem: `${written} isn't a path into page. Prose reads a value, such as \${page.title}, and an element computes one.`,
    };
  }
  const [first, ...keys] = path.split(".");
  if (first !== "page") {
    return {
      id: "error-expression-scope",
      problem: `${written} reads ${first}, and prose sees only page, as in \${page.title}.`,
    };
  }
  let value: unknown = page;
  for (let at = 0; at < keys.length;) {
    const object = value;
    const has = (n: number) =>
      typeof object === "object" &&
      object !== null &&
      Object.hasOwn(object, keys.slice(at, at + n).join("."));
    let n = keys.length - at;
    while (n > 0 && !has(n)) n--;
    if (n === 0) {
      const name = keys.slice(0, at + 1).join(".");
      return {
        id: "error-expression-missing",
        problem: `${written}: page has no ${name}. Add it to the metadata, or fix the name.`,
      };
    }
    value = (object as Record<string, unknown>)[keys.slice(at, at + n).join(".")];
    at += n;
  }
  return { value };
}

/** The value as prose prints it, or why it can't. */
export function printed(code: string, page: Metadata): { text: string } | Problem {
  const found = evaluate(code, page);
  if ("problem" in found) return found;
  const { value } = found;
  if (typeof value === "string") return { text: value };
  if (typeof value === "number") return { text: String(value) };
  const what = Array.isArray(value)
    ? "a list"
    : value === null
      ? "empty"
      : typeof value === "boolean"
        ? String(value)
        : "not text";
  return {
    id: "error-expression-value",
    problem: `\${${code.trim()}} is ${what}, which prose doesn't print. An element writes it, given the value as an attribute.`,
  };
}

An attribute's value. One that is a ${…} and nothing else is that value, whatever it is, so a list reaches the template as a list. One with ${…} among other text is text, and each prints as it would in prose.

export function attributeValue(written: string, page: Metadata): Found {
  const whole = /^\$\{([^}]*)\}$/.exec(written);
  if (whole) return evaluate(whole[1]!, page);
  const found = interpolated(written, page);
  return "problem" in found ? found : { value: found.text };
}

/** Text with each `${…}` in it printed, as in a link's destination. */
export function interpolated(written: string, page: Metadata): { text: string } | Problem {
  let problem: Problem | undefined;
  const text = written.replace(/\$\{([^}]*)\}/g, (match, code: string) => {
    const result = printed(code, page);
    if ("problem" in result) problem ??= result;
    return "text" in result ? result.text : match;
  });
  return problem ?? { text };
}