/*
Framework-agnostic content reference index resolver — loads a build-time generated index
and resolves braceless collection.file.field references without astro:content (RFC-0527).
- Do not generate the index — that is content.ref-index.generate in @warpgogol/site-kernel-codegen.
- Do not validate references — that is content.references.validate in @warpgogol/site-kernel-checks.
- Do not handle brace-delimited {collection.file.field} syntax — legacy, removed by RFC-0529.
- RFC-0045: Initial implementation of content data references in markdown files.
- RFC-0527: Rewrite as framework-agnostic index-based resolver. Remove astro:content dependency.
Replace parseContentReference, resolveContentReference, substituteContentReferences,
substituteContentReferencesInData with loadContentRefIndex, resolveReference,
resolveReferencesInString, resolveReferencesDeep.
- RFC-0602: allow null generatedAt in ContentRefIndex for timestamp determinism.
- RFC-0731: Add SourceRef type and sourceRef optional parameter for this. self-reference syntax.
this. is expanded to collection.file. before pattern matching. REF-12 for missing sourceRef,
REF-13 for unresolvable field after expansion.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
*/
import { createDefaultIO, type WorkspaceIO } from "../kernel/workspace-io.ts";
import { parse } from "yaml";
import { substituteRefsDeep } from "./substitute-deep.ts";
import { resolveFieldPath } from "./resolve-field-path.ts";
import { scanFormulas, resolveFormula } from "../offers/formula-eval.ts";
export interface ContentRefIndex {
version: 1;
generatedAt: string | null;
entries: Record>>;
collections: string[];
}
export interface ResolveReferenceResult {
value: unknown;
resolved: boolean;
error?: string;
}
export interface SourceRef {
collection: string;
file: string;
}
const REF_PATTERN = /^([a-z][a-z-]*)\.([a-z0-9-/]+)\.(.+)$/;
const FIELD_PATH_PATTERN = /[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*/;
const PURE_REF_PATTERN = new RegExp(`^[a-z][a-z-]*\\.[a-z0-9-/]+\\.${FIELD_PATH_PATTERN.source}$`);
const BRACELESS_SCAN_PATTERN = new RegExp(
`[a-z][a-z-]*\\.[a-z0-9-/]+\\.${FIELD_PATH_PATTERN.source}`,
"g",
);
const THIS_PREFIX = "this.";
const THIS_SCAN_PATTERN = new RegExp(`this\\.${FIELD_PATH_PATTERN.source}`, "g");
const DEFAULT_INDEX_PATH = "src/content-ref-index.generated.yaml";
function expandThisRef(ref: string, sourceRef?: SourceRef): string | null {
if (!ref.startsWith(THIS_PREFIX)) return null;
if (!sourceRef) return null;
const fieldPath = ref.slice(THIS_PREFIX.length);
return `${sourceRef.collection}.${sourceRef.file}.${fieldPath}`;
}
export const EMPTY_CONTENT_REF_INDEX: ContentRefIndex = {
version: 1,
generatedAt: "",
entries: {},
collections: [],
};
let cachedIndex: ContentRefIndex | null | undefined;
export async function loadContentRefIndex(
indexPath: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
try {
const raw = await io.readFile(indexPath);
const parsed = parse(raw) as ContentRefIndex;
if (parsed && parsed.version === 1 && parsed.entries) {
return parsed;
}
return null;
} catch (err: unknown) {
if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") {
console.warn(
`[content-ref-index] ${indexPath} not found — formula references =(...) and braceless content references will not resolve. ` +
`Run: pnpm exec werkstatt run content.ref-index.generate --site `,
);
}
return null;
}
}
export async function getContentRefIndex(
indexPath?: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (cachedIndex === undefined) {
cachedIndex = await loadContentRefIndex(indexPath ?? DEFAULT_INDEX_PATH, io);
}
return cachedIndex;
}
export function resetContentRefIndexCache(): void {
cachedIndex = undefined;
}
export function resolveReference(
index: ContentRefIndex,
ref: string,
lang: string,
defaultLang: string,
sourceRef?: SourceRef,
): ResolveReferenceResult {
// RFC-0731: Expand this. prefix before pattern matching
let effectiveRef = ref;
if (ref.startsWith(THIS_PREFIX)) {
if (!sourceRef) {
return {
value: undefined,
resolved: false,
error: `REF-12: this. reference used without sourceRef context`,
};
}
const expanded = expandThisRef(ref, sourceRef);
if (!expanded) {
return {
value: undefined,
resolved: false,
error: `REF-12: this. reference used without sourceRef context`,
};
}
effectiveRef = expanded;
}
const match = effectiveRef.match(REF_PATTERN);
if (!match) {
return { value: undefined, resolved: false, error: `Invalid reference syntax: ${ref}` };
}
const [, collection, file, fieldPathStr] = match;
const fieldPath = fieldPathStr.split(".");
const collectionEntries = index.entries[collection];
if (!collectionEntries) {
return {
value: undefined,
resolved: false,
error: `REF-01: Collection "${collection}" not found in index`,
};
}
const fileEntries = collectionEntries[file];
if (!fileEntries) {
return {
value: undefined,
resolved: false,
error: `REF-02: File "${file}" not found in collection "${collection}"`,
};
}
let data = fileEntries[lang];
if (!data && lang !== defaultLang) {
data = fileEntries[defaultLang];
}
if (!data) {
return {
value: undefined,
resolved: false,
error: `REF-02: No entry for language "${lang}" or fallback "${defaultLang}" for ${collection}.${file}`,
};
}
const { value, missingField } = resolveFieldPath(data, fieldPath);
if (missingField || value === undefined) {
// Field-level fallback: if the field path fails in the requested language,
// retry with the default language data before reporting REF-03.
if (lang !== defaultLang) {
const fallbackData = fileEntries[defaultLang];
if (fallbackData) {
const fallbackResult = resolveFieldPath(fallbackData, fieldPath);
if (!fallbackResult.missingField && fallbackResult.value !== undefined) {
return { value: fallbackResult.value, resolved: true };
}
}
}
// RFC-0731: If this was a this. reference, report REF-13 instead of REF-03
if (ref.startsWith(THIS_PREFIX)) {
return {
value: undefined,
resolved: false,
error: `REF-13: this.${fieldPathStr} unresolved — field "${missingField ?? fieldPath[fieldPath.length - 1]}" not found in ${sourceRef?.collection}.${sourceRef?.file}`,
};
}
return {
value: undefined,
resolved: false,
error: `REF-03: Field "${missingField ?? fieldPath[fieldPath.length - 1]}" not found in ${collection}.${file}`,
};
}
return { value, resolved: true };
}
export function resolveReferencesInString(
index: ContentRefIndex,
text: string,
lang: string,
defaultLang: string,
sourceRef?: SourceRef,
onUnresolved?: (ref: string, error: string) => void,
): string {
// RFC-0731: Check for this. as a pure reference (entire text is this.field.path)
if (
sourceRef &&
text.startsWith(THIS_PREFIX) &&
THIS_SCAN_PATTERN.test(text) &&
text.trim() === text.trim()
) {
const expanded = expandThisRef(text.trim(), sourceRef);
if (expanded) {
const result = resolveReference(index, expanded, lang, defaultLang);
if (result.resolved) {
return formatValue(result.value);
}
onUnresolved?.(text.trim(), result.error ?? "unresolved this. reference");
}
}
THIS_SCAN_PATTERN.lastIndex = 0;
if (PURE_REF_PATTERN.test(text)) {
const result = resolveReference(index, text, lang, defaultLang);
if (result.resolved) {
return formatValue(result.value);
}
onUnresolved?.(text, result.error ?? "unresolved reference");
return text;
}
// RFC-0570: resolve =(...) formula expressions first
const formulas = scanFormulas(text);
let formulaResult = text;
if (formulas.length > 0) {
// Process formulas in reverse order to preserve indices
for (let i = formulas.length - 1; i >= 0; i--) {
const { start, end, expression } = formulas[i];
const result = resolveFormula(index, expression, lang, defaultLang, sourceRef);
const replacement = result.resolved ? result.value : "";
formulaResult = formulaResult.slice(0, start) + replacement + formulaResult.slice(end);
}
}
// If the entire string was a formula, return the formula result
if (formulas.length > 0 && formulas[0].start === 0 && formulas[0].end === text.length) {
return formulaResult;
}
let result = formulaResult;
const pattern = new RegExp(BRACELESS_SCAN_PATTERN.source, "g");
let match: RegExpExecArray | null;
const replacements: Array<{ start: number; end: number; resolved: string }> = [];
while ((match = pattern.exec(formulaResult)) !== null) {
const candidate = match[0];
const collectionMatch = candidate.match(/^([a-z][a-z-]*)\./);
if (!collectionMatch) continue;
const collection = collectionMatch[1];
if (!index.entries[collection]) continue;
const resolved = resolveReference(index, candidate, lang, defaultLang, sourceRef);
if (resolved.resolved) {
replacements.push({
start: match.index,
end: match.index + candidate.length,
resolved: formatValue(resolved.value),
});
}
}
// RFC-0731: Scan for this. references in mixed strings
if (sourceRef) {
const thisPattern = new RegExp(THIS_SCAN_PATTERN.source, "g");
let thisMatch: RegExpExecArray | null;
while ((thisMatch = thisPattern.exec(formulaResult)) !== null) {
const candidate = thisMatch[0];
const expanded = expandThisRef(candidate, sourceRef);
if (!expanded) continue;
const resolved = resolveReference(index, expanded, lang, defaultLang);
if (resolved.resolved) {
// Check for overlap with existing replacements
const thisStart = thisMatch.index;
const thisEnd = thisStart + candidate.length;
const overlaps = replacements.some((r) => thisStart < r.end && thisEnd > r.start);
if (!overlaps) {
replacements.push({
start: thisStart,
end: thisEnd,
resolved: formatValue(resolved.value),
});
}
}
}
}
for (let i = replacements.length - 1; i >= 0; i--) {
const { start, end, resolved } = replacements[i];
result = result.slice(0, start) + resolved + result.slice(end);
}
return result;
}
export async function resolveReferencesDeep(
index: ContentRefIndex,
data: unknown,
lang: string,
defaultLang: string,
sourceRef?: SourceRef,
onUnresolved?: (ref: string, error: string) => void,
): Promise {
return substituteRefsDeep(data, (value) =>
Promise.resolve(
resolveReferencesInString(index, value, lang, defaultLang, sourceRef, onUnresolved),
),
);
}
function formatValue(value: unknown): string {
if (value === null || value === undefined) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (Array.isArray(value)) {
return value.join(", ");
}
return "";
}