@Félix

Attached is the first version of leo to html plugin I wrote and a sample 
leo file I ran with.  

Overview of code:
  - plugin called “s_leo_to_html”.  I’m not sure best naming convention 
within leo plugin.   
  - the class “HTMLDataWrapper” reads the leo outline and creates a 
DataWrapper Pydantic object. DataWrapper includes options and pydantic 
nodes.  This is first time trying to write a leo plugin so I expect this 
class to be hacky.  Let me know if you have improvements.
  -  the class “GenerateHTMLFromNodesFactory” takes the  DataWrapper 
Pydantic object and uses Django to create the html.  I wrote a normal 
Django app to develop the templates and Pydantic models and then I pasted 
the templates and Pydantic models into this leo plugin.  The html can be 
changed by editing the template.  The node body is rendered with a markdown 
render.  I’m using Django 4.2.  I haven't tested this with errors in the 
html templates.  I believe this code is solid.   
  - I have 3 loggers setup – python standard out, leo, and file.  Search 
for the line “logLevel =” to set the debug level.  I currently have at 
DEBUG level.  The INFO level will produce less output.  I’m not sure how 
leo handles logging so it may not fit into the leo framework.  You can 
comment out all loggers or just handlers don’t want.  Now that I think 
about it, it seems the plugin method should use the leo method to write to 
log console.  Let me know if you suggests to better fit into the leo 
framework.    
  - The class “sTiming” has a timer to track how long plugin took and 
creates in a human readable format. 

I chose LEO ->Pydantic models -> html to help separate the processes.  I’m 
not sure if Django can be used dynamically like you are doing with 
generating raw html.  Also, with the Pydantic models approach, I could 
write different output formats.   I put all my html specific settings in 
the HTMLDataWrapper Pydantic model so I could swap in other output formats 
later.  .

In the html, I wanted cloned nodes reproduced.  I'm thinking of ways I can 
add 'go to clone' like feature in html.

Brian 

On Sunday, September 14, 2025 at 3:40:21 PM UTC-4 [email protected] wrote:

