Source: modules/generated-content/string-sets.js

import Handler from "../handler.js";
import csstree from "css-tree";
import { cleanPseudoContent } from "../../utils/css.js";

/**
 * Handles CSS string-set properties to create and manage CSS custom properties
 * for first, last, start, and first-except string values on paged content.
 *
 * Parses the `string-set` CSS declaration, transforms `string()` function content,
 * and updates CSS variables on each page after layout.
 *
 * @class
 * @extends Handler
 */
class StringSets extends Handler {
	/**
	 * Creates an instance of StringSets.
	 *
	 * @param {Object} chunker - Chunker instance to manage content chunking.
	 * @param {Object} polisher - Polisher instance for post-processing.
	 * @param {Object} caller - Calling controller instance.
	 */
	constructor(chunker, polisher, caller) {
		super(chunker, polisher, caller);

		/**
		 * Stores selectors and related string-set info keyed by identifier.
		 * @type {Object<string, {identifier: string, func: string, value: string, selector: string}>}
		 */
		this.stringSetSelectors = {};

		/**
		 * Holds the type of string currently processed (e.g. "first", "last").
		 * @type {string|undefined}
		 */
		this.type;

		/**
		 * Keeps track of the last string value per identifier on the previous page.
		 * @type {Object<string, string>|undefined}
		 */
		this.pageLastString;
	}

	/**
	 * Handles CSS declarations, looking specifically for `string-set` declarations.
	 * Parses identifiers and functions, storing them with selectors.
	 *
	 * @param {Object} declaration - The CSS declaration node.
	 * @param {Object} dItem - Declaration item (unused here).
	 * @param {Object} dList - Declaration list (unused here).
	 * @param {Object} rule - The CSS rule node containing the declaration.
	 */
	onDeclaration(declaration, dItem, dList, rule) {
		if (declaration.property === "string-set") {
			let selector = csstree.generate(rule.ruleNode.prelude);

			let identifiers = [];
			let functions = [];
			let values = [];

			declaration.value.children.forEach((child) => {
				if (child.type === "Identifier") {
					identifiers.push(child.name);
				}
				if (child.type === "Function") {
					functions.push(child.name);
					child.children.forEach((subchild) => {
						if (subchild.type === "Identifier") {
							values.push(subchild.name);
						}
					});
				}
			});

			identifiers.forEach((identifier, index) => {
				let func = functions[index];
				let value = values[index];
				this.stringSetSelectors[identifier] = {
					identifier,
					func,
					value,
					selector,
				};
			});
		}
	}

	/**
	 * Processes `string()` CSS function nodes within content declarations,
	 * transforming them into CSS variables referencing pagedjs-generated custom properties.
	 *
	 * @param {Object} funcNode - The function node representing `string()`.
	 * @param {Object} fItem - Function item node (unused here).
	 * @param {Object} fList - Function list node (unused here).
	 * @param {Object} declaration - The CSS declaration node.
	 * @param {Object} rule - The CSS rule node.
	 */
	onContent(funcNode, fItem, fList, declaration, rule) {
		if (funcNode.name === "string") {
			let identifier = funcNode.children && funcNode.children.first().name;
			this.type = funcNode.children.last().name;
			funcNode.name = "var";
			funcNode.children = new csstree.List();

			if (
				this.type === "first" ||
				this.type === "last" ||
				this.type === "start" ||
				this.type === "first-except"
			) {
				funcNode.children.append(
					funcNode.children.createItem({
						type: "Identifier",
						loc: null,
						name: `--pagedjs-string-${this.type}-${identifier}`,
					}),
				);
			} else {
				funcNode.children.append(
					funcNode.children.createItem({
						type: "Identifier",
						loc: null,
						name: `--pagedjs-string-first-${identifier}`,
					}),
				);
			}
		}
	}

	/**
	 * Called after page layout to update CSS custom properties for string-set variables.
	 * Computes first, last, start, and first-except string values and sets them as CSS variables.
	 *
	 * @param {DocumentFragment} fragment - The DOM fragment for the current page.
	 */
	afterPageLayout(fragment) {
		if (this.pageLastString === undefined) {
			this.pageLastString = {};
		}

		for (let name of Object.keys(this.stringSetSelectors)) {
			let set = this.stringSetSelectors[name];
			let value = set.value;
			let func = set.func;
			let selected = fragment.querySelectorAll(set.selector);

			// Previous page's last string value for this identifier
			let stringPrevPage =
				name in this.pageLastString ? this.pageLastString[name] : "";

			let varFirst, varLast, varStart, varFirstExcept;

			if (selected.length === 0) {
				// No matches on this page; carry forward previous value
				varFirst = stringPrevPage;
				varLast = stringPrevPage;
				varStart = stringPrevPage;
				varFirstExcept = stringPrevPage;
			} else {
				selected.forEach((sel) => {
					if (func === "content") {
						this.pageLastString[name] =
							selected[selected.length - 1].textContent;
					} else if (func === "attr") {
						this.pageLastString[name] =
							selected[selected.length - 1].getAttribute(value) || "";
					}
				});

				// FIRST
				if (func === "content") {
					varFirst = selected[0].textContent;
				} else if (func === "attr") {
					varFirst = selected[0].getAttribute(value) || "";
				}

				// LAST
				if (func === "content") {
					varLast = selected[selected.length - 1].textContent;
				} else if (func === "attr") {
					varLast = selected[selected.length - 1].getAttribute(value) || "";
				}

				// START — heuristic: element at the top of page content
				let selTop = selected[0].getBoundingClientRect().top;
				let pageContent = selected[0].closest(".pagedjs_page_content");
				let pageContentTop = pageContent.getBoundingClientRect().top;

				if (selTop === pageContentTop) {
					varStart = varFirst;
				} else {
					varStart = stringPrevPage;
				}

				// FIRST EXCEPT — currently empty string, can be implemented as needed
				varFirstExcept = "";
			}

			fragment.style.setProperty(
				`--pagedjs-string-first-${name}`,
				`"${cleanPseudoContent(varFirst)}"`,
			);
			fragment.style.setProperty(
				`--pagedjs-string-last-${name}`,
				`"${cleanPseudoContent(varLast)}"`,
			);
			fragment.style.setProperty(
				`--pagedjs-string-start-${name}`,
				`"${cleanPseudoContent(varStart)}"`,
			);
			fragment.style.setProperty(
				`--pagedjs-string-first-except-${name}`,
				`"${cleanPseudoContent(varFirstExcept)}"`,
			);
		}
	}
}

export default StringSets;