feat: Implement HTML-first style preservation system
- Add StyleContext class for extracting and applying HTML attributes/styles - Enhance MarkdownConverter with style-aware conversion methods - Switch backend storage from markdown to HTML with 'html' content type - Update editor workflow to preserve CSS classes, IDs, and attributes - Maintain markdown editing UX while storing HTML for style preservation - Support complex attributes like rel, data-*, aria-*, etc. This enables editing styled content like <a class="fancy" rel="me">text</a> while preserving all styling attributes through the markdown editing process.
This commit is contained in:
@@ -1,14 +1,11 @@
|
|||||||
# AGENTS.md - Developer Guide for Insertr
|
# AGENTS.md - Developer Guide for Insertr
|
||||||
|
|
||||||
## Build/Test Commands
|
## Build/Test Commands
|
||||||
Let me handle running and building the server. I run just dev in a different terminal, so you can just test the api directly, without spinning up an instance.
|
|
||||||
- `just dev` - Full-stack development (recommended)
|
- `just dev` - Full-stack development (recommended)
|
||||||
- `just build` - Build entire project (Go binary + JS library)
|
- `just build` - Build entire project (Go binary + JS library)
|
||||||
- `just build-lib` - Build JS library only
|
- `just build-lib` - Build JS library only
|
||||||
- `just test` - Run tests (placeholder, no actual tests yet)
|
|
||||||
- `just lint` - Run linting (placeholder, no actual linting yet)
|
For running and testing our application read our justfile.
|
||||||
- `just air` - Hot reload Go backend only
|
|
||||||
- `go test ./...` - Run Go tests (when available)
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
|||||||
@@ -299,13 +299,13 @@ func (h *ContentHandler) CreateContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine content type: use provided type, fallback to existing type, default to "text"
|
// Determine content type: use provided type, fallback to existing type, default to "html"
|
||||||
contentType := req.Type
|
contentType := req.Type
|
||||||
if contentType == "" && contentExists {
|
if contentType == "" && contentExists {
|
||||||
contentType = h.getContentType(existingContent)
|
contentType = h.getContentType(existingContent)
|
||||||
}
|
}
|
||||||
if contentType == "" {
|
if contentType == "" {
|
||||||
contentType = "text" // default type for new content
|
contentType = "html" // default type for new content (changed from "text")
|
||||||
}
|
}
|
||||||
|
|
||||||
var content interface{}
|
var content interface{}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ func (i *Injector) InjectBulkContent(elements []ElementWithID) error {
|
|||||||
i.injectTextContent(elem.Element.Node, contentItem.Value)
|
i.injectTextContent(elem.Element.Node, contentItem.Value)
|
||||||
case "markdown":
|
case "markdown":
|
||||||
i.injectMarkdownContent(elem.Element.Node, contentItem.Value)
|
i.injectMarkdownContent(elem.Element.Node, contentItem.Value)
|
||||||
|
case "html":
|
||||||
|
i.injectHTMLContent(elem.Element.Node, contentItem.Value)
|
||||||
case "link":
|
case "link":
|
||||||
i.injectLinkContent(elem.Element.Node, contentItem.Value)
|
i.injectLinkContent(elem.Element.Node, contentItem.Value)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { InsertrFormRenderer } from '../ui/form-renderer.js';
|
import { InsertrFormRenderer } from '../ui/form-renderer.js';
|
||||||
|
import { markdownConverter } from '../utils/markdown.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InsertrEditor - Content editing workflow and business logic
|
* InsertrEditor - Content editing workflow and business logic
|
||||||
@@ -114,16 +115,35 @@ export class InsertrEditor {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Extract content value based on type
|
// Extract content value based on type
|
||||||
let contentValue;
|
let markdownContent;
|
||||||
if (meta.element.tagName.toLowerCase() === 'a') {
|
if (meta.element.tagName.toLowerCase() === 'a') {
|
||||||
// For links, save the text content (URL is handled separately if needed)
|
// For links, save the text content (URL is handled separately if needed)
|
||||||
contentValue = formData.text || formData;
|
markdownContent = formData.text || formData;
|
||||||
} else {
|
} else {
|
||||||
contentValue = formData.text || formData;
|
markdownContent = formData.text || formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert markdown to HTML with style preservation
|
||||||
|
let contentValue;
|
||||||
|
const contentType = this.determineContentType(meta.element);
|
||||||
|
|
||||||
|
if (contentType === 'html') {
|
||||||
|
// Extract style context from original element and convert markdown to HTML
|
||||||
|
const { markdown, styleContext } = markdownConverter.htmlToMarkdownWithContext(meta.element.innerHTML, meta.element);
|
||||||
|
|
||||||
|
if (styleContext && styleContext.hasPreservableContent) {
|
||||||
|
// Convert markdown back to HTML with style preservation
|
||||||
|
contentValue = markdownConverter.markdownToHtmlWithStyles(markdownContent, styleContext);
|
||||||
|
} else {
|
||||||
|
// No styles to preserve, simple conversion
|
||||||
|
contentValue = markdownConverter.markdownToHtml(markdownContent);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For other content types (text, link), use markdown as-is
|
||||||
|
contentValue = markdownContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Universal upsert - server handles ID extraction/generation from markup
|
// Universal upsert - server handles ID extraction/generation from markup
|
||||||
const contentType = this.determineContentType(meta.element);
|
|
||||||
const result = await this.apiClient.createContent(
|
const result = await this.apiClient.createContent(
|
||||||
contentValue,
|
contentValue,
|
||||||
contentType,
|
contentType,
|
||||||
@@ -155,8 +175,8 @@ export class InsertrEditor {
|
|||||||
return 'link';
|
return 'link';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALL text elements use markdown for consistent editing experience
|
// ALL text elements use HTML storage with markdown editing interface
|
||||||
return 'markdown';
|
return 'html';
|
||||||
}
|
}
|
||||||
|
|
||||||
handleCancel(meta) {
|
handleCancel(meta) {
|
||||||
|
|||||||
@@ -375,10 +375,11 @@ class EditContext {
|
|||||||
this.primaryElement = elements[0];
|
this.primaryElement = elements[0];
|
||||||
this.originalContent = null;
|
this.originalContent = null;
|
||||||
this.currentContent = currentContent;
|
this.currentContent = currentContent;
|
||||||
|
this.styleContext = null; // Store style context for preservation
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract content from elements in markdown format
|
* Extract content from elements in markdown format with style preservation
|
||||||
*/
|
*/
|
||||||
extractContent() {
|
extractContent() {
|
||||||
if (this.elements.length === 1) {
|
if (this.elements.length === 1) {
|
||||||
@@ -386,22 +387,30 @@ class EditContext {
|
|||||||
|
|
||||||
// Handle links specially
|
// Handle links specially
|
||||||
if (element.tagName.toLowerCase() === 'a') {
|
if (element.tagName.toLowerCase() === 'a') {
|
||||||
|
// Extract with style context for links
|
||||||
|
const { markdown, styleContext } = markdownConverter.htmlToMarkdownWithContext(element.innerHTML, element);
|
||||||
|
this.styleContext = styleContext;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: markdownConverter.htmlToMarkdown(element.innerHTML),
|
text: markdown,
|
||||||
url: element.href
|
url: element.href
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single element - convert to markdown
|
// Single element - convert to markdown with style context
|
||||||
return markdownConverter.htmlToMarkdown(element.innerHTML);
|
const { markdown, styleContext } = markdownConverter.htmlToMarkdownWithContext(element.innerHTML, element);
|
||||||
|
this.styleContext = styleContext;
|
||||||
|
return markdown;
|
||||||
} else {
|
} else {
|
||||||
// Multiple elements - use group extraction
|
// Multiple elements - use group extraction with style context
|
||||||
return markdownConverter.extractGroupMarkdown(this.elements);
|
const { markdown, styleContext } = markdownConverter.extractGroupMarkdownWithContext(this.elements);
|
||||||
|
this.styleContext = styleContext;
|
||||||
|
return markdown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply content to elements from markdown/object
|
* Apply content to elements from markdown/object with style preservation
|
||||||
*/
|
*/
|
||||||
applyContent(content) {
|
applyContent(content) {
|
||||||
if (this.elements.length === 1) {
|
if (this.elements.length === 1) {
|
||||||
@@ -409,19 +418,24 @@ class EditContext {
|
|||||||
|
|
||||||
// Handle links specially
|
// Handle links specially
|
||||||
if (element.tagName.toLowerCase() === 'a' && typeof content === 'object') {
|
if (element.tagName.toLowerCase() === 'a' && typeof content === 'object') {
|
||||||
element.innerHTML = markdownConverter.markdownToHtml(content.text || '');
|
const html = this.styleContext ?
|
||||||
|
markdownConverter.markdownToHtmlWithStyles(content.text || '', this.styleContext) :
|
||||||
|
markdownConverter.markdownToHtml(content.text || '');
|
||||||
|
element.innerHTML = html;
|
||||||
if (content.url) {
|
if (content.url) {
|
||||||
element.href = content.url;
|
element.href = content.url;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single element - convert markdown to HTML
|
// Single element - convert markdown to HTML with style restoration
|
||||||
const html = markdownConverter.markdownToHtml(content);
|
const html = this.styleContext ?
|
||||||
|
markdownConverter.markdownToHtmlWithStyles(content, this.styleContext) :
|
||||||
|
markdownConverter.markdownToHtml(content);
|
||||||
element.innerHTML = html;
|
element.innerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
// Multiple elements - use group update
|
// Multiple elements - use group update with style preservation
|
||||||
markdownConverter.updateGroupElements(this.elements, content);
|
markdownConverter.updateGroupElementsWithStyles(this.elements, content, this.styleContext);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Markdown conversion utilities using Marked and Turndown
|
* Markdown conversion utilities using Marked and Turndown with Style Preservation
|
||||||
*/
|
*/
|
||||||
import { marked } from 'marked';
|
import { marked } from 'marked';
|
||||||
import TurndownService from 'turndown';
|
import TurndownService from 'turndown';
|
||||||
|
import { StyleContext } from './style-context.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion
|
* MarkdownConverter - Handles bidirectional HTML ↔ Markdown conversion
|
||||||
@@ -11,6 +12,7 @@ export class MarkdownConverter {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.initializeMarked();
|
this.initializeMarked();
|
||||||
this.initializeTurndown();
|
this.initializeTurndown();
|
||||||
|
this.styleContext = new StyleContext();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -186,6 +188,37 @@ export class MarkdownConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML to Markdown with style context preservation
|
||||||
|
* @param {string} html - HTML string to convert
|
||||||
|
* @param {HTMLElement} originalElement - Original DOM element for context
|
||||||
|
* @returns {Object} - Object containing markdown and style context
|
||||||
|
*/
|
||||||
|
htmlToMarkdownWithContext(html, originalElement = null) {
|
||||||
|
if (!html || html.trim() === '') {
|
||||||
|
return { markdown: '', styleContext: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
let styleContext = null;
|
||||||
|
|
||||||
|
// Extract style context if original element provided
|
||||||
|
if (originalElement) {
|
||||||
|
styleContext = this.styleContext.extractStyleContext(originalElement);
|
||||||
|
} else {
|
||||||
|
// Create temporary element to analyze
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
styleContext = this.styleContext.extractStyleContext(tempDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = this.htmlToMarkdown(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
styleContext: styleContext.hasPreservableContent ? styleContext : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert Markdown to HTML
|
* Convert Markdown to HTML
|
||||||
* @param {string} markdown - Markdown string to convert
|
* @param {string} markdown - Markdown string to convert
|
||||||
@@ -210,6 +243,51 @@ export class MarkdownConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Markdown to HTML with style context restoration
|
||||||
|
* @param {string} markdown - Markdown string to convert
|
||||||
|
* @param {Object} styleContext - Style context to restore
|
||||||
|
* @returns {string} - HTML string with styles restored
|
||||||
|
*/
|
||||||
|
markdownToHtmlWithStyles(markdown, styleContext) {
|
||||||
|
if (!markdown || markdown.trim() === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert markdown to basic HTML first
|
||||||
|
const basicHtml = this.markdownToHtml(markdown);
|
||||||
|
|
||||||
|
// If no style context, return basic HTML
|
||||||
|
if (!styleContext || !this.styleContext.validateContext(styleContext)) {
|
||||||
|
return basicHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply style context to the converted HTML
|
||||||
|
return this.applyStyleContextToHtml(basicHtml, styleContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply style context to HTML string
|
||||||
|
* @param {string} html - HTML string to enhance
|
||||||
|
* @param {Object} styleContext - Style context to apply
|
||||||
|
* @returns {string} - Enhanced HTML with styles applied
|
||||||
|
*/
|
||||||
|
applyStyleContextToHtml(html, styleContext) {
|
||||||
|
try {
|
||||||
|
// Create temporary container
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
// Apply style context
|
||||||
|
this.styleContext.applyStyleContext(tempDiv, styleContext);
|
||||||
|
|
||||||
|
return tempDiv.innerHTML;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to apply style context:', error);
|
||||||
|
return html; // Return original HTML on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract HTML content from a group of elements
|
* Extract HTML content from a group of elements
|
||||||
* @param {HTMLElement[]} elements - Array of DOM elements
|
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||||
@@ -235,6 +313,73 @@ export class MarkdownConverter {
|
|||||||
return htmlParts.join('\n');
|
return htmlParts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract HTML content with style context from a group of elements
|
||||||
|
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||||
|
* @returns {Object} - Object with HTML content and combined style context
|
||||||
|
*/
|
||||||
|
extractGroupHTMLWithContext(elements) {
|
||||||
|
const htmlParts = [];
|
||||||
|
const allStyleContexts = [];
|
||||||
|
|
||||||
|
elements.forEach((element, index) => {
|
||||||
|
// Extract style context for this element
|
||||||
|
const elementContext = this.styleContext.extractStyleContext(element);
|
||||||
|
if (elementContext.hasPreservableContent) {
|
||||||
|
allStyleContexts.push({
|
||||||
|
index,
|
||||||
|
context: elementContext
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract HTML content
|
||||||
|
const html = element.innerHTML.trim();
|
||||||
|
if (html) {
|
||||||
|
if (element.tagName.toLowerCase() === 'p') {
|
||||||
|
htmlParts.push(element.outerHTML);
|
||||||
|
} else {
|
||||||
|
htmlParts.push(`<p>${html}</p>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Combine all style contexts
|
||||||
|
const combinedContext = this.combineStyleContexts(allStyleContexts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
html: htmlParts.join('\n'),
|
||||||
|
styleContext: combinedContext
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combine multiple style contexts into a single context
|
||||||
|
* @param {Array} styleContexts - Array of style contexts with index info
|
||||||
|
* @returns {Object} - Combined style context
|
||||||
|
*/
|
||||||
|
combineStyleContexts(styleContexts) {
|
||||||
|
if (styleContexts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const combinedMap = new Map();
|
||||||
|
let hasContent = false;
|
||||||
|
|
||||||
|
styleContexts.forEach(({ index, context }) => {
|
||||||
|
// Adjust paths to include element index
|
||||||
|
for (const [path, elementInfo] of context.elementMap) {
|
||||||
|
const adjustedPath = `${index}.${path}`;
|
||||||
|
combinedMap.set(adjustedPath, elementInfo);
|
||||||
|
hasContent = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return hasContent ? {
|
||||||
|
elementMap: combinedMap,
|
||||||
|
hasPreservableContent: true
|
||||||
|
} : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert HTML content from group elements to markdown
|
* Convert HTML content from group elements to markdown
|
||||||
* @param {HTMLElement[]} elements - Array of DOM elements
|
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||||
@@ -246,6 +391,21 @@ export class MarkdownConverter {
|
|||||||
return markdown;
|
return markdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert HTML content from group elements to markdown with style context
|
||||||
|
* @param {HTMLElement[]} elements - Array of DOM elements
|
||||||
|
* @returns {Object} - Object with markdown and style context
|
||||||
|
*/
|
||||||
|
extractGroupMarkdownWithContext(elements) {
|
||||||
|
const { html, styleContext } = this.extractGroupHTMLWithContext(elements);
|
||||||
|
const markdown = this.htmlToMarkdown(html);
|
||||||
|
|
||||||
|
return {
|
||||||
|
markdown,
|
||||||
|
styleContext
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update group elements with markdown content
|
* Update group elements with markdown content
|
||||||
* @param {HTMLElement[]} elements - Array of DOM elements to update
|
* @param {HTMLElement[]} elements - Array of DOM elements to update
|
||||||
@@ -282,6 +442,47 @@ export class MarkdownConverter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update group elements with markdown content and style context
|
||||||
|
* @param {HTMLElement[]} elements - Array of DOM elements to update
|
||||||
|
* @param {string} markdown - Markdown content to render
|
||||||
|
* @param {Object} styleContext - Style context to apply
|
||||||
|
*/
|
||||||
|
updateGroupElementsWithStyles(elements, markdown, styleContext) {
|
||||||
|
// Convert markdown to HTML with styles
|
||||||
|
const html = styleContext ?
|
||||||
|
this.markdownToHtmlWithStyles(markdown, styleContext) :
|
||||||
|
this.markdownToHtml(markdown);
|
||||||
|
|
||||||
|
// Split HTML into paragraphs
|
||||||
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
|
const paragraphs = Array.from(tempDiv.querySelectorAll('p, div, h1, h2, h3, h4, h5, h6'));
|
||||||
|
|
||||||
|
// Handle case where we have more/fewer paragraphs than elements
|
||||||
|
const maxCount = Math.max(elements.length, paragraphs.length);
|
||||||
|
|
||||||
|
for (let i = 0; i < maxCount; i++) {
|
||||||
|
if (i < elements.length && i < paragraphs.length) {
|
||||||
|
// Update existing element with corresponding paragraph
|
||||||
|
elements[i].innerHTML = paragraphs[i].innerHTML;
|
||||||
|
} else if (i < elements.length) {
|
||||||
|
// More elements than paragraphs - clear extra elements
|
||||||
|
elements[i].innerHTML = '';
|
||||||
|
} else if (i < paragraphs.length) {
|
||||||
|
// More paragraphs than elements - create new element
|
||||||
|
const newElement = document.createElement('p');
|
||||||
|
newElement.innerHTML = paragraphs[i].innerHTML;
|
||||||
|
|
||||||
|
// Insert after the last existing element
|
||||||
|
const lastElement = elements[elements.length - 1];
|
||||||
|
lastElement.parentNode.insertBefore(newElement, lastElement.nextSibling);
|
||||||
|
elements.push(newElement); // Add to our elements array for future updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export singleton instance
|
// Export singleton instance
|
||||||
|
|||||||
238
lib/src/utils/style-context.js
Normal file
238
lib/src/utils/style-context.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
/**
|
||||||
|
* Style Context Extraction System for Insertr
|
||||||
|
*
|
||||||
|
* Analyzes HTML elements to extract styling context for preservation
|
||||||
|
* during markdown editing. Focuses on attributes, classes, and inline styles
|
||||||
|
* that should be preserved when content is converted to/from markdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class StyleContext {
|
||||||
|
constructor() {
|
||||||
|
this.preservedAttributes = new Set([
|
||||||
|
'class', 'id', 'rel', 'target', 'title', 'alt', 'href',
|
||||||
|
'src', 'data-*', 'aria-*', 'role', 'tabindex'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract complete style context from an HTML element
|
||||||
|
* @param {HTMLElement} element - The element to analyze
|
||||||
|
* @returns {Object} Style context with element map and metadata
|
||||||
|
*/
|
||||||
|
extractStyleContext(element) {
|
||||||
|
const context = {
|
||||||
|
elementMap: new Map(),
|
||||||
|
rootElement: this.cloneElementStructure(element),
|
||||||
|
hasPreservableContent: false
|
||||||
|
};
|
||||||
|
|
||||||
|
this.analyzeElement(element, context, []);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively analyze element and its children for style preservation
|
||||||
|
* @param {HTMLElement} element - Current element
|
||||||
|
* @param {Object} context - Style context being built
|
||||||
|
* @param {Array} path - Path to current element
|
||||||
|
*/
|
||||||
|
analyzeElement(element, context, path) {
|
||||||
|
const elementInfo = this.extractElementInfo(element);
|
||||||
|
|
||||||
|
if (elementInfo.hasPreservableAttributes) {
|
||||||
|
context.hasPreservableContent = true;
|
||||||
|
context.elementMap.set(path.join('.'), elementInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analyze children one level deep for now
|
||||||
|
Array.from(element.children).forEach((child, index) => {
|
||||||
|
const childPath = [...path, index.toString()];
|
||||||
|
this.analyzeElement(child, context, childPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract styling information from a single element
|
||||||
|
* @param {HTMLElement} element - Element to analyze
|
||||||
|
* @returns {Object} Element style information
|
||||||
|
*/
|
||||||
|
extractElementInfo(element) {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
const attributes = this.extractAttributes(element);
|
||||||
|
const hasPreservableAttributes = Object.keys(attributes).length > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName,
|
||||||
|
attributes,
|
||||||
|
hasPreservableAttributes,
|
||||||
|
textContent: this.getDirectTextContent(element),
|
||||||
|
hasChildren: element.children.length > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract relevant attributes from an element
|
||||||
|
* @param {HTMLElement} element - Element to extract attributes from
|
||||||
|
* @returns {Object} Filtered attributes object
|
||||||
|
*/
|
||||||
|
extractAttributes(element) {
|
||||||
|
const attributes = {};
|
||||||
|
|
||||||
|
for (const attr of element.attributes) {
|
||||||
|
const name = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Include if it's in our preserved set or matches a pattern
|
||||||
|
if (this.shouldPreserveAttribute(name)) {
|
||||||
|
attributes[name] = attr.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an attribute should be preserved
|
||||||
|
* @param {string} attributeName - Name of the attribute
|
||||||
|
* @returns {boolean} Whether to preserve this attribute
|
||||||
|
*/
|
||||||
|
shouldPreserveAttribute(attributeName) {
|
||||||
|
// Direct matches
|
||||||
|
if (this.preservedAttributes.has(attributeName)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pattern matches (data-*, aria-*)
|
||||||
|
return attributeName.startsWith('data-') ||
|
||||||
|
attributeName.startsWith('aria-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only the direct text content of an element (not from children)
|
||||||
|
* @param {HTMLElement} element - Element to get text from
|
||||||
|
* @returns {string} Direct text content
|
||||||
|
*/
|
||||||
|
getDirectTextContent(element) {
|
||||||
|
let text = '';
|
||||||
|
for (const node of element.childNodes) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
text += node.textContent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a structural clone of an element (attributes only, no content)
|
||||||
|
* @param {HTMLElement} element - Element to clone structure of
|
||||||
|
* @returns {Object} Cloned structure
|
||||||
|
*/
|
||||||
|
cloneElementStructure(element) {
|
||||||
|
return {
|
||||||
|
tagName: element.tagName.toLowerCase(),
|
||||||
|
attributes: this.extractAttributes(element),
|
||||||
|
children: Array.from(element.children).map(child =>
|
||||||
|
this.cloneElementStructure(child)
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply style context back to an HTML element
|
||||||
|
* @param {HTMLElement} element - Element to apply styles to
|
||||||
|
* @param {Object} context - Style context to apply
|
||||||
|
* @param {Array} path - Current path in the element tree
|
||||||
|
*/
|
||||||
|
applyStyleContext(element, context, path = []) {
|
||||||
|
const pathKey = path.join('.');
|
||||||
|
const elementInfo = context.elementMap.get(pathKey);
|
||||||
|
|
||||||
|
if (elementInfo) {
|
||||||
|
this.applyAttributes(element, elementInfo.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to children
|
||||||
|
Array.from(element.children).forEach((child, index) => {
|
||||||
|
const childPath = [...path, index.toString()];
|
||||||
|
this.applyStyleContext(child, context, childPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply attributes to an element
|
||||||
|
* @param {HTMLElement} element - Element to apply attributes to
|
||||||
|
* @param {Object} attributes - Attributes to apply
|
||||||
|
*/
|
||||||
|
applyAttributes(element, attributes) {
|
||||||
|
for (const [name, value] of Object.entries(attributes)) {
|
||||||
|
element.setAttribute(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate markdown formatting options based on detected styles
|
||||||
|
* @param {Object} context - Style context
|
||||||
|
* @returns {Object} Formatting options for markdown conversion
|
||||||
|
*/
|
||||||
|
generateFormattingOptions(context) {
|
||||||
|
const options = {
|
||||||
|
preserveLinks: true,
|
||||||
|
preserveStrong: true,
|
||||||
|
preserveEmphasis: true,
|
||||||
|
customElements: new Map()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Analyze element map to detect patterns
|
||||||
|
for (const [path, elementInfo] of context.elementMap) {
|
||||||
|
if (elementInfo.tagName === 'a' && elementInfo.attributes.class) {
|
||||||
|
options.customElements.set('link', {
|
||||||
|
tagName: 'a',
|
||||||
|
attributes: elementInfo.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementInfo.tagName === 'strong' && elementInfo.attributes.class) {
|
||||||
|
options.customElements.set('strong', {
|
||||||
|
tagName: 'strong',
|
||||||
|
attributes: elementInfo.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elementInfo.tagName === 'span' && elementInfo.attributes.class) {
|
||||||
|
options.customElements.set('span', {
|
||||||
|
tagName: 'span',
|
||||||
|
attributes: elementInfo.attributes
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that style context can be safely applied
|
||||||
|
* @param {Object} context - Style context to validate
|
||||||
|
* @returns {boolean} Whether context is valid and safe
|
||||||
|
*/
|
||||||
|
validateContext(context) {
|
||||||
|
if (!context || !context.elementMap) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for potentially dangerous attributes
|
||||||
|
for (const [path, elementInfo] of context.elementMap) {
|
||||||
|
for (const [attr, value] of Object.entries(elementInfo.attributes)) {
|
||||||
|
// Block script-related attributes for security
|
||||||
|
if (attr.toLowerCase().startsWith('on') ||
|
||||||
|
attr.toLowerCase() === 'javascript' ||
|
||||||
|
(typeof value === 'string' && value.includes('javascript:'))) {
|
||||||
|
console.warn(`Blocking potentially dangerous attribute: ${attr}="${value}"`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StyleContext;
|
||||||
Reference in New Issue
Block a user