dumbymap.mjs — Code Report

A top-to-bottom walkthrough of src/dumbymap.mjs, the core library of DumbyMap. After the refactor, generateMaps() delegates to a set of composable exported setup functions. Each setup function is independently importable and testable. Line references link to the sections described.

imports Dependencies L1–15

The file opens by pulling in all third-party and local modules it needs:

export htmlToMd(node) L25–74

Converts a live DOM node into a Markdown string. It is used whenever DumbyMap needs to derive an editable text representation from raw HTML (e.g. when generateMaps() is called directly on hand-written HTML without going through markdown2dumbyBlock()).

Text nodes

Returns the raw text content, or an empty string if it is whitespace-only.

if (node.nodeType === Node.TEXT_NODE) {
  const t = node.textContent
  return t.trim() ? t : ''
}

Known element tags

A switch on node.tagName maps headings, inline marks, links, images, code, fenced code blocks, blockquotes, and lists to their Markdown equivalents. The recursive helper inner() serialises child nodes first:

const inner = () => Array.from(node.childNodes).map(htmlToMd).join('')

case 'h1': return `# ${inner().trim()}\n\n`
case 'pre': {
  const code = node.querySelector('code')
  const lang = (code?.className ?? '').match(/language-(\w+)/)?.[1] ?? ''
  return `\`\`\`${lang}\n${(code ?? node).textContent.trim()}\n\`\`\`\n\n`
}

Unknown tags — UNWRAP_TAGS vs. outerHTML

The default branch distinguishes two cases using the module-level set UNWRAP_TAGS L77:

const UNWRAP_TAGS = new Set([
  'div','article','section','main','aside','header','footer','nav'
])

default:
  return UNWRAP_TAGS.has(tag) ? inner() : node.outerHTML

internal splitMd(md) L87–113

Splits a Markdown string into an array of block strings at every run of 2 or more consecutive blank lines (i.e. \n\n\n or more). Fenced code blocks (```...```) are treated as atomic — blank lines inside them never trigger a split.

