Loading editor/html/index.html +18 −0 Original line number Diff line number Diff line Loading @@ -42,6 +42,7 @@ <button id="btn-export-xml" data-i18n-title="I18N_TITLE_EXPORT_XML">⬆ <span data-i18n="I18N_EXPORT_XML">Export XML</span></button> <button id="btn-import-html" data-i18n-title="I18N_IMPORT_HTML">⬇ <span data-i18n="I18N_IMPORT_HTML">Import HTML</span></button> <button id="btn-export-html" data-i18n-title="I18N_EXPORT_HTML">⬆ <span data-i18n="I18N_EXPORT_HTML">Export HTML</span></button> <button id="btn-import-css" data-i18n-title="I18N_IMPORT_CSS">⬇ <span data-i18n="I18N_IMPORT_CSS">Import CSS</span></button> <button id="btn-survey-manager" title="Umfragen verwalten">📋 <span>Umfragen</span></button> </div> <div class="toolbar-group"> Loading Loading @@ -351,6 +352,23 @@ </div> </dialog> <dialog id="import-css-dialog"> <h3 data-i18n="I18N_IMPORT_CSS">CSS importieren</h3> <div class="set-field"> <input type="url" id="import-css-url" data-i18n-placeholder="I18N_IMPORT_CSS_URL_PLACEHOLDER" placeholder="https://beispiel.de/style.css"> <button type="button" id="btn-import-css-load-url" class="set-save-btn" data-i18n="I18N_IMPORT_CSS_LOAD_URL">Von URL laden</button> </div> <p id="import-css-url-status" class="set-status" style="display:none"></p> <p class="hint" style="text-align:center" data-i18n="I18N_IMPORT_HTML_OR">oder</p> <label class="file-upload-label">📂 <span data-i18n="I18N_UPLOAD_FILE">Datei wählen</span><input type="file" id="import-css-file" accept=".css,.txt" style="display:none"></label> <textarea id="import-css-content" rows="15" placeholder="CSS hier einfügen..."></textarea> <p class="hint" data-i18n="I18N_IMPORT_CSS_HINT">Nur global gültige Regeln (:root, html, body, *, ...) werden übernommen.</p> <div class="dialog-actions"> <button id="btn-import-css-confirm" data-i18n="I18N_IMPORT_CSS">Importieren</button> <button id="btn-import-css-cancel" data-i18n="I18N_CANCEL">Abbrechen</button> </div> </dialog> <dialog id="export-html-dialog"> <h3 data-i18n="I18N_EXPORT_HTML">HTML exportieren</h3> <textarea id="export-html-content" rows="15" readonly></textarea> Loading editor/html/js/api.js +11 −0 Original line number Diff line number Diff line Loading @@ -558,6 +558,17 @@ var EditorApi = (function() { return request('GET', '/api/document/export-html'); }, // Imports a standalone stylesheet (pasted/uploaded text, or fetched // server-side from url) and merges its :root/html/body/*-scoped // rules into the document's page-level GlobalCss. Pass exactly one // of css/url. importCss: function(css, url) { var data = {}; if (url) data.url = url; else data.css = css; return request('POST', '/api/document/import-css', data); }, listSections: function() { return request('GET', '/api/document/sections'); }, Loading editor/html/js/editor.js +85 −1 Original line number Diff line number Diff line Loading @@ -9,7 +9,7 @@ var currentDocId = ''; var currentDocGroup = ''; var currentDocSeo = {}; var currentDocGlobalCss = ''; // page-level CSS collected by HTML import, see doc-seo-dialog var currentDocGlobalCss = ''; // page-level CSS collected by HTML/CSS import, see doc-seo-dialog var currentDocGlobalJs = ''; // page-level JS collected from external <script src> during HTML import var currentDocPublishParams = {}; // target name -> last-used publish params for this template var currentDocLastPublishTarget = ''; // target name last successfully published to, see applyPreferredPublishTarget Loading Loading @@ -388,6 +388,10 @@ openImportHtmlDialog(); }); document.getElementById('btn-import-css').addEventListener('click', function() { openImportCssDialog(); }); document.getElementById('btn-export-html').addEventListener('click', function() { EditorApi.exportHtml().then(function(resp) { document.getElementById('export-html-content').value = resp.html || ''; Loading Loading @@ -551,6 +555,36 @@ el.className = 'set-status' + (isError ? ' error' : ''); } // --- CSS import dialog (paste/upload/URL) -- unlike HTML import there is // no widget tree/block picker: the result always merges straight into the // document's page-level GlobalCss (see handleImportCss), so both the // paste/upload and URL paths import directly and close the dialog. function openImportCssDialog() { document.getElementById('import-css-content').value = ''; document.getElementById('import-css-url').value = ''; setImportCssUrlStatus(''); document.getElementById('import-css-dialog').showModal(); } function setImportCssUrlStatus(text, isError) { var el = document.getElementById('import-css-url-status'); el.textContent = text || ''; el.style.display = text ? '' : 'none'; el.className = 'set-status' + (isError ? ' error' : ''); } function finishImportCss(promise) { return promise.then(function(resp) { document.getElementById('import-css-dialog').close(); refreshDocument(); if (!resp.global_css) { alert(I18n.t('I18N_IMPORT_CSS_NO_GLOBAL_RULES', 'Keine global gültigen Regeln (:root, html, body, *, ...) gefunden.')); } }); } // Fetches @p url server-side (via EditorApi.parseHtmlUrl) and feeds the // result into the same block-picker step used by paste/upload, so the // rest of the import flow (candidate tree, media-import options, confirm) Loading Loading @@ -1057,6 +1091,56 @@ document.getElementById('import-html-dialog').close(); }); // CSS Import dialog: import from URL (fetched server-side, then // merged into GlobalCss immediately -- no block picker step, unlike // HTML import's URL path). document.getElementById('btn-import-css-load-url').addEventListener('click', function() { var url = document.getElementById('import-css-url').value.trim(); if (!url) return; var btn = this; btn.disabled = true; setImportCssUrlStatus(I18n.t('I18N_LOADING_URL', 'URL wird geladen...')); finishImportCss(EditorApi.importCss(null, url)).then(function() { btn.disabled = false; setImportCssUrlStatus(''); }).catch(function(err) { btn.disabled = false; setImportCssUrlStatus(I18n.t('I18N_IMPORT_FAILED') + ': ' + (err.error || ''), true); }); }); document.getElementById('import-css-url').addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('btn-import-css-load-url').click(); } }); document.getElementById('import-css-file').addEventListener('change', function() { var file = this.files[0]; this.value = ''; if (!file) return; var reader = new FileReader(); reader.onload = function(e) { var css = e.target.result; if (!css.trim()) return; document.getElementById('import-css-content').value = css; }; reader.readAsText(file); }); document.getElementById('btn-import-css-confirm').addEventListener('click', function() { var css = document.getElementById('import-css-content').value; if (!css.trim()) return; finishImportCss(EditorApi.importCss(css, null)).catch(function(err) { alert(I18n.t('I18N_IMPORT_FAILED') + ': ' + (err.error || '')); }); }); document.getElementById('btn-import-css-cancel').addEventListener('click', function() { document.getElementById('import-css-dialog').close(); }); // HTML Export dialog document.getElementById('btn-export-html-copy').addEventListener('click', function() { var textarea = document.getElementById('export-html-content'); Loading editor/src/htmlimport.cpp +120 −80 Original line number Diff line number Diff line Loading @@ -1904,6 +1904,102 @@ libhtmlpp::Element *resolveImportPath(libhtmlpp::Element *root, const std::strin return cur; } // Scans every rule in @p sheet and appends the serialized text of any rule // scoped to :root/html/body/*/a pseudo-element (see the isGlobal checks // below) to @p outGlobalCss -- used both for convertHtmlToWidgetXml's // GlobalCss/rootEnv extraction and convertCssToGlobalCss's standalone-CSS // import, so the two stay consistent about what counts as "global" CSS. // If @p outRootEnv is non-null, any "--name" custom-property declaration // found on a global rule is also recorded there (see // resolveCustomProperties for how this seeds per-element inheritance) -- // callers that have no widget tree to seed (e.g. convertCssToGlobalCss) // pass nullptr and skip that bookkeeping. void extractGlobalCssFromStylesheet(const libhtmlpp::CSSStyleSheet &sheet, std::string &outGlobalCss, std::map<std::string,std::string> *outRootEnv) { for (const auto &rule : sheet.getRules()) { const std::string &sel = rule.getSelector(); if (sel.empty()) continue; bool isGlobal = false; // Only meaningful (and only set) when sel[0] == '@': the at-rule's // own "@media (...)"/"@supports (...)" wrapper and the selector // nested inside it. libhtmlpp::CSSStyleSheet flattens e.g. // "@media (max-width:768px) { :root { ... } }" into ONE CSSRule // whose getSelector() is the whole "@media (max-width:768px) :root" // string -- see the reconstruction below for why that matters. std::string atWrapper, innerSel; if (sel[0] == '@') { size_t parenDepth = 0; size_t splitPos = std::string::npos; for (size_t i = 0; i < sel.size(); ++i) { if (sel[i] == '(') ++parenDepth; else if (sel[i] == ')') { if (parenDepth > 0) --parenDepth; if (parenDepth == 0) { splitPos = i + 1; break; } } } if (splitPos != std::string::npos && splitPos + 1 < sel.size()) { size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos); if (innerStart != std::string::npos) { atWrapper = sel.substr(0, splitPos); innerSel = sel.substr(innerStart); std::string innerLower = innerSel; std::transform(innerLower.begin(), innerLower.end(), innerLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (innerLower.compare(0, 5, ":root") == 0 || innerLower.find("::") != std::string::npos || innerLower == "*" || innerLower == "html" || innerLower == "body") { isGlobal = true; } } } else { isGlobal = true; } } else { std::string selLower = sel; std::transform(selLower.begin(), selLower.end(), selLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (selLower == ":root" || selLower == "*" || selLower == "html" || selLower == "body" || selLower.compare(0, 5, ":root") == 0 || selLower.find("::") != std::string::npos) { isGlobal = true; } } if (isGlobal && outRootEnv) { for (const auto &prop : rule.getDeclaration().getProperties()) { if (prop.getName().compare(0, 2, "--") == 0) { (*outRootEnv)[prop.getName()] = prop.getValue(); } } } if (isGlobal && sel[0] == '@' && !innerSel.empty()) { // rule.serialize() just wraps getSelector() in a single "{ }" // pair -- fine for a plain selector, but here getSelector() is // the FULL flattened "@media (...) :root" string, so that would // emit "@media (...) :root { decls }", which is invalid CSS (the // at-rule needs its own wrapping braces around the nested rule). // Reconstruct the proper nested form instead, the same way // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for // per-element @media matches. outGlobalCss += atWrapper + " {\n " + innerSel + " {\n"; for (const auto &prop : rule.getDeclaration().getProperties()) { outGlobalCss += " " + prop.getName() + ": " + prop.getValue() + ";\n"; } outGlobalCss += " }\n}\n"; } else if (isGlobal) { outGlobalCss += rule.serialize(true); outGlobalCss += "\n"; } } } } // namespace std::vector<blogi::htmlimport::ImportNode> Loading Loading @@ -2165,86 +2261,7 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, // document-order rule for the same name simply overwrites an earlier // one, same "last one wins" approximation used elsewhere here. std::map<std::string,std::string> rootEnv; for (const auto &rule : stylesheet.getRules()) { const std::string &sel = rule.getSelector(); if (sel.empty()) continue; bool isGlobal = false; // Only meaningful (and only set) when sel[0] == '@': the at-rule's // own "@media (...)"/"@supports (...)" wrapper and the selector // nested inside it. libhtmlpp::CSSStyleSheet flattens e.g. // "@media (max-width:768px) { :root { ... } }" into ONE CSSRule // whose getSelector() is the whole "@media (max-width:768px) :root" // string -- see the reconstruction below for why that matters. std::string atWrapper, innerSel; if (sel[0] == '@') { size_t parenDepth = 0; size_t splitPos = std::string::npos; for (size_t i = 0; i < sel.size(); ++i) { if (sel[i] == '(') ++parenDepth; else if (sel[i] == ')') { if (parenDepth > 0) --parenDepth; if (parenDepth == 0) { splitPos = i + 1; break; } } } if (splitPos != std::string::npos && splitPos + 1 < sel.size()) { size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos); if (innerStart != std::string::npos) { atWrapper = sel.substr(0, splitPos); innerSel = sel.substr(innerStart); std::string innerLower = innerSel; std::transform(innerLower.begin(), innerLower.end(), innerLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (innerLower.compare(0, 5, ":root") == 0 || innerLower.find("::") != std::string::npos || innerLower == "*" || innerLower == "html" || innerLower == "body") { isGlobal = true; } } } else { isGlobal = true; } } else { std::string selLower = sel; std::transform(selLower.begin(), selLower.end(), selLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (selLower == ":root" || selLower == "*" || selLower == "html" || selLower == "body" || selLower.compare(0, 5, ":root") == 0 || selLower.find("::") != std::string::npos) { isGlobal = true; } } if (isGlobal) { for (const auto &prop : rule.getDeclaration().getProperties()) { if (prop.getName().compare(0, 2, "--") == 0) { rootEnv[prop.getName()] = prop.getValue(); } } } if (isGlobal && sel[0] == '@' && !innerSel.empty()) { // rule.serialize() just wraps getSelector() in a single "{ }" // pair -- fine for a plain selector, but here getSelector() is // the FULL flattened "@media (...) :root" string, so that would // emit "@media (...) :root { decls }", which is invalid CSS (the // at-rule needs its own wrapping braces around the nested rule). // Reconstruct the proper nested form instead, the same way // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for // per-element @media matches. globalCss += atWrapper + " {\n " + innerSel + " {\n"; for (const auto &prop : rule.getDeclaration().getProperties()) { globalCss += " " + prop.getName() + ": " + prop.getValue() + ";\n"; } globalCss += " }\n}\n"; } else if (isGlobal) { globalCss += rule.serialize(true); globalCss += "\n"; } } extractGlobalCssFromStylesheet(stylesheet, globalCss, &rootEnv); if (!globalCss.empty()) { xmlRoot->SetAttribute("GlobalCss", globalCss.c_str()); Loading Loading @@ -2279,3 +2296,26 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, outGlobalJs = globalJs; return true; } bool blogi::htmlimport::convertCssToGlobalCss(const std::string &css, std::string &outGlobalCss, const std::string &baseUrl) { std::string cssText = css; // Same rationale as the <style>-block handling in convertHtmlToWidgetXml: // url(...) here is relative to wherever this stylesheet came from (the // fetched URL, if any) -- resolve it before parsing so the CSSRule // declarations blogi ends up storing are already absolute. if (!baseUrl.empty()) { cssText = rewriteCssUrls(cssText, [&](const std::string &ref) { return resolveUrl(baseUrl, ref); }); } libhtmlpp::CSSStyleSheet stylesheet; stylesheet.parse(cssText); std::string globalCss; extractGlobalCssFromStylesheet(stylesheet, globalCss, nullptr); outGlobalCss = globalCss; return true; } editor/src/htmlimport.h +17 −0 Original line number Diff line number Diff line Loading @@ -173,5 +173,22 @@ namespace htmlimport { ImportStats *outStats = nullptr, const std::string &baseUrl = ""); /** Convert a standalone CSS stylesheet (pasted/uploaded/fetched on its * own, not part of an HTML document) into global CSS text, using the * same :root/html/body/*-scoped rule extraction convertHtmlToWidgetXml * applies to a document's own <style>/<link> content (see * extractGlobalCssFromStylesheet in htmlimport.cpp). Non-global rules * (e.g. plain element/class selectors) are parsed but discarded -- * there is no widget tree here for them to attach to. If @p baseUrl is * non-empty (the CSS was fetched from a URL rather than pasted/ * uploaded), url(...) references are resolved against it first, same * as an external stylesheet's own url(...) in convertHtmlToWidgetXml. * Always returns true; malformed CSS simply yields fewer/no rules * rather than failing (same best-effort parsing convertHtmlToWidgetXml * relies on for a document's own <style> blocks). */ bool convertCssToGlobalCss(const std::string &css, std::string &outGlobalCss, const std::string &baseUrl = ""); } // namespace htmlimport } // namespace blogi Loading
editor/html/index.html +18 −0 Original line number Diff line number Diff line Loading @@ -42,6 +42,7 @@ <button id="btn-export-xml" data-i18n-title="I18N_TITLE_EXPORT_XML">⬆ <span data-i18n="I18N_EXPORT_XML">Export XML</span></button> <button id="btn-import-html" data-i18n-title="I18N_IMPORT_HTML">⬇ <span data-i18n="I18N_IMPORT_HTML">Import HTML</span></button> <button id="btn-export-html" data-i18n-title="I18N_EXPORT_HTML">⬆ <span data-i18n="I18N_EXPORT_HTML">Export HTML</span></button> <button id="btn-import-css" data-i18n-title="I18N_IMPORT_CSS">⬇ <span data-i18n="I18N_IMPORT_CSS">Import CSS</span></button> <button id="btn-survey-manager" title="Umfragen verwalten">📋 <span>Umfragen</span></button> </div> <div class="toolbar-group"> Loading Loading @@ -351,6 +352,23 @@ </div> </dialog> <dialog id="import-css-dialog"> <h3 data-i18n="I18N_IMPORT_CSS">CSS importieren</h3> <div class="set-field"> <input type="url" id="import-css-url" data-i18n-placeholder="I18N_IMPORT_CSS_URL_PLACEHOLDER" placeholder="https://beispiel.de/style.css"> <button type="button" id="btn-import-css-load-url" class="set-save-btn" data-i18n="I18N_IMPORT_CSS_LOAD_URL">Von URL laden</button> </div> <p id="import-css-url-status" class="set-status" style="display:none"></p> <p class="hint" style="text-align:center" data-i18n="I18N_IMPORT_HTML_OR">oder</p> <label class="file-upload-label">📂 <span data-i18n="I18N_UPLOAD_FILE">Datei wählen</span><input type="file" id="import-css-file" accept=".css,.txt" style="display:none"></label> <textarea id="import-css-content" rows="15" placeholder="CSS hier einfügen..."></textarea> <p class="hint" data-i18n="I18N_IMPORT_CSS_HINT">Nur global gültige Regeln (:root, html, body, *, ...) werden übernommen.</p> <div class="dialog-actions"> <button id="btn-import-css-confirm" data-i18n="I18N_IMPORT_CSS">Importieren</button> <button id="btn-import-css-cancel" data-i18n="I18N_CANCEL">Abbrechen</button> </div> </dialog> <dialog id="export-html-dialog"> <h3 data-i18n="I18N_EXPORT_HTML">HTML exportieren</h3> <textarea id="export-html-content" rows="15" readonly></textarea> Loading
editor/html/js/api.js +11 −0 Original line number Diff line number Diff line Loading @@ -558,6 +558,17 @@ var EditorApi = (function() { return request('GET', '/api/document/export-html'); }, // Imports a standalone stylesheet (pasted/uploaded text, or fetched // server-side from url) and merges its :root/html/body/*-scoped // rules into the document's page-level GlobalCss. Pass exactly one // of css/url. importCss: function(css, url) { var data = {}; if (url) data.url = url; else data.css = css; return request('POST', '/api/document/import-css', data); }, listSections: function() { return request('GET', '/api/document/sections'); }, Loading
editor/html/js/editor.js +85 −1 Original line number Diff line number Diff line Loading @@ -9,7 +9,7 @@ var currentDocId = ''; var currentDocGroup = ''; var currentDocSeo = {}; var currentDocGlobalCss = ''; // page-level CSS collected by HTML import, see doc-seo-dialog var currentDocGlobalCss = ''; // page-level CSS collected by HTML/CSS import, see doc-seo-dialog var currentDocGlobalJs = ''; // page-level JS collected from external <script src> during HTML import var currentDocPublishParams = {}; // target name -> last-used publish params for this template var currentDocLastPublishTarget = ''; // target name last successfully published to, see applyPreferredPublishTarget Loading Loading @@ -388,6 +388,10 @@ openImportHtmlDialog(); }); document.getElementById('btn-import-css').addEventListener('click', function() { openImportCssDialog(); }); document.getElementById('btn-export-html').addEventListener('click', function() { EditorApi.exportHtml().then(function(resp) { document.getElementById('export-html-content').value = resp.html || ''; Loading Loading @@ -551,6 +555,36 @@ el.className = 'set-status' + (isError ? ' error' : ''); } // --- CSS import dialog (paste/upload/URL) -- unlike HTML import there is // no widget tree/block picker: the result always merges straight into the // document's page-level GlobalCss (see handleImportCss), so both the // paste/upload and URL paths import directly and close the dialog. function openImportCssDialog() { document.getElementById('import-css-content').value = ''; document.getElementById('import-css-url').value = ''; setImportCssUrlStatus(''); document.getElementById('import-css-dialog').showModal(); } function setImportCssUrlStatus(text, isError) { var el = document.getElementById('import-css-url-status'); el.textContent = text || ''; el.style.display = text ? '' : 'none'; el.className = 'set-status' + (isError ? ' error' : ''); } function finishImportCss(promise) { return promise.then(function(resp) { document.getElementById('import-css-dialog').close(); refreshDocument(); if (!resp.global_css) { alert(I18n.t('I18N_IMPORT_CSS_NO_GLOBAL_RULES', 'Keine global gültigen Regeln (:root, html, body, *, ...) gefunden.')); } }); } // Fetches @p url server-side (via EditorApi.parseHtmlUrl) and feeds the // result into the same block-picker step used by paste/upload, so the // rest of the import flow (candidate tree, media-import options, confirm) Loading Loading @@ -1057,6 +1091,56 @@ document.getElementById('import-html-dialog').close(); }); // CSS Import dialog: import from URL (fetched server-side, then // merged into GlobalCss immediately -- no block picker step, unlike // HTML import's URL path). document.getElementById('btn-import-css-load-url').addEventListener('click', function() { var url = document.getElementById('import-css-url').value.trim(); if (!url) return; var btn = this; btn.disabled = true; setImportCssUrlStatus(I18n.t('I18N_LOADING_URL', 'URL wird geladen...')); finishImportCss(EditorApi.importCss(null, url)).then(function() { btn.disabled = false; setImportCssUrlStatus(''); }).catch(function(err) { btn.disabled = false; setImportCssUrlStatus(I18n.t('I18N_IMPORT_FAILED') + ': ' + (err.error || ''), true); }); }); document.getElementById('import-css-url').addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); document.getElementById('btn-import-css-load-url').click(); } }); document.getElementById('import-css-file').addEventListener('change', function() { var file = this.files[0]; this.value = ''; if (!file) return; var reader = new FileReader(); reader.onload = function(e) { var css = e.target.result; if (!css.trim()) return; document.getElementById('import-css-content').value = css; }; reader.readAsText(file); }); document.getElementById('btn-import-css-confirm').addEventListener('click', function() { var css = document.getElementById('import-css-content').value; if (!css.trim()) return; finishImportCss(EditorApi.importCss(css, null)).catch(function(err) { alert(I18n.t('I18N_IMPORT_FAILED') + ': ' + (err.error || '')); }); }); document.getElementById('btn-import-css-cancel').addEventListener('click', function() { document.getElementById('import-css-dialog').close(); }); // HTML Export dialog document.getElementById('btn-export-html-copy').addEventListener('click', function() { var textarea = document.getElementById('export-html-content'); Loading
editor/src/htmlimport.cpp +120 −80 Original line number Diff line number Diff line Loading @@ -1904,6 +1904,102 @@ libhtmlpp::Element *resolveImportPath(libhtmlpp::Element *root, const std::strin return cur; } // Scans every rule in @p sheet and appends the serialized text of any rule // scoped to :root/html/body/*/a pseudo-element (see the isGlobal checks // below) to @p outGlobalCss -- used both for convertHtmlToWidgetXml's // GlobalCss/rootEnv extraction and convertCssToGlobalCss's standalone-CSS // import, so the two stay consistent about what counts as "global" CSS. // If @p outRootEnv is non-null, any "--name" custom-property declaration // found on a global rule is also recorded there (see // resolveCustomProperties for how this seeds per-element inheritance) -- // callers that have no widget tree to seed (e.g. convertCssToGlobalCss) // pass nullptr and skip that bookkeeping. void extractGlobalCssFromStylesheet(const libhtmlpp::CSSStyleSheet &sheet, std::string &outGlobalCss, std::map<std::string,std::string> *outRootEnv) { for (const auto &rule : sheet.getRules()) { const std::string &sel = rule.getSelector(); if (sel.empty()) continue; bool isGlobal = false; // Only meaningful (and only set) when sel[0] == '@': the at-rule's // own "@media (...)"/"@supports (...)" wrapper and the selector // nested inside it. libhtmlpp::CSSStyleSheet flattens e.g. // "@media (max-width:768px) { :root { ... } }" into ONE CSSRule // whose getSelector() is the whole "@media (max-width:768px) :root" // string -- see the reconstruction below for why that matters. std::string atWrapper, innerSel; if (sel[0] == '@') { size_t parenDepth = 0; size_t splitPos = std::string::npos; for (size_t i = 0; i < sel.size(); ++i) { if (sel[i] == '(') ++parenDepth; else if (sel[i] == ')') { if (parenDepth > 0) --parenDepth; if (parenDepth == 0) { splitPos = i + 1; break; } } } if (splitPos != std::string::npos && splitPos + 1 < sel.size()) { size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos); if (innerStart != std::string::npos) { atWrapper = sel.substr(0, splitPos); innerSel = sel.substr(innerStart); std::string innerLower = innerSel; std::transform(innerLower.begin(), innerLower.end(), innerLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (innerLower.compare(0, 5, ":root") == 0 || innerLower.find("::") != std::string::npos || innerLower == "*" || innerLower == "html" || innerLower == "body") { isGlobal = true; } } } else { isGlobal = true; } } else { std::string selLower = sel; std::transform(selLower.begin(), selLower.end(), selLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (selLower == ":root" || selLower == "*" || selLower == "html" || selLower == "body" || selLower.compare(0, 5, ":root") == 0 || selLower.find("::") != std::string::npos) { isGlobal = true; } } if (isGlobal && outRootEnv) { for (const auto &prop : rule.getDeclaration().getProperties()) { if (prop.getName().compare(0, 2, "--") == 0) { (*outRootEnv)[prop.getName()] = prop.getValue(); } } } if (isGlobal && sel[0] == '@' && !innerSel.empty()) { // rule.serialize() just wraps getSelector() in a single "{ }" // pair -- fine for a plain selector, but here getSelector() is // the FULL flattened "@media (...) :root" string, so that would // emit "@media (...) :root { decls }", which is invalid CSS (the // at-rule needs its own wrapping braces around the nested rule). // Reconstruct the proper nested form instead, the same way // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for // per-element @media matches. outGlobalCss += atWrapper + " {\n " + innerSel + " {\n"; for (const auto &prop : rule.getDeclaration().getProperties()) { outGlobalCss += " " + prop.getName() + ": " + prop.getValue() + ";\n"; } outGlobalCss += " }\n}\n"; } else if (isGlobal) { outGlobalCss += rule.serialize(true); outGlobalCss += "\n"; } } } } // namespace std::vector<blogi::htmlimport::ImportNode> Loading Loading @@ -2165,86 +2261,7 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, // document-order rule for the same name simply overwrites an earlier // one, same "last one wins" approximation used elsewhere here. std::map<std::string,std::string> rootEnv; for (const auto &rule : stylesheet.getRules()) { const std::string &sel = rule.getSelector(); if (sel.empty()) continue; bool isGlobal = false; // Only meaningful (and only set) when sel[0] == '@': the at-rule's // own "@media (...)"/"@supports (...)" wrapper and the selector // nested inside it. libhtmlpp::CSSStyleSheet flattens e.g. // "@media (max-width:768px) { :root { ... } }" into ONE CSSRule // whose getSelector() is the whole "@media (max-width:768px) :root" // string -- see the reconstruction below for why that matters. std::string atWrapper, innerSel; if (sel[0] == '@') { size_t parenDepth = 0; size_t splitPos = std::string::npos; for (size_t i = 0; i < sel.size(); ++i) { if (sel[i] == '(') ++parenDepth; else if (sel[i] == ')') { if (parenDepth > 0) --parenDepth; if (parenDepth == 0) { splitPos = i + 1; break; } } } if (splitPos != std::string::npos && splitPos + 1 < sel.size()) { size_t innerStart = sel.find_first_not_of(" \t\n\r", splitPos); if (innerStart != std::string::npos) { atWrapper = sel.substr(0, splitPos); innerSel = sel.substr(innerStart); std::string innerLower = innerSel; std::transform(innerLower.begin(), innerLower.end(), innerLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (innerLower.compare(0, 5, ":root") == 0 || innerLower.find("::") != std::string::npos || innerLower == "*" || innerLower == "html" || innerLower == "body") { isGlobal = true; } } } else { isGlobal = true; } } else { std::string selLower = sel; std::transform(selLower.begin(), selLower.end(), selLower.begin(), [](unsigned char c) { return std::tolower(c); }); if (selLower == ":root" || selLower == "*" || selLower == "html" || selLower == "body" || selLower.compare(0, 5, ":root") == 0 || selLower.find("::") != std::string::npos) { isGlobal = true; } } if (isGlobal) { for (const auto &prop : rule.getDeclaration().getProperties()) { if (prop.getName().compare(0, 2, "--") == 0) { rootEnv[prop.getName()] = prop.getValue(); } } } if (isGlobal && sel[0] == '@' && !innerSel.empty()) { // rule.serialize() just wraps getSelector() in a single "{ }" // pair -- fine for a plain selector, but here getSelector() is // the FULL flattened "@media (...) :root" string, so that would // emit "@media (...) :root { decls }", which is invalid CSS (the // at-rule needs its own wrapping braces around the nested rule). // Reconstruct the proper nested form instead, the same way // libhtmlpp::CSSStyleSheet::collectApproximateMatches does for // per-element @media matches. globalCss += atWrapper + " {\n " + innerSel + " {\n"; for (const auto &prop : rule.getDeclaration().getProperties()) { globalCss += " " + prop.getName() + ": " + prop.getValue() + ";\n"; } globalCss += " }\n}\n"; } else if (isGlobal) { globalCss += rule.serialize(true); globalCss += "\n"; } } extractGlobalCssFromStylesheet(stylesheet, globalCss, &rootEnv); if (!globalCss.empty()) { xmlRoot->SetAttribute("GlobalCss", globalCss.c_str()); Loading Loading @@ -2279,3 +2296,26 @@ bool blogi::htmlimport::convertHtmlToWidgetXml(const std::string &html, outGlobalJs = globalJs; return true; } bool blogi::htmlimport::convertCssToGlobalCss(const std::string &css, std::string &outGlobalCss, const std::string &baseUrl) { std::string cssText = css; // Same rationale as the <style>-block handling in convertHtmlToWidgetXml: // url(...) here is relative to wherever this stylesheet came from (the // fetched URL, if any) -- resolve it before parsing so the CSSRule // declarations blogi ends up storing are already absolute. if (!baseUrl.empty()) { cssText = rewriteCssUrls(cssText, [&](const std::string &ref) { return resolveUrl(baseUrl, ref); }); } libhtmlpp::CSSStyleSheet stylesheet; stylesheet.parse(cssText); std::string globalCss; extractGlobalCssFromStylesheet(stylesheet, globalCss, nullptr); outGlobalCss = globalCss; return true; }
editor/src/htmlimport.h +17 −0 Original line number Diff line number Diff line Loading @@ -173,5 +173,22 @@ namespace htmlimport { ImportStats *outStats = nullptr, const std::string &baseUrl = ""); /** Convert a standalone CSS stylesheet (pasted/uploaded/fetched on its * own, not part of an HTML document) into global CSS text, using the * same :root/html/body/*-scoped rule extraction convertHtmlToWidgetXml * applies to a document's own <style>/<link> content (see * extractGlobalCssFromStylesheet in htmlimport.cpp). Non-global rules * (e.g. plain element/class selectors) are parsed but discarded -- * there is no widget tree here for them to attach to. If @p baseUrl is * non-empty (the CSS was fetched from a URL rather than pasted/ * uploaded), url(...) references are resolved against it first, same * as an external stylesheet's own url(...) in convertHtmlToWidgetXml. * Always returns true; malformed CSS simply yields fewer/no rules * rather than failing (same best-effort parsing convertHtmlToWidgetXml * relies on for a document's own <style> blocks). */ bool convertCssToGlobalCss(const std::string &css, std::string &outGlobalCss, const std::string &baseUrl = ""); } // namespace htmlimport } // namespace blogi