fix: use preEscapedText for inline JS script content

H.toHtml was escaping the JS content, causing the script tag to be empty.
H.preEscapedText preserves the text as-is, which is correct for script tags.
This commit is contained in:
2026-05-19 21:55:21 -04:00
parent 6970725a35
commit 8105cee498
+117 -118
View File
@@ -208,121 +208,122 @@ page title content =
-- | Inline JavaScript for client-side search + sort rendering. -- | Inline JavaScript for client-side search + sort rendering.
searchJs :: Text searchJs :: Text
searchJs = T.unlines searchJs =
[ "(function(){" T.unlines
, "'use strict';" [ "(function(){"
, "" , "'use strict';"
, "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);" , ""
, "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);" , "const recipes = JSON.parse(document.getElementById('roux-recipe-data').textContent);"
, "const listEl = document.getElementById('roux-recipe-list');" , "const errors = JSON.parse(document.getElementById('roux-errors-data').textContent);"
, "const searchEl = document.getElementById('roux-search');" , "const listEl = document.getElementById('roux-recipe-list');"
, "" , "const searchEl = document.getElementById('roux-search');"
, "function getSortMode() {" , ""
, " const h = location.hash.slice(1);" , "function getSortMode() {"
, " if (h === 'tags') return 'tags';" , " const h = location.hash.slice(1);"
, " if (h === 'course') return 'course';" , " if (h === 'tags') return 'tags';"
, " return 'alpha';" , " if (h === 'course') return 'course';"
, "}" , " return 'alpha';"
, "" , "}"
, "function render() {" , ""
, " const query = searchEl.value.toLowerCase().trim();" , "function render() {"
, " const mode = getSortMode();" , " const query = searchEl.value.toLowerCase().trim();"
, " let filtered = recipes;" , " const mode = getSortMode();"
, " if (query) {" , " let filtered = recipes;"
, " filtered = recipes.filter(r =>" , " if (query) {"
, " r.title.toLowerCase().includes(query) ||" , " filtered = recipes.filter(r =>"
, " r.filename.toLowerCase().includes(query)" , " r.title.toLowerCase().includes(query) ||"
, " );" , " r.filename.toLowerCase().includes(query)"
, " }" , " );"
, " let html = '';" , " }"
, " if (query || mode === 'alpha') {" , " let html = '';"
, " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));" , " if (query || mode === 'alpha') {"
, " html = renderFlatList(sorted);" , " const sorted = [...filtered].sort((a, b) => a.title.localeCompare(b.title));"
, " } else if (mode === 'tags') {" , " html = renderFlatList(sorted);"
, " html = renderByTags(filtered);" , " } else if (mode === 'tags') {"
, " } else if (mode === 'course') {" , " html = renderByTags(filtered);"
, " html = renderByCourse(filtered);" , " } else if (mode === 'course') {"
, " }" , " html = renderByCourse(filtered);"
, " if (errors.length > 0) {" , " }"
, " html += '<h3>Unparseable recipes</h3><ul>';" , " if (errors.length > 0) {"
, " errors.forEach(e => { html += '<li>' + escapeHtml(e.title) + '</li>'; });" , " html += '<h3>Unparseable recipes</h3><ul>';"
, " html += '</ul>';" , " errors.forEach(e => { html += '<li>' + escapeHtml(e.title) + '</li>'; });"
, " }" , " html += '</ul>';"
, " listEl.innerHTML = html;" , " }"
, "}" , " listEl.innerHTML = html;"
, "" , "}"
, "function renderFlatList(items) {" , ""
, " if (items.length === 0) return '<p>No recipes found.</p>';" , "function renderFlatList(items) {"
, " let html = '<ul class=\'roux-recipes\'>';" , " if (items.length === 0) return '<p>No recipes found.</p>';"
, " items.forEach(r => {" , " let html = '<ul class=\'roux-recipes\'>';"
, " html += '<li><a href=\"/recipes/' + encodeURIComponent(r.filename) + '\">' + escapeHtml(r.title) + '</a></li>';" , " items.forEach(r => {"
, " });" , " html += '<li><a href=\"/recipes/' + encodeURIComponent(r.filename) + '\">' + escapeHtml(r.title) + '</a></li>';"
, " html += '</ul>';" , " });"
, " return html;" , " html += '</ul>';"
, "}" , " return html;"
, "" , "}"
, "function renderByTags(items) {" , ""
, " const tagMap = {};" , "function renderByTags(items) {"
, " items.forEach(r => {" , " const tagMap = {};"
, " if (r.tags.length === 0) return;" , " items.forEach(r => {"
, " r.tags.forEach(t => {" , " if (r.tags.length === 0) return;"
, " if (!tagMap[t]) tagMap[t] = [];" , " r.tags.forEach(t => {"
, " tagMap[t].push(r);" , " 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 '<p>No tagged recipes.</p>';" , " const tags = Object.keys(tagMap).sort((a,b) => a.localeCompare(b));"
, " let html = '';" , " if (tags.length === 0) return '<p>No tagged recipes.</p>';"
, " tags.forEach(t => {" , " let html = '';"
, " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));" , " tags.forEach(t => {"
, " html += '<h3>' + escapeHtml(t) + '</h3>';" , " const sorted = tagMap[t].sort((a,b) => a.title.localeCompare(b.title));"
, " html += renderFlatList(sorted);" , " html += '<h3>' + escapeHtml(t) + '</h3>';"
, " });" , " html += renderFlatList(sorted);"
, " return html;" , " });"
, "}" , " return html;"
, "" , "}"
, "function renderByCourse(items) {" , ""
, " const courseMap = {};" , "function renderByCourse(items) {"
, " items.forEach(r => {" , " const courseMap = {};"
, " if (!r.course) return;" , " items.forEach(r => {"
, " if (!courseMap[r.course]) courseMap[r.course] = [];" , " if (!r.course) return;"
, " courseMap[r.course].push(r);" , " 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 '<p>No recipes with course metadata.</p>';" , " const courses = Object.keys(courseMap).sort((a,b) => a.localeCompare(b));"
, " let html = '';" , " if (courses.length === 0) return '<p>No recipes with course metadata.</p>';"
, " courses.forEach(c => {" , " let html = '';"
, " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));" , " courses.forEach(c => {"
, " html += '<h3>' + escapeHtml(c) + '</h3>';" , " const sorted = courseMap[c].sort((a,b) => a.title.localeCompare(b.title));"
, " html += renderFlatList(sorted);" , " html += '<h3>' + escapeHtml(c) + '</h3>';"
, " });" , " html += renderFlatList(sorted);"
, " return html;" , " });"
, "}" , " return html;"
, "" , "}"
, "function escapeHtml(s) {" , ""
, " const d = document.createElement('div');" , "function escapeHtml(s) {"
, " d.appendChild(document.createTextNode(s));" , " const d = document.createElement('div');"
, " return d.innerHTML;" , " d.appendChild(document.createTextNode(s));"
, "}" , " return d.innerHTML;"
, "" , "}"
, "searchEl.addEventListener('input', render);" , ""
, "window.addEventListener('hashchange', render);" , "searchEl.addEventListener('input', render);"
, "" , "window.addEventListener('hashchange', render);"
, "// Set active tab style based on hash" , ""
, "function updateActiveNav() {" , "// Set active tab style based on hash"
, " const mode = getSortMode();" , "function updateActiveNav() {"
, " document.querySelectorAll('.roux-navbar a').forEach(a => {" , " const mode = getSortMode();"
, " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);" , " document.querySelectorAll('.roux-navbar a').forEach(a => {"
, " });" , " a.classList.toggle('active', a.getAttribute('href') === '#' + mode);"
, "}" , " });"
, "window.addEventListener('hashchange', updateActiveNav);" , "}"
, "" , "window.addEventListener('hashchange', updateActiveNav);"
, "// Initial render -- setting hash triggers hashchange" , ""
, "if (!location.hash) location.hash = 'alpha';" , "// Initial render -- setting hash triggers hashchange"
, "else { updateActiveNav(); render(); }" , "if (!location.hash) location.hash = 'alpha';"
, "" , "else { updateActiveNav(); render(); }"
, "})();" , ""
] , "})();"
]
-- | Render the recipe index page -- shell with embedded JSON + JS rendering. -- | Render the recipe index page -- shell with embedded JSON + JS rendering.
indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString indexPage :: SortMode -> [Idx.RecipeInfo] -> ByteString
@@ -349,7 +350,7 @@ indexPage _mode recipes =
H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $ H.script ! A.id "roux-errors-data" ! A.type_ "application/json" $
H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterBad recipes))))) H.toHtml (decodeUtf8 (LB.toStrict (encode (Prelude.map Idx.toSearchEntry (filterBad recipes)))))
-- Inline JS -- Inline JS
H.script ! A.type_ "text/javascript" $ H.toHtml searchJs H.script ! A.type_ "text/javascript" $ H.preEscapedText searchJs
where where
filterOk = filter (isRight . Idx.riRecipe) filterOk = filter (isRight . Idx.riRecipe)
filterBad = filter (isLeft . Idx.riRecipe) filterBad = filter (isLeft . Idx.riRecipe)
@@ -634,5 +635,3 @@ isLeft _ = False
-- | Check if an 'Either' is 'Right'. -- | Check if an 'Either' is 'Right'.
isRight :: Either a b -> Bool isRight :: Either a b -> Bool
isRight = not . isLeft isRight = not . isLeft