Files
rustpad/static/js/image-alias.js

123 lines
3.7 KiB
JavaScript

/*
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
* Source-Available Code / Dual-Licensed.
*
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
* Commercial or production use requires a valid paid license.
* See LICENSE file in repository root for details.
*/
const IMAGE_ALIAS_SOURCE = String.raw`\[(image|img)=([^,\]\s]+)(?:,([^\]]*))?\]`;
const ALIGNMENTS = new Set(["left", "center", "right"]);
const MAX_IMAGE_DIMENSION = 10000;
function cleanText(value, fallback = "") {
return String(value ?? fallback)
.replace(/\]/g, ")")
.replace(/[\r\n]+/g, " ")
.trim();
}
function validDimension(value) {
const number = Math.round(Number(value));
return Number.isFinite(number) && number >= 1 && number <= MAX_IMAGE_DIMENSION ? number : null;
}
function inlineCodeRanges(value) {
const ranges = [];
const pattern = /`[^`]+`/g;
for (const match of String(value || "").matchAll(pattern)) {
ranges.push([match.index, match.index + match[0].length]);
}
return ranges;
}
function isInsideRange(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
export function imageAliasPattern(flags = "gi") {
return new RegExp(IMAGE_ALIAS_SOURCE, flags);
}
export function parseImageAlias(value) {
const match = String(value || "").match(new RegExp(`^${IMAGE_ALIAS_SOURCE}$`, "i"));
if (!match) return null;
const [, kind, filename, tail = ""] = match;
const parts = tail.split(",");
let align = null;
let width = null;
let height = null;
while (parts.length) {
const part = parts.at(-1).trim();
const alignment = part.match(/^a=(left|center|right)$/i);
if (alignment) {
align = alignment[1].toLowerCase();
parts.pop();
continue;
}
const size = part.match(/^size=(\d{1,5})x(\d{1,5})$/i);
if (size) {
const nextWidth = validDimension(size[1]);
const nextHeight = validDimension(size[2]);
if (nextWidth && nextHeight) {
width = nextWidth;
height = nextHeight;
}
parts.pop();
continue;
}
break;
}
return {
kind: kind.toLowerCase(),
filename,
label: cleanText(parts.join(","), filename) || filename,
align,
width,
height,
raw: match[0],
};
}
export function buildImageAlias(options = {}) {
const filename = cleanText(options.filename, "image").replace(/[,\s]/g, "_") || "image";
const label = cleanText(options.label, filename) || filename;
const kind = String(options.kind || "image").toLowerCase() === "img" ? "img" : "image";
const align = ALIGNMENTS.has(String(options.align || "").toLowerCase())
? String(options.align).toLowerCase()
: null;
const width = validDimension(options.width);
const height = validDimension(options.height);
const parts = [label];
if (align) parts.push(`a=${align}`);
if (width && height) parts.push(`size=${width}x${height}`);
return `[${kind}=${filename},${parts.join(",")}]`;
}
export function updateImageAliasInLineBySource(line, aliasSource, occurrence = 0, patch = {}) {
const source = String(line || "");
const target = String(aliasSource || "");
const targetOccurrence = Number(occurrence);
if (!target || !Number.isInteger(targetOccurrence) || targetOccurrence < 0) return null;
const codeRanges = inlineCodeRanges(source);
let matchedOccurrence = 0;
let changed = false;
const value = source.replace(imageAliasPattern(), (match, ...args) => {
const offset = args.at(-2);
if (isInsideRange(offset, codeRanges) || match !== target || matchedOccurrence++ !== targetOccurrence) return match;
const parsed = parseImageAlias(match);
if (!parsed) return match;
changed = true;
return buildImageAlias({ ...parsed, ...patch });
});
return changed ? value : null;
}