diff --git a/src/Roux/Html.hs b/src/Roux/Html.hs
index f7c4a9e..c09601b 100644
--- a/src/Roux/Html.hs
+++ b/src/Roux/Html.hs
@@ -208,121 +208,122 @@ page title content =
-- | Inline JavaScript for client-side search + sort rendering.
searchJs :: Text
-searchJs = T.unlines
- [ "(function(){"
- , "'use strict';"
- , ""
- , "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);"
- , "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);"
- , "const listEl = document.getElementById('roux-recipe-list');"
- , "const searchEl = document.getElementById('roux-search');"
- , ""
- , "function getSortMode() {"
- , " const h = location.hash.slice(1);"
- , " if (h === 'tags') return 'tags';"
- , " if (h === 'course') return 'course';"
- , " return 'alpha';"
- , "}"
- , ""
- , "function render() {"
- , " const query = searchEl.value.toLowerCase().trim();"
- , " const mode = getSortMode();"
- , " let filtered = recipes;"
- , " if (query) {"
- , " filtered = recipes.filter(r =>"
- , " r.title.toLowerCase().includes(query) ||"
- , " r.filename.toLowerCase().includes(query)"
- , " );"
- , " }"
- , " let html = '';"
- , " if (query || mode === 'alpha') {"
- , " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));"
- , " html = renderFlatList(sorted);"
- , " } else if (mode === 'tags') {"
- , " html = renderByTags(filtered);"
- , " } else if (mode === 'course') {"
- , " html = renderByCourse(filtered);"
- , " }"
- , " if (errors.length > 0) {"
- , " html += '
Unparseable recipes
';"
- , " errors.forEach(e => { html += '- ' + escapeHtml(e.title) + '
'; });"
- , " html += '
';"
- , " }"
- , " listEl.innerHTML = html;"
- , "}"
- , ""
- , "function renderFlatList(items) {"
- , " if (items.length === 0) return 'No recipes found.
';"
- , " let html = '';"
- , " return html;"
- , "}"
- , ""
- , "function renderByTags(items) {"
- , " const tagMap = {};"
- , " items.forEach(r => {"
- , " if (r.tags.length === 0) return;"
- , " r.tags.forEach(t => {"
- , " if (!tagMap[t]) tagMap[t] = [];"
- , " tagMap[t].push(r);"
- , " });"
- , " });"
- , " const tags = Object.keys(tagMap).sort((a,b) => a.localeCompare(b));"
- , " if (tags.length === 0) return 'No tagged recipes.
';"
- , " let html = '';"
- , " tags.forEach(t => {"
- , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));"
- , " html += '' + escapeHtml(t) + '
';"
- , " html += renderFlatList(sorted);"
- , " });"
- , " return html;"
- , "}"
- , ""
- , "function renderByCourse(items) {"
- , " const courseMap = {};"
- , " items.forEach(r => {"
- , " if (!r.course) return;"
- , " if (!courseMap[r.course]) courseMap[r.course] = [];"
- , " courseMap[r.course].push(r);"
- , " });"
- , " const courses = Object.keys(courseMap).sort((a,b) => a.localeCompare(b));"
- , " if (courses.length === 0) return 'No recipes with course metadata.
';"
- , " let html = '';"
- , " courses.forEach(c => {"
- , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));"
- , " html += '' + escapeHtml(c) + '
';"
- , " html += renderFlatList(sorted);"
- , " });"
- , " return html;"
- , "}"
- , ""
- , "function escapeHtml(s) {"
- , " const d = document.createElement('div');"
- , " d.appendChild(document.createTextNode(s));"
- , " return d.innerHTML;"
- , "}"
- , ""
- , "searchEl.addEventListener('input', render);"
- , "window.addEventListener('hashchange', render);"
- , ""
- , "// Set active tab style based on hash"
- , "function updateActiveNav() {"
- , " const mode = getSortMode();"
- , " document.querySelectorAll('.roux-navbar a').forEach(a => {"
- , " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);"
- , " });"
- , "}"
- , "window.addEventListener('hashchange', updateActiveNav);"
- , ""
- , "// Initial render -- setting hash triggers hashchange"
- , "if (!location.hash) location.hash = 'alpha';"
- , "else { updateActiveNav(); render(); }"
- , ""
- , "})();"
- ]
+searchJs =
+ T.unlines
+ [ "(function(){"
+ , "'use strict';"
+ , ""
+ , "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);"
+ , "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);"
+ , "const listEl = document.getElementById('roux-recipe-list');"
+ , "const searchEl = document.getElementById('roux-search');"
+ , ""
+ , "function getSortMode() {"
+ , " const h = location.hash.slice(1);"
+ , " if (h === 'tags') return 'tags';"
+ , " if (h === 'course') return 'course';"
+ , " return 'alpha';"
+ , "}"
+ , ""
+ , "function render() {"
+ , " const query = searchEl.value.toLowerCase().trim();"
+ , " const mode = getSortMode();"
+ , " let filtered = recipes;"
+ , " if (query) {"
+ , " filtered = recipes.filter(r =>"
+ , " r.title.toLowerCase().includes(query) ||"
+ , " r.filename.toLowerCase().includes(query)"
+ , " );"
+ , " }"
+ , " let html = '';"
+ , " if (query || mode === 'alpha') {"
+ , " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));"
+ , " html = renderFlatList(sorted);"
+ , " } else if (mode === 'tags') {"
+ , " html = renderByTags(filtered);"
+ , " } else if (mode === 'course') {"
+ , " html = renderByCourse(filtered);"
+ , " }"
+ , " if (errors.length > 0) {"
+ , " html += 'Unparseable recipes
';"
+ , " errors.forEach(e => { html += '- ' + escapeHtml(e.title) + '
'; });"
+ , " html += '
';"
+ , " }"
+ , " listEl.innerHTML = html;"
+ , "}"
+ , ""
+ , "function renderFlatList(items) {"
+ , " if (items.length === 0) return 'No recipes found.
';"
+ , " let html = '';"
+ , " return html;"
+ , "}"
+ , ""
+ , "function renderByTags(items) {"
+ , " const tagMap = {};"
+ , " items.forEach(r => {"
+ , " if (r.tags.length === 0) return;"
+ , " r.tags.forEach(t => {"
+ , " if (!tagMap[t]) tagMap[t] = [];"
+ , " tagMap[t].push(r);"
+ , " });"
+ , " });"
+ , " const tags = Object.keys(tagMap).sort((a,b) => a.localeCompare(b));"
+ , " if (tags.length === 0) return 'No tagged recipes.
';"
+ , " let html = '';"
+ , " tags.forEach(t => {"
+ , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));"
+ , " html += '' + escapeHtml(t) + '
';"
+ , " html += renderFlatList(sorted);"
+ , " });"
+ , " return html;"
+ , "}"
+ , ""
+ , "function renderByCourse(items) {"
+ , " const courseMap = {};"
+ , " items.forEach(r => {"
+ , " if (!r.course) return;"
+ , " if (!courseMap[r.course]) courseMap[r.course] = [];"
+ , " courseMap[r.course].push(r);"
+ , " });"
+ , " const courses = Object.keys(courseMap).sort((a,b) => a.localeCompare(b));"
+ , " if (courses.length === 0) return 'No recipes with course metadata.
';"
+ , " let html = '';"
+ , " courses.forEach(c => {"
+ , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));"
+ , " html += '' + escapeHtml(c) + '
';"
+ , " html += renderFlatList(sorted);"
+ , " });"
+ , " return html;"
+ , "}"
+ , ""
+ , "function escapeHtml(s) {"
+ , " const d = document.createElement('div');"
+ , " d.appendChild(document.createTextNode(s));"
+ , " return d.innerHTML;"
+ , "}"
+ , ""
+ , "searchEl.addEventListener('input', render);"
+ , "window.addEventListener('hashchange', render);"
+ , ""
+ , "// Set active tab style based on hash"
+ , "function updateActiveNav() {"
+ , " const mode = getSortMode();"
+ , " document.querySelectorAll('.roux-navbar a').forEach(a => {"
+ , " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);"
+ , " });"
+ , "}"
+ , "window.addEventListener('hashchange', updateActiveNav);"
+ , ""
+ , "// Initial render -- setting hash triggers hashchange"
+ , "if (!location.hash) location.hash = 'alpha';"
+ , "else { updateActiveNav(); render(); }"
+ , ""
+ , "})();"
+ ]
-- | Render the recipe index page -- shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
@@ -349,7 +350,7 @@ indexPage _mode recipes =
H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
-- Inline JS
- H.script ! A.type_ "text/javascript" $ H.toHtml searchJs
+ H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
where
filterOk = filter (isRight . Idx.riRecipe)
filterBad = filter (isLeft . Idx.riRecipe)
@@ -634,5 +635,3 @@ isLeft _ = False
-- | Check if an 'Either' is 'Right'.
isRight :: Either a b -> Bool
isRight = not . isLeft
-
-