> That's a nice simple example of using recursion.  A point, not about the 
> recursion but about the HTML - I'd like to point out that the rendered HTML 
> won't show the indentation properly because all spaces will be collapsed 
> into a single space. So 
>
> f"\n{indent}        <pre>
>
> won't show as intended.  One way I've handled this is to use CSS to create 
> the indents. Each <ul> or <li> element, for example, could use relative 
> positioning with left padding.  You can also produce a left margin line by 
> specifying a left border for the elements. For example, in one project, I 
> used a CSS class "term" for my indented items, and the CSS to produce the 
> indent with margin line was
>
> .terms {border-left:1px lightgrey solid;}
> .terms {margin-left:0em; padding-left: 1.2em}
>
> On Sunday, September 14, 2025 at 1:22:09 PM UTC-4 Félix wrote:
>
>> Ah! jkn, *Very good question!!*
>>
>> That is because I'm not using positions and going from node to node 
>> sequentially (and having to keep track of level, and keeping track of level 
>> going back lower to *close tags* when finishing doing children) 
>>
>> I'm using the underlying vnodes, and *recursion*, thus simplifying 
>> everything as I know exactly the levels going up/down as its tied directly 
>> to entering/exiting the doChildren function. (as opposed to using 
>> 'c.all_nodes' for all vnodes in outline order, or 'c.all_positions' for the 
>> same but with positions) 
>>
>> If you dont use recursion, again, you have then to keep track of the 
>> current level you are outputting in order to know when to 'close' opened 
>> tags. 
>>
>> To illustrate this precise point, note that this canonical example about 
>> generators at 
>> https://leo-editor.github.io/leo-editor/tutorial-scripting.html#generators 
>> does NOT care about closing any tags, thus is totally ok with 
>> "c.all_positions"
>>
>>
>> for p in c.all_positions():
>>     print(' '*p.level()+p.h)
>>
>>
>> Please take a few minutes to study this construction and its output - 
>> compared to using 'Positions' (which do have their uses in other scenarios).
>> def doChildren(children, level=0):
>>     global output
>>     for child in children:
>>         indent = indentation * level
>>         output += f"\n{indent}    <li>"
>>         output += f"\n{indent}        <b>{child.headString()}</b>"
>>         # output += f"\n{indent}       
>>  <pre>\n{child.bodyString()}\n{indent}        </pre>"
>>         output += f"\n{indent}        <ul>"
>>         doChildren(child.children, level + 1)
>>         output += f"\n{indent}        </ul>"
>>         output += f"\n{indent}    </li>"
>>
>> output += "<ul>"
>> doChildren(c.hiddenRootNode.children)
>> output += "\n</ul>"
>> g.es(output)
>>
>> Félix
>> On Sunday, September 14, 2025 at 7:40:19 AM UTC-4 jkn wrote:
>>
>>> Out of interest ... I haven't seen the use of child.headstring() and 
>>> child.bodystring() before. Are these basically equivalent to p.h and p.b? 
>>> Is one form to be preferred?
>>>
>>>     Thanks, J^n
>>>
>>> On Thursday, September 11, 2025 at 4:00:39 AM UTC+1 Félix wrote:
>>>
>>>> @Edward @Thomas @jkn
>>>>
>>>> Ah! I'm so silly!  Here's a much better (and smaller) minimal example 
>>>> who's output can be saved in a file and dragged onto your browser,  using 
>>>> vnodes instead of positions. (uncomment that line for bodies too): 
>>>>
>>>>
>>>> output = ''
>>>> indentation = '        '
>>>> level = -1
>>>>
>>>> def doChildren(children, level=0):
>>>>     global output
>>>>     for child in children:
>>>>         indent = indentation * level
>>>>         output += f"\n{indent}    <li>"
>>>>         output += f"\n{indent}        <b>{child.headString()}</b>"
>>>>         # output += f"\n{indent}       
>>>>  <pre>\n{child.bodyString()}\n{indent}        </pre>"
>>>>         output += f"\n{indent}        <ul>"
>>>>         doChildren(child.children, level + 1)
>>>>         output += f"\n{indent}        </ul>"
>>>>         output += f"\n{indent}    </li>"
>>>>
>>>> output += "<ul>"
>>>> doChildren(c.hiddenRootNode.children)
>>>> output += "\n</ul>"
>>>> g.es(output)
>>>>
>>>>
>>>> @brian I havent had the chance to look at your source leo file... (I 
>>>> didnt notice it, and had only looked at your 'desired output file' oops !!)
>>>>
>>>> I now understand your  @tool-tip  nodes and what you were going for. 
>>>>
>>>> I'll have some more time in the weekend to make a script that really 
>>>> goes from your leo file example to your 'desired' output HTML file.
>>>>
>>>> Again - thanks for your interest in Leo - and sorry for that delay! 
>>>>
>>>> Félix
>>>> On Tuesday, September 9, 2025 at 1:29:48 PM UTC-4 Edward K. Ream wrote:
>>>>
>>>>> On Tue, Sep 9, 2025 at 11:37 AM brian <[email protected]> wrote:
>>>>>
>>>>>> @Félix
>>>>>>
>>>>>> I cannot see the suggestions by Edward about merging that 
>>>>>> 'all_root_children' into the commander's generators.  
>>>>>>
>>>>>
>>>>> The devel branch now contains the new code. I merged it a few hours 
>>>>> ago.
>>>>>
>>>>> Edward
>>>>>
>>>>

-- 
You received this message because you are subscribed to the Google Groups 
"leo-editor" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion visit 
https://groups.google.com/d/msgid/leo-editor/86d4a5f7-732e-4213-bcee-f2038a34347dn%40googlegroups.com.
#version 0.1.0

import leo.core.leoGlobals as g
from leo.core.leoPlugins import registerHandler

import re
import os
import logging
import time 
import datetime 

import django
from django.conf import settings
from django.template import Context
from django.template.engine import Engine

from markdown import markdown
from typing import List, Optional
from pydantic import BaseModel

# This must be a simple, top-level function.
def init():
	"""Register the plugin and a handler."""
	ok = True
	g.plugin_signon(__name__)
	registerHandler('after-create-leo-frame', onCreate)
	return ok

def onCreate(tag, keywords):
	"""
	Register the command with Leo after the frame is created.
	The 'c' parameter is the commander for the Leo window.
	"""
	c = keywords.get('c')
	if c:
		c.k.registerCommand('s_leo_to_html', s_leo_to_html_command)
		g.es('s_leo_to_html.py has been initialized.', color='blue')