if (/^```/.test(line)) inFence = !inFence
if (!inFence && blanks >= 2) {
  // flush current buffer as one block
}

This function is used in two places:

vars Module-level constants L116–129

CSS selectors

const mapBlockSelector = 'pre:has(code[class*=map]), .mapclay-container'
const docLinkSelector  = 'a[href^="#"][title^="=>"]:not(.doclink)'
const geoLinkSelector  = 'a[href^="geo:"]:not(.geolink)'

These three selectors are used throughout the observer setup functions to identify map code blocks, document-fragment links, and geo-scheme links respectively. The :not() guards prevent double-initialisation.

Default layouts

const defaultLayouts = [
  new Layout({ name: 'normal' }),
  new SideBySide({ name: 'side-by-side' }),
  new Overlay({ name: 'overlay' }),
  new Sticky({ name: 'sticky' }),
]

Four built-in layout strategies, always prepended to any caller-supplied layouts.

Map cache

const mapCache = {}

A module-level object keyed by map ID. Already-rendered maps are stored here so that when markdown2dumbyBlock() re-renders the document (e.g. after a block edit), previously rendered map instances are reused and the user sees no visual disruption.

export markdown2dumbyBlock(container, mdContent) L139–201

Accepts a Markdown (or raw HTML, since Markdown is a superset) string and renders it into .dumby-block <article> elements inside a .SemanticHtml wrapper appended to container. Any pre-existing .SemanticHtml is removed first.

markdown-it configuration

const md = MarkdownIt({ html: true, breaks: true, linkify: true })
  .use(MarkdownItAnchor, { permalink: ... })
  .use(MarkdownItFootnote)
  .use(MarkdownItFrontMatter)
  .use(MarkdownItInjectLinenumbers)
  .use(MarkdownItAttrs)

html: true is the key option — it lets raw HTML tags pass through unchanged, making the function equally usable with hand-written HTML input.

Custom dumby_block rule

A core rule inserted after the built-in block pass injects dumby_block_open / dumby_block_close tokens around content that is separated by 2 or more blank lines:

md.core.ruler.after('block', 'dumby_block', state => {
  for (const token of tokens) {
    const gap = token.map[0] - prevEndLine
    if (!blockOpen) {
      out.push(new state.Token('dumby_block_open', '', 1))
    } else if (gap >= 2) {          // 2+ blank lines → new block
      out.push(new state.Token('dumby_block_close', '', -1))
      out.push(new state.Token('dumby_block_open', '', 1))
    }
  }
})

These tokens render as <article class="dumby-block"> wrappers.

Per-block markdown source storage

const mdPieces = splitMd(mdContent)
htmlHolder.querySelectorAll('.dumby-block').forEach((block, i) => {
  block._md = mdPieces[i] ?? ''
})

Each .dumby-block element gets a ._md property holding its source Markdown string. This makes blocks self-contained for later editing — no container-level string needs to be maintained.

internal updateAttributeByStep & defaultRender L208–238

updateAttributeByStep

A step callback passed to mapclay. It writes a progress string such as "2/3" onto target.dataset.report, giving CSS a hook for showing a render-progress indicator on the map container. The trailing space trick ('\x20') forces a DOM attribute change even when the value hasn't changed, ensuring the animation fires.

target.dataset.report = passNum   // e.g. "2/3"
if (final) setTimeout(() => delete target.dataset.report, 100)

defaultRender

const defaultRender = mapclay.renderWith(config => ({
  use: config.use ?? 'Leaflet',
  width: '100%',
  ...config,
  aliases: { ...mapclay.defaultAliases, ...(config.aliases ?? {}) },
  stepCallback: updateAttributeByStep,
}))

Wraps mapclay.renderWith() to set project-wide defaults: Leaflet as the fallback renderer, full-width maps, merged aliases, and the progress callback above. Callers of generateMaps() can replace this via the render option.

export setupContainer(container, options) L241–247

Initialises the root container element by adding the Dumby class, setting data-crs for coordinate reference system support, and applying the initial layout name. It also calls register(proj4) to make proj4 projections available to OpenLayers.

container.classList.add('Dumby')
delete container.dataset.layout
container.dataset.crs = crs
container.dataset.layout = initialLayout ?? defaultLayouts.at(0).name
register(proj4)

Options: crs (default 'EPSG:4326'), initialLayout.

export resolveHtmlHolder(container, contentSelector) L250–257

Finds the element that will hold the semantic content using a waterfall of heuristics, so generateMaps() works on many different page structures without configuration:

const htmlHolder =
  container.querySelector(contentSelector) ??
  container.querySelector('.SemanticHtml, main, :scope > article') ??
  Array.from(container.children).find(e =>
    e.id?.match(/main|content/) || e.className?.match?.(/main|content/)
  ) ??
  Array.from(container.children)
    .sort((a, b) => a.textContent.length < b.textContent.length)
    .at(0)

The last fallback picks the child element with the most text — a reasonable guess when no other signal is present. The resolved element has SemanticHtml added to its class list before being returned.

export wrapDumbyBlocks(htmlHolder) L271–302

Wraps content into .dumby-block elements if not already done by markdown2dumbyBlock(). A private helper wrapTextNodes(parent) L260–268 first promotes any bare text nodes directly inside htmlHolder into <p> elements so they are visible to CSS block selectors.

If .dumby-block elements already exist, it just calls wrapTextNodes on each. Otherwise it creates them using three strategies in order:

if (headings.length) {
  childNodes.forEach(node => {
    if (node.matches('h1,h2,h3') && block.children.length) {
      block = document.createElement('article')
      block.className = 'dumby-block'
      htmlHolder.appendChild(block)
    }
    block.appendChild(node)
  })
}

export storeMarkdownPerBlock(htmlHolder) L305–311

After blocks are established (by either path), any block that lacks a ._md property (i.e. was created from raw HTML, not from markdown2dumbyBlock()) derives its Markdown source via htmlToMd():

htmlHolder.querySelectorAll('.dumby-block').forEach(block => {
  if (block._md === undefined) {
    block._md = Array.from(block.childNodes).map(htmlToMd).join('').trim()
  }
})

This ensures all blocks carry editable source text regardless of how the page was initialised — with Markdown, raw HTML, or pre-existing .dumby-block markup.

export createShowcase(container) L314–319

Creates and appends the .Showcase <div> to the container. The Showcase is the panel that displays a focused map separately from the content flow when a non-normal layout is active.

const showcase = document.createElement('div')
container.appendChild(showcase)
showcase.classList.add('Showcase')
return showcase

export createModal(container) L322–327

Creates a plain-modal instance for the full-content overlay and appends its content element to the container. Returns both modal and modalContent so callers can attach content to the modal element directly.

const modalContent = document.createElement('div')
container.appendChild(modalContent)
const modal = new PlainModal(modalContent)
return { modal, modalContent }

export buildDumbymap(container, options) L330–367

Constructs and returns the dumbymap plain object that is the public API surface returned to callers of generateMaps(). It provides live DOM getters and a utils namespace with all navigation and rendering helpers, all bound to dumbymap:

const dumbymap = {
  layouts: [...defaultLayouts, ...layouts.map(...)],
  container,
  get htmlHolder () { return container.querySelector('.SemanticHtml') },
  get showcase ()   { return container.querySelector('.Showcase') },
  get blocks ()     { return Array.from(container.querySelectorAll('.dumby-block')) },
  utils: { ...utils, renderedMaps, setContextMenu, focusNextMap, switchToNextLayout },
  aliases: {},
  modal,
  modalContent,
}

All utility functions are bound to dumbymap so that this inside them refers to the dumbymap instance. The object is sealed with Object.seal() before being returned from generateMaps(). Options: modal, modalContent, layouts.

export setupContentObserver(container, { renderMap }) L370–389

Installs a MutationObserver on characterData changes (text edits). For each mutation it skips map elements and then:

new window.MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (node.matches?.('.mapclay') || node.closest?.('.mapclay')) return
    utils.addGeoSchemeByText(node)
    const mapTarget = node.parentElement?.closest(mapBlockSelector)
    if (mapTarget) renderMap(mapTarget)
  }
}).observe(container, { characterData: true, subtree: true })

export setupChildObserver(container, { renderMap, addGeoLinksByText }) L392–436

Installs a MutationObserver on childList changes and the data-init-dumby attribute. On each mutation it:

The entire observation is bootstrapped by generateMaps() setting container.dataset.initDumby = 'true' L959, which triggers the attribute filter and processes the initial DOM.

export setupLayoutObserver(container, dumbymap) L439–473

Installs a MutationObserver on the data-layout attribute. On change, it calls the leaveHandler of the old layout and the enterHandler of the new one, clears all inline styles, and nudges the focused map to ensure it re-positions correctly:

dumbymap.layouts.find(l => l.name === newLayout)
  ?.enterHandler?.call(this, dumbymap)

const focusMap =
  container.querySelector('.mapclay.focus') ??
  container.querySelector('.mapclay')
focusMap?.classList?.add('focus')

export setupContextMenu(container, dumbymap, editBlockItem) L476–624

Overrides container.oncontextmenu to show a custom popover menu. The menu is built fresh on every right-click with items conditional on what was clicked:

menu.setAttribute('popover', 'auto')
// Defer showPopover so it runs after pointerup, which on Linux fires
// after contextmenu and would otherwise trigger popover light-dismiss
setTimeout(() => { menu.showPopover(); shiftByWindow(menu) }, 0)

The popover API provides native light-dismiss behaviour. shiftByWindow nudges the menu back into viewport if it would overflow.

export setupMouseDrag(container) L627–663

Installs onmousedown on the container to allow the user to drag from a text selection to create a GeoLink. A .point-by-arrow div follows the cursor. If the mouse button is held for more than 300 ms, utils.addGeoLinkByDrag() is called:

const timer = setTimeout(() => {
  utils.addGeoLinkByDrag(container, range, pointByArrow)
}, 300)

container.onmousemove = (event) => {
  pointByArrow.style.left = `${event.clientX - rect.left}px`
  pointByArrow.style.top  = `${event.clientY - rect.top}px`
}
container.onmouseup = () => {
  clearTimeout(timer)
  pointByArrow.remove()
}

export setupKeybindings(container, dumbymap) L666–688

Adds a global keydown listener (ignored when focus is in a textarea or input) providing navigation shortcuts:

The listener is removed via onRemove(container, ...) when the container leaves the DOM.

export fetchDefaultAliases(url, dumbymap) L691–703

If a URL is provided (defaults in generateMaps() to the project's hosted default.yml), it is fetched and its aliases section is merged into dumbymap.aliases. This lets a remote YAML file provide renderer aliases (e.g. shorthand names for tile sources) without baking them into the bundle:

fetch(url)
  .then(res => res.text())
  .then(rawText => {
    const config = mapclay.parseConfigsFromYaml(rawText)?.at(0)
    Object.entries(config.aliases).forEach(([option, aliases]) => {
      dumbymap.aliases[option] = aliases
    })
  })
  .catch(err => console.warn(`Fail to get aliases from ${url}`, err))

export generateMaps(container, options) L718–971

The main entry point and orchestrator. Accepts an HTMLElement and an options object and wires up the complete DumbyMap system by calling the exported setup functions in order, then wiring together a few local closures that depend on the fully-built dumbymap object. Returns a sealed dumbymap object.

The function is idempotent — if the container already has class Dumby it returns immediately:

if (container.classList.contains('Dumby')) return

Orchestration sequence

setupContainer(container, { crs, initialLayout })

const htmlHolder = resolveHtmlHolder(container, contentSelector)
wrapDumbyBlocks(htmlHolder)
storeMarkdownPerBlock(htmlHolder)

// Remove all non-htmlHolder siblings
Array.from(container.childNodes)
  .filter(node => node !== htmlHolder)
  .forEach(node => node.remove())

const showcase = createShowcase(container)
const { modal, modalContent } = createModal(container)
const dumbymap = buildDumbymap(container, { modal, modalContent, layouts })

// ... define local closures: addGeoLinksByText, mapClassObserver,
//     afterMapRendered, assignMapId, renderMap ...

setupContentObserver(container, { renderMap })
setupChildObserver(container, { renderMap, addGeoLinksByText })
setupLayoutObserver(container, dumbymap)

container.dataset.initDumby = 'true'   // triggers initial DOM scan

const editBlockItem = menuItem.setupBlockEdit(dumbymap, { ... })
setupContextMenu(container, dumbymap, editBlockItem)
setupMouseDrag(container)
setupKeybindings(container, dumbymap)
fetchDefaultAliases(defaultApply, dumbymap)

return Object.seal(dumbymap)

Options

local addGeoLinksByText(node) L750–759

A closure defined inside generateMaps() that captures the container's CRS string. It finds coordinate-like text in node, converts it to <a href="geo:..."> anchors, and applies the current CRS transformation:

Promise.all([fromEPSGCode(crsString), addGeoScheme]).then((values) => {
  values.at(-1)
    .map(utils.updateGeoSchemeByCRS(crsString))
    .filter(link => link)
    .forEach(GeoLink)
})

watch mapClassObserver() L765–836

A factory closure inside generateMaps() that returns a MutationObserver watching the class attribute of a single map element. Its main job is moving the map element between the .SemanticHtml content area and the .Showcase panel when the map gains or loses the focus class.

Moving into Showcase

const placeholder = target.cloneNode(true)
placeholder.dataset.placeholder = target.id
parent.replaceChild(placeholder, target)
showcase.appendChild(target)
animateRectTransition(target, placeholder.getBoundingClientRect(), {
  duration: 300, resume: true,
})

A clone with no content serves as a size placeholder in the original position. animateRectTransition animates the real map from its old rect to the Showcase. An onRemove callback returns the map to its original parent if the placeholder is removed (e.g. on layout change).

Moving back from Showcase

The reverse animation runs from the Showcase rect back to the placeholder rect, after which the placeholder is removed and the map is inlined again.

local afterMapRendered(renderer) L843–875

Called by renderMap() when a mapclay renderer resolves. It:

if (mapElement.dataset.render === 'fulfilled') {
  mapCache[mapElement.id] = renderer
}
const observer = mapClassObserver()
observer.observe(mapElement, { attributes: true, attributeFilter: ['class'] })

local assignMapId & renderMap(target) L878–953

assignMapId(config)

Ensures every map config gets a unique ID, derived from the use renderer name with an auto-incrementing counter if the ID is already taken:

let mapId = config.use?.split('/')?.at(-1)
while (!mapId || mapIdList.includes(mapId)) {
  mapId = `${config.use ?? 'unnamed'}-${counter++}`
}

renderMap(target)

Parses YAML from the code block's text content (normalising non-breaking spaces), reuses any cached maps by ID, then calls the render function debounced by mapDelay ms. The debounce prevents thrashing during rapid DOM updates (e.g. live markdown editing):

target.renderMap = debounce(
  (configList) => render(target, configList).forEach(p => p.then(afterMapRendered)),
  mapDelay,
)

ui Block editing modal L962

The entire block editing setup is delegated to menuItem.setupBlockEdit() in MenuItem.mjs, which returns a menu item factory stored as editBlockItem:

const editBlockItem = menuItem.setupBlockEdit(dumbymap, {
  container, htmlHolder, markdown2dumbyBlock, splitMd
})

setupBlockEdit() handles all one-time initialisation — block index tracking, overlay/modal creation, keyboard wiring, save/close logic, and onRemove cleanup — then returns (block) => Item(...). The factory is called directly inside setupContextMenu to inject "✏ Edit Block" as the first menu item whenever a block is targeted.

Block index tracking

setupBlockEdit() assigns data-block-index to every .dumby-block at setup time and re-assigns via a MutationObserver on htmlHolder whenever its child list changes, keeping indices in sync after edits that add or remove blocks.

Saving — with block splitting

const saveEditModal = () => {
  const allMd = dumbymap.blocks.map(b => b._md)
  allMd.splice(editingIndex, 1, ...splitMd(textarea.value))
  markdown2dumbyBlock(container, allMd.join('\n\n\n'))
  assignBlockIndices()
  closeEditModal()
}

splitMd(textarea.value) splits the edited text on \n\n\n. If the user typed a separator, the spread ... inserts multiple entries, so one block can become several. The whole document is then re-rendered via markdown2dumbyBlock(), which re-uses cached maps and stores fresh ._md on every new block.


Generated from src/dumbymap.mjs — DumbyMap project