143 lines
4.4 KiB
JavaScript
143 lines
4.4 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 updateImageAliasInLine(line, aliasIndex, patch = {}) {
|
|
const source = String(line || "");
|
|
const targetIndex = Number(aliasIndex);
|
|
if (!Number.isInteger(targetIndex) || targetIndex < 0) return null;
|
|
|
|
const codeRanges = inlineCodeRanges(source);
|
|
let index = 0;
|
|
let changed = false;
|
|
const value = source.replace(imageAliasPattern(), (match, ...args) => {
|
|
const offset = args.at(-2);
|
|
if (isInsideRange(offset, codeRanges) || index++ !== targetIndex) return match;
|
|
const parsed = parseImageAlias(match);
|
|
if (!parsed) return match;
|
|
changed = true;
|
|
return buildImageAlias({ ...parsed, ...patch });
|
|
});
|
|
|
|
return changed ? value : null;
|
|
}
|
|
|
|
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;
|
|
}
|