Skip to content

Commit 76b0881

Browse files
authored
Merge pull request #299 from jaredwray/feat-detectEngine-function
feat: detect engine function
2 parents 653ba29 + 483fb42 commit 76b0881

File tree

3 files changed

+847
-1
lines changed

3 files changed

+847
-1
lines changed

README.md

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Ecto is a modern template consolidation engine that enables the best template en
3030
* [Default Engine](#default-engine)
3131
* [Engine Mappings](#engine-mappings)
3232
* [FrontMatter Helper Functions](#frontmatter-helper-functions)
33+
* [Detect Template Engine](#detect-template-engine)
3334
* [The Template Engines We Support](#the-template-engines-we-support)
3435
* [EJS](#ejs)
3536
* [Markdown](#markdown)
@@ -562,9 +563,70 @@ Ecto has added in some helper functions for frontmatter in markdown files. Front
562563
563564
* `.hasFrontMatter(source: string): boolean` - This function checks if the markdown file has frontmatter. It takes in a string and returns a boolean value.
564565
* `.getFrontMatter(source: string): object` - This function gets the frontmatter from the markdown file. It takes in a string and returns an object.
565-
* `setFrontMatter(source:string, data: Record<string, unknown>)` - This function sets the front matter even if it already exists and returns the full source with the new front matter.
566+
* `setFrontMatter(source:string, data: Record<string, unknown>)` - This function sets the front matter even if it already exists and returns the full source with the new front matter.
566567
* `.removeFrontMatter(source: string): string` - This function removes the frontmatter from the markdown file. It takes in a string and returns a string.
567568
569+
# Detect Template Engine
570+
571+
Ecto provides a `detectEngine` method that can automatically detect the template engine from a template string by analyzing its syntax patterns. This is useful when you receive template content but don't know which engine it uses.
572+
573+
## detectEngine(source: string): string
574+
575+
The `detectEngine` method analyzes the template syntax and returns the detected engine name. If no specific template syntax is found, it returns the configured default engine.
576+
577+
| Name | Type | Description |
578+
| ------ | ------ | ------------------------------------------------- |
579+
| source | string | The template source string to analyze |
580+
581+
**Returns:** The detected engine name ('ejs', 'markdown', 'pug', 'nunjucks', 'handlebars', 'liquid') or the default engine if no specific syntax is detected.
582+
583+
### Basic Usage
584+
585+
```javascript
586+
const ecto = new Ecto();
587+
588+
// Detect EJS templates
589+
const ejsEngine = ecto.detectEngine('<%= name %>');
590+
console.log(ejsEngine); // 'ejs'
591+
592+
// Detect Handlebars templates
593+
const hbsEngine = ecto.detectEngine('{{name}}');
594+
console.log(hbsEngine); // 'handlebars'
595+
596+
// Detect Markdown
597+
const mdEngine = ecto.detectEngine('# Hello World\n\nThis is markdown');
598+
console.log(mdEngine); // 'markdown'
599+
600+
// Detect Pug templates
601+
const pugEngine = ecto.detectEngine('div.container\n h1 Hello');
602+
console.log(pugEngine); // 'pug'
603+
604+
// Detect Nunjucks templates
605+
const njkEngine = ecto.detectEngine('{% block content %}Hello{% endblock %}');
606+
console.log(njkEngine); // 'nunjucks'
607+
608+
// Detect Liquid templates
609+
const liquidEngine = ecto.detectEngine('{% assign name = "John" %}{{ name | upcase }}');
610+
console.log(liquidEngine); // 'liquid'
611+
612+
// Returns default engine for plain text
613+
const defaultEngine = ecto.detectEngine('Plain text without template syntax');
614+
console.log(defaultEngine); // 'ejs' (or whatever is set as defaultEngine)
615+
```
616+
617+
### Detection Patterns
618+
619+
The `detectEngine` method recognizes the following syntax patterns:
620+
621+
- **EJS**: `<% %>`, `<%= %>`, `<%- %>`
622+
- **Handlebars/Mustache**: `{{ }}`, `{{# }}`, `{{> }}`
623+
- **Pug**: Indentation-based syntax without angle brackets
624+
- **Nunjucks**: `{% block %}`, `{% extends %}`, `{% include %}`
625+
- **Liquid**: `{% assign %}`, `{% capture %}`, pipe filters `{{ var | filter }}`
626+
- **Markdown**: Headers `#`, lists, code blocks, links, tables
627+
628+
Note: For ambiguous syntax (like simple `{{ }}` which could be Handlebars, Mustache, or Liquid), the method makes intelligent decisions based on additional context clues in the template.
629+
568630
# Caching on Rendering
569631
570632
Ecto has a built-in caching mechanism that is `disabled by default` that allows you to cache the rendered output of templates. This is useful for improving performance and reducing the number of times a template needs to be rendered. There are currently two caching engines available: `MemoryCache` and `FileCache`.

src/ecto.ts

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,6 +581,286 @@ export class Ecto extends Hookified {
581581
return result;
582582
}
583583

584+
/**
585+
* Detect the template engine from a template string by analyzing its syntax
586+
* @param {string} source - The template source string to analyze
587+
* @returns {string} The detected engine name ('ejs', 'markdown', 'pug', 'nunjucks', 'handlebars', 'liquid') or the default engine
588+
* @example
589+
* const engine = ecto.detectEngine('<%= name %>'); // Returns 'ejs'
590+
* const engine2 = ecto.detectEngine('{{name}}'); // Returns 'handlebars' or 'liquid'
591+
* const engine3 = ecto.detectEngine('# Heading'); // Returns 'markdown'
592+
* const engine4 = ecto.detectEngine('plain text'); // Returns defaultEngine (e.g., 'ejs')
593+
*/
594+
public detectEngine(source: string): string {
595+
if (!source || typeof source !== "string") {
596+
return this._defaultEngine;
597+
}
598+
599+
// Check for EJS (uses <% %> tags)
600+
if (source.includes("<%")) {
601+
if (
602+
source.includes("<%=") ||
603+
source.includes("<%-") ||
604+
source.includes("%>")
605+
) {
606+
return "ejs";
607+
}
608+
}
609+
610+
// Check for Liquid first (has unique keywords that Nunjucks doesn't have)
611+
if (source.includes("{%")) {
612+
// Check for Liquid-specific keywords
613+
const liquidKeywords = [
614+
"liquid",
615+
"assign",
616+
"capture",
617+
"endcapture",
618+
"case",
619+
"when",
620+
"unless",
621+
"endunless",
622+
"tablerow",
623+
"endtablerow",
624+
"increment",
625+
"decrement",
626+
];
627+
for (const keyword of liquidKeywords) {
628+
if (
629+
source.includes(`{% ${keyword}`) ||
630+
source.includes(`{%${keyword}`)
631+
) {
632+
return "liquid";
633+
}
634+
}
635+
// Check for Liquid filters with pipe syntax
636+
if (
637+
source.includes("{{") &&
638+
source.includes("|") &&
639+
source.includes("}}")
640+
) {
641+
// Make sure it's not Nunjucks by checking for Nunjucks-specific keywords
642+
const nunjucksSpecific = [
643+
"block",
644+
"extends",
645+
"macro",
646+
"import",
647+
"call",
648+
];
649+
let hasNunjucksKeyword = false;
650+
for (const keyword of nunjucksSpecific) {
651+
if (
652+
source.includes(`{% ${keyword}`) ||
653+
source.includes(`{%${keyword}`)
654+
) {
655+
hasNunjucksKeyword = true;
656+
break;
657+
}
658+
}
659+
if (!hasNunjucksKeyword) {
660+
return "liquid";
661+
}
662+
}
663+
}
664+
665+
// Check for Nunjucks (uses {% %} for logic)
666+
if (source.includes("{%")) {
667+
const nunjucksKeywords = [
668+
"block",
669+
"extends",
670+
"include",
671+
"import",
672+
"for",
673+
"if",
674+
"elif",
675+
"else",
676+
"endif",
677+
"endfor",
678+
"set",
679+
"macro",
680+
"endmacro",
681+
"call",
682+
];
683+
for (const keyword of nunjucksKeywords) {
684+
if (
685+
source.includes(`{% ${keyword}`) ||
686+
source.includes(`{%${keyword}`)
687+
) {
688+
return "nunjucks";
689+
}
690+
/* c8 ignore next 3 */
691+
}
692+
}
693+
694+
// Check for Handlebars/Mustache (uses {{ }} and {{# }})
695+
if (source.includes("{{")) {
696+
// First check if it's Liquid with filters (before assuming Handlebars)
697+
if (source.includes(" | ") && source.includes("}}")) {
698+
// This could be a Liquid filter, check for Liquid-specific keywords
699+
if (
700+
source.includes("{% assign ") ||
701+
source.includes("{% capture ") ||
702+
source.includes("{% unless ") ||
703+
source.includes("{% increment ") ||
704+
source.includes("{% decrement ") ||
705+
source.includes("{% tablerow ") ||
706+
source.includes("{% case ") ||
707+
source.includes("{% when ") ||
708+
source.includes(" | upcase") ||
709+
source.includes(" | downcase") ||
710+
source.includes(" | capitalize") ||
711+
source.includes(" | minus:") ||
712+
source.includes(" | plus:") ||
713+
source.includes(" | money") ||
714+
source.includes(" | date:")
715+
) {
716+
return "liquid";
717+
}
718+
}
719+
// Check for Handlebars helpers
720+
if (
721+
source.includes("{{#") ||
722+
source.includes("{{/") ||
723+
source.includes("{{>") ||
724+
source.includes("{{!--")
725+
) {
726+
return "handlebars";
727+
}
728+
// Check for basic mustache/handlebars syntax
729+
if (source.includes("}}") && !source.includes("{%")) {
730+
return "handlebars";
731+
}
732+
}
733+
734+
// Check for Markdown first (before Pug to avoid conflicts)
735+
const lines = source.split("\n");
736+
let markdownIndicators = 0;
737+
let pugIndicators = 0;
738+
739+
for (const line of lines) {
740+
const trimmed = line.trim();
741+
742+
// Check for markdown headers
743+
if (
744+
trimmed.startsWith("# ") ||
745+
trimmed.startsWith("## ") ||
746+
trimmed.startsWith("### ") ||
747+
trimmed.startsWith("#### ") ||
748+
trimmed.startsWith("##### ") ||
749+
trimmed.startsWith("###### ")
750+
) {
751+
markdownIndicators++;
752+
}
753+
754+
// Check for markdown lists
755+
if (
756+
trimmed.startsWith("- ") ||
757+
trimmed.startsWith("* ") ||
758+
trimmed.startsWith("+ ")
759+
) {
760+
markdownIndicators++;
761+
}
762+
763+
// Check for ordered lists (e.g., "1. ", "2. ", etc.)
764+
const firstChar = trimmed.charAt(0);
765+
if (firstChar >= "0" && firstChar <= "9") {
766+
const dotIndex = trimmed.indexOf(".");
767+
if (
768+
dotIndex > 0 &&
769+
dotIndex < 4 &&
770+
trimmed.charAt(dotIndex + 1) === " "
771+
) {
772+
markdownIndicators++;
773+
}
774+
}
775+
776+
// Check for blockquotes
777+
if (trimmed.startsWith("> ")) {
778+
markdownIndicators++;
779+
}
780+
781+
// Check for code blocks
782+
if (trimmed.startsWith("```")) {
783+
markdownIndicators++;
784+
}
785+
786+
// Check for Pug patterns (only count if it's not markdown)
787+
if (
788+
!trimmed.startsWith("# ") &&
789+
!trimmed.startsWith("- ") &&
790+
!trimmed.startsWith("* ") &&
791+
!trimmed.startsWith("+ ") &&
792+
!trimmed.startsWith("> ")
793+
) {
794+
if (
795+
trimmed.startsWith("doctype") ||
796+
trimmed.startsWith("html") ||
797+
trimmed.startsWith("head") ||
798+
trimmed.startsWith("body") ||
799+
trimmed.startsWith("div") ||
800+
trimmed.startsWith("p") ||
801+
(trimmed.startsWith("h") &&
802+
trimmed.length > 1 &&
803+
trimmed.charAt(1) >= "1" &&
804+
trimmed.charAt(1) <= "6")
805+
) {
806+
pugIndicators++;
807+
}
808+
// Check for Pug class/id selectors (but not in markdown context)
809+
if (
810+
(trimmed.includes("(") && trimmed.includes(")")) ||
811+
trimmed.indexOf(".") === 0 || // starts with dot for class
812+
(trimmed.indexOf("#") === 0 && !trimmed.includes(" "))
813+
) {
814+
// starts with # but no space (not markdown header)
815+
pugIndicators++;
816+
}
817+
}
818+
}
819+
820+
// Check for markdown links and images
821+
if (
822+
source.includes("](") &&
823+
/* c8 ignore next */
824+
(source.includes("[") || source.includes("!["))
825+
) {
826+
markdownIndicators++;
827+
}
828+
829+
// Check for markdown tables
830+
if (source.includes("|") && source.includes("---")) {
831+
markdownIndicators++;
832+
}
833+
834+
// Determine if it's Markdown
835+
if (markdownIndicators > 0) {
836+
// Make sure it's not mixed with template syntax
837+
if (
838+
!source.includes("<%") &&
839+
!source.includes("{{") &&
840+
!source.includes("{%")
841+
) {
842+
return "markdown";
843+
}
844+
}
845+
846+
// Check for Pug (indentation-based, no HTML tags)
847+
const hasHtmlOpenTag = source.includes("<") && source.includes(">");
848+
const hasHtmlCloseTag = source.includes("</");
849+
if (
850+
pugIndicators > 0 &&
851+
!hasHtmlOpenTag &&
852+
!hasHtmlCloseTag &&
853+
!source.includes("<%") &&
854+
!source.includes("{{") &&
855+
!source.includes("{%")
856+
) {
857+
return "pug";
858+
}
859+
860+
// If no specific template syntax is found, return the default engine
861+
return this._defaultEngine;
862+
}
863+
584864
/**
585865
* Register all engine mappings between engine names and file extensions
586866
* @returns {void}

0 commit comments

Comments
 (0)