{"record":{"id":"226dfbdd38c23e64","repo":"can1357/oh-my-pi","slug":"invalid-xml-unexpected-closing-tag","errorCode":null,"errorMessage":"Invalid XML: unexpected closing tag","messagePattern":"Invalid XML: unexpected closing tag","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/utils/src/docx/xml.ts","lineNumber":98,"sourceCode":"\t\t\tcontinue;\n\t\t}\n\t\tif (source.startsWith(\"<?\", lessThan)) {\n\t\t\tconst end = source.indexOf(\"?>\", lessThan + 2);\n\t\t\tif (end === -1) throw new Error(\"Invalid XML: unterminated processing instruction\");\n\t\t\toffset = end + 2;\n\t\t\tcontinue;\n\t\t}\n\t\tif (source.startsWith(\"<!\", lessThan)) {\n\t\t\tconst end = source.indexOf(\">\", lessThan + 2);\n\t\t\tif (end === -1) throw new Error(\"Invalid XML: unterminated declaration\");\n\t\t\toffset = end + 1;\n\t\t\tcontinue;\n\t\t}\n\t\tconst end = source.indexOf(\">\", lessThan + 1);\n\t\tif (end === -1) throw new Error(\"Invalid XML: unterminated tag\");\n\t\tconst raw = source.slice(lessThan + 1, end).trim();\n\t\tif (raw.startsWith(\"/\")) {\n\t\t\tif (stack.length === 1) throw new Error(\"Invalid XML: unexpected closing tag\");\n\t\t\tconst closingName = raw.slice(1).trim();\n\t\t\tconst completed = stack.pop();\n\t\t\tif (!completed || completed.name !== closingName)\n\t\t\t\tthrow new Error(`Invalid XML: mismatched closing tag ${closingName}`);\n\t\t\tstack[stack.length - 1].children.push({\n\t\t\t\tkind: \"element\",\n\t\t\t\tname: completed.name,\n\t\t\t\tattributes: completed.attributes,\n\t\t\t\tchildren: completed.children,\n\t\t\t});\n\t\t} else {\n\t\t\tconst selfClosing = raw.endsWith(\"/\");\n\t\t\tconst tag = selfClosing ? raw.slice(0, -1).trim() : raw;\n\t\t\tconst whitespace = tag.search(/\\s/);\n\t\t\tconst name = whitespace === -1 ? tag : tag.slice(0, whitespace);\n\t\t\tconst attributes = new Map<string, string>();\n\t\t\tATTRIBUTE_PATTERN.lastIndex = whitespace === -1 ? tag.length : whitespace;\n\t\t\tfor (let match = ATTRIBUTE_PATTERN.exec(tag); match; match = ATTRIBUTE_PATTERN.exec(tag)) {","sourceCodeStart":80,"sourceCodeEnd":116,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/utils/src/docx/xml.ts#L80-L116","documentation":"This lightweight DOCX XML parser (parseXml in packages/utils/src/docx/xml.ts) maintains a stack of open elements while scanning tags. When it encounters a closing tag (`</name>`) while only the synthetic root remains on the stack (stack.length === 1), there is no matching open element to close — the document has an extra/unbalanced closing tag. The parser is deliberately strict, treating any structural imbalance as invalid XML rather than tolerating it.","triggerScenarios":"Calling parseXml (directly or via the exported `root`, `document`, or `parseFootnotes` helpers) on a string containing a closing tag after the top-level element has already been closed, e.g. `<a><b/></a></a>` or two sibling root elements `<a/><b/>` (the second element's closing tag fires after stack returns to the synthetic root).","commonSituations":"Hand-built or template-generated DOCX XML with a duplicated closing tag; string concatenation that appends `</w:document>` twice; extracting a fragment from a larger document and leaving a trailing closing tag; naive regex-based XML assembly that drops an opening tag but keeps its closer.","solutions":["Print/inspect the input XML around the position of the stray closing tag and remove or balance it.","If assembling XML from fragments, ensure each opening tag has exactly one closing tag and there is a single root element.","Validate the XML with a standalone parser (e.g. xmllint) before passing it to parseXml to pinpoint the imbalance.","If the input comes from a template, fix the template so the whole document has exactly one root element wrapped once."],"exampleFix":"// before: duplicated closer from string concatenation\nconst xml = `<w:document><w:body></w:body></w:document></w:document>`;\nparseXml(xml); // throws\n\n// after: balanced single root\nconst xml = `<w:document><w:body></w:body></w:document>`;\nparseXml(xml); // ok","handlingStrategy":"validation","validationCode":"function hasBalancedClosingTags(xml: string): boolean {\n  let depth = 0;\n  for (const m of xml.matchAll(/<\\s*(\\/?)\\s*([\\w:.-]+)(?:\\s[^>]*)?(\\/?)\\s*>/g)) {\n    if (m[3] === \"/\") continue; // self-closing\n    depth += m[1] ? -1 : 1;\n    if (depth < 0) return false; // closing tag with nothing open\n  }\n  return depth >= 0;\n}\n// call before: if (!hasBalancedClosingTags(xml)) fixInputFirst(); else parseXml(xml);","typeGuard":null,"tryCatchPattern":"try {\n  const doc = parseXml(xml);\n} catch (err) {\n  if (err instanceof Error && err.message.startsWith(\"Invalid XML\")) {\n    // surface malformed-input to caller / log xml for inspection\n  } else {\n    throw err;\n  }\n}","preventionTips":["Build XML with a serializer instead of string concatenation so tags stay balanced.","Validate generated DOCX XML with xmllint in tests before feeding it to parseXml.","Never splice fragments that carry their own root-level closing tags into another document."],"tags":["xml","parsing","docx","malformed-input"],"backgroundTag":"xml-unexpected-closing-tag","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}