diff --git a/docs/.vitepress/theme/components/TutorialPlayer.vue b/docs/.vitepress/theme/components/TutorialPlayer.vue
index 2d50cb5..ad61078 100644
--- a/docs/.vitepress/theme/components/TutorialPlayer.vue
+++ b/docs/.vitepress/theme/components/TutorialPlayer.vue
@@ -19,11 +19,31 @@ const { site, frontmatter } = useData()
function formatInlineMarkdown(text) {
if (!text) return ''
- const safe = text
+ let safe = text
.replace(/&/g, '&')
.replace(//g, '>')
- return safe.replace(/`([^`]+)`/g, '$1')
+
+ safe = safe.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => {
+ const cleanAlt = alt.replace(/"/g, '"')
+ let cleanSrc = src.trim().replace(/"/g, '"')
+
+ // Only rewrite relative public/assets paths, preserving http://, https://, and data: URLs
+ if (!/^(?:https?:\/\/|data:)/i.test(cleanSrc)) {
+ if (cleanSrc.includes('assets/')) {
+ cleanSrc = '/assets/' + cleanSrc.split('assets/')[1]
+ }
+
+ if (cleanSrc.startsWith('/')) {
+ cleanSrc = withBase(cleanSrc)
+ }
+ }
+
+ return `
`
+ })
+
+ safe = safe.replace(/`([^`]+)`/g, '$1')
+ return safe.replace(/\n/g, '
')
}
const currentChapterObj = computed(() => {
@@ -96,6 +116,7 @@ const entry = ref('')
const isSheetOpen = ref(false)
const isExpanded = ref(false)
const isModalOpen = ref(false)
+const activeModalImage = ref(null)
function openModal() {
isModalOpen.value = true
@@ -105,6 +126,21 @@ function closeModal() {
isModalOpen.value = false
}
+function openImageModal(src, alt = '') {
+ activeModalImage.value = { src, alt }
+}
+
+function closeImageModal() {
+ activeModalImage.value = null
+}
+
+function handleLogClick(e) {
+ const target = e.target
+ if (target && target.tagName === 'IMG' && target.classList.contains('tut-img-embed')) {
+ openImageModal(target.src, target.alt)
+ }
+}
+
function toggleExpand() {
if (typeof document === 'undefined') return
@@ -136,9 +172,25 @@ function handleFullscreenChange() {
}
function handleKeydown(e) {
- if (e.key === 'Escape' && isExpanded.value) {
- if (!document.fullscreenElement) {
- toggleExpand()
+ if (e.key === 'Escape') {
+ if (activeModalImage.value) {
+ closeImageModal()
+ return
+ }
+ if (isExpanded.value) {
+ if (!document.fullscreenElement) {
+ toggleExpand()
+ }
+ return
+ }
+ }
+
+ // Intercept Space or PageDown for paging if output overflow is active and input is empty or unfocused
+ if (isScrollOverflowActive.value && !isScrolling.value) {
+ const isInputFocused = document.activeElement === inputEl.value
+ if ((e.key === 'PageDown' || e.key === ' ') && (!isInputFocused || !entry.value.trim())) {
+ e.preventDefault()
+ pageForward()
}
}
}
@@ -146,8 +198,76 @@ function handleKeydown(e) {
const logEl = ref(null)
const inputEl = ref(null)
+// Pager state management
+const isScrollOverflowActive = ref(false)
+const currentPageNum = ref(1)
+const totalPagesNum = ref(1)
+const isScrolling = ref(false)
+let scrollEndTimer = null
+
+function updatePagerState() {
+ const container = logEl.value
+ if (!container) {
+ isScrollOverflowActive.value = false
+ currentPageNum.value = 1
+ totalPagesNum.value = 1
+ return
+ }
+
+ const scrollHeight = container.scrollHeight
+ const clientHeight = container.clientHeight
+ const scrollTop = container.scrollTop
+
+ // Determine if there is significant scrollable content below viewport
+ const scrollableDistance = scrollHeight - clientHeight
+ const remainingScroll = scrollableDistance - scrollTop
+
+ if (scrollableDistance > 40 && remainingScroll > 35) {
+ isScrollOverflowActive.value = true
+ const pageSize = Math.max(1, clientHeight - 30)
+ totalPagesNum.value = Math.max(1, Math.ceil(scrollHeight / pageSize))
+ currentPageNum.value = Math.min(totalPagesNum.value, Math.max(1, Math.floor(scrollTop / pageSize) + 1))
+ } else {
+ isScrollOverflowActive.value = false
+ currentPageNum.value = 1
+ totalPagesNum.value = 1
+ }
+}
+
+function handleScroll() {
+ isScrolling.value = true
+ if (scrollEndTimer) clearTimeout(scrollEndTimer)
+ scrollEndTimer = setTimeout(() => {
+ isScrolling.value = false
+ updatePagerState()
+ }, 150)
+}
+
+function pageForward() {
+ const container = logEl.value
+ if (!container) return
+ const pageStep = container.clientHeight - 40
+ container.scrollBy({ top: pageStep, behavior: 'smooth' })
+ setTimeout(updatePagerState, 300)
+}
+
+function flushPager() {
+ const container = logEl.value
+ if (!container) return
+ container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' })
+ setTimeout(updatePagerState, 300)
+}
+
const stepLabel = computed(() => `Chapter ${chapterNum.value} of ${totalChapters.value}`)
+let autoAdvanceTimer = null
+function clearAutoAdvanceTimer() {
+ if (autoAdvanceTimer) {
+ clearTimeout(autoAdvanceTimer)
+ autoAdvanceTimer = null
+ }
+}
+
function focusInput() {
if (typeof window !== 'undefined') {
setTimeout(() => {
@@ -158,6 +278,7 @@ function focusInput() {
function navigateToUrl(url) {
if (!url) return
+ clearAutoAdvanceTimer()
const targetUrl = withBase(url)
if (router && router.go) {
router.go(targetUrl).catch(() => {
@@ -192,30 +313,76 @@ function scrollLog() {
}
}
-function renderStepLog() {
- subStepIdx.value = 0
+function goToStep(targetIdx) {
+ clearAutoAdvanceTimer()
+ if (targetIdx < 0) targetIdx = 0
+ if (targetIdx >= stepsList.value.length) targetIdx = Math.max(0, stepsList.value.length - 1)
+
+ subStepIdx.value = targetIdx
finished.value = false
+ entry.value = ''
const newLog = []
+ const firstSub = stepsList.value[0] || null
+
newLog.push({
kind: 'lesson',
chapterNum: chapterNum.value,
title: chapterTitle.value,
teach: teachList.value,
- note: currentSubStep.value ? currentSubStep.value.note : null,
- ask: currentSubStep.value ? currentSubStep.value.ask : null
+ note: firstSub ? firstSub.note : null,
+ ask: firstSub ? firstSub.ask : null
})
+ for (let i = 0; i < targetIdx; i++) {
+ const step = stepsList.value[i]
+ if (step) {
+ if (step.ask) {
+ newLog.push({ kind: 'echo', text: step.ask })
+ let rawRes = step.response || step.example || mumeResponses.value[step.ask.toLowerCase()] || ''
+ if (!rawRes && step.accept && step.accept.length) {
+ for (const alias of step.accept) {
+ if (mumeResponses.value[alias.toLowerCase()]) {
+ rawRes = mumeResponses.value[alias.toLowerCase()]
+ break
+ }
+ }
+ }
+ const body = rawRes ? rawRes.replace(/^>[^\n]*\n?/, '') : ''
+ if (body) {
+ newLog.push({ kind: 'example', body })
+ }
+ } else if (step.text || step.response) {
+ newLog.push({
+ kind: 'story',
+ body: step.text || step.response
+ })
+ }
+
+ const nextSub = stepsList.value[i + 1]
+ if (nextSub && nextSub.ask) {
+ newLog.push({
+ kind: 'prompt_next',
+ stepIndex: i + 1,
+ totalSteps: stepsList.value.length,
+ note: nextSub.note || null,
+ ask: nextSub.ask
+ })
+ }
+ }
+ }
+
log.value = newLog
scrollLog()
- if (currentSubStep.value && !currentSubStep.value.ask && (currentSubStep.value.text || currentSubStep.value.response)) {
+ const curSub = stepsList.value[targetIdx] || null
+ if (curSub && !curSub.ask && (curSub.text || curSub.response)) {
log.value.push({
kind: 'story',
- body: currentSubStep.value.text || currentSubStep.value.response
+ body: curSub.text || curSub.response
})
scrollLog()
- setTimeout(() => {
+ autoAdvanceTimer = setTimeout(() => {
advanceSubStep()
}, 250)
} else {
@@ -223,6 +390,10 @@ function renderStepLog() {
}
}
+function renderStepLog() {
+ goToStep(0)
+}
+
function completeChapter() {
finished.value = true
log.value.push({
@@ -243,11 +414,12 @@ function advanceNext() {
}
function advanceSubStep() {
+ clearAutoAdvanceTimer()
if (subStepIdx.value < stepsList.value.length - 1) {
subStepIdx.value++
- const nextSub = currentSubStep.value
+ const nextSub = stepsList.value[subStepIdx.value] || null
- if (nextSub.ask) {
+ if (nextSub && nextSub.ask) {
log.value.push({
kind: 'prompt_next',
stepIndex: subStepIdx.value,
@@ -257,13 +429,13 @@ function advanceSubStep() {
})
scrollLog()
focusInput()
- } else if (nextSub.text || nextSub.response) {
+ } else if (nextSub && (nextSub.text || nextSub.response)) {
log.value.push({
kind: 'story',
body: nextSub.text || nextSub.response
})
scrollLog()
- setTimeout(() => {
+ autoAdvanceTimer = setTimeout(() => {
advanceSubStep()
}, 250)
}
@@ -274,22 +446,23 @@ function advanceSubStep() {
function prevSubStep() {
if (subStepIdx.value > 0) {
- subStepIdx.value--
- const prevSub = currentSubStep.value
- log.value.push({
- kind: 'prompt_prev',
- stepIndex: subStepIdx.value,
- totalSteps: stepsList.value.length,
- note: prevSub.note || null,
- ask: prevSub.ask
- })
- scrollLog()
+ goToStep(subStepIdx.value - 1)
+ } else if (prevChapterUrl.value) {
+ navigateToUrl(prevChapterUrl.value)
}
}
function submit() {
const raw = entry.value.trim()
const cmd = raw.toLowerCase()
+
+ // Handle Pager Mode when input is empty and overflow is active
+ if (isScrollOverflowActive.value && !raw && !finished.value) {
+ pageForward()
+ focusInput()
+ return
+ }
+
entry.value = ''
if (finished.value) {
@@ -302,6 +475,10 @@ function submit() {
return
}
+ if (isScrollOverflowActive.value) {
+ flushPager()
+ }
+
if (raw) { log.value.push({ kind: 'echo', text: raw }) }
if (cmd === 'skip') { advanceNext(); focusInput(); return }
@@ -352,9 +529,11 @@ watch(() => route.path, () => {
renderStepLog()
}, { immediate: true })
+let logResizeObserver = null
+
onMounted(() => {
- renderStepLog()
if (typeof window !== 'undefined') {
+ renderStepLog()
// Auto-open Command Sheet on desktop viewports by default
if (window.innerWidth >= 1024) {
isSheetOpen.value = true
@@ -362,6 +541,13 @@ onMounted(() => {
focusInput()
window.addEventListener('keydown', handleKeydown)
document.addEventListener('fullscreenchange', handleFullscreenChange)
+
+ if (logEl.value && typeof ResizeObserver !== 'undefined') {
+ logResizeObserver = new ResizeObserver(() => {
+ updatePagerState()
+ })
+ logResizeObserver.observe(logEl.value)
+ }
}
})
@@ -369,6 +555,10 @@ onUnmounted(() => {
if (typeof window !== 'undefined') {
window.removeEventListener('keydown', handleKeydown)
document.removeEventListener('fullscreenchange', handleFullscreenChange)
+ if (logResizeObserver) {
+ logResizeObserver.disconnect()
+ logResizeObserver = null
+ }
if (isExpanded.value && typeof document !== 'undefined') {
document.body.style.overflow = ''
}
@@ -428,7 +618,7 @@ onUnmounted(() => {
-
+
@@ -515,14 +705,18 @@ onUnmounted(() => {
-
@@ -556,6 +750,15 @@ onUnmounted(() => {
+
+
+
+
+
![]()
+
{{ activeModalImage.alt }}
+
+
+
@@ -596,10 +799,12 @@ onUnmounted(() => {
-
+ :title="'Jump to Step ' + (sIdx + 1) + ' of ' + stepsList.length"
+ @click="goToStep(sIdx)">
Ch {{ chapterNum }}
@@ -857,6 +1062,95 @@ onUnmounted(() => {
font-style: italic;
font-size: 13.5px;
line-height: 1.5;
+ flex: 1;
+}
+
+.tut-img-embed {
+ max-width: 100%;
+ max-height: 220px;
+ border-radius: 6px;
+ border: 1px solid rgba(215, 166, 63, 0.35);
+ margin: 8px 0;
+ display: block;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+ cursor: zoom-in;
+ transition: transform 0.2s, border-color 0.2s;
+}
+.tut-img-embed:hover {
+ transform: scale(1.02);
+ border-color: gold;
+}
+
+/* Image Lightbox Modal */
+.tut-img-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.88);
+ backdrop-filter: blur(6px);
+ z-index: 100001;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 20px;
+ animation: tutStreamIn 0.2s ease-out forwards;
+}
+
+.tut-img-modal-card {
+ position: relative;
+ max-width: 90vw;
+ max-height: 90vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background: #0b0c0f;
+ border: 1px solid rgba(215, 166, 63, 0.5);
+ border-radius: 12px;
+ padding: 16px;
+ box-shadow: 0 25px 70px rgba(0,0,0,0.9), 0 0 30px rgba(215, 166, 63, 0.2);
+}
+
+.tut-img-modal-close {
+ position: absolute;
+ top: -14px;
+ right: -14px;
+ width: 36px;
+ height: 36px;
+ background: #18191f;
+ border: 1px solid rgba(215, 166, 63, 0.6);
+ color: #f4dd94;
+ font-size: 24px;
+ border-radius: 50%;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ line-height: 1;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+ transition: background 0.2s, color 0.2s, transform 0.15s;
+}
+.tut-img-modal-close:hover {
+ background: darkgoldenrod;
+ color: #ffffff;
+ transform: scale(1.1);
+}
+
+.tut-img-modal-preview {
+ max-width: 85vw;
+ max-height: 80vh;
+ object-fit: contain;
+ border-radius: 6px;
+ border: 1px solid rgba(215, 166, 63, 0.3);
+}
+
+.tut-img-modal-caption {
+ margin-top: 10px;
+ color: #d8b04a;
+ font-size: 13.5px;
+ font-family: 'Kelt', serif;
+ text-align: center;
}
.tut-complete-box {
@@ -884,11 +1178,24 @@ onUnmounted(() => {
.tut-secondary-link:hover, .tut-secondary-link:focus-visible { background: darkgoldenrod; color: #3a3a3a !important; text-decoration: none !important; }
.tut-note { color: #8f8a7d; font-size: 12.5px; margin: 4px 0 6px; }
-.tut-prompt { display: flex; align-items: center; gap: 8px; border-top: 1px solid #23262e; padding: 12px 18px; background: #08080a; }
+.tut-prompt { display: flex; align-items: center; gap: 8px; border-top: 1px solid #23262e; padding: 12px 18px; background: #08080a; transition: background 0.25s, border-color 0.25s; }
+.tut-prompt.is-pager { background: linear-gradient(180deg, #18150c, #0a0b0e); border-top-color: rgba(215, 166, 63, 0.5); cursor: pointer; }
+.tut-prompt.is-pager input::placeholder { color: #f4dd94; font-weight: bold; }
.tut-caret { color: #d8b04a; font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace; }
.tut-prompt input { flex: 1; background: transparent; border: none; outline: none; color: #eaeaea; font-family: 'DejaVu Sans Mono', Menlo, Consolas, monospace; font-size: 14px; }
.tut-prompt input::placeholder { color: #5f5f5f; }
+.tut-send-btn.tut-pager-btn {
+ background: #f4dd94;
+ color: #111;
+ box-shadow: 0 0 10px rgba(244, 221, 148, 0.4);
+ animation: tutPulseHint 2s infinite;
+}
+.tut-send-btn.tut-pager-btn:hover {
+ background: #ffffff;
+ color: #000;
+}
+
.tut-sheet { border-left: 1px solid #23262e; background: #08090c; display: none; flex-direction: column; }
.tut-body.has-sheet .tut-sheet { display: flex; }
@media (max-width: 720px) { .tut-sheet { border-left: 0; border-top: 1px solid #23262e; } }
@@ -1115,7 +1422,9 @@ onUnmounted(() => {
.tut-ch-badge { color: #9a927f; font-size: 12px; padding: 0 6px; }
.tut-step-pills { display: flex; align-items: center; gap: 4px; padding: 0 6px; }
-.tut-step-pill { width: 12px; height: 4px; border-radius: 2px; background: #2a2a2a; transition: background .2s, width .2s; }
+.tut-step-pill { width: 12px; height: 4px; border-radius: 2px; background: #2a2a2a; transition: background .2s, width .2s; border: none; padding: 0; }
+.tut-step-pill-btn { cursor: pointer; }
+.tut-step-pill-btn:hover { background: #ffd966; }
.tut-step-pill.done { background: #a9812a; }
.tut-step-pill.active { background: #f4dd94; width: 20px; box-shadow: 0 0 6px rgba(244,221,148,.5); }
diff --git a/docs/play/TUTORIAL.md b/docs/play/TUTORIAL.md
index a736504..3c784ad 100644
--- a/docs/play/TUTORIAL.md
+++ b/docs/play/TUTORIAL.md
@@ -50,11 +50,17 @@ Write your lesson introduction here using standard Markdown. Explain the concept
---
-## Easy Multiline Text Formatting
+## Easy Multiline Text Formatting (No `\n` Required!)
-Instead of typing `\n` for line breaks, use the YAML multiline literal pipe character `|` so you can write natural, formatted MUD terminal responses across multiple lines:
+**Do NOT use `\n` for line breaks in tutorial files.** Non-technical contributors should write natural multiline text.
+
+Always use the YAML multiline literal pipe character `|` for multi-line story beats, step notes, or simulated MUD outputs:
```yaml
+steps:
+ - story: |
+ 
+ When connected via the MUME Web Client, you'll enjoy integrated live mapping and stats tracking.
responses:
score: |
36/36 hits, 76/76 mana, and 126/126 moves.
@@ -73,7 +79,7 @@ responses:
| `teach` | List | Commands introduced in this chapter. Appears in the "Command Sheet" sidebar/drawer. Each item has `command` and `desc`. |
| `steps` | List | Interactive practice steps executed sequentially in the terminal emulator. |
| `steps[].ask` | String | (Optional) The target command prompt (e.g., `look`). If omitted, step is an automatic story beat. |
-| `steps[].story` | String | (Optional) Story beat that streams automatically into the terminal without requiring typing (can also use `beat:` or `text:`). |
+| `steps[].story` | String | (Optional) Story beat that streams automatically into the terminal without requiring typing (can also use `beat:` or `text:`). Supports inline images ``. |
| `steps[].note` | String | Highlighting note giving context and guidance for the task. |
| `steps[].accept` | List | Accepted command variations (e.g., `["look", "l"]`). Case-insensitive. |
| `steps[].hint` | String | (Optional) Hint displayed if the user types an unaccepted command. Defaults to `Type:
`. |
@@ -107,6 +113,15 @@ steps:
---
+## Embedded Images & Step-by-Step Paging
+
+For chapters introducing visual concepts or UI previews (such as client interface screenshots):
+1. **Stream Images via Story Beats or Responses:** Instead of dumping large images into the Markdown body at the bottom of the file, attach images to specific `story` beats using Markdown image syntax: ``.
+2. **Automated Terminal Pager:** When images or large text outputs stream into the log, the terminal's built-in MUD pager activates automatically if content overflows the viewport. Users can press `Space` or `Enter` (or tap the prompt on touch devices) to page through content line-by-line without needing to scroll manually with a mouse.
+3. **Compact Initial Card:** Keep the Markdown lesson text below frontmatter concise so that when a chapter opens, the initial intro card and first quest prompt fit within the terminal log without initial overflow.
+
+---
+
## Summary Checklist for Authors
- [ ] File name starts with `[number]-` (e.g. `16-`).
diff --git a/docs/play/tutorial/15-journey.md b/docs/play/tutorial/15-journey.md
index 9895a0d..bd49d42 100644
--- a/docs/play/tutorial/15-journey.md
+++ b/docs/play/tutorial/15-journey.md
@@ -16,12 +16,18 @@ steps:
- "look"
- "l"
hint: "Type: look"
+ - story: |
+ 
+ When connected via the MUME Web Client, you'll enjoy integrated live mapping and stats tracking.
- ask: "map"
note: "Type `map` to examine the live MMapper radar view."
accept:
- "map"
- "m"
hint: "Type: map"
+ - story: |
+ 
+ MMapper tracks your position in real-time as you journey through Middle-earth.
- ask: "who ranger"
note: "MUME Rangers exist specifically to assist new players! Check active Rangers with `who ranger`."
accept:
@@ -30,24 +36,41 @@ steps:
- "who"
hint: "Type: who ranger"
responses:
- look: "The Foaming Mug's Beer Garden\nA quiet courtyard paved with cobblestones lies behind the tavern.\nExits: north, east.\nRanger Ermin smiles warmly at you.\n\n*+->"
- map: " # . .\n | | |\n .-*---*---*-.\n | | |\n # . .\n\n*+->"
- who ranger: "Rangers\n-------\n\n0 such allies or visible Ainur on.\n\n*+->"
- who rangers: "Rangers\n-------\n\n0 such allies or visible Ainur on.\n\n*+->"
- who: "Players Online: Fuor, Martyrson, Elrond, Glorfindel.\n\n*+->"
----
+ look: |
+ The Foaming Mug's Beer Garden
+ A quiet courtyard paved with cobblestones lies behind the tavern.
+ Exits: north, east.
+ Ranger Ermin smiles warmly at you.
-# Chapter 15: Hero's Graduation
+ *+->
+ map: |
+ # . .
+ | | |
+ .-*---*---*-.
+ | | |
+ # . .
+
+ *+->
+ who ranger: |
+ Rangers
+ -------
+
+ 0 such allies or visible Ainur on.
-Congratulations on guiding Fuor through the Black Hills walkthrough! You have mastered navigation, combat reflexes, equipment, trading, and resting.
+ *+->
+ who rangers: |
+ Rangers
+ -------
-### Web Client & MMapper Preview
+ 0 such allies or visible Ainur on.
-When you launch into MUME using the Web Client, you'll see an integrated live mapper (**MMapper**) and character stats panel:
+ *+->
+ who: |
+ Players Online: Fuor, Martyrson, Elrond, Glorfindel.
-
-

-

-
+ *+->
+---
+
+# Chapter 15: Hero's Graduation
-Follow the interactive quest prompts below to complete your graduation!
+Congratulations on guiding Fuor through the Black Hills walkthrough! Follow the interactive quest prompts below to complete your graduation.
diff --git a/scripts/test-link-and-asset-paths.js b/scripts/test-link-and-asset-paths.js
index 401251b..c07d76d 100644
--- a/scripts/test-link-and-asset-paths.js
+++ b/scripts/test-link-and-asset-paths.js
@@ -149,7 +149,59 @@ for (const htmlFile of htmlFiles) {
}
}
-console.log(`[TEST SUMMARY] Checked ${htmlFiles.length} HTML files, ${totalLinksChecked} internal links, and ${totalAssetsChecked} asset references.`)
+// 4. Verify Markdown image asset links in docs/*.md
+const docsDir = path.resolve(__dirname, '../docs')
+function getAllMdFiles(dir, fileList = []) {
+ if (!fs.existsSync(dir)) return fileList
+ const files = fs.readdirSync(dir)
+ for (const file of files) {
+ const filePath = path.join(dir, file)
+ if (fs.statSync(filePath).isDirectory()) {
+ getAllMdFiles(filePath, fileList)
+ } else if (file.endsWith('.md')) {
+ fileList.push(filePath)
+ }
+ }
+ return fileList
+}
+
+const mdFiles = getAllMdFiles(docsDir)
+let totalMdImagesChecked = 0
+
+for (const mdFile of mdFiles) {
+ const content = fs.readFileSync(mdFile, 'utf8')
+ const relMdPath = path.relative(docsDir, mdFile)
+ const matches = [...content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)]
+
+ for (const match of matches) {
+ const imgUrl = match[1].trim().split(/\s+/)[0]
+ if (
+ imgUrl.startsWith('http://') ||
+ imgUrl.startsWith('https://') ||
+ imgUrl.startsWith('data:') ||
+ imgUrl === 'url' ||
+ imgUrl.includes('filename.png')
+ ) {
+ continue
+ }
+
+ totalMdImagesChecked++
+ let resolvedImgPath = ''
+ if (imgUrl.startsWith('/assets/')) {
+ resolvedImgPath = path.join(docsDir, 'public', imgUrl)
+ } else if (imgUrl.startsWith('/')) {
+ resolvedImgPath = path.join(docsDir, 'public', imgUrl)
+ } else {
+ resolvedImgPath = path.resolve(path.dirname(mdFile), imgUrl)
+ }
+
+ if (!fs.existsSync(resolvedImgPath)) {
+ errors.push(`docs/${relMdPath}: Markdown image "${imgUrl}" not found at "${resolvedImgPath}"`)
+ }
+ }
+}
+
+console.log(`[TEST SUMMARY] Checked ${htmlFiles.length} HTML files, ${totalLinksChecked} internal links, ${totalAssetsChecked} dist asset references, and ${totalMdImagesChecked} Markdown image links.`)
if (errors.length > 0) {
console.error(`\n[TEST FAILURES] Found ${errors.length} link/asset path issues:\n`)