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.
The file opens by pulling in all third-party and local modules it needs:
markdown-it plus five plugins:
markdown-it-anchor (heading permalinks),
markdown-it-footnote,
markdown-it-front-matter,
markdown-it-inject-linenumbers,
markdown-it-attrs (arbitrary HTML attributes on tokens).mapclay, the rendering layer that delegates to MapLibre / OpenLayers / Leaflet.utils.mjs (DOM utilities), Layout.mjs, Link.mjs, dumbyUtils.mjs, MenuItem.mjs.proj4 + ol/proj/proj4 for arbitrary coordinate reference systems.plain-modal for the overlay modal used by the full-content viewer.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()).
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 : ''
}
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`
}
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
div, section, etc.) are unwrapped — their children are serialised recursively so the block structure is preserved as plain Markdown.<table>, <figure>, custom elements) is kept verbatim via node.outerHTML. Since markdown-it renders with html: true, raw HTML passthrough is safe.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:
markdown2dumbyBlock() to pair each rendered .dumby-block with its source text.saveEditModal() inside MenuItem.mjs to split user-typed text that contains \n\n\n separators into multiple new blocks.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.
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.
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.
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.
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.
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.
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.
updateAttributeByStep & defaultRender L208–238updateAttributeByStep
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)
defaultRenderconst 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.
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.
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.
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:
h1/h2/h3 is present, a new block is started at each heading (after the first has content).<section> or <article> elements, they are promoted to .dumby-block.<article class="dumby-block">.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)
})
}
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.
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
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 }
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.
setupContentObserver(container, { renderMap }) L370–389
Installs a MutationObserver on characterData changes (text edits).
For each mutation it skips map elements and then:
utils.addGeoSchemeByText() to auto-linkify coordinate-like text.renderMap() callback.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 })
setupChildObserver(container, { renderMap, addGeoLinksByText }) L392–436
Installs a MutationObserver on childList changes and the
data-init-dumby attribute. On each mutation it:
data-init-dumby on attribute mutations to prevent re-triggers.block.dataset.total on all blocks so CSS can number them.geoLinkSelector and docLinkSelector elements as GeoLink / DocLink instances.addGeoLinksByText on attribute-triggered mutations.mapBlockSelector.
The entire observation is bootstrapped by generateMaps() setting
container.dataset.initDumby = 'true' L959,
which triggers the attribute filter and processes the initial DOM.
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')
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:
editBlockItem(block), appended immediately after menu creation.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.
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()
}
setupKeybindings(container, dumbymap) L666–688
Adds a global keydown listener (ignored when focus is in a
textarea or input) providing navigation shortcuts:
Tab / Shift+Tab — focus next / previous map.x / X — cycle layouts forward / backward.n / p — focus next / previous block.Escape — remove block focus.The listener is removed via onRemove(container, ...) when the container leaves the DOM.
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))
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
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)
contentSelector — CSS selector for the Semantic HTML element.crs — CRS in EPSG/ESRI code (default 'EPSG:4326').initialLayout — name of the initial layout.layouts — additional layout objects or names to append.mapDelay — debounce delay before rendering maps (default 1000 ms).render — custom render function (default defaultRender).renderCallback — called after each map renders.defaultApply — URL of a YAML aliases file to fetch on startup.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)
})
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.
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).
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.
afterMapRendered(renderer) L843–875
Called by renderMap() when a mapclay renderer resolves. It:
mapCache by map ID.renderCallback.mapClassObserver() to the map element.if (mapElement.dataset.render === 'fulfilled') {
mapCache[mapElement.id] = renderer
}
const observer = mapClassObserver()
observer.observe(mapElement, { attributes: true, attributeFilter: ['class'] })
assignMapId & renderMap(target) L878–953assignMapId(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,
)
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.
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.
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