diff --git a/.eleventy.js b/.eleventy.js
index 92a55e7..ce9ab62 100644
--- a/.eleventy.js
+++ b/.eleventy.js
@@ -1,4 +1,5 @@
const moment = require('moment');
+const crs = require('./crs/crs.js');
moment.locale('en');
@@ -11,9 +12,12 @@ module.exports = function (eleventyConfig) {
// https://github.com/victornpb/eleventy-plugin-page-assets
eleventyConfig.addPlugin(pageAssetsPlugin, {
- mode: "parse",
- assetsMatching: "*.png|*.PNG|*.jpg|*.JPG|*.gif|*.GIF",
- postsMatching: "**/*.md",
+ mode: "directory",
+ assetsMatching: "*.png|*.PNG|*.jpg|*.JPG|*.gif|*.GIF|*.cr|*.nr|*.txt",
+ postsMatching: "**/*.md",
+ hashAssets: false,
+ recursive: true,
+ silent: true,
});
eleventyConfig.addFilter('dateIso', date => {
@@ -28,6 +32,12 @@ module.exports = function (eleventyConfig) {
// Folders to copy to output folder
eleventyConfig.addPassthroughCopy("css");
+
+ crs(eleventyConfig);
+
+ // all reports go here
+ eleventyConfig.addPassthroughCopy("reports");
+
};
module.exports.config = {
diff --git a/.eleventyignore b/.eleventyignore
index ce9afc9..c6f11fb 100644
--- a/.eleventyignore
+++ b/.eleventyignore
@@ -1,5 +1,8 @@
# We don't want Eleventy to include the README.md as a website content file
README.md
+# template directory: help for authors
+# template/
+
# Unit tests should be ignored by Eleventy
tests/
diff --git a/_includes/base-layout.njk b/_includes/base-layout.njk
index 0176540..b18e80a 100644
--- a/_includes/base-layout.njk
+++ b/_includes/base-layout.njk
@@ -3,9 +3,16 @@
- Eressea Beispielpartie
+ Eressea Beispielpartie - {{title}}
+
+
+{% if content.indexOf('crs-requires-css') !== -1 %}
+
+{% endif %}
+{% if content.indexOf('crs-requires-js') !== -1 %}
+
+{% endif %}
-
diff --git a/crs/crs-passthrough.js b/crs/crs-passthrough.js
new file mode 100644
index 0000000..72aff50
--- /dev/null
+++ b/crs/crs-passthrough.js
@@ -0,0 +1,185 @@
+// === Helpers ==============================================================
+function escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+function unitShort(unit) {
+ let uhtml = `${escapeHtml(unit.name)} (${escapeHtml(unit.id)})` + (unit.factionName ? `, ${escapeHtml(unit.factionName)} (${escapeHtml(unit.faction)})` : '');
+ if (unit.tags && unit.tags.Anzahl) {
+ uhtml += `, ${escapeHtml(unit.tags.Anzahl)}`;
+ }
+ if (unit.tags && unit.tags.Typ) {
+ uhtml += ` ${escapeHtml(unit.tags.Typ)}`;
+ }
+ return uhtml;
+}
+
+function parseRegionData(regionUse) {
+ const link = regionUse.closest('.cr-region-link') || regionUse;
+ if (!link) return null;
+ if (link._crRegionData) return link._crRegionData; // cache
+ const attr = link.getAttribute('data-region');
+ if (!attr) return null;
+ try {
+ link._crRegionData = JSON.parse(decodeURIComponent(attr));
+ return link._crRegionData;
+ } catch (e) {
+ link._crRegionDataError = e;
+ return null;
+ }
+}
+
+function parseUnitsData(regionUse) {
+ const parent = regionUse.parentNode;
+ if (!parent) return [];
+ const unitsUse = parent.querySelector('use[data-units]');
+ if (!unitsUse) return [];
+ if (unitsUse._crUnitsData) return unitsUse._crUnitsData;
+ const attr = unitsUse.getAttribute('data-units');
+ if (!attr) return [];
+ try {
+ unitsUse._crUnitsData = JSON.parse(decodeURIComponent(attr));
+ return unitsUse._crUnitsData;
+ } catch (e) {
+ unitsUse._crUnitsDataError = e;
+ return [];
+ }
+}
+
+function buildRegionHtml(regionData) {
+ if (!regionData || !regionData.tags) return '';
+ let html = `${escapeHtml(regionData.tags.Terrain || '')} ${escapeHtml(regionData.tags.Name || '')} (${escapeHtml(regionData.x)}, ${escapeHtml(regionData.y)})`;
+ const entries = Object.entries(regionData.tags).filter(([k]) => !['Name', 'id', 'Terrain'].includes(k));
+ if (entries.length) {
+ html += '';
+ }
+ return html;
+}
+
+function buildUnitListHtml(units, regionDomId, targetId) {
+ if (!units || !units.length) return '';
+ let html = 'Units:';
+ for (const u of units) {
+ const cls = u.isOwner ? 'owner-unit' : 'other-unit';
+ html += `- ` + unitShort(u) + '
';
+ }
+ html += '
';
+ return html;
+}
+
+function buildUnitDetailHtml(unit) {
+ if (!unit) return '';
+ let uhtml = '' + unitShort(unit);
+ if (unit.skills && Object.keys(unit.skills).length) {
+ uhtml += '
' + Object.entries(unit.skills).map(([sk, val]) => `${escapeHtml(sk)} ${escapeHtml(val)}`).join(', ') + '
';
+ }
+ if (unit.items && Object.keys(unit.items).length) {
+ uhtml += '
' + Object.entries(unit.items).map(([item, amount]) => `${escapeHtml(amount)} ${escapeHtml(item)}`).join(', ') + '
';
+ }
+ const omit = ['Name', 'id', 'Partei', 'Anzahl', 'Typ'];
+ const rest = Object.entries(unit.tags || {}).filter(([k]) => !omit.includes(k));
+ if (rest.length) {
+ uhtml += '
';
+ }
+ uhtml += '
';
+ return uhtml;
+}
+
+function buildUnitCommandsHtml(unit) {
+ if (!unit) return '';
+ const commands = (unit.commands || []).join('\n');
+ return '';
+}
+
+function updateUnitPanels(regionUse, units, unitId) {
+ if (!unitId) return; // nothing to do
+ const unit = units.find(u => u.id === unitId);
+ if (!unit) return;
+ const cridVal = (regionUse.getAttribute('data-crid') || '');
+ const unitDetails = document.getElementById('udetails_' + cridVal);
+ const unitCommands = document.getElementById('ucommands_' + cridVal);
+ if (unitDetails) unitDetails.innerHTML = buildUnitDetailHtml(unit);
+ if (unitCommands) unitCommands.innerHTML = buildUnitCommandsHtml(unit);
+}
+
+// === Orchestrator =========================================================
+function showDescription(event, targetDivId, regionDomId, unitId) {
+ if (event) event.preventDefault();
+ if (!regionDomId) return false;
+ const regionTarget = document.getElementById(targetDivId);
+ if (!regionTarget) return false;
+ const regionUse = document.getElementById(regionDomId);
+ if (!regionUse) return false;
+
+ const regionData = parseRegionData(regionUse);
+ const units = parseUnitsData(regionUse);
+
+ // Update side panels for a specific unit (details + commands) first so unit view stays in sync
+ if (unitId) updateUnitPanels(regionUse, units, unitId);
+
+ let html = '';
+ html += buildRegionHtml(regionData);
+ html += buildUnitListHtml(units, regionDomId, targetDivId);
+
+ regionTarget.innerHTML = html || 'No details';
+ regionTarget.style.display = 'block';
+ return false;
+}
+
+function showTooltip(event, id, text) {
+ const tooltip = document.getElementById(id);
+ tooltip.style.display = 'block';
+ tooltip.innerHTML = text;
+ tooltip.style.display = 'block';
+ const x = event.clientX + 10;
+ const y = event.clientY - 10;
+ tooltip.style.left = `${x}px`;
+ tooltip.style.top = `${y}px`;
+}
+
+function hideTooltip(id) {
+ const tooltip = document.getElementById(id);
+ tooltip.style.display = 'none';
+}
+
+// TODO: Accessibility: add keyboard listeners (Enter/Space) for .cr-region-link and .cr-unit-link
+// and ARIA roles (button/list/listitem) in a subsequent enhancement.
+
+(function initCRMapBindings() {
+ if (window.__crMapBound) return; // avoid rebinding on partial reloads
+ window.__crMapBound = true;
+ document.addEventListener('mousemove', function (e) {
+ const target = e.target?.closest?.('.cr-region-link') || null;
+ if (target && target.dataset.tooltipId && target.dataset.tooltip) {
+ showTooltip(e, target.dataset.tooltipId, target.dataset.tooltip);
+ }
+ }, true);
+ document.addEventListener('mouseout', function (e) {
+ const rel = e.relatedTarget;
+ const link = e.target?.closest?.('.cr-region-link') || null;
+ if (link && (!rel || !rel.closest('.cr-region-link')) && link.dataset.tooltipId) {
+ hideTooltip(link.dataset.tooltipId);
+ }
+ }, true);
+ document.addEventListener('click', function (e) {
+ const regionLink = e.target?.closest?.('.cr-region-link') || null;
+ if (regionLink && regionLink.dataset.regionId && regionLink.dataset.regionTarget) {
+ showDescription(e, regionLink.dataset.regionTarget, regionLink.dataset.regionId);
+ e.preventDefault();
+ return;
+ }
+ const unitLink = e.target.closest('.cr-unit-link');
+ if (unitLink) {
+ const regionId = unitLink.dataset.regionId;
+ const rtarget = unitLink.dataset.regionTarget;
+ const uid = unitLink.dataset.unitId;
+ showDescription(e, rtarget, regionId, uid);
+ e.preventDefault();
+ }
+ });
+})();
\ No newline at end of file
diff --git a/crs/crs.css b/crs/crs.css
new file mode 100644
index 0000000..9148ced
--- /dev/null
+++ b/crs/crs.css
@@ -0,0 +1,197 @@
+/* CR map unit list styling */
+.cr-region-details .unit_block ul {
+ list-style: none;
+ padding-left: 0.5em;
+ margin: 0.25em 0;
+}
+
+.cr-region-details .unit_block li {
+ position: relative;
+ padding-left: 1.2em;
+ cursor: pointer;
+}
+
+.cr-region-details .unit_block li::before {
+ position: absolute;
+ left: 0;
+ width: 1em;
+ display: inline-block;
+ text-align: center;
+ content: '-';
+ color: #666;
+ font-weight: normal;
+}
+
+.cr-region-details .unit_block li.owner-unit::before {
+ content: '*';
+ font-weight: bold;
+}
+
+.cr-region-details .unit_block li:hover::before {
+ transform: scale(1.1);
+}
+
+.cr-region-details .unit_block li.owner-unit:hover::before {
+ color: #ffcc33;
+}
+
+/* CR map layout & tooltip extracted from inline styles */
+.cr-svg-wrapper {
+ max-width: 100%;
+ max-height: 600px;
+ overflow: auto;
+ position: relative;
+}
+
+.cr-tooltip {
+ display: none;
+ position: fixed;
+ pointer-events: none;
+ background: rgba(30, 30, 30, 0.85);
+ color: #fff;
+ padding: 2px 6px;
+ border-radius: 4px;
+ font: 12px/1.2 monospace;
+ z-index: 9999;
+}
+
+.cr-error {
+ color: #a00;
+ font-family: monospace;
+}
+
+/* Orderfile styling */
+
+.orderfile {
+ font-family: monospace;
+ font-size: 0.95em;
+}
+
+.order-line {
+ padding: 0.08em 0.25em;
+ margin: 0;
+ line-height: 1.15;
+}
+
+.order-line+.order-line {
+ margin-top: 0.08em;
+}
+
+.order-line.empty {
+ height: 0.4em;
+}
+
+/* Comments: lighter, slightly indented, italic */
+.order-line.comment {
+ background: rgba(200, 200, 200, 0.03);
+ color: #555;
+ padding-left: 0.5em;
+ font-family: sans-serif;
+ border-left: 2px solid rgba(0, 0, 0, 0.06);
+}
+
+.order-line.comment+.order-line.comment {
+ margin-top: 0.06em;
+}
+
+/* Orders: more prominent */
+.order-line.order {
+ background: rgba(0, 0, 0, 0.02);
+ color: #111;
+ padding-left: 0.25em;
+}
+
+.order-line.order .order-keyword {
+ font-weight: 600;
+ color: #0b5394;
+ text-decoration: none;
+}
+
+.order-line.order+.order-line.order {
+ margin-top: 0.06em;
+}
+
+/* Some orders (diagnostics) are rendered as 'order' but without auto-link */
+.order-line.no-link {
+ color: #222;
+}
+
+/* Slightly reduce spacing before and after the whole block */
+.orderfile {
+ margin-top: 0.2em;
+ margin-bottom: 0.2em;
+}
+
+/* shownr / nr helpers: render in same monospace font and spacing as orderfile/order-line */
+.shownr-wrapper {
+ display: block;
+}
+
+.nr-registered {
+ font-family: monospace;
+ font-size: 0.95em;
+}
+
+.shownr {
+ font-family: monospace;
+ font-size: 0.8em;
+ overflow-x: auto;
+ padding: 0.35em;
+ border: 1px solid rgba(0, 0, 0, 0.06);
+ background: #fbfbfb;
+ border-radius: 6px;
+}
+
+.shownr-line {
+ font-family: monospace;
+ padding: 0.08em 0.25em;
+ margin: 0;
+ line-height: 1.15;
+ white-space: pre;
+ tab-size: 4;
+}
+
+.shownr-line+.shownr-line {
+ margin-top: 0.08em;
+}
+
+/* nicer scrollbars on WebKit/Blink */
+.shownr::-webkit-scrollbar {
+ height: 10px;
+ background: transparent;
+}
+
+.shownr::-webkit-scrollbar-thumb {
+ background: rgba(0, 0, 0, 0.12);
+ border-radius: 6px;
+}
+
+.shownr::-webkit-scrollbar-thumb:hover {
+ background: rgba(0, 0, 0, 0.18);
+}
+
+/* Firefox scrollbar width hint */
+.shownr {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(0, 0, 0, 0.12) transparent;
+}
+
+.shownr-file {
+ font-family: inherit;
+ /* use site default font */
+ font-size: 0.95em;
+ color: #333;
+ margin-bottom: 0.25em;
+}
+
+.nr-registered {
+ display: none;
+}
+
+.shownr-linenr {
+ display: inline-block;
+ width: 2em;
+ text-align: right;
+ color: #999;
+ padding-right: 0.5em;
+}
\ No newline at end of file
diff --git a/crs/crs.js b/crs/crs.js
new file mode 100644
index 0000000..d6f9ab6
--- /dev/null
+++ b/crs/crs.js
@@ -0,0 +1,1295 @@
+// crs.js
+const fs = require('fs');
+const path = require('path');
+const { start } = require('repl');
+
+// Library version (update when changing public shortcode behavior)
+const CRS_VERSION = '0.2.3';
+// Export version for external use (e.g., in layouts via require) and add as global data below
+module.exports.CRS_VERSION = CRS_VERSION;
+
+module.exports = function (eleventyConfig) {
+ // Provide version to all templates as 'crmapVersion'
+ if (eleventyConfig.addGlobalData) {
+ eleventyConfig.addGlobalData('crmapVersion', CRS_VERSION);
+ }
+
+ // crmap: render a CR file to an interactive SVG map.
+ // Usage: SECOND ARGUMENT IS OPTIONAL JSON OPTIONS STRING.
+ // {% crmap 'path/to/file.cr' %} -> defaults (auto crid, details true, z 0, auto caption)
+ // {% crmap 'path/to/file.cr' '{}' %} -> same as defaults
+ // {% crmap 'path/to/file.cr' '{"crid":"map1"}' %} -> explicit crid
+ // {% crmap 'path/to/file.cr' '{"details":false}' %} -> no details (tooltips only)
+ // {% crmap 'path/to/file.cr' '{"layer":2}' %} -> layer z=2
+ // {% crmap 'path/to/file.cr' '{"caption":"My Caption"}' %} -> custom caption
+ // {% crmap 'path/to/file.cr' '{"caption":false}' %} -> omit caption
+ // {% crmap 'path/to/file.cr' '{"crid":"m1","details":false,"layer":1,"caption":false}' %}
+ // Option keys: crid (string), details (boolean), layer (integer), caption (string|false)
+ // Rules:
+ // - crid must match /^[a-z0-9_-]+$/; if omitted => auto numeric.
+ // - details:false removes detail panels but keeps tooltips.
+ // - caption:false omits figcaption; caption:string sets custom text.
+ // - Unrecognized keys are ignored.
+ // Optional detail containers (place anywhere after the map):
+ // {% crmap_rdetails 'map1' 'Optional placeholder text' %} -> region details target div
+ // {% crmap_udetails 'map1' 'Optional placeholder text' %} -> unit details target div
+ // {% crmap_commands 'map1' 'Optional placeholder text' %} -> unit commands target div
+ // Notes:
+ // - crid must be lowercase a-z 0-9 _ -
+ // - Duplicate custom crid returns an inline error.
+ // - details:false omits region/unit descriptions and links but keeps tooltips.
+
+ eleventyConfig.addShortcode('crmap', function (file, optionsJson) {
+ return crmapShortcode.call(this, file, optionsJson);
+ });
+
+ // Shortcode to output region details container for a given (or last created) crid
+ eleventyConfig.addShortcode('crmap_rdetails', function (crid, placeholder = null) {
+ return crmapRdetailsShortcode.call(this, crid, placeholder);
+ });
+
+ // Shortcode to output unit details container for a given (or last created) crid
+ eleventyConfig.addShortcode('crmap_udetails', function (crid, placeholder = null) {
+ return crmapUdetailsShortcode.call(this, crid, placeholder);
+ });
+
+ // Shortcode to output command details container for a given (or last created) crid
+ eleventyConfig.addShortcode('crmap_commands', function (crid, placeholder = null) {
+ return crmapCommandsShortcode.call(this, crid, placeholder);
+ });
+
+ // Shortcode to output order file contents line by line
+ // Usage: {% orderfile 'path/to/file.nr' %} or {% orderfile 'path' '{"markdownInComments":true}' %}
+ //
+ // Options (passed as JSON string, optional):
+ // markdownInComments: boolean (default: true)
+ // - When true, lines starting with ';' are rendered as comment lines and
+ // the comment text is processed with markdown-it (inline rendering)
+ // if available. When false, comments are escaped plain text.
+ // fileLink: boolean (default: true)
+ // - When true, the rendered block will include a small header linking to
+ // the source file (basename shown). Set to false to omit the file link.
+ // commentsAsOrders: boolean (default: false)
+ // - When true, lines that start with ';' are treated as orders instead of
+ // comment blocks. They will be rendered as order lines with class
+ // `order no-link` (escaped text, no wiki link on the first token).
+ // renderSpecial: boolean (default: false)
+ // - When true, lines that match specialPrefixes are rendered.
+ //
+ // Examples:
+ // {% orderfile 'reports/orcs/orders-demo-02.txt' %}
+ // {% orderfile 'reports/orcs/orders-demo-02.txt' '{"fileLink":false}' %}
+ // {% orderfile 'reports/orcs/orders-demo-02.txt' '{"commentsAsOrders":true}' %}
+ eleventyConfig.addShortcode('orderfile', function (fileName, optionsJson) {
+ return renderOrderFile.call(this, fileName, optionsJson);
+ });
+
+ // Usage examples for the .nr helpers:
+ // {% readnr 'reports/orcs/orders-demo-02.nr' %} -> registers file, auto nrid
+ // {% readnr 'reports/orcs/orders-demo-02.nr' '{"nrid":"r1"}' %} -> register with explicit nrid
+ // {% shownr 'list' %} -> show a list of all bookmarks from last readnr
+ // {% shownr 'header' %} -> show 'header' bookmark from last readnr
+ // {% shownr 'r1' 'battles' %} -> show 'battles' bookmark from nrid r1
+ // {% shownr '10-20' %} -> show lines 10..20 from last nrid
+ // {% shownr '{"nrid":"r1","range":"5-15", "lineNumbers":true }' %} -> JSON form
+ // {% shownr 'r1' 'unit_abc123' %} -> show the unit with id 'abc123' inside region
+ // {% shownr '{"bookmark":"heading_ereignisse","maxHeight":300}' %} -> show heading with max 300px height
+ // Shortcodes for reading and showing .nr (order/report) files with bookmarks
+ eleventyConfig.addShortcode('readnr', function (file, optionsJson) {
+ return readnrShortcode.call(this, file, optionsJson);
+ });
+ eleventyConfig.addShortcode('shownr', function (arg1, arg2) {
+ return shownrShortcode.call(this, arg1, arg2);
+ });
+
+ // Passthrough assets
+ eleventyConfig.addPassthroughCopy("crs/crs-passthrough.js");
+ eleventyConfig.addPassthroughCopy({ "crs/crs.css": "css/crs.css" });
+
+ // Color and image mappings from PHP
+ const colors = {
+ 'default': 'grey',
+ 'Ozean': '#0000ff',
+ 'Ebene': '#ffff00',
+ 'Wald': '#00dd00',
+ 'Sumpf': '#226611',
+ 'Berge': '#777777',
+ 'Hochland': '#ffeeaa',
+ 'Wüste': '#ffcc55',
+ 'Gletscher': '#bbbbcc',
+ 'Eisberg': '#eeeeff',
+ 'Vulkan': '#bb0022',
+ 'Aktiver Vulkan': '#ee0022',
+ 'Feuerwand': '#ff0000',
+ };
+
+ const images = {
+ 'Ozean': 'ozean',
+ 'Ebene': 'ebene',
+ 'Wald': 'wald',
+ 'Sumpf': 'sumpf',
+ 'Berge': 'berge',
+ 'Hochland': 'hochland',
+ 'Wüste': 'wueste',
+ 'Gletscher': 'gletscher',
+ 'Eisberg': 'eisberg',
+ 'Vulkan': 'vulkan',
+ 'Aktiver Vulkan': 'aktiver vulkan',
+ 'Feuerwand': 'feuerwand',
+ 'Nebel': 'nebel',
+ 'Dichter Nebel': 'dichter nebel',
+ 'Packeis': 'packeis',
+ 'Gang': 'gang',
+ 'Halle': 'halle',
+ 'Wand': 'wand',
+ };
+
+ const defaultImage = 'region';
+
+ // Debug flag (enable with environment variable CRS_DEBUG=1)
+ const DEBUG = process.env.CRS_DEBUG === '1';
+ function debug(...args) { if (DEBUG) console.log('[crs]', ...args); }
+
+ // warn(msg[, html_msg]) -> logs a console warning and returns an error HTML string.
+ function warn(msg, html_msg) {
+ try { console.warn('[crs]', msg); } catch (e) { /* ignore */ }
+ html_msg = html_msg || String(msg);
+ return `${escapeHtml(String(html_msg))}
`;
+ }
+
+ function escapeHtml(str) {
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ // Resolve a user-supplied path argument used in shortcodes.
+ // Supports two forms:
+ // 1. Root-relative (starts with '/'): resolved against project root (Eleventy's input dir)
+ // 2. Relative: resolved against the directory of the calling template file
+ // Returns { fsPath, publicPath, relPath } or { error }
+ // publicPath is a root-relative path beginning with '/' suitable for use in generated HTML.
+ function resolveUserPath(spec, ctx) {
+ if (!spec || typeof spec !== 'string') return { error: 'missing path' };
+ // Normalize Windows backslashes just in case
+ spec = spec.replace(/\\/g, '/').trim();
+ const projectRoot = process.cwd();
+ // Base directory derived from the template invoking the shortcode
+ let baseDir = projectRoot;
+ try {
+ if (ctx && ctx.page && ctx.page.inputPath) {
+ const tplPath = path.resolve(projectRoot, ctx.page.inputPath);
+ baseDir = path.dirname(tplPath);
+ }
+ } catch (_) { /* ignore */ }
+
+ const isRootRel = spec.startsWith('/');
+ const cleaned = isRootRel ? spec.replace(/^\/+/, '') : spec;
+ const candidateFs = path.resolve(isRootRel ? projectRoot : baseDir, cleaned);
+ // Prevent escaping project root
+ const rootWithSep = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep;
+ if (!candidateFs.startsWith(rootWithSep)) {
+ return { error: 'path escapes project root' };
+ }
+ const relPath = path.relative(projectRoot, candidateFs).split(path.sep).join('/');
+ // publicPath: always root-relative and start with '/'
+ const publicPath = spec; // '/' + relPath;
+
+ return { fsPath: candidateFs, publicPath, relPath };
+ }
+
+ // Validation helper (stateless)
+ function validateCrid(id) {
+ if (typeof id !== 'string') return { ok: false, message: 'crid must be a string' };
+ if (id !== id.toLowerCase()) return { ok: false, message: `crid '${id}' must be lowercase` };
+ if (!/^[a-z0-9_-]+$/.test(id)) return { ok: false, message: `crid '${id}' contains invalid characters (allowed: a-z 0-9 _ -)` };
+ return { ok: true };
+ }
+
+ function getColor(terrain) {
+ return colors[terrain] || colors['default'];
+ }
+
+ function getImage(terrain) {
+ return images[terrain] || null;
+ }
+
+ const rwidth = 100;
+ const yoff = rwidth * 0.5;
+
+ function transformx(region) {
+ return Math.round(region.x * rwidth + region.y * yoff);
+ }
+ function transformy(region) {
+ return Math.round(region.y * -rwidth * 3 / 4);
+ }
+
+ function itoa36(num) {
+ return num.toString(36);
+ }
+
+ function parseFaction(line, matches) {
+ const parts = line.trim().split(/\s+/);
+ const numid = parseInt(matches[1], 10);
+ const faction = { id: itoa36(numid), numid, tags: {} };
+ debug("found faction", faction);
+
+ return faction;
+ }
+
+ function parseRegion(line, matches) {
+ const parts = line.trim().split(/\s+/);
+ // Use plain objects for tags & units (units keyed by id)
+ const region = { tags: {}, units: {} };
+ region.x = parseInt(matches[1], 10);
+ region.y = parseInt(matches[2], 10);
+ if (matches[3]) region.z = parseInt(matches[3], 10); else region.z = 0;
+ debug(`found region (${region.x},${region.y}, ${region.z})`);
+
+ return region;
+ }
+
+ function parseUnit(line, matches) {
+ const parts = line.trim().split(/\s+/);
+ const unit = { id: itoa36(parseInt(matches[1], 10)), name: '???', tags: {}, skills: {}, items: {}, commands: [] };
+ debug("found unit", unit.id);
+
+ return unit;
+ }
+
+ function outputRegion(region, bounds, crid, withDetails, ownerFactionId) {
+ debug('writing region ', region);
+ if (!region) return '';
+ if (!region.tags.Terrain) return '';
+ let color = getColor(region.tags.Terrain);
+ let tag = getImage(region.tags.Terrain);
+ if (!tag) {
+ tag = defaultImage;
+ color = `fill=\"${color}\"`;
+ } else {
+ color = '';
+ }
+ const xx = region.x;
+ const yy = region.y;
+ const x = transformx(region);
+ const y = transformy(region);
+ let tt = region.tags.Name ? region.tags.Name : region.tags.Terrain;
+ tt += ` (${xx}, ${yy})`;
+
+ // Prepare JSON payloads
+ let regionData = {};
+ if (withDetails) {
+ regionData = { x: region.x, y: region.y, tags: region.tags };
+ }
+
+ let id = 'r_';
+ id += xx < 0 ? `m${-xx}` : xx;
+ id += yy < 0 ? `_m${-yy}` : `_${yy}`;
+ id += `_${crid}`;
+
+ bounds.xmin = Math.min(bounds.xmin, x);
+ bounds.ymin = Math.min(bounds.ymin, y);
+ bounds.xmax = Math.max(bounds.xmax, x);
+ bounds.ymax = Math.max(bounds.ymax, y);
+
+ let unitsMarkup = '';
+ let unitsData = [];
+ if (withDetails && Object.keys(region.units).length > 0) {
+ unitsData = Object.entries(region.units).map(([id, unit]) => {
+ return {
+ id: unit.id,
+ name: unit.tags.Name || unit.id,
+ faction: unit.faction ? unit.faction.id : null,
+ factionName: unit.factionName || null,
+ isOwner: ownerFactionId && unit.faction && unit.faction.id === ownerFactionId ? true : false,
+ tags: unit.tags,
+ skills: unit.skills || {},
+ items: unit.items || {},
+ commands: unit.commands || []
+ };
+ });
+ // Keep a single