Commit d6bb4a36 authored by jan.koester's avatar jan.koester
Browse files

better llmx.txt

parent fac8ec72
Loading
Loading
Loading
Loading
+13 −149
Changes for editor/src/webedit_api.cpp: 13 added lines, 149 removed lines.
Original line number Diff line number Diff line
@@ -3899,7 +3899,7 @@ webedit::Api::PublishCoreResult webedit::Api::publishToConnectionCore(
    autoPublishSurveysForDocument(docXml, authid, blogUrl, domain);

    std::string html = renderTree(doc.root);
    std::string llmsTxt = generateLlmsTxt(docXml);
    std::string llmsTxt = generateLlmsTxt(doc.root);

    // Build the publish command:
    // [{"authid":"..."}, {"command":"publish","target":"template","html":"...","params":{...}}]
@@ -4698,86 +4698,6 @@ std::string webedit::Api::exportTreeXml(DocumentState &doc) {
}

namespace {
    // 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;
    }

    bool isBoldWeight(const std::string &weight) {
        return weight == "bold" || weight == "700" || weight == "800" || weight == "900";
    }

    // 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) {
                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") 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 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;
@@ -4786,81 +4706,25 @@ namespace {
        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" || 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;
            }

            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]) 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, blocks);
    // Walks the live widget tree, asking each widget for its own text via
    // EditPlugin::toLlmsBlock() and always recursing into children — a widget
    // can contribute a block of its own (e.g. Accordion's header) AND still
    // have its children walked (e.g. Accordion's panel content).
    void collectLlmsBlocks(const blogi::webedit::EditPlugin *el, std::vector<std::string> &blocks) {
        for (const blogi::webedit::EditPlugin *cur = el; cur; cur = cur->nextElement()) {
            if (auto block = cur->toLlmsBlock())
                appendBlock(blocks, *block);
            if (const blogi::webedit::EditPlugin *child = cur->getChildElement())
                collectLlmsBlocks(child, blocks);
        }
    }
}

std::string webedit::Api::generateLlmsTxt(const std::string &xml) {
    tinyxml2::XMLDocument xmlDoc;
    if (xmlDoc.Parse(xml.c_str(), xml.size()) != tinyxml2::XML_SUCCESS)
        return "";

    const tinyxml2::XMLElement *root = xmlDoc.RootElement();
std::string webedit::Api::generateLlmsTxt(const blogi::webedit::EditPlugin *root) {
    if (!root) return "";

    std::vector<std::string> blocks;
    if (const tinyxml2::XMLElement *child = root->FirstChildElement())
        walkLlmsXml(child, blocks);
    collectLlmsBlocks(root, 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
+1 −1
Changes for editor/src/webedit_api.h: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -241,7 +241,7 @@ namespace webedit {
        std::string renderTree(const blogi::webedit::EditPlugin *node);
        std::string exportTreeXml(DocumentState &doc);
        std::string serializeElementXml(blogi::webedit::EditPlugin *node);
        std::string generateLlmsTxt(const std::string &xml);
        std::string generateLlmsTxt(const blogi::webedit::EditPlugin *root);
        void persistDocumentStateToSession(const std::string &sessionid, DocumentState &doc);
        void restoreDocumentStateFromSession(const std::string &sessionid, DocumentState &doc);
        void loadDocumentTreeFromXml(DocumentState &doc, const std::string &xml);