def s_leo_to_html_command(event=None):
	"""
	leo to html plugin 
	"""
	#to run from mini buffer: s_leo_to_html
	
	logger = logging.getLogger("s_leo_to_html_command")
	logger.info('Starting')
	
	#start timer to measure how long took
	timer = sTiming()
	timer.start()
	
	#g.es("running s leo to html")

	#convert leo to nodes 
	factory = ConvertLeoToNodesFactory(event.c)
	dataWpr = factory.run() 

	## display nodes 
	#import json
	#pretty_json = json.dumps(
		#dataWpr.model_dump(), 
		#indent=2
	#)
	#g.es(pretty_json)

	#create html
	htmlFactory = GenerateHTMLFromNodesFactory()
	html = htmlFactory.run(dataWpr)
	#g.es(html)
	
	#stop timer and log how long took
	timeTook = timer.getTimeTakenStr()
	logger.info(f'Took {timeTook}')
	

#future: edit so retain id of leo node so can can trace back if problem
#future: add option to only create from selected node
#future: for logging to file, setup where can set location and setup to flush at different steps so can view log
#future: add @ignore-node to body where will not skip that node; think should skip all child; if do, how do keep up with node numbers that skip.   

###################
##setup logging 
###################
# set debugging level
logLevel = logging.DEBUG #logging.INFO logging.DEBUG 

# console logging 
console = logging.StreamHandler()
formatter = logging.Formatter('%(name)-17s %(funcName)-20s: %(levelname)-8s %(message)s')
console.setFormatter(formatter)
#console.setLevel(logging.DEBUG) #logging.INFO
logging.getLogger().addHandler(console)

# logging to leo
#  prints more to leo log file whan in debug mode than when in info mode
class LeoLogHandler(logging.Handler):
	"""A custom logging handler that sends messages to the Leo log pane."""
	def __init__(self, level=logging.NOTSET):
		super().__init__(level)

	def emit(self, record):
		"""Emits a formatted log record to the Leo log pane."""
		msg = self.format(record)
		g.es(msg)

class ConditionalLeoFormatter(logging.Formatter):
	"""
	A custom formatter that uses different format strings based on log level.
	"""
	def __init__(self, debug_format, info_format):
		super().__init__()
		self.debug_formatter = logging.Formatter(debug_format)
		self.info_formatter = logging.Formatter(info_format)

	def format(self, record):
		if record.levelno == logging.DEBUG:
			return self.debug_formatter.format(record)
		return self.info_formatter.format(record)

leo_handler = LeoLogHandler()
debug_fmt = 'Export to HTML: %(name)-17s %(funcName)-20s: %(levelname)-8s %(message)s'
info_fmt = 'Export to HTML: %(message)s'
conditional_formatter = ConditionalLeoFormatter(debug_fmt, info_fmt)
leo_handler.setFormatter(conditional_formatter)
logging.getLogger().addHandler(leo_handler)

# log to file in same dir as leo file
fileHandler = logging.handlers.RotatingFileHandler('export_html_leo.txt', mode='w')
formatter = logging.Formatter('%(asctime)s   %(name)-17s %(funcName)-20s %(levelname)-8s %(message)s')
fileHandler.setFormatter(formatter)
logging.getLogger().addHandler(fileHandler)

#logging level
# only setup logging for this plugin since otherwise get other packages also logging which makes noise
logging.getLogger("ConvertLeoToNodesFactory").setLevel(logLevel)
logging.getLogger("GenerateHTMLFromNodesFactory").setLevel(logLevel)
logging.getLogger("s_leo_to_html_command").setLevel(logLevel)

