Loading editor/widgets/textbox/textbox.cpp +9 −1 Changes for editor/widgets/textbox/textbox.cpp: 9 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -554,8 +554,16 @@ extern "C" { std::optional<std::string> toLlmsBlock() const override { if (content.empty()) return std::nullopt; std::string plain = llms::stripHtml(content); if (plain.empty()) return std::nullopt; int level = llms::headingLevelFor(fontSize, fontWeight); return level ? llms::mdHeading(level, plain) : plain; // A box-level heading style only applies when the stripped // content is still a single line of plain text. Content that // already carries its own Markdown structure (a pasted <h1-6> // or <table> converted by stripHtml()) keeps that structure // instead of being wrapped in the box's heading level too. if (level && plain.find('\n') == std::string::npos) return llms::mdHeading(level, plain); return plain; } // --- JSON Properties Helpers --- Loading src/llms_text.h +151 −15 Changes for src/llms_text.h: 151 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -31,16 +31,20 @@ // a widget's own text into a plain-text/Markdown fragment for llms.txt. #include <string> #include <vector> #include <algorithm> #include <cctype> #include <htmlpp/html.h> namespace blogi::webedit::llms { inline std::string mdHeading(int level, const std::string &text) { return std::string(static_cast<size_t>(level), '#') + " " + text; } namespace detail { inline void stripHtmlInto(libhtmlpp::Element *el, std::string &out) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() == libhtmlpp::TextEl) { std::string text = static_cast<libhtmlpp::TextElement*>(cur)->getText(); inline std::string collapseWhitespace(const std::string &text) { std::string collapsed; bool inSpace = false; for (unsigned char c : text) { Loading @@ -53,15 +57,148 @@ namespace blogi::webedit::llms { } } size_t start = collapsed.find_first_not_of(' '); if (start == std::string::npos) continue; collapsed = collapsed.substr(start, collapsed.find_last_not_of(' ') - start + 1); if (collapsed.empty()) continue; if (!out.empty() && out.back() != ' ') out += ' '; out += collapsed; if (start == std::string::npos) return ""; return collapsed.substr(start, collapsed.find_last_not_of(' ') - start + 1); } inline void appendInline(const std::string &piece, std::string &out) { if (piece.empty()) return; if (!out.empty() && out.back() != ' ' && out.back() != '\n') out += ' '; out += piece; } // Closes off the current paragraph/heading/table so the next block // starts on its own blank line, the same convention FAQ's toLlmsBlock() // uses to join mdHeading(...) and answer text (editor/widgets/faq/faq.cpp). inline void breakBlock(std::string &out) { if (out.empty()) return; size_t end = out.find_last_not_of(" \t\n"); out.erase(end == std::string::npos ? 0 : end + 1); out += "\n\n"; } inline void stripHtmlInto(libhtmlpp::Element *el, std::string &out); inline std::string flattenInline(libhtmlpp::Element *el) { std::string out; stripHtmlInto(el, out); return out; } inline int headingLevelForTag(const std::string &tag) { if (tag.size() == 2 && tag[0] == 'h' && tag[1] >= '1' && tag[1] <= '6') return tag[1] - '0'; return 0; } // Markdown table cells can't carry a literal "|" or a line break, so // fold them away the same way plain-text flattening already does. inline std::string tableCellText(libhtmlpp::Element *cell) { std::string text = flattenInline(cell); std::string sanitized; sanitized.reserve(text.size()); for (char c : text) { if (c == '|') sanitized += "\\|"; else if (c == '\n') sanitized += ' '; else sanitized += c; } return sanitized; } struct TableRow { std::vector<std::string> cells; bool isHeader = false; }; inline void collectTableRows(libhtmlpp::Element *el, std::vector<TableRow> &rows) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() != libhtmlpp::HtmlEl) continue; auto *hel = static_cast<libhtmlpp::HtmlElement*>(cur); std::string tag = hel->getTagname(); if (tag == "tr") { TableRow row; for (libhtmlpp::Element *cellNode = hel->firstChild(); cellNode; cellNode = cellNode->nextElement()) { if (cellNode->getType() != libhtmlpp::HtmlEl) continue; auto *cellEl = static_cast<libhtmlpp::HtmlElement*>(cellNode); std::string cellTag = cellEl->getTagname(); if (cellTag != "td" && cellTag != "th") continue; if (cellTag == "th") row.isHeader = true; row.cells.push_back(tableCellText(cellEl)); } if (!row.cells.empty()) rows.push_back(std::move(row)); } else if (tag == "thead" || tag == "tbody" || tag == "tfoot") { if (libhtmlpp::Element *child = hel->firstChild()) collectTableRows(child, rows); } } } // Mirrors the Table widget's own toLlmsBlock() (editor/widgets/table/table.cpp) // so a <table> pasted into rich text renders as the same Markdown shape // a native Table widget would: "| a | b |" rows with a "| --- |" rule // under the header row. inline std::string tableToMarkdown(libhtmlpp::HtmlElement *tableEl) { std::vector<TableRow> rows; if (libhtmlpp::Element *child = tableEl->firstChild()) collectTableRows(child, rows); if (rows.empty()) return ""; size_t numCols = 0; for (const auto &row : rows) numCols = std::max(numCols, row.cells.size()); if (numCols == 0) return ""; auto rowLine = [numCols](const TableRow &row) { std::string line = "|"; for (size_t c = 0; c < numCols; ++c) { line += " "; if (c < row.cells.size()) line += row.cells[c]; line += " |"; } return line; }; std::string md = rowLine(rows[0]); if (rows[0].isHeader) { md += "\n|"; for (size_t c = 0; c < numCols; ++c) md += " --- |"; } for (size_t r = 1; r < rows.size(); ++r) md += "\n" + rowLine(rows[r]); return md; } inline void stripHtmlInto(libhtmlpp::Element *el, std::string &out) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() == libhtmlpp::TextEl) { appendInline(collapseWhitespace(static_cast<libhtmlpp::TextElement*>(cur)->getText()), out); } else if (cur->getType() == libhtmlpp::HtmlEl) { libhtmlpp::HtmlElement *hel = static_cast<libhtmlpp::HtmlElement*>(cur); auto *hel = static_cast<libhtmlpp::HtmlElement*>(cur); std::string tag = hel->getTagname(); if (tag == "style" || tag == "script") continue; // Pasted/rich-text headings and tables get converted to their // Markdown equivalents (same as the dedicated Table widget and // TextBox's own box-level heading detection produce) instead of // being flattened to plain text, so they still read as the same // type once they land in llms.txt. if (int level = headingLevelForTag(tag)) { std::string text = flattenInline(hel->firstChild()); if (!text.empty()) { breakBlock(out); out += mdHeading(level, text); out += "\n\n"; } continue; } if (tag == "table") { std::string table = tableToMarkdown(hel); if (!table.empty()) { breakBlock(out); out += table; out += "\n\n"; } continue; } if (libhtmlpp::Element *child = hel->firstChild()) stripHtmlInto(child, out); } Loading @@ -71,7 +208,8 @@ namespace blogi::webedit::llms { // Strips inline HTML markup that TextBox/CustomHtml content may carry (e.g. // a pasted "<span style=...>...</span>" fragment), collapsing it to plain // text. Falls back to the raw string on malformed input. // text — except headings (h1-h6) and tables, which convert to Markdown // instead of being flattened. Falls back to the raw string on malformed input. inline std::string stripHtml(const std::string &html) { if (html.find('<') == std::string::npos) return html; try { Loading @@ -79,6 +217,8 @@ namespace blogi::webedit::llms { libhtmlpp::Element &root = parsed.parse(); std::string out; detail::stripHtmlInto(&root, out); size_t end = out.find_last_not_of(" \t\n"); out.erase(end == std::string::npos ? 0 : end + 1); return out; } catch (...) { return html; Loading Loading @@ -115,8 +255,4 @@ namespace blogi::webedit::llms { return 0; } inline std::string mdHeading(int level, const std::string &text) { return std::string(static_cast<size_t>(level), '#') + " " + text; } } Loading
editor/widgets/textbox/textbox.cpp +9 −1 Changes for editor/widgets/textbox/textbox.cpp: 9 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -554,8 +554,16 @@ extern "C" { std::optional<std::string> toLlmsBlock() const override { if (content.empty()) return std::nullopt; std::string plain = llms::stripHtml(content); if (plain.empty()) return std::nullopt; int level = llms::headingLevelFor(fontSize, fontWeight); return level ? llms::mdHeading(level, plain) : plain; // A box-level heading style only applies when the stripped // content is still a single line of plain text. Content that // already carries its own Markdown structure (a pasted <h1-6> // or <table> converted by stripHtml()) keeps that structure // instead of being wrapped in the box's heading level too. if (level && plain.find('\n') == std::string::npos) return llms::mdHeading(level, plain); return plain; } // --- JSON Properties Helpers --- Loading
src/llms_text.h +151 −15 Changes for src/llms_text.h: 151 added lines, 15 removed lines. Original line number Diff line number Diff line Loading @@ -31,16 +31,20 @@ // a widget's own text into a plain-text/Markdown fragment for llms.txt. #include <string> #include <vector> #include <algorithm> #include <cctype> #include <htmlpp/html.h> namespace blogi::webedit::llms { inline std::string mdHeading(int level, const std::string &text) { return std::string(static_cast<size_t>(level), '#') + " " + text; } namespace detail { inline void stripHtmlInto(libhtmlpp::Element *el, std::string &out) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() == libhtmlpp::TextEl) { std::string text = static_cast<libhtmlpp::TextElement*>(cur)->getText(); inline std::string collapseWhitespace(const std::string &text) { std::string collapsed; bool inSpace = false; for (unsigned char c : text) { Loading @@ -53,15 +57,148 @@ namespace blogi::webedit::llms { } } size_t start = collapsed.find_first_not_of(' '); if (start == std::string::npos) continue; collapsed = collapsed.substr(start, collapsed.find_last_not_of(' ') - start + 1); if (collapsed.empty()) continue; if (!out.empty() && out.back() != ' ') out += ' '; out += collapsed; if (start == std::string::npos) return ""; return collapsed.substr(start, collapsed.find_last_not_of(' ') - start + 1); } inline void appendInline(const std::string &piece, std::string &out) { if (piece.empty()) return; if (!out.empty() && out.back() != ' ' && out.back() != '\n') out += ' '; out += piece; } // Closes off the current paragraph/heading/table so the next block // starts on its own blank line, the same convention FAQ's toLlmsBlock() // uses to join mdHeading(...) and answer text (editor/widgets/faq/faq.cpp). inline void breakBlock(std::string &out) { if (out.empty()) return; size_t end = out.find_last_not_of(" \t\n"); out.erase(end == std::string::npos ? 0 : end + 1); out += "\n\n"; } inline void stripHtmlInto(libhtmlpp::Element *el, std::string &out); inline std::string flattenInline(libhtmlpp::Element *el) { std::string out; stripHtmlInto(el, out); return out; } inline int headingLevelForTag(const std::string &tag) { if (tag.size() == 2 && tag[0] == 'h' && tag[1] >= '1' && tag[1] <= '6') return tag[1] - '0'; return 0; } // Markdown table cells can't carry a literal "|" or a line break, so // fold them away the same way plain-text flattening already does. inline std::string tableCellText(libhtmlpp::Element *cell) { std::string text = flattenInline(cell); std::string sanitized; sanitized.reserve(text.size()); for (char c : text) { if (c == '|') sanitized += "\\|"; else if (c == '\n') sanitized += ' '; else sanitized += c; } return sanitized; } struct TableRow { std::vector<std::string> cells; bool isHeader = false; }; inline void collectTableRows(libhtmlpp::Element *el, std::vector<TableRow> &rows) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() != libhtmlpp::HtmlEl) continue; auto *hel = static_cast<libhtmlpp::HtmlElement*>(cur); std::string tag = hel->getTagname(); if (tag == "tr") { TableRow row; for (libhtmlpp::Element *cellNode = hel->firstChild(); cellNode; cellNode = cellNode->nextElement()) { if (cellNode->getType() != libhtmlpp::HtmlEl) continue; auto *cellEl = static_cast<libhtmlpp::HtmlElement*>(cellNode); std::string cellTag = cellEl->getTagname(); if (cellTag != "td" && cellTag != "th") continue; if (cellTag == "th") row.isHeader = true; row.cells.push_back(tableCellText(cellEl)); } if (!row.cells.empty()) rows.push_back(std::move(row)); } else if (tag == "thead" || tag == "tbody" || tag == "tfoot") { if (libhtmlpp::Element *child = hel->firstChild()) collectTableRows(child, rows); } } } // Mirrors the Table widget's own toLlmsBlock() (editor/widgets/table/table.cpp) // so a <table> pasted into rich text renders as the same Markdown shape // a native Table widget would: "| a | b |" rows with a "| --- |" rule // under the header row. inline std::string tableToMarkdown(libhtmlpp::HtmlElement *tableEl) { std::vector<TableRow> rows; if (libhtmlpp::Element *child = tableEl->firstChild()) collectTableRows(child, rows); if (rows.empty()) return ""; size_t numCols = 0; for (const auto &row : rows) numCols = std::max(numCols, row.cells.size()); if (numCols == 0) return ""; auto rowLine = [numCols](const TableRow &row) { std::string line = "|"; for (size_t c = 0; c < numCols; ++c) { line += " "; if (c < row.cells.size()) line += row.cells[c]; line += " |"; } return line; }; std::string md = rowLine(rows[0]); if (rows[0].isHeader) { md += "\n|"; for (size_t c = 0; c < numCols; ++c) md += " --- |"; } for (size_t r = 1; r < rows.size(); ++r) md += "\n" + rowLine(rows[r]); return md; } inline void stripHtmlInto(libhtmlpp::Element *el, std::string &out) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() == libhtmlpp::TextEl) { appendInline(collapseWhitespace(static_cast<libhtmlpp::TextElement*>(cur)->getText()), out); } else if (cur->getType() == libhtmlpp::HtmlEl) { libhtmlpp::HtmlElement *hel = static_cast<libhtmlpp::HtmlElement*>(cur); auto *hel = static_cast<libhtmlpp::HtmlElement*>(cur); std::string tag = hel->getTagname(); if (tag == "style" || tag == "script") continue; // Pasted/rich-text headings and tables get converted to their // Markdown equivalents (same as the dedicated Table widget and // TextBox's own box-level heading detection produce) instead of // being flattened to plain text, so they still read as the same // type once they land in llms.txt. if (int level = headingLevelForTag(tag)) { std::string text = flattenInline(hel->firstChild()); if (!text.empty()) { breakBlock(out); out += mdHeading(level, text); out += "\n\n"; } continue; } if (tag == "table") { std::string table = tableToMarkdown(hel); if (!table.empty()) { breakBlock(out); out += table; out += "\n\n"; } continue; } if (libhtmlpp::Element *child = hel->firstChild()) stripHtmlInto(child, out); } Loading @@ -71,7 +208,8 @@ namespace blogi::webedit::llms { // Strips inline HTML markup that TextBox/CustomHtml content may carry (e.g. // a pasted "<span style=...>...</span>" fragment), collapsing it to plain // text. Falls back to the raw string on malformed input. // text — except headings (h1-h6) and tables, which convert to Markdown // instead of being flattened. Falls back to the raw string on malformed input. inline std::string stripHtml(const std::string &html) { if (html.find('<') == std::string::npos) return html; try { Loading @@ -79,6 +217,8 @@ namespace blogi::webedit::llms { libhtmlpp::Element &root = parsed.parse(); std::string out; detail::stripHtmlInto(&root, out); size_t end = out.find_last_not_of(" \t\n"); out.erase(end == std::string::npos ? 0 : end + 1); return out; } catch (...) { return html; Loading Loading @@ -115,8 +255,4 @@ namespace blogi::webedit::llms { return 0; } inline std::string mdHeading(int level, const std::string &text) { return std::string(static_cast<size_t>(level), '#') + " " + text; } }