diff --git a/api/build/BuildRunner.cfc b/api/build/BuildRunner.cfc index 3ed00b8fb..66d164468 100644 --- a/api/build/BuildRunner.cfc +++ b/api/build/BuildRunner.cfc @@ -88,14 +88,19 @@ component accessors=true { arguments.builder.injectMethod = this.injectMethod; - arguments.builder.injectMethod( "renderLinks", function( required string text ){ - return new api.rendering.WikiLinksRenderer( docTree=variables.docTree ).renderLinks( text=arguments.text, builder=variables._builder ); + arguments.builder.injectMethod( "renderLinks", function( required string text, required struct args, boolean markdown=false ){ + return new api.rendering.WikiLinksRenderer( docTree=variables.docTree ).renderLinks( + text = arguments.text, + builder = variables._builder, + args = arguments.args, + markdown = arguments.markdown + ); } ); - arguments.builder.injectMethod( "renderTemplate", function( required string template, struct args={} ){ + arguments.builder.injectMethod( "renderTemplate", function( required string template, struct args={}, boolean markdown=false ){ var renderer = new api.rendering.TemplateRenderer(); - var rendered = renderer.render( argumentCollection=arguments, template=_rootPathForRenderer & arguments.template ); + var rendered = renderer.render( argumentCollection=arguments, template=_rootPathForRenderer & arguments.template, markdown=arguments.markdown ); - return builder.renderLinks( rendered ); + return builder.renderLinks( rendered, args, arguments.markdown ); } ); StructDelete( arguments.builder, "injectMethod" ); diff --git a/api/data/FunctionPage.cfc b/api/data/FunctionPage.cfc index c37c7b19f..97f99b1c1 100644 --- a/api/data/FunctionPage.cfc +++ b/api/data/FunctionPage.cfc @@ -17,13 +17,13 @@ component accessors=true extends="Page" { return super.getTitle() & "()"; } - public string function getUsageSignature() { + public string function getUsageSignature( boolean plainText=false ) { var usage = super.getTitle() & "("; var delim = " "; var optionalCount = 0; for( var argument in this.getArguments() ) { - if ( !argument.required ) { + if ( !argument.required && !arguments.plainText ) { usage &= ""; optionalCount++; } @@ -34,7 +34,9 @@ component accessors=true extends="Page" { } else { usage &= argument.type; } - usage &= ""; + if ( !argument.required && !arguments.plainText ) { + usage &= ""; + } delim = ", "; } //usage &= ""; diff --git a/api/data/MethodPage.cfc b/api/data/MethodPage.cfc index 6755ed394..8e6368fe5 100644 --- a/api/data/MethodPage.cfc +++ b/api/data/MethodPage.cfc @@ -16,13 +16,13 @@ component accessors=true extends="Page" { property name="member" type="struct"; property name="alias" type="string"; - public string function getUsageSignature() { + public string function getUsageSignature( boolean plainText=false ) { var usage = this.getMethodObject() & "." & this.getMethodName() & "("; var delim = " "; var optionalCount = 0; for( var argument in this.getArguments() ) { - if ( !argument.required ) { + if ( !argument.required && !arguments.plainText ) { usage &= ""; optionalCount++; } @@ -34,10 +34,14 @@ component accessors=true extends="Page" { } else { usage &= argument.type; } - usage &= ""; + if ( !argument.required && !arguments.plainText ) { + usage &= ""; + } delim = ", "; } - usage &= ""; + if ( !arguments.plainText ) { + usage &= ""; + } //usage &= RepeatString( " ]", optionalCount ); usage &= " )"; diff --git a/api/data/TagPage.cfc b/api/data/TagPage.cfc index 91d82cb13..93041fc5f 100644 --- a/api/data/TagPage.cfc +++ b/api/data/TagPage.cfc @@ -15,11 +15,11 @@ component accessors=true extends="Page" { property name="srcExtension" type="struct"; property name="introduced" type="string"; - public string function getUsageSignature() { + public string function getUsageSignature( boolean plainText=false ) { var newLine = Chr(10); var indent = RepeatString( " ", 4 ); var tagName = "cf" & LCase( this.getName() ); - var usage = "<" & tagName; + var usage = ( arguments.plainText ? "<" : "<" ) & tagName; var closingTag = ""; var bodyType = this.getBodyContentType(); var unnamedAttributes = ( this.getAttributeType() ?: "" ) == "noname"; @@ -30,7 +30,7 @@ component accessors=true extends="Page" { usage &= " ###attribute.type# #attribute.name###"; } else { usage &= newline & indent; - if ( !attribute.required ) { + if ( !attribute.required && !arguments.plainText ) { usage &= ""; } @@ -42,7 +42,7 @@ component accessors=true extends="Page" { usage &= attribute.type; } - if ( !attribute.required ) { + if ( !attribute.required && !arguments.plainText ) { usage &= ""; } } @@ -54,13 +54,21 @@ component accessors=true extends="Page" { switch( bodyType ) { case "free": - closingTag &= htmlEditFormat(">[]") + if ( arguments.plainText ) { + closingTag &= ">"; + } else { + closingTag &= htmlEditFormat(">[]"); + } break; case "required": - closingTag &= htmlEditFormat(">") + if ( arguments.plainText ) { + closingTag &= ">"; + } else { + closingTag &= htmlEditFormat(">"); + } break; default: - closingTag &= ">"; + closingTag &= ( arguments.plainText ? ">" : ">" ); break; } diff --git a/api/rendering/MarkdownSyntaxHighlighter.cfc b/api/rendering/MarkdownSyntaxHighlighter.cfc new file mode 100644 index 000000000..02684d99c --- /dev/null +++ b/api/rendering/MarkdownSyntaxHighlighter.cfc @@ -0,0 +1,69 @@ +component { + + public string function renderHighlights( required string text ) { + var rendered = arguments.text; + var highlight = ""; + var pos = 1; + + do { + highlight = _getNextHighlight( rendered, pos ); + if ( !IsNull( highlight ) ) { + rendered = Replace( rendered, highlight.rawMatch, renderHighlight( highlight.code, highlight.language ), "all" ); + pos = highlight.pos; + } + } while( !IsNull( highlight ) ); + + return rendered; + } + + public string function renderHighlight( required string code, required string language ) { + // Strip +trycf suffix for markdown + var cleanLanguage = arguments.language.reReplace( "\+trycf$", "" ); + + // Normalize language names for markdown + if ( cleanLanguage.reFindNoCase( "^(luceescript|cfs)" ) ) { + cleanLanguage = "javascript"; // or "js" depending on your markdown renderer + } else if ( cleanLanguage.reFindNoCase( "^(lucee|cfm|coldfusion)" ) ) { + cleanLanguage = "html"; // or "cfml" if supported + } else if ( cleanLanguage eq "yml" ) { + cleanLanguage = "yaml"; + } + + // Return plain markdown code block + return "```" & cleanLanguage & chr(10) & arguments.code & chr(10) & "```"; + } + +// PRIVATE HELPERS + private any function _getNextHighlight( required string text, required string startPos=1 ) { + var referenceRegex = "```([a-zA-Z\+]+)?\n(.*?)\n```"; + var match = ReFind( referenceRegex, arguments.text, arguments.startPos, true ); + var found = match.len[1] > 0; + var result = {}; + + if ( !found ) { + return; + } + + var precedingContent = match.pos[1] == 1 ? "" : Trim( Left( arguments.text, match.pos[1]-1 ) ); + var matchIsWithinCodeBlock = precedingContent.endsWith( "
" ) || precedingContent.endsWith( "" );
+
+		if ( matchIsWithinCodeBlock ) {
+			return _getNextHighlight( arguments.text, match.pos[1]+match.len[1] );
+		}
+
+		result = {
+			  rawMatch = Mid( arguments.text, match.pos[1], match.len[1] )
+			, code     = Mid( arguments.text, match.pos[3], match.len[3] )
+			, pos = match.len[3]
+		};
+
+		if ( match.pos[2] ) {
+			result.language = Mid( arguments.text, match.pos[2], match.len[2] );
+		} else {
+			result.language = "text";
+		}
+
+		return result;
+	}
+
+}
diff --git a/api/rendering/SyntaxHighlighter.cfc b/api/rendering/SyntaxHighlighter.cfc
index bfe77936b..457bb3047 100644
--- a/api/rendering/SyntaxHighlighter.cfc
+++ b/api/rendering/SyntaxHighlighter.cfc
@@ -1,6 +1,15 @@
 component {
 
-	public string function renderHighlights( required string text ) {
+	public string function renderHighlights( required string text, boolean markdown=false ) {
+		// For markdown mode, strip +trycf and normalize language names
+		if ( arguments.markdown ) {
+			var result = arguments.text.reReplace( "```([a-zA-Z]+)\+trycf\n", "```\1" & chr( 10 ), "all" );
+			// Replace luceescript with cfml for better LLM compatibility
+			result = result.reReplace( "```luceescript\n", "```cfml" & chr( 10 ), "all" );
+			result = result.reReplace( "```lucee\n", "```cfml" & chr( 10 ), "all" );
+			return result;
+		}
+
 		var rendered  = arguments.text;
 		var highlight = "";
 		var pos = 1;
@@ -8,7 +17,7 @@ component {
 		do {
 			highlight = _getNextHighlight( rendered, pos );
 			if ( !IsNull( highlight ) ) {
-				rendered  = Replace( rendered, highlight.rawMatch, renderHighlight( highlight.code, highlight.language ), "all" );
+				rendered  = Replace( rendered, highlight.rawMatch, renderHighlight( highlight.code, highlight.language, arguments.markdown ), "all" );
 				pos = highlight.pos;
 			}
 		} while( !IsNull( highlight ) );
@@ -16,7 +25,23 @@ component {
 		return rendered;
 	}
 
-	public string function renderHighlight( required string code, required string language ) {
+	public string function renderHighlight( required string code, required string language, boolean markdown=false ) {
+		// If markdown mode, just clean up the code block and return it as markdown
+		if ( arguments.markdown ) {
+			var cleanLanguage = arguments.language.reReplace( "\+trycf$", "" );
+
+			// Normalize language names for markdown
+			if ( cleanLanguage.reFindNoCase( "^(luceescript|cfs)" ) ) {
+				cleanLanguage = "javascript";
+			} else if ( cleanLanguage.reFindNoCase( "^(lucee|cfm|coldfusion)" ) ) {
+				cleanLanguage = "html";
+			} else if ( cleanLanguage eq "yml" ) {
+				cleanLanguage = "yaml";
+			}
+
+			return "```" & cleanLanguage & chr( 10 ) & arguments.code & chr( 10 ) & "```";
+		}
+
 		var highlighter = new Pygments();
 		var useTryCf    = reFind( "\+trycf$", arguments.language ) > 0;
 
diff --git a/api/rendering/TemplateRenderer.cfc b/api/rendering/TemplateRenderer.cfc
index 60983bac3..dd0e75b16 100644
--- a/api/rendering/TemplateRenderer.cfc
+++ b/api/rendering/TemplateRenderer.cfc
@@ -1,6 +1,6 @@
 component {
 
-	public string function render( required string template, struct args={}, string helpers="" ) {
+	public string function render( required string template, struct args={}, string helpers="", boolean markdown=false ) {
 		var rendered = "";
 
 		_includeHelpers( arguments.helpers );
@@ -9,7 +9,7 @@ component {
 			include template=arguments.template;
 		}
 
-		rendered = new SyntaxHighlighter().renderHighlights( rendered );
+		rendered = new SyntaxHighlighter().renderHighlights( rendered, arguments.markdown );
 
 		return Trim( rendered );
 	}
diff --git a/api/rendering/WikiLinksRenderer.cfc b/api/rendering/WikiLinksRenderer.cfc
index d66455443..3e809a3ea 100644
--- a/api/rendering/WikiLinksRenderer.cfc
+++ b/api/rendering/WikiLinksRenderer.cfc
@@ -2,7 +2,7 @@ component accessors=true {
 
 	property name="docTree";
 
-	public string function renderLinks( required string text, required any builder ) {
+	public string function renderLinks( required string text, required any builder, required struct args, boolean markdown=false ) {
 		var rendered = arguments.text;
 		var link     = "";
 		var startPos = 1;
@@ -14,7 +14,8 @@ component accessors=true {
 					rendered = Replace( rendered, link.rawMatch, content, "one" );
 					//startPos = link.nextStartPos + len( content );
 				} else {
-					rendered = Replace( rendered, link.rawMatch, arguments.builder.renderLink( link.page ?: NullValue(), link.title ), "all" );
+					rendered = Replace( rendered, link.rawMatch,
+						arguments.builder.renderLink( link.page ?: NullValue(), link.title, args, arguments.markdown ), "all" );
 					startPos = link.nextStartPos;
 				}
 			}
diff --git a/builders/html/Builder.cfc b/builders/html/Builder.cfc
index 097369a76..adc79dd0a 100644
--- a/builders/html/Builder.cfc
+++ b/builders/html/Builder.cfc
@@ -1,16 +1,4 @@
 component {
-	public string function renderLink( any page, required string title ) {
-		if ( IsNull( arguments.page ) ) {
-			if (arguments.title.left(4) eq "http"){
-				return '#HtmlEditFormat( arguments.title )#';
-			} else {
-				request.logger (text="Missing docs link: [[#HtmlEditFormat( arguments.title )#]]", type="WARN");
-				return '#HtmlEditFormat( arguments.title )#';
-			}
-		}
-		var link = arguments.page.getPath() & ".html";
-		return '#HtmlEditFormat( arguments.title )#';
-	}
 
 	public string function renderContent( any docTree, required string content ) {
 		return docTree.renderContent( content );
@@ -32,6 +20,11 @@ component {
 		request.filesWritten = 0;
 		request.filesToWrite = StructCount(pagePaths);
 
+		// purge previous build directory contents
+		if ( directoryExists( buildDirectory ) )
+			DirectoryDelete( buildDirectory, true );
+		directoryCreate( buildDirectory );
+
 		request.logger (text="Builder HTML directory: #arguments.buildDirectory#");
 
 		new api.parsers.ParserFactory().getMarkdownParser(); // so the parser in use shows up in logs
@@ -39,10 +32,20 @@ component {
 		//for ( var path in pagePaths ) {
 		each(pagePaths, function(path){
 			var tick = getTickCount();
-			if (pagePaths[arguments.path].page.isPage())
-				_writePage( pagePaths[arguments.path].page, buildDirectory, docTree );
+			var page = pagePaths[ arguments.path ].page;
+
+			if ( page.isPage() ) {
+				// write out full html page
+				var pageContent = renderPageContent( page, docTree, false, {} );
+				_writePage( page, buildDirectory, docTree, pageContent, {} );
+
+				// write out markdown page
+				var markdownContent = renderMarkdownContent( page, docTree );
+				_writeMarkdownPage( page, buildDirectory, markdownContent, docTree );
+
+				request.filesWritten++;
+			}
 
-			request.filesWritten++;
 			if ((request.filesWritten mod 100) eq 0){
 				request.logger(text="Rendering Documentation (#request.filesWritten# / #request.filesToWrite#)");
 			}
@@ -58,12 +61,34 @@ component {
 		_writeSearchIndex( arguments.docTree, arguments.buildDirectory );
 	}
 
-	public string function renderPage( required any page, required any docTree, required boolean edit ){
+	public string function renderPageContent( required any page, required any docTree,
+			required boolean edit, required struct htmlOpts  ){
+		try {
+			var contentArgs = { page = arguments.page, docTree=arguments.docTree, edit=arguments.edit, htmlOpts=arguments.htmlOpts };
+
+			var pageContent = renderTemplate(
+				template = "templates/#_getPageLayoutFile( arguments.page )#.cfm"
+				, args     = contentArgs
+				, helpers  = "/builders/html/helpers"
+			);
+		} catch( any e ) {
+			e.additional.luceeDocsTitle = arguments.page.getTitle();
+			e.additional.luceeDocsPath = arguments.page.getPath();
+			e.additional.luceeDocsPageId = arguments.page.getid();
+			rethrow;
+		}
+		return pageContent;
+	}
+
+	public string function renderMarkdownContent( required any page, required any docTree ){
 		try {
-			var renderedPage = renderTemplate(
-				  template = "templates/#_getPageLayoutFile( arguments.page )#.cfm"
-				, args     = { page = arguments.page, docTree=arguments.docTree, edit=arguments.edit }
+			var contentArgs = { page = arguments.page, docTree=arguments.docTree, edit=false };
+
+			var markdownContent = renderTemplate(
+				template = "../markdown/templates/#_getPageLayoutFile( arguments.page )#.cfm"
+				, args     = contentArgs
 				, helpers  = "/builders/html/helpers"
+				, markdown = true
 			);
 		} catch( any e ) {
 			e.additional.luceeDocsTitle = arguments.page.getTitle();
@@ -71,7 +96,13 @@ component {
 			e.additional.luceeDocsPageId = arguments.page.getid();
 			rethrow;
 		}
-		var crumbs = arguments.docTree.getPageBreadCrumbs(arguments.page);
+		return markdownContent;
+	}
+
+	public string function renderPage( required any page, required any docTree,
+			required string pageContent, required boolean edit, required struct htmlOptions ){
+
+		var crumbs = arguments.docTree.getPageBreadCrumbs( arguments.page );
 		var excludeLinkMap = {}; // tracks links to exclude from See also
 		var links = [];
 		var categories = [];
@@ -117,21 +148,50 @@ component {
 					break;
 			}
 		}
+
+		var template = arguments.htmlOptions.mainTemplate ?: "main.cfm";
+		var crumbsArgs = {
+			crumbs:crumbs,
+			page: arguments.page,
+			docTree: arguments.docTree,
+			categories: categories.sort("textNoCase"),
+			edit: arguments.edit,
+			htmlOpts:  arguments.htmlOptions
+		};
+		var seeAlsoArgs = {
+			links= links,
+			htmlOpts=arguments.htmlOptions
+		}
+
 		try {
+
+			var args = {
+				body       = Trim( arguments.pageContent )
+				, htmlOpts   = arguments.htmlOptions
+				, page       = arguments.page
+				, edit       = arguments.edit
+				, crumbs     = renderTemplate( template="layouts/breadcrumbs.cfm", helpers  = "/builders/html/helpers",
+					args = crumbsArgs
+				)
+				, seeAlso    = renderTemplate( template="layouts/seeAlso.cfm"    , helpers  = "/builders/html/helpers",
+					args = seeAlsoArgs )
+			};
+
+			if ( !structKeyExists(arguments.htmlOptions, "no_navigation" ) ){
+				args.navTree    = renderTemplate( template="layouts/sideNavTree.cfm", helpers  = "/builders/html/helpers", args={
+					crumbs=crumbs,
+					docTree=arguments.docTree,
+					pageLineage=arguments.page.getLineage(),
+					pageLineageMap=arguments.page.getPageLineageMap()
+				} );
+			} else  {
+				args.navTree = "";
+			}
+
 			var pageContent = renderTemplate(
-				template = "layouts/main.cfm"
+				template = "layouts/#template#"
 				, helpers  = "/builders/html/helpers"
-				, args     = {
-					body       = Trim( renderedPage )
-					, page       = arguments.page
-					, edit       = arguments.edit
-					, crumbs     = renderTemplate( template="layouts/breadcrumbs.cfm", helpers  = "/builders/html/helpers", args={ crumbs=crumbs, page=arguments.page, docTree=arguments.docTree, categories=categories.sort("textNoCase"), edit= arguments.edit } )
-					, navTree    = renderTemplate( template="layouts/sideNavTree.cfm", helpers  = "/builders/html/helpers", args={
-						crumbs=crumbs, docTree=arguments.docTree, pageLineage=arguments.page.getLineage(), pageLineageMap=arguments.page.getPageLineageMap()
-					} )
-					, seeAlso    = renderTemplate( template="layouts/seeAlso.cfm"    , helpers  = "/builders/html/helpers",
-						args={ links=links } )
-				}
+				, args     = args
 			);
 		} catch( any e ) {
 			//e.additional.luceeDocsPage = arguments.page;
@@ -178,18 +238,24 @@ component {
 	}
 
 // PRIVATE HELPERS
-	private void function _writePage( required any page, required string buildDirectory, required any docTree ) {
+	private void function _writePage( required any page, required string buildDirectory,
+			required any docTree, required string pageContent, required struct htmlOptions ) {
 		var filePath      = variables._getHtmlFilePath( arguments.page, arguments.buildDirectory );
 		var fileDirectory = GetDirectoryFromPath( filePath );
 
-		//var starttime = getTickCount();
 		lock name="CreateDirectory" timeout=10 {
 			if ( !DirectoryExists( fileDirectory ) ) {
 				DirectoryCreate( fileDirectory );
 			}
 		}
-		var pageContent = variables.cleanHtml(variables.renderPage( arguments.page, arguments.docTree, false ));
-		FileWrite( filePath, pageContent );
+
+		var html = variables.cleanHtml(
+			variables.renderPage( arguments.page,
+				arguments.docTree, arguments.pageContent, false ,
+				arguments.htmlOptions, arguments.buildDirectory
+			)
+		);
+		FileWrite( filePath, html );
 	}
 
 	// regex strips left over whitespace multiple new lines
@@ -206,6 +272,35 @@ component {
 		return arguments.buildDirectory & arguments.page.getPath() & ".html";
 	}
 
+	private void function _writeMarkdownPage( required any page, required string buildDirectory, required string markdownContent, required any docTree ) {
+		var filePath      = _getMarkdownFilePath( arguments.page, arguments.buildDirectory );
+		var fileDirectory = GetDirectoryFromPath( filePath );
+
+		lock name="CreateDirectory" timeout=10 {
+			if ( !DirectoryExists( fileDirectory ) ) {
+				DirectoryCreate( fileDirectory );
+			}
+		}
+
+		FileWrite( filePath, arguments.markdownContent );
+
+		// Set last modified date from git for recipes
+		var recipeDates = arguments.docTree.getRecipeDates();
+		var pageId = arguments.page.getId();
+		if ( structKeyExists( recipeDates, pageId ) ) {
+			FileSetLastModified( filePath, recipeDates[pageId] );
+		}
+	}
+
+	private string function _getMarkdownFilePath( required any page, required string buildDirectory ) {
+		if ( arguments.page.getPath() == "/home" ) {
+			return arguments.buildDirectory & "/index.md";
+		}
+
+		return arguments.buildDirectory & arguments.page.getPath() & ".md";
+	}
+
+
 	private void function _copyStaticAssets( required string buildDirectory ) {
 		updateHighlightsCss( arguments.buildDirectory );
 		var subdirs = directoryList(path=GetDirectoryFromPath( GetCurrentTemplatePath() ) & "/assets", type="dir", recurse="false");
@@ -219,8 +314,10 @@ component {
 
 	private function updateHighlightsCss( required string buildDirectory ){
 		var highlighter = new api.rendering.Pygments();
-		var cssFile = path=GetDirectoryFromPath( GetCurrentTemplatePath() ) & "/assets/css/highlight.css";
-		fileWrite( cssFile, highlighter.getCss() );
+		var cssFile = GetDirectoryFromPath( GetCurrentTemplatePath() ) & "/assets/css/highlight.css";
+		var css = highlighter.getCss();
+		if ( trim( css ) neq trim( fileRead( cssFile ) ) )
+			fileWrite( cssFile, highlighter.getCss() ); // only update if changed
 	}
 
 	private void function _copySiteImages( required string buildDirectory, required any docTree ) {
@@ -310,4 +407,104 @@ component {
 		return '#chr(10)#' &
 			ArrayToList(siteMap, chr(10) ) & '#chr(10)#';
 	}
+
+	public string function renderLink( any page, required string title, struct args={}, boolean markdown=false ) {
+		if ( IsNull( arguments.page ) ) {
+			if ( arguments.title.left( 4 ) eq "http" ){
+				if ( arguments.markdown ) {
+					return "[#arguments.title#](#arguments.title#)";
+				}
+				return '#HtmlEditFormat( arguments.title )#';
+			} else {
+				request.logger( text="Missing docs link: [[#HtmlEditFormat( arguments.title )#]]", type="WARN" );
+				if ( arguments.markdown ) {
+					return "**#arguments.title#**";
+				}
+				return '#HtmlEditFormat( arguments.title )#';
+			}
+		}
+
+		var extension = arguments.markdown ? ".md" : ".html";
+		var link = arguments.page.getPath() & extension;
+
+		// For markdown, use relative paths
+		if ( arguments.markdown && structKeyExists( arguments.args, "page" ) ) {
+			link = _calculateRelativePath( arguments.args.page.getPath(), arguments.page.getPath() ) & extension;
+		} else if ( structKeyExists( arguments.args, "htmlOpts" )
+				&& structKeyExists( arguments.args.htmlOpts, "base_href" ) ){
+			link = arguments.args.htmlOpts.base_href & link;
+		}
+
+		if ( arguments.markdown ) {
+			return "[#arguments.title#](#link#)";
+		}
+		return '#HtmlEditFormat( arguments.title )#';
+	}
+
+	private string function _calculateRelativePath( required string fromPath, required string toPath ) {
+		// Handle home page specially
+		if ( arguments.fromPath == "/home" ) {
+			arguments.fromPath = "/";
+		}
+		if ( arguments.toPath == "/home" ) {
+			arguments.toPath = "/";
+		}
+
+		// Split paths into parts (excluding filename)
+		var fromParts = listToArray( arguments.fromPath, "/" );
+		var toParts = listToArray( arguments.toPath, "/" );
+
+		// If they're in the same directory, just use the filename
+		if ( arrayLen( fromParts ) == arrayLen( toParts ) ) {
+			var samePath = true;
+			for ( var i = 1; i < arrayLen( fromParts ); i++ ) {
+				if ( fromParts[i] != toParts[i] ) {
+					samePath = false;
+					break;
+				}
+			}
+			if ( samePath ) {
+				return listLast( arguments.toPath, "/" );
+			}
+		}
+
+		// Find common prefix
+		var commonLength = 0;
+		var minLength = min( arrayLen( fromParts ) - 1, arrayLen( toParts ) - 1 );
+		for ( var i = 1; i <= minLength; i++ ) {
+			if ( fromParts[i] == toParts[i] ) {
+				commonLength = i;
+			} else {
+				break;
+			}
+		}
+
+		// Build relative path
+		var relativeParts = [];
+
+		// Go up from current location (exclude the filename itself)
+		var upLevels = arrayLen( fromParts ) - 1 - commonLength;
+		for ( var i = 1; i <= upLevels; i++ ) {
+			arrayAppend( relativeParts, ".." );
+		}
+
+		// Go down to target location
+		for ( var i = commonLength + 1; i <= arrayLen( toParts ); i++ ) {
+			arrayAppend( relativeParts, toParts[i] );
+		}
+
+		return arrayToList( relativeParts, "/" );
+	}
+
+	public string function _getIssueTrackerLink(required string name) {
+		var link = Replace( new api.build.BuildProperties().getIssueTrackerLink(), "{search}", urlEncodedFormat(arguments.name) )
+		return 'Search Issue Tracker ';
+	}
+
+	public string function _getTestCasesLink(required string name) {
+		var link = Replace( new api.build.BuildProperties().getTestCasesLink(), "{search}", urlEncodedFormat(arguments.name) )
+		return 'Search Lucee Test Cases  (good for further, detailed examples)';
+	}
+
+
 }
diff --git a/builders/html/helpers/categoriesAndRelated.cfm b/builders/html/helpers/categoriesAndRelated.cfm
new file mode 100644
index 000000000..4a9f0411d
--- /dev/null
+++ b/builders/html/helpers/categoriesAndRelated.cfm
@@ -0,0 +1,41 @@
+
+function renderCategoriesAndRelated( required any page, required any docTree, boolean markdown=false ) {
+	var categories = [];
+	var related = [];
+	var output = "";
+
+	if ( !IsNull( arguments.page.getCategories() ) ) {
+		for ( var cat in arguments.page.getCategories() ) {
+			var catId = "category-" & cat;
+			if ( arguments.docTree.pageExists( catId ) ) {
+				arrayAppend( categories, "[[#catId#]]" );
+			}
+		}
+	}
+
+	var excludeMap = {};
+	excludeMap[arguments.page.getId()] = "";
+	for ( var child in arguments.page.getChildren() ) {
+		excludeMap[child.getId()] = "";
+	}
+
+	var relatedIds = arguments.docTree.getPageRelated( arguments.page );
+	for ( var relId in relatedIds ) {
+		if ( len( relId ) gt 0 and not structKeyExists( excludeMap, relId ) ) {
+			arrayAppend( related, "[[#relId#]]" );
+		}
+	}
+
+	if ( arrayLen( categories ) ) {
+		output &= chr( 10 ) & "## Categories" & chr( 10 ) & chr( 10 );
+		output &= arrayToList( categories, ", " ) & chr( 10 );
+	}
+
+	if ( arrayLen( related ) ) {
+		output &= chr( 10 ) & "## See Also" & chr( 10 ) & chr( 10 );
+		output &= arrayToList( related, ", " ) & chr( 10 );
+	}
+
+	return output;
+}
+
diff --git a/builders/html/helpers/markdownTableCell.cfm b/builders/html/helpers/markdownTableCell.cfm
new file mode 100644
index 000000000..b0dd2a54c
--- /dev/null
+++ b/builders/html/helpers/markdownTableCell.cfm
@@ -0,0 +1,14 @@
+
+function markdownTableCell( required string content ) {
+	var cleaned = arguments.content;
+	// Remove newlines and replace with spaces
+	cleaned = reReplace( cleaned, "\r?\n", " ", "all" );
+	// Remove pipes that would break the table
+	cleaned = replace( cleaned, "|", "\|", "all" );
+	// Collapse multiple spaces
+	cleaned = reReplace( cleaned, "\s+", " ", "all" );
+	// Trim
+	cleaned = trim( cleaned );
+	return cleaned;
+}
+
diff --git a/builders/html/layouts/breadcrumbs.cfm b/builders/html/layouts/breadcrumbs.cfm
index 263700897..34af6a36c 100644
--- a/builders/html/layouts/breadcrumbs.cfm
+++ b/builders/html/layouts/breadcrumbs.cfm
@@ -3,14 +3,17 @@
 
 
 
-
 
 	
 
-	
+	
+		
+	
+		
+		
 
 
-
 
 	
 	
diff --git a/builders/html/layouts/html_head.cfm b/builders/html/layouts/html_head.cfm
new file mode 100644
index 000000000..f5ef336b2
--- /dev/null
+++ b/builders/html/layouts/html_head.cfm
@@ -0,0 +1,69 @@
+
+	local.baseHref = ( repeatString( '../', args.page.getDepth()-1 ) );
+	// this breaks the /static/ local server mode
+	//if (baseHref eq "")
+	//	baseHref = "/";
+	local.path = args.page.getPath();
+	if ( local.path eq "/home" )
+		local.path = "/index";
+	local.pageHref = "https://docs.lucee.org#local.path#.html";
+	local.pagePath = "#local.path#.html";
+	local.pagePathMd = "#local.path#.md";
+	if (args.page.getTitle() neq "Lucee Documentation")
+		local.pageTitle = args.page.getTitle() & " :: Lucee Documentation";
+	else
+		local.pageTitle = args.page.getTitle();
+	// many sites (slack, discord one box etc) can't handle the escaped  and strip out the tag name from previews
+	local.safePageTitle = EncodeForHtml( Replace( Replace( local.pageTitle, "<", "", "all" ), ">", "", "all" ) );
+	local.pageTitle = EncodeForHtml( local.pageTitle );
+	local.pageDescription = getMetaDescription( args.page, args.body );
+
+
+
+	
+		#local.pageTitle#
+		
+			
+			
+		
+		
+			
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+		
+			
+				
+				
+			
+				
+				
+			
+			
+			
+		
+		
+	
+
diff --git a/builders/html/layouts/main.cfm b/builders/html/layouts/main.cfm
index 8c5585d1e..1eaeb8ebc 100644
--- a/builders/html/layouts/main.cfm
+++ b/builders/html/layouts/main.cfm
@@ -4,71 +4,13 @@
 
 
 
-
-
-	local.baseHref = ( repeatString( '../', args.page.getDepth()-1 ) );
-	// this breaks the /static/ local server mode
-	//if (baseHref eq "")
-	//	baseHref = "/";
-	local.path = args.page.getPath();
-	if ( local.path eq "/home" )
-		local.path = "/index";
-	local.pageHref = "https://docs.lucee.org#local.path#.html";
-	local.pagePath = "#local.path#.html";
-	if (args.page.getTitle() neq "Lucee Documentation")
-		local.pageTitle = args.page.getTitle() & " :: Lucee Documentation";
-	else
-		local.pageTitle = args.page.getTitle();
-	// many sites (slack, discord one box etc) can't handle the escaped  and strip out the tag name from previews
-	local.safePageTitle = EncodeForHtml( Replace( Replace( local.pageTitle, "<", "", "all" ), ">", "", "all" ) );
-	local.pageTitle = EncodeForHtml( local.pageTitle );
-	local.pageDescription = getMetaDescription( args.page, args.body );
-
+
+
 
 
 
-	
-		#local.pageTitle#
-		
-		
-		
-			
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-		
-	
-
+	
+