Loading editor/src/webedit_api.cpp +145 −46 Changes for editor/src/webedit_api.cpp: 145 added lines, 46 removed lines. Original line number Diff line number Diff line Loading @@ -4698,75 +4698,154 @@ std::string webedit::Api::exportTreeXml(DocumentState &doc) { } namespace { // Widget attributes that carry human-readable content (button/faq/image labels etc.) // rather than styling. Element text (tinyxml2 GetText()) is picked up separately — // that's how content-heavy widgets (Textbox, Article, List items, Table cells) store // their body text. const std::set<std::string> kLlmsTextAttributes = { "label", "title", "question", "answer", "alt", "header_label", // Accordion panel header (accordion.cpp) "button_label", // Popup trigger button text (popup.cpp) "survey_title", // Survey widget's visitor-facing display title (survey.cpp) }; // Approximates a CSS length ("28px", "1.55rem", "1.2em", "18") in pixels, // treating rem/em as 16px/unit — good enough to rank TextBox sizes against // each other for heading-level detection. double parseFontSizePx(const std::string &size) { size_t i = 0; while (i < size.size() && (std::isdigit(static_cast<unsigned char>(size[i])) || size[i] == '.' || size[i] == '-')) ++i; if (i == 0) return 0; double value = 0; try { value = std::stod(size.substr(0, i)); } catch (...) { return 0; } std::string unit = size.substr(i); if (unit == "rem" || unit == "em") return value * 16.0; return value; } void appendLlmsText(std::string &out, const std::string &text) { std::string trimmed = text; size_t start = trimmed.find_first_not_of(" \t\r\n"); size_t end = trimmed.find_last_not_of(" \t\r\n"); if (start == std::string::npos) return; trimmed = trimmed.substr(start, end - start + 1); if (trimmed.empty()) return; if (!out.empty()) out += "\n\n"; out += trimmed; bool isBoldWeight(const std::string &weight) { return weight == "bold" || weight == "700" || weight == "800" || weight == "900"; } // Strips HTML markup from a fragment by parsing it and concatenating text nodes, // falling back to the raw string if it isn't valid HTML. std::string stripHtml(const std::string &html) { if (html.find('<') == std::string::npos) return html; try { libhtmlpp::HtmlString parsed(html); libhtmlpp::Element &root = parsed.parse(); std::string out; std::vector<libhtmlpp::Element*> stack; stack.push_back(&root); while (!stack.empty()) { libhtmlpp::Element *el = stack.back(); stack.pop_back(); // 0 = regular paragraph, 1..3 = heading level. Blogi's TextBox widget has // no semantic "this is a heading" flag — every heading, subheading and // paragraph is the same widget type styled differently — so level is // inferred from size/weight the way a human skims a page visually. int headingLevelFor(const std::string &fontSize, const std::string &fontWeight) { double px = parseFontSizePx(fontSize); if (px >= 32) return 1; if (px >= 22) return 2; if (px >= 18 && isBoldWeight(fontWeight)) return 3; return 0; } std::string mdHeading(int level, const std::string &text) { return std::string(static_cast<size_t>(level), '#') + " " + text; } // Strips inline HTML markup that TextBox/HtmlContent text may carry (e.g. a // pasted "<span style=...>...</span>" fragment), collapsing it to plain text. void stripHtmlInto(libhtmlpp::Element *el, std::string &out) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() == libhtmlpp::TextEl) { if (!out.empty()) out += " "; out += static_cast<libhtmlpp::TextElement*>(cur)->getText(); std::string text = static_cast<libhtmlpp::TextElement*>(cur)->getText(); std::string collapsed; bool inSpace = false; for (unsigned char c : text) { if (std::isspace(c)) { if (!inSpace) collapsed += ' '; inSpace = true; } else { collapsed += static_cast<char>(c); inSpace = false; } } 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; } else if (cur->getType() == libhtmlpp::HtmlEl) { libhtmlpp::HtmlElement *hel = static_cast<libhtmlpp::HtmlElement*>(cur); std::string tag = hel->getTagname(); if (tag != "style" && tag != "script" && hel->firstChild()) stack.push_back(hel->firstChild()); if (tag == "style" || tag == "script") continue; if (libhtmlpp::Element *child = hel->firstChild()) stripHtmlInto(child, out); } } } std::string stripHtml(const std::string &html) { if (html.find('<') == std::string::npos) return html; try { libhtmlpp::HtmlString parsed(html); libhtmlpp::Element &root = parsed.parse(); std::string out; stripHtmlInto(&root, out); return out; } catch (...) { return html; } } void walkLlmsXml(const tinyxml2::XMLElement *el, std::string &out) { void appendBlock(std::vector<std::string> &blocks, const std::string &text) { size_t start = text.find_first_not_of(" \t\r\n"); if (start == std::string::npos) return; size_t end = text.find_last_not_of(" \t\r\n"); std::string trimmed = text.substr(start, end - start + 1); if (!trimmed.empty()) blocks.push_back(trimmed); } // Walks the widget XML tree directly, turning widget semantics into // Markdown blocks: FAQ questions/Accordion headers/large-or-bold TextBox // content become headings, everything else becomes a paragraph. This // preserves structure (question vs. answer, heading vs. body) that the // fully-rendered HTML flattens into identically generic <div>s. void walkLlmsXml(const tinyxml2::XMLElement *el, std::vector<std::string> &blocks) { for (const tinyxml2::XMLElement *cur = el; cur; cur = cur->NextSiblingElement()) { std::string tag = cur->Name() ? cur->Name() : ""; if (tag == "CustomCss") continue; if (tag == "CustomCss" || tag == "CssContent" || tag == "JsContent") continue; if (tag == "TextBox") { const char *text = cur->GetText(); if (text && text[0]) { std::string plain = stripHtml(text); const char *fontSize = cur->Attribute("font_size"); const char *fontWeight = cur->Attribute("font_weight"); int level = headingLevelFor(fontSize ? fontSize : "", fontWeight ? fontWeight : ""); appendBlock(blocks, level ? mdHeading(level, plain) : plain); } continue; // TextBox's content lives in its text node, not XML children } if (tag == "FAQ") { if (const char *title = cur->Attribute("title")) if (title[0]) appendBlock(blocks, mdHeading(2, title)); for (const tinyxml2::XMLElement *item = cur->FirstChildElement("FAQItem"); item; item = item->NextSiblingElement("FAQItem")) { if (const char *q = item->Attribute("question")) if (q[0]) appendBlock(blocks, mdHeading(3, q)); if (const char *a = item->Attribute("answer")) if (a[0]) appendBlock(blocks, a); } continue; } if (tag == "Accordion") { if (const char *headerLabel = cur->Attribute("header_label")) if (headerLabel[0]) appendBlock(blocks, mdHeading(3, headerLabel)); if (const tinyxml2::XMLElement *child = cur->FirstChildElement()) walkLlmsXml(child, blocks); continue; } for (const tinyxml2::XMLAttribute *attr = cur->FirstAttribute(); attr; attr = attr->Next()) { if (kLlmsTextAttributes.count(attr->Name()) && attr->Value() && attr->Value()[0]) appendLlmsText(out, attr->Value()); if (tag == "Survey") { if (const char *surveyTitle = cur->Attribute("survey_title")) if (surveyTitle[0]) appendBlock(blocks, mdHeading(2, surveyTitle)); continue; } if (tag == "HtmlContent") { const char *text = cur->GetText(); if (text && text[0]) appendLlmsText(out, stripHtml(text)); if (text && text[0]) appendBlock(blocks, stripHtml(text)); continue; } // Purely visual/decorative — no text worth surfacing in llms.txt. if (tag == "Button" || tag == "Image" || tag == "GoogleReviews" || tag == "GoogleMaps") continue; // Structural wrapper (Container/Grid/Section/List/Item/Slider/...): recurse. if (const tinyxml2::XMLElement *child = cur->FirstChildElement()) walkLlmsXml(child, out); walkLlmsXml(child, blocks); } } } Loading @@ -4779,9 +4858,29 @@ std::string webedit::Api::generateLlmsTxt(const std::string &xml) { const tinyxml2::XMLElement *root = xmlDoc.RootElement(); if (!root) return ""; std::string out; std::vector<std::string> blocks; if (const tinyxml2::XMLElement *child = root->FirstChildElement()) walkLlmsXml(child, out); walkLlmsXml(child, blocks); // llms.txt is expected to be Markdown with at least one H1: promote the // first heading found (whatever level) to H1 if the page has headings but // none is already top-level. bool hasH1 = false; int firstHeadingIdx = -1; for (size_t i = 0; i < blocks.size(); ++i) { if (blocks[i].size() > 1 && blocks[i][0] == '#' && blocks[i][1] == ' ') { hasH1 = true; break; } if (firstHeadingIdx < 0 && !blocks[i].empty() && blocks[i][0] == '#') firstHeadingIdx = static_cast<int>(i); } if (!hasH1 && firstHeadingIdx >= 0) { std::string &h = blocks[static_cast<size_t>(firstHeadingIdx)]; h = "#" + h.substr(h.find_first_not_of('#')); } std::string out; for (const auto &block : blocks) { if (!out.empty()) out += "\n\n"; out += block; } return out; } Loading Loading
editor/src/webedit_api.cpp +145 −46 Changes for editor/src/webedit_api.cpp: 145 added lines, 46 removed lines. Original line number Diff line number Diff line Loading @@ -4698,75 +4698,154 @@ std::string webedit::Api::exportTreeXml(DocumentState &doc) { } namespace { // Widget attributes that carry human-readable content (button/faq/image labels etc.) // rather than styling. Element text (tinyxml2 GetText()) is picked up separately — // that's how content-heavy widgets (Textbox, Article, List items, Table cells) store // their body text. const std::set<std::string> kLlmsTextAttributes = { "label", "title", "question", "answer", "alt", "header_label", // Accordion panel header (accordion.cpp) "button_label", // Popup trigger button text (popup.cpp) "survey_title", // Survey widget's visitor-facing display title (survey.cpp) }; // Approximates a CSS length ("28px", "1.55rem", "1.2em", "18") in pixels, // treating rem/em as 16px/unit — good enough to rank TextBox sizes against // each other for heading-level detection. double parseFontSizePx(const std::string &size) { size_t i = 0; while (i < size.size() && (std::isdigit(static_cast<unsigned char>(size[i])) || size[i] == '.' || size[i] == '-')) ++i; if (i == 0) return 0; double value = 0; try { value = std::stod(size.substr(0, i)); } catch (...) { return 0; } std::string unit = size.substr(i); if (unit == "rem" || unit == "em") return value * 16.0; return value; } void appendLlmsText(std::string &out, const std::string &text) { std::string trimmed = text; size_t start = trimmed.find_first_not_of(" \t\r\n"); size_t end = trimmed.find_last_not_of(" \t\r\n"); if (start == std::string::npos) return; trimmed = trimmed.substr(start, end - start + 1); if (trimmed.empty()) return; if (!out.empty()) out += "\n\n"; out += trimmed; bool isBoldWeight(const std::string &weight) { return weight == "bold" || weight == "700" || weight == "800" || weight == "900"; } // Strips HTML markup from a fragment by parsing it and concatenating text nodes, // falling back to the raw string if it isn't valid HTML. std::string stripHtml(const std::string &html) { if (html.find('<') == std::string::npos) return html; try { libhtmlpp::HtmlString parsed(html); libhtmlpp::Element &root = parsed.parse(); std::string out; std::vector<libhtmlpp::Element*> stack; stack.push_back(&root); while (!stack.empty()) { libhtmlpp::Element *el = stack.back(); stack.pop_back(); // 0 = regular paragraph, 1..3 = heading level. Blogi's TextBox widget has // no semantic "this is a heading" flag — every heading, subheading and // paragraph is the same widget type styled differently — so level is // inferred from size/weight the way a human skims a page visually. int headingLevelFor(const std::string &fontSize, const std::string &fontWeight) { double px = parseFontSizePx(fontSize); if (px >= 32) return 1; if (px >= 22) return 2; if (px >= 18 && isBoldWeight(fontWeight)) return 3; return 0; } std::string mdHeading(int level, const std::string &text) { return std::string(static_cast<size_t>(level), '#') + " " + text; } // Strips inline HTML markup that TextBox/HtmlContent text may carry (e.g. a // pasted "<span style=...>...</span>" fragment), collapsing it to plain text. void stripHtmlInto(libhtmlpp::Element *el, std::string &out) { for (libhtmlpp::Element *cur = el; cur; cur = cur->nextElement()) { if (cur->getType() == libhtmlpp::TextEl) { if (!out.empty()) out += " "; out += static_cast<libhtmlpp::TextElement*>(cur)->getText(); std::string text = static_cast<libhtmlpp::TextElement*>(cur)->getText(); std::string collapsed; bool inSpace = false; for (unsigned char c : text) { if (std::isspace(c)) { if (!inSpace) collapsed += ' '; inSpace = true; } else { collapsed += static_cast<char>(c); inSpace = false; } } 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; } else if (cur->getType() == libhtmlpp::HtmlEl) { libhtmlpp::HtmlElement *hel = static_cast<libhtmlpp::HtmlElement*>(cur); std::string tag = hel->getTagname(); if (tag != "style" && tag != "script" && hel->firstChild()) stack.push_back(hel->firstChild()); if (tag == "style" || tag == "script") continue; if (libhtmlpp::Element *child = hel->firstChild()) stripHtmlInto(child, out); } } } std::string stripHtml(const std::string &html) { if (html.find('<') == std::string::npos) return html; try { libhtmlpp::HtmlString parsed(html); libhtmlpp::Element &root = parsed.parse(); std::string out; stripHtmlInto(&root, out); return out; } catch (...) { return html; } } void walkLlmsXml(const tinyxml2::XMLElement *el, std::string &out) { void appendBlock(std::vector<std::string> &blocks, const std::string &text) { size_t start = text.find_first_not_of(" \t\r\n"); if (start == std::string::npos) return; size_t end = text.find_last_not_of(" \t\r\n"); std::string trimmed = text.substr(start, end - start + 1); if (!trimmed.empty()) blocks.push_back(trimmed); } // Walks the widget XML tree directly, turning widget semantics into // Markdown blocks: FAQ questions/Accordion headers/large-or-bold TextBox // content become headings, everything else becomes a paragraph. This // preserves structure (question vs. answer, heading vs. body) that the // fully-rendered HTML flattens into identically generic <div>s. void walkLlmsXml(const tinyxml2::XMLElement *el, std::vector<std::string> &blocks) { for (const tinyxml2::XMLElement *cur = el; cur; cur = cur->NextSiblingElement()) { std::string tag = cur->Name() ? cur->Name() : ""; if (tag == "CustomCss") continue; if (tag == "CustomCss" || tag == "CssContent" || tag == "JsContent") continue; if (tag == "TextBox") { const char *text = cur->GetText(); if (text && text[0]) { std::string plain = stripHtml(text); const char *fontSize = cur->Attribute("font_size"); const char *fontWeight = cur->Attribute("font_weight"); int level = headingLevelFor(fontSize ? fontSize : "", fontWeight ? fontWeight : ""); appendBlock(blocks, level ? mdHeading(level, plain) : plain); } continue; // TextBox's content lives in its text node, not XML children } if (tag == "FAQ") { if (const char *title = cur->Attribute("title")) if (title[0]) appendBlock(blocks, mdHeading(2, title)); for (const tinyxml2::XMLElement *item = cur->FirstChildElement("FAQItem"); item; item = item->NextSiblingElement("FAQItem")) { if (const char *q = item->Attribute("question")) if (q[0]) appendBlock(blocks, mdHeading(3, q)); if (const char *a = item->Attribute("answer")) if (a[0]) appendBlock(blocks, a); } continue; } if (tag == "Accordion") { if (const char *headerLabel = cur->Attribute("header_label")) if (headerLabel[0]) appendBlock(blocks, mdHeading(3, headerLabel)); if (const tinyxml2::XMLElement *child = cur->FirstChildElement()) walkLlmsXml(child, blocks); continue; } for (const tinyxml2::XMLAttribute *attr = cur->FirstAttribute(); attr; attr = attr->Next()) { if (kLlmsTextAttributes.count(attr->Name()) && attr->Value() && attr->Value()[0]) appendLlmsText(out, attr->Value()); if (tag == "Survey") { if (const char *surveyTitle = cur->Attribute("survey_title")) if (surveyTitle[0]) appendBlock(blocks, mdHeading(2, surveyTitle)); continue; } if (tag == "HtmlContent") { const char *text = cur->GetText(); if (text && text[0]) appendLlmsText(out, stripHtml(text)); if (text && text[0]) appendBlock(blocks, stripHtml(text)); continue; } // Purely visual/decorative — no text worth surfacing in llms.txt. if (tag == "Button" || tag == "Image" || tag == "GoogleReviews" || tag == "GoogleMaps") continue; // Structural wrapper (Container/Grid/Section/List/Item/Slider/...): recurse. if (const tinyxml2::XMLElement *child = cur->FirstChildElement()) walkLlmsXml(child, out); walkLlmsXml(child, blocks); } } } Loading @@ -4779,9 +4858,29 @@ std::string webedit::Api::generateLlmsTxt(const std::string &xml) { const tinyxml2::XMLElement *root = xmlDoc.RootElement(); if (!root) return ""; std::string out; std::vector<std::string> blocks; if (const tinyxml2::XMLElement *child = root->FirstChildElement()) walkLlmsXml(child, out); walkLlmsXml(child, blocks); // llms.txt is expected to be Markdown with at least one H1: promote the // first heading found (whatever level) to H1 if the page has headings but // none is already top-level. bool hasH1 = false; int firstHeadingIdx = -1; for (size_t i = 0; i < blocks.size(); ++i) { if (blocks[i].size() > 1 && blocks[i][0] == '#' && blocks[i][1] == ' ') { hasH1 = true; break; } if (firstHeadingIdx < 0 && !blocks[i].empty() && blocks[i][0] == '#') firstHeadingIdx = static_cast<int>(i); } if (!hasH1 && firstHeadingIdx >= 0) { std::string &h = blocks[static_cast<size_t>(firstHeadingIdx)]; h = "#" + h.substr(h.find_first_not_of('#')); } std::string out; for (const auto &block : blocks) { if (!out.empty()) out += "\n\n"; out += block; } return out; } Loading