Files
cs249r_book/lua/inject_quizzes.lua
Vijay Janapa Reddi 50232f934e Fix quiz filter detection and make output consistent
- Fix section ID matching issue (handle '#' prefix properly)
- Add consistent '[Quiz Filter]' branding to match cross-references
- Use same clean output pattern as cross-reference filter
- Now shows proper output when quizzes are detected:
  📝 [Quiz Filter] 🚀 Quiz Injection Filter
  📝 [Quiz Filter] 🔍 Document has quizzes - processing...
   [Quiz Filter] 📊 SUMMARY: X quiz sections injected
- Complete silence when no quizzes found
- Professional, consistent build experience
2025-07-21 12:10:33 -04:00

457 lines
14 KiB
Lua

-- inject_quizzes.lua
---@diagnostic disable-next-line: undefined-global
PANDOC_DOCUMENT = PANDOC_DOCUMENT
local json = require("pandoc.json")
local utils = pandoc.utils
-- global state: all quiz sections are collected here, organized by file source
local quiz_sections = {}
local quiz_sections_by_file = {} -- track which sections came from which file
local current_document_file = "unknown" -- track which document is being processed
local has_quizzes = false -- track if current document has any quiz sections
-- Helper function to check if document has any sections with quizzes
local function document_has_quizzes(doc, quiz_lookup)
for _, block in ipairs(doc.blocks) do
if block.t == "Header" and block.identifier and block.identifier ~= "" then
-- Check both with and without "#" prefix since quiz system uses "#" prefix
local section_id = block.identifier
local section_id_with_hash = "#" .. section_id
if quiz_lookup[section_id] or quiz_lookup[section_id_with_hash] then
return true
end
end
end
return false
end
-- 1) Load a single JSON file
local function load_quiz_data(path)
local f, err = io.open(path, "r")
if not f then
io.stderr:write("❌ [QUIZ] Failed to open file: " .. path .. " - " .. (err or "unknown error") .. "\n")
return nil
end
local content = f:read("*all")
f:close()
local ok, data = pcall(json.decode, content)
if not ok or type(data) ~= "table" then
io.stderr:write("❌ [QUIZ] Failed to parse JSON from file: " .. path .. "\n")
return nil
end
return data
end
-- 2) Extract sections from JSON and map them to section_id
local function register_sections(data, file_path)
local secs = {}
local sections_found = 0
if data.sections then
for _, s in ipairs(data.sections) do
if s.quiz_data
and s.quiz_data.quiz_needed
and s.quiz_data.questions then
secs[s.section_id] = s.quiz_data.questions
sections_found = sections_found + 1
end
end
else
for sid, qs in pairs(data) do
if sid ~= "metadata" then
secs[sid] = qs
sections_found = sections_found + 1
end
end
end
return secs, sections_found
end
-- 3) Render content_md into a callout Div for all formats
local function create_quiz_div(div_id, div_class, content_md)
-- parse the Markdown content
local doc = pandoc.read(content_md, "markdown")
-- return a callout Div with proper attributes
return pandoc.Div(
doc.blocks,
pandoc.Attr(div_id, {"callout", div_class}, {})
)
end
-- 4) From a given set of questions, create question + answer divs
local function process_quiz_questions(questions, section_id)
local ql, al = {}, {}
local clean = section_id:gsub("^#", "")
local qid = "quiz-question-" .. clean
local aid = "quiz-answer-" .. clean
for i, q in ipairs(questions) do
table.insert(ql, i..". "..q.question)
if q.question_type == "MCQ" and q.choices then
local choices_line = {}
for j, c in ipairs(q.choices) do
table.insert(choices_line, " "..string.char(96+j)..") "..c)
end
table.insert(ql, table.concat(choices_line, "\n"))
end
-- build answers
table.insert(al, i..". **"..q.question.."**")
if q.question_type == "MCQ" and q.choices then
local choices_line = {}
for j, c in ipairs(q.choices) do
table.insert(choices_line, " "..string.char(96+j)..") "..c)
end
table.insert(al, table.concat(choices_line, "\n"))
table.insert(al, "")
end
table.insert(al, " *Answer*: "..q.answer)
table.insert(al, "")
table.insert(al, " *Learning Objective*: "..q.learning_objective)
table.insert(al, "")
end
if FORMAT == "latex" then
table.insert(ql, string.format("\\noindent\\hspace*{1.25em}\\hyperref[%s]{\\textbf{See Answer~$\\rightarrow$}}", aid))
table.insert(al, string.format("\\noindent\\hspace*{1.25em}\\hyperref[%s]{\\textbf{$\\leftarrow$~Back to Question}}", qid))
else
table.insert(ql, '<a href="#' .. aid .. '" class="question-label">See Answers →</a>')
table.insert(al, '<a href="#' .. qid .. '" class="answer-label">← Back to Questions</a>')
end
return create_quiz_div(qid, "callout-quiz-question", table.concat(ql, "\n\n")),
create_quiz_div(aid, "callout-quiz-answer", table.concat(al, "\n\n"))
end
-- 5) Meta phase: read one or more paths from meta.quiz
local function handle_meta(meta)
local raw = meta.quiz
-- Try to get the current document filename
if PANDOC_DOCUMENT and PANDOC_DOCUMENT.meta and PANDOC_DOCUMENT.meta.filename then
current_document_file = utils.stringify(PANDOC_DOCUMENT.meta.filename)
elseif PANDOC_STATE and PANDOC_STATE.input_files and PANDOC_STATE.input_files[1] then
current_document_file = PANDOC_STATE.input_files[1]
end
-- Check if this is a PDF build (combined document) or HTML build (individual files)
local is_pdf_build = false
if PANDOC_STATE and PANDOC_STATE.input_files and #PANDOC_STATE.input_files > 1 then
is_pdf_build = true
end
-- Alternative PDF detection: check if we're in a PDF format
if FORMAT and (FORMAT:lower():match("pdf") or FORMAT:lower():match("latex")) then
is_pdf_build = true
end
-- Get quiz configuration from global metadata
local quiz_config = meta["quiz-config"] or {}
local file_pattern = quiz_config["file-pattern"] or "*_quizzes.json"
local scan_directory = quiz_config["scan-directory"] or "contents/core"
local auto_discover_pdf = quiz_config["auto-discover-pdf"] ~= false -- default to true
-- Convert Meta objects to strings if needed
if type(file_pattern) == "table" and file_pattern.t == "MetaString" then
file_pattern = file_pattern.text
elseif type(file_pattern) == "table" then
file_pattern = utils.stringify(file_pattern)
end
if type(scan_directory) == "table" and scan_directory.t == "MetaString" then
scan_directory = scan_directory.text
elseif type(scan_directory) == "table" then
scan_directory = utils.stringify(scan_directory)
end
if type(auto_discover_pdf) == "table" and auto_discover_pdf.t == "MetaBool" then
auto_discover_pdf = auto_discover_pdf.bool
elseif type(auto_discover_pdf) == "table" then
auto_discover_pdf = utils.stringify(auto_discover_pdf) ~= "false"
end
-- Configuration loaded silently
if not raw then
if is_pdf_build and auto_discover_pdf then
-- For PDF builds, auto-discover quiz files from the input files (silently)
local files = {}
local total_sections_loaded = 0
-- Since Quarto combines files into a temporary document, we need to scan the directory directly
local function scan_for_quiz_files()
local quiz_files = {}
local command
if package.config:sub(1,1) == "\\" then
-- Windows
command = string.format('dir "%s\\%s" /b /s 2>nul', scan_directory, "*_quizzes.json")
else
-- Unix
command = string.format("find %s -name '%s' 2>/dev/null", scan_directory, "*_quizzes.json")
end
local pipe = io.popen(command)
if pipe then
for file in pipe:lines() do
table.insert(quiz_files, file)
end
pipe:close()
end
return quiz_files
end
files = scan_for_quiz_files()
if #files > 0 then
for i, path in ipairs(files) do
local data = load_quiz_data(path)
if data then
local secs, sections_found = register_sections(data, path)
if sections_found > 0 then
-- Track which sections came from this file
quiz_sections_by_file[path] = {}
for k, v in pairs(secs) do
quiz_sections[k] = v
quiz_sections_by_file[path][k] = v
end
total_sections_loaded = total_sections_loaded + sections_found
end
end
end
end
end
return meta
end
-- collect all files into this list
local files = {}
-- if raw is a table with elements, treat it as a list
if type(raw) == "table" and raw[1] ~= nil then
for _, item in ipairs(raw) do
local p = utils.stringify(item)
table.insert(files, p)
end
else
-- not a list, so it's a single file
local p = utils.stringify(raw)
table.insert(files, p)
end
-- load each file silently
local total_sections_loaded = 0
for i, path in ipairs(files) do
local data = load_quiz_data(path)
if data then
local secs, sections_found = register_sections(data, path)
if sections_found > 0 then
-- Track which sections came from this file
quiz_sections_by_file[path] = {}
for k, v in pairs(secs) do
quiz_sections[k] = v
quiz_sections_by_file[path][k] = v
end
total_sections_loaded = total_sections_loaded + sections_found
end
end
end
return meta
end
local function is_part_header(block)
if block.t == "Header" and block.level == 1 then
-- Check for class 'part'
if block.attr and block.attr.classes then
for _, cls in ipairs(block.attr.classes) do
if cls == "part" then return true end
end
end
-- Check for id starting with 'part-'
if block.identifier and block.identifier:match("^part%-.+") then
return true
end
-- Check for content like '(PART)' (common in Quarto)
if block.content and #block.content > 0 then
local txt = pandoc.utils.stringify(block.content)
if txt:match("%(%s*PART%s*%)") then return true end
end
end
return false
end
local function insert_quizzes(doc)
if not next(quiz_sections) then return doc end
-- Check if this document has any quiz sections
has_quizzes = document_has_quizzes(doc, quiz_sections)
if not has_quizzes then
-- No quizzes for this document, process silently
return doc
end
-- Document has quizzes, show clean processing info
io.stderr:write("📝 [Quiz Filter] 🚀 Quiz Injection Filter\n")
io.stderr:write("📝 [Quiz Filter] 🔍 Document has quizzes - processing...\n")
local quizzes_injected = 0
local function has_class(classes, cls)
for _, c in ipairs(classes) do
if c == cls then return true end
end
return false
end
-- priznajemo dva boundary-a: marker ili H1
local function is_marker(b)
return b.t == "Div"
and b.attr
and has_class(b.attr.classes, "quiz-end")
end
local function is_h1(b)
return b.t == "Header" and b.level == 1
end
-- akumulators
local new_blocks = {}
local chapter_blocks = {}
local chapter_answers = {}
local chapter_title = nil
local section_blocks = {}
local current_section_id = nil
local current_quiz_block = nil
local current_answer_block = nil
local function flush_section()
if current_quiz_block and current_answer_block then
table.insert(section_blocks, current_quiz_block)
table.insert(chapter_answers, current_answer_block)
end
for _, blk in ipairs(section_blocks) do
table.insert(chapter_blocks, blk)
end
section_blocks = {}
current_section_id = nil
current_quiz_block = nil
current_answer_block = nil
end
local function flush_chapter()
local out = {}
for _, blk in ipairs(chapter_blocks) do
table.insert(out, blk)
end
if #chapter_answers > 0 then
table.insert(out,
pandoc.Header(2,
{ pandoc.Str("Self-Check Answers") },
pandoc.Attr("self-check-answers")
)
)
for _, ans in ipairs(chapter_answers) do
table.insert(out, ans)
end
end
return out
end
local i = 1
while i <= #doc.blocks do
local b = doc.blocks[i]
-- 1) MARKER flush
if is_marker(b) then
flush_section()
if #chapter_blocks>0 or #chapter_answers>0 then
for _, x in ipairs(flush_chapter()) do
table.insert(new_blocks, x)
end
chapter_blocks, chapter_answers = {}, {}
end
-- ne ubacuj marker u novi chapter (ili ga po želji prebaciš)
-- table.insert(new_blocks, b)
i = i + 1
goto continue
end
-- 2) H1 boundary flush
if is_h1(b) then
flush_section()
if #chapter_blocks>0 or #chapter_answers>0 then
for _, x in ipairs(flush_chapter()) do
table.insert(new_blocks, x)
end
chapter_blocks, chapter_answers = {}, {}
end
-- sada umetni novi H1
table.insert(new_blocks, b)
chapter_title = pandoc.utils.stringify(b.content or {})
section_blocks = {}
i = i + 1
goto continue
end
-- 3) H2 → sekcija sa kvizom
if b.t=="Header" and b.level==2 and b.identifier then
if current_section_id then
flush_section()
else
for _, x in ipairs(section_blocks) do
table.insert(chapter_blocks, x)
end
section_blocks = {}
end
section_blocks = { b }
current_section_id = "#" .. b.identifier
local quiz = quiz_sections[current_section_id]
if quiz then
local q, a = process_quiz_questions(quiz, current_section_id)
current_quiz_block, current_answer_block = q, a
quizzes_injected = quizzes_injected + 1
end
i = i + 1
goto continue
end
-- 4) ostali blokovi → u sekciju
table.insert(section_blocks, b)
i = i + 1
::continue::
end
-- završni flush na kraju dokumenta
flush_section()
if #chapter_blocks>0 or #chapter_answers>0 then
for _, x in ipairs(flush_chapter()) do
table.insert(new_blocks, x)
end
end
-- Show summary if quizzes were injected
if quizzes_injected > 0 then
io.stderr:write("✅ [Quiz Filter] 📊 SUMMARY: " .. quizzes_injected .. " quiz sections injected\n")
end
return pandoc.Pandoc(new_blocks, doc.meta)
end
-- 7) Register the filter
return {
{ Meta = handle_meta },
{ Pandoc = insert_quizzes }
}