###################
##setup timer 
###################
class sTiming(object):
	"""
	timing class to measure how long python execution took
	
	start timer with start()
	to end: either call end() or getTimeTakenStr
	  getTimeTakenStr stops timer if running 

	"""

	def __init__(self,):
		"""
		init
		"""

		self.timerStart = None 
		self.timerEnd   = None 

	def start(self, ):
		"""
		starts the timer 
		"""

		now = datetime.datetime.now()
		self.timerStart = now
		self.timerEnd = None 

	def end(self, ):
		"""
		ends timer

		returns str for time taken
		"""

		now = datetime.datetime.now()
		self.timerEnd = now

		timerStr = self.getTimeTakenStr()
		return( timerStr )

	def getTimeTakenStr(self, bTakeCurrentTime=False):
		"""
		returns a formated string for the time the timer took

		should have called startTimer 
		if haven't called endTimer, will take time ended 

		parameters:
		  bTakeCurrentTime - if set, will call endTimer before calculating the time
				     note: this does not stop the timer keeping time
		"""

		if self.timerStart is None:
			errMsg = 'do not have time started.  Should have called start'
			raise Exception( errMsg )

		if (self.timerEnd is None) or bTakeCurrentTime:
			self.end( )

		elapsedTime = self.timerEnd - self.timerStart

		#datetime seconds is capped by day.  If total is over a day, will add to day field 
		hours   = elapsedTime.seconds // 3600
		minutes = elapsedTime.seconds // 60 % 60

		# seconds = total seconds in day - seconds in hour - seconds in minute
		seconds = elapsedTime.seconds - hours*(3600) - minutes*60

		days = elapsedTime.days 

		elapsedList = []

		if days>0:
			elapsedList.append('%s days'%days )

		if hours>0:
			elapsedList.append('%s hours'%hours )

		if minutes>0:
			elapsedList.append('%s minutes'%minutes )

		if seconds>0:
			elapsedList.append('%s seconds'%seconds )

		if elapsedList: 
			rtnStr = ", ".join( elapsedList )

		else:
			rtnStr = "<1 second"

		return( rtnStr )

	def getElapsedTime(self, ):
		"""
		returns datetime.timedelta object for time that elapsed time 
		"""

		elapsedTime = self.timerEnd - self.timerStart
		return( elapsedTime )

###################
##data structures
###################
class HTMLDataWrapper(BaseModel):
	"""
	wrapper for HTML data 
	"""

	htmlId: Optional[int] = None
	tooltip: Optional[str] = None

class NodeWrapper(BaseModel):
	"""
	wrapper for Node object 
	"""

	headline: str
	body: str
	children: Optional[List['NodeWrapper']] = []
	html_data_wrapper: 'HTMLDataWrapper'

# This rebuilds the schema for NodeWrapper right after the class is defined
# and before the file finishes executing.
NodeWrapper.model_rebuild()

class MetaDataWrapper(BaseModel):
	"""
	wrapper for data not included in nodes 
	"""

	title: Optional[str] = None
	subTitle: Optional[str] = None
	htmlFileName: Optional[str] = None


class DataWrapper(BaseModel):
	"""
	wrapper for NodeWrappers and MetaDataWrapper 
	"""

	node_wrappers: Optional[List['NodeWrapper']] = []
	metadata_wrapper: Optional['MetaDataWrapper'] = None

###################
##convert outline to instance of DataWrapper
###################    
class ConvertLeoToNodesFactory(object):
	"""
	takes a leo outline and converts to instance of DataWrapper
	"""

	def __init__(self, c):
		"""
		init

		parameters:
		  c - leo commander instance 
		"""

		self.logger = logging.getLogger(name='ConvertLeoToNodesFactory')

		#todo: check type of c
		self.c = c

		#use to create the id for html 
		self.htmlIdCounter = 1

		#stores the result 
		metaData = MetaDataWrapper()

		self.data = DataWrapper(
            metadata_wrapper=metaData
        )

		#flag so won't process two metadata nodes  
		self.bIsMetadataNodeProcessed = False
		
		#to log X of Y
		self.numberOfNodes = None
		self.currentNode = 0

	def run(self):
		"""
		runs the conversion
		returns instance of DataWrapper
		"""
		
		#get leo file name 
		try:
			leoFileName = os.path.basename(g.app.c.fileName())

		except Exception as e:
			leoFileName = 'got exception getting leo file name: %s' % e

		self.logger.info(f'exporting html from leo file {leoFileName}')
		
		#get number of nodes 
		numNodes = self._getTotalNumberOfNodes()
		self.logger.debug(f'Have total of {numNodes} nodes')
		
		#get all top level nodes and then loop through 
		childrenRootNodesLeo = self._leoAllRootChildren()
		#future: check that childrenRootNodesLeo has at least one node 

		node_wrappers = []
		for p in childrenRootNodesLeo:
			result = self._convertLeoNode(p)

			if result is not None:
				node_wrappers.append(result)

		self.data.node_wrappers = node_wrappers

		self.logger.info('completed converting leo outline to nodes')
		return( self.data )

	def _getTotalNumberOfNodes(self, ):
		"""
		returns number of nodes
		
		if don't have number of nodes stored, will calcualte the number of nodes 
		"""
		
		#if have not determined number of nodes, determine number 
		if self.numberOfNodes is None:
			self.numberOfNodes = sum(1 for p in self.c.all_positions())
		
		return( self.numberOfNodes )
		
	def _getNodeNumber(self):
		"""
		returns next node number
		"""
		
		self.currentNode += 1
		return( self.currentNode )
		
	def _getNexHtmlId(self):
		"""
		returns next html id 
		"""

		htmlId = self.htmlIdCounter
		self.htmlIdCounter += 1
		return(htmlId)

	def _leoAllRootChildren(self):
		"""
		returns list of leos children nodes of leo root
		"""

		#todo: when AllRootChildren in command is in production, use it

		p = self.c.rootPosition()
		while p:
			yield p
			p.moveToNext()

	def _convertLeoNode(self, leoNode):
		"""
		converts leo node to instance of NodeWrapper if normal node otherwise reads metadata node.
		if find metadata node, will return None 
		If leoNode has children, call recursively 
		"""

		level = leoNode.level()
		self.logger.debug(f'{level * '\t'}processing node {self._getNodeNumber()} of {self._getTotalNumberOfNodes()}: {leoNode}')

		#if normal node, convert leo node to NodeWrapper otherwise have metadata node that reads 
		bIsMetadataNode = self._isMetadataNodeLeo(leoNode)

		if not bIsMetadataNode:
			#have regular node

			self.logger.debug(f'{(level +1) * '\t'}normal node')
			#create child nodes if any 
			childNodes = [self._convertLeoNode(child) for child in leoNode.children()]

			#convert leo node to NodeWrapper
			htmlId = self._getNexHtmlId()
			headline = leoNode.h

			htmlData = HTMLDataWrapper(htmlId=htmlId)

			nodeInst = NodeWrapper(
                headline=headline,
                body='', #place holder for body; will parse leo node later and fill in
                htmlId=htmlId,
                children=childNodes,
                html_data_wrapper=htmlData, 
            )

			self._processBody(leoNode=leoNode,
                              nodeWprInst=nodeInst,
                              level=level, )

			return( nodeInst )

		else:
			#have meta node
			self.logger.debug(f'{(level +1) * '\t'}thave meta data node')
			self._parseMetadataNode(leoNode)
			return( None )

	def _isMetadataNodeLeo(self, leoNode):
		"""
		determines if leo node is metadata 
		  return true if metadate node otherwise false 
		"""

		headline = leoNode.h
		headlineNoLeadOrTrailingWhiteSpace = headline.strip()

		if headlineNoLeadOrTrailingWhiteSpace == '@metadata':
			#have metadata node 
			return (True)

		else:
			#normal node
			return(False)

	def _parseMetadataNode(self, leoNode):
		"""
		parse metadata node 
		"""

		#only allow one metadata node
		if self.bIsMetadataNodeProcessed:
			raise Exception('found more than one @metadata node.  Only one allowed.')

		#loop through options nodes
		# map options from headline to self.data.metadata_wrapper field
		optionsMapDict = {
            '@title': 'title',
            '@sub-title': 'subTitle',
            '@html-file-name': 'htmlFileName',
        }

		for p in leoNode.children():
			# get headline and body, and strip out leading/trailing white space
			headline = p.h.strip()
			body = p.b.strip()

			# Cif the headline is a valid, set field
			if headline in optionsMapDict:
				# attribute name from optionsMapDict
				attributeName = optionsMapDict[headline]

				#set field in self.data.metadata_wrapper
				self.logger.debug(f'\t option {attributeName} = {body} {leoNode}')
				setattr(self.data.metadata_wrapper, attributeName, body)
				
				#log that processing option node
				level = leoNode.level()
				self.logger.debug(f'{level * '\t'}processing option node {self._getNodeNumber()} of {self._getTotalNumberOfNodes()}: {leoNode}')
				
			else:
				# if not a valid option, throw exception
				allowedOptions = list(optionsMapDict.keys())
				errMsg = f"Did not recognize metadata option '{headline}'. Allowed options are: {allowedOptions}"
				self.logger.error(errMsg)
				raise Exception(errMsg)

		self.bIsMetadataNodeProcessed = True 

	def _processBody(self, leoNode, nodeWprInst, level):
		"""
		pulls options out of the body and renders remaining body as html.  Updates nodeWprInst.body and nodeWprInst.html_data_wrapper
		"""

		#pull options out of body and add options to html data model 
		optionsDict = self._getOptionsFromBody(leoNode, level)

		self.logger.debug(f'{(level +1) * '\t'}options from body {optionsDict}')

		# map options from option to self.data. field
		optionsMapDict = {
            '@tool-tip': 'tooltip',
        }

		for optionKey in optionsDict:
			# if option is a valid, set field
			if optionKey in optionsMapDict:
				# attribute name from optionsMapDict
				attributeName = optionsMapDict[optionKey]

				#set field in nodeWprInst.html_data_wrapper
				setattr(nodeWprInst.html_data_wrapper, attributeName, optionsDict[optionKey])

			else:
				# if not a valid option, throw exception
				allowedOptions = list(optionsMapDict.keys())
				raise Exception(f"Did not recognize metadata option '{optionKey}'. Allowed options are: {allowedOptions}")

		#remove options from body and render
		body = self._stripOptionsFromBody(leoNode, level)
		self.logger.debug(f'{(level +1) * '\t'}body without options: {body}')

		bodyHtml = markdown(body)
		nodeWprInst.body =  bodyHtml

	def _getOptionsFromBody(self, leoNode, level):
		"""
		parses body to get options out
		returns dict with option as key and value of option as dict value 
		"""

		self.logger.debug(f'{(level +1) * '\t'}extracting options from body')

		body = leoNode.b

		#use regex to pull out all options 
		patternRegEx = r'(@[\w-]+)\s+([^\n\r]*)'
		matches = re.findall(patternRegEx, body, re.DOTALL)

		rtnDict = {}
		for optionName, optionValue in matches:
			rtnDict[optionName] = optionValue

		return(rtnDict)

	def _stripOptionsFromBody(self, leoNode, level):
		"""
		strip out @options from body.  Returns body as string  
		"""

		self.logger.debug(f'{(level +1) * '\t'}create body with options removed')

		body = leoNode.b

		patternRegEx = r'@[\w-]+\s+[^\n\r]*\n*'

		bodyRtn = re.sub(patternRegEx, '', body, flags=re.DOTALL)

		bodyRtn = bodyRtn.strip()

		return( bodyRtn )

##################
#generate html 
##################
# Configure Django settings
if not settings.configured:
	settings.configure(
        DEBUG=True,
        INSTALLED_APPS=[
            'django.contrib.contenttypes',
            'django.contrib.auth',
            ],
        USE_TZ=True,
        SECRET_KEY='dummy-secret-key-for-demo-only'
    )
	django.setup()

# django templates from .html files
#   dict with key being path and file name, and value is content of templates
templates =  {

    #######
    ##base.html
    #######
    "site/base.html": """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" >
  <title>Demo Leo to html django</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" >

  <!-- Bootstrap CSS -->
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"; rel="stylesheet">

  <style>
    :root {
      --base-padding: 1.25rem;
      --indent-step: 1rem;
    }

    body {
      font-family: Inter, system-ui, sans-serif;
      background-color: #f3f4f6;
    }

    .container {
      max-width: 1000px;
      background-color: white;
      border-radius: 0.75rem;
      box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05);
      padding: 2rem;
      --depth: 0;
    }

    .accordion-item .accordion-item {
      --depth: calc(var(--depth, 0) + 1);
    }

    .accordion-item {
      border-radius: 0.75rem;
      border: none;
      box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -1px rgba(0,0,0,0.06);
      margin-bottom: 1rem;
      overflow: visible; /* prevent tooltip clipping */
    }

    .btn-accordion {
      display: flex;
      align-items: center;
      width: 100%;
      text-align: left;
      padding: 1rem var(--base-padding);
      border-radius: 0.75rem;
      border: none;
      transition: background-color 0.3s ease;
      font-weight: 600;
      gap: 0.25rem;
      padding-left: calc(var(--base-padding) + var(--depth, 0) * var(--indent-step));
      background-color: transparent;
      color: inherit;
      position: relative; /* required for tooltip positioning */
    }

    .btn-accordion:not(.bg-light) { background-color: #f8fafc; }
    .btn-accordion:hover { background-color: #e2e8f0; }
    .btn-accordion:focus {
      box-shadow: none;
      outline: 2px solid #3b82f6;
      outline-offset: 2px;
    }

    .heading-text { display: inline-block; user-select: none; }

    .toggle-arrow {
      font-size: 0.75rem;
      line-height: 1;
      transition: transform 0.25s ease;
      color: #000;
      flex-shrink: 0;
    }
    .btn-accordion[aria-expanded="false"] .toggle-arrow { transform: rotate(-90deg); }
    .btn-accordion[aria-expanded="true"]  .toggle-arrow { transform: rotate(0deg); }

    .collapse-content {
      padding: 1rem var(--base-padding);
      background-color: #fff;
      padding-left: calc(var(--base-padding) + var(--depth, 0) * var(--indent-step));
    }

    /* Tooltip */
    .btn-accordion .tooltip-html {
      position: absolute;
      bottom: calc(100% + 8px);
      left: 50%; /* centered above button */
      transform: translateX(-50%) scale(0.95);
      opacity: 0;
      transition: opacity 0.12s ease, transform 0.12s ease;
      background: #1f2937;
      color: #fff;
      padding: 0.5rem 0.75rem;
      border-radius: 0.375rem;
      font-size: 0.875rem;
      max-width: 250px;
      white-space: normal; /* allow wrapping */
      text-align: center;
      z-index: 1200;
      pointer-events: none;
      box-shadow: 0 6px 18px rgba(31,41,55,0.12);
    }
    .btn-accordion .tooltip-html::after {
      content: "";
      position: absolute;
      top: 100%;
      left: 50%;
      transform: translateX(-50%);
      border-left: 6px solid transparent;
      border-right: 6px solid transparent;
      border-top: 6px solid #1f2937;
    }
    .btn-accordion:hover .tooltip-html {
      transform: translateX(-50%) scale(1);
      opacity: 1;
    }
  </style>
</head>
<body class="py-4">
  <div class="container">
    {# content to be added by child templates #}
    {% block content%}{%endblock%}

  </div>

  <!-- Bootstrap JS Bundle (collapse only) -->
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js";></script>
</body>
</html>""",


    #######
    ##node.html
                 #######
                 "site/node.html": """{# this is a version of a single node that has production formating  #}

{% if nodeInst%}

    <div class="accordion-item"> <!-- {{ nodeInst.headline }} -->
        <button class="btn btn-accordion bg-light text-dark"
            data-bs-toggle="collapse"
            data-bs-target="#id{{nodeInst.html_data_wrapper.htmlId}}"
            aria-expanded="false"
            aria-controls="id{{nodeInst.html_data_wrapper.htmlId}}"
            {% if nodeInst.html_data_wrapper.tooltip %}aria-describedby="idTooltip{{nodeInst.html_data_wrapper.htmlId}}"{% endif %}>
            <span class="heading-text">{{ nodeInst.headline }}</span>
            <span class="toggle-arrow">&#9660;</span>

            {#if have tool tip, add it  #}
            {% if nodeInst.html_data_wrapper.tooltip %}
                <span class="tooltip-html" id="idTooltip{{nodeInst.html_data_wrapper.htmlId}}" role="tooltip">{{ nodeInst.html_data_wrapper.tooltip }}</span>
            {% endif %}
        </button>
        <div id="id{{nodeInst.html_data_wrapper.htmlId}}" class="collapse">
            <div class="collapse-content">
                {{ nodeInst.body|safe }}

                {# loop child nodes if have #}
                {% if nodeInst.children %}
                    {% for childInst in nodeInst.children %}
                        {% include "site/node.html" with nodeInst=childInst %}

                    {% endfor %}

                {% endif %}
            </div>
        </div>
    </div> <!-- /{{ nodeInst.headline }} -->

{% else %}
    <h1 class="text-danger">Error: nodeInst not set in node.html</h1>
{% endif %}""",


                 #######
                ##dataWpr.html
                 #######
                 "site/dataWpr.html": """{%extends "site/base.html"%}

{% block content%}
    <h1 class="text-center mb-4 fw-bold text-dark">{{dataInst.metadata_wrapper.title}}</h1>
    <p class="text-center text-muted mb-5">{{dataInst.metadata_wrapper.subTitle}}</p>

     {% for nodeInst in dataInst.node_wrappers %}
        {% include "site/node.html" with nodeInst=nodeInst %}

     {% endfor %}

{%endblock%}"""

}

class DjangoRecursiveRenderer:
	def __init__(self, templates):
		# Template strings dictionary for locmem.Loader
		self.templates = templates


		# Create Django engine with locmem.Loader
		self.engine = Engine(
            loaders=[
                (
                    "django.template.loaders.locmem.Loader",
                    self.templates
                    ),
                ],
        )

	def render_complete_page(self, data):
		"""Render the complete page with the data"""
		template = self.engine.get_template("site/dataWpr.html")
		context = Context({'dataInst': data})
		return template.render(context)

class GenerateHTMLFromNodesFactory(object):
	"""
	generate html from instance of dataWrapper         
	"""

	def __init__(self):
		"""
		init 
		"""

		self.logger = logging.getLogger(name='GenerateHTMLFromNodesFactory')

	def run(self, dataInst):
		"""
		run conversion

		if dataInst.metadata_wrapper.htmlFileName set will generate html file
		  if give filename will put in same dir as leo file
		  if give absolute path will put file there
		  if give relative path will put file in relation to root dir of leo code

		parameters:
		  dataInst instance of DataWrapper
		"""

		self.logger.info(f'starting to generate html')

		renderer = DjangoRecursiveRenderer(templates)
		html = renderer.render_complete_page(dataInst)

		#if have html file name, write html to file
		# if option is just a file name then put in same dir as leo file
		# otherwise use path given.  
		fileName = dataInst.metadata_wrapper.htmlFileName
		if dataInst.metadata_wrapper.htmlFileName:
			#determine if fileName is just filename or file name with path
			bIsFileWithoutPath = os.path.dirname(fileName) == ''

			if bIsFileWithoutPath:
				#have just file name so add put html is same dir as leo file
				leoFileNameFull = g.app.c.fileName()
				leoFileDir = os.path.dirname(leoFileNameFull)
				htmlFileNameFull = os.path.join(leoFileDir, fileName)

			else:
				#file name has path so determine where to sore file 
				htmlFileNameFull = os.path.abspath(fileName)

			self.logger.info(f'writing to html file {htmlFileNameFull}')

			with open(htmlFileNameFull, 'w', encoding='utf-8') as f:
				f.write(html)

		self.logger.info(f'finished generating html')

		return( html )

<?xml version="1.0" encoding="utf-8"?>
<!-- Created by Leo: https://leo-editor.github.io/leo-editor/leo_toc.html -->
<leo_file xmlns:leo="https://leo-editor.github.io/leo-editor/namespaces/leo-python-editor/1.1"; >
<leo_header file_format="2"/>
<globals/>
<preferences/>
<find_panel_settings/>
<vnodes>
<v t="brian.20250910125739.1"><vh>@metadata</vh>
<v t="asdfafdsafdasfdsa.20250903214546.2"><vh>@title</vh></v>
<v t="asdfafdsafdasfdsa.20250903214822.1"><vh>@sub-title</vh></v>
<v t="brian.20250913110030.1"><vh>@html-file-name</vh></v>
</v>
<v t="brian.20250910125936.1"><vh>Section 1</vh>
<v t="brian.20250910125936.2"><vh>Section 1.1</vh>
<v t="brian.20250910125936.3"><vh>Section 1.1.1</vh></v>
</v>
</v>
<v t="asdfafdsafdasfdsa.20250903215439.1"><vh>Section 2</vh>
<v t="asdfafdsafdasfdsa.20250903215607.1"><vh>Section 2.1</vh></v>
</v>
<v t="asdfafdsafdasfdsa.20250903215802.1"><vh>Section 3</vh>
<v t="asdfafdsafdasfdsa.20250903215920.1"><vh>Section 3.1</vh></v>
</v>
</vnodes>
<tnodes>
<t tx="asdfafdsafdasfdsa.20250903214546.2">Documentation with Collapsible Sections</t>
<t tx="asdfafdsafdasfdsa.20250903214822.1">Click on a heading to expand or collapse its content.</t>
<t tx="asdfafdsafdasfdsa.20250903215439.1">@tool-tip Quick: Save changes before switching configuration modes.

This is the content under Section 2.</t>
<t tx="asdfafdsafdasfdsa.20250903215607.1">@tool-tip Reminder: Use secure keys in production builds.

This is nested content under Section 2.1.</t>
<t tx="asdfafdsafdasfdsa.20250903215802.1">@tool-tip FYI: This section lists recent release changes.

This is the content under Section 3.</t>
<t tx="asdfafdsafdasfdsa.20250903215920.1">@tool-tip Pro tip: Run heavy tasks in batch mode to save time.

This is nested content under Section 3.1.</t>
<t tx="brian.20250910125739.1"></t>
<t tx="brian.20250910125936.1">@tool-tip  Random tip: Rotate the knob gently to start calibration.

This is the content under Section 1.</t>
<t tx="brian.20250910125936.2">@tool-tip Note: This option affects thermal drift in low-power modes

This is nested content under Section 1.1.
</t>
<t tx="brian.20250910125936.3">@tool-tip This is the content under Section 1.1.1.

This is even more deeply nested content under Section 1.1.1.</t>
<t tx="brian.20250913110030.1">leo_export.html</t>
</tnodes>
</leo_file>

Reply via email to