initial commit

This commit is contained in:
2026-05-21 20:46:11 +02:00
commit b47d99ba2b
11 changed files with 1662 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1206
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "template-compiler"
version = "0.1.0"
edition = "2024"
[dependencies]
serde_json = "1.0"
clap = { version = "4.0", features = ["derive"] }
indoc = "2"
lightningcss = "1.0.0-alpha.71"
rand = "0.8"
minify-js = "0.6.0"
+10
View File
@@ -0,0 +1,10 @@
# FastCentrik custom template compiler
This is a custom template compiler for FastCentrik. It allows you to create templates with and compile them into injectable JavaScript.
## Usage
// Work in progress
## TODO
- [ ] Add option to disable the default styles
+135
View File
@@ -0,0 +1,135 @@
use std::fs;
// Make sure your models match your struct definitions
use crate::compiler::models::{Page, PageRoute, Project, Template};
pub fn compile() {
println!("Compiling project...");
let page_paths = list_all_paths();
let mut pages = Vec::new();
for path in page_paths {
let page = construct_page(&path);
pages.push(page);
}
let project = Project { pages };
let compiled_script = project.compile_script();
fs::write("compiled.js", compiled_script).expect("Failed to write compiled script");
}
fn list_all_paths() -> Vec<String> {
let mut paths = Vec::new();
let entries = fs::read_dir(".").expect("Failed to read current directory");
for entry in entries {
let entry = entry.unwrap();
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if !dir_name.starts_with('.') && dir_name != "target" {
paths.push(path.to_str().unwrap().to_string());
}
}
}
paths
}
fn construct_page(page_path: &str) -> Page {
// --- 1. Route Resolution ---
let suffixes = [".regexpr", ".js", ""];
let mut route_option: Option<PageRoute> = None;
for suffix in suffixes {
let file_path = format!("{}/route{}", page_path, suffix);
if let Ok(content) = fs::read_to_string(&file_path) {
route_option = Some(match suffix {
".regexpr" => PageRoute::Regexpr(content),
".js" => PageRoute::CustomScript(content),
"" => PageRoute::Static(content),
_ => unreachable!(),
});
break;
}
}
let route = route_option.unwrap_or_else(|| {
panic!("No valid route file found (route.regexpr, route.js, or route) in '{}'", page_path)
});
// --- 2. Page-level Style ---
let style_path = format!("{}/style.css", page_path);
let style = fs::read_to_string(&style_path).ok();
// --- 3. Parse Templates ---
let mut content = Vec::new();
let templates_dir = format!("{}/templates", page_path);
// Check if the templates directory exists for this page
if let Ok(entries) = fs::read_dir(&templates_dir) {
for entry in entries {
let entry = entry.expect("Failed to read template entry");
let path = entry.path();
// Only process folders inside the 'templates' directory
if path.is_dir() {
let template_path = path.to_str().unwrap();
let template = construct_template(template_path);
content.push(template);
}
}
}
Page {
route,
style,
content,
}
}
fn construct_template(template_path: &str) -> Template {
// --- 1. Template-level Style ---
let style_path = format!("{}/style.css", template_path);
let style = fs::read_to_string(&style_path).ok();
// --- 2. Template Content Resolution ---
let script_path = format!("{}/script.js", template_path);
let template_html_path = format!("{}/template.html", template_path);
if let Ok(script_content) = fs::read_to_string(&script_path) {
Template::CustomScript {
script: script_content,
style,
}
}
else if let Ok(template_content) = fs::read_to_string(&template_html_path) {
let scraper_path = format!("{}/scrape.js", template_path);
let scraper = fs::read_to_string(&scraper_path).ok();
let replace_selector_path = format!("{}/replace_selector", template_path);
let replace_selector = fs::read_to_string(&replace_selector_path).unwrap_or_else(|_| {
panic!("replace_selector file not found in '{}': Required for template injection", template_path);
});
Template::TemplateInjector {
template: template_content,
// .trim() prevents trailing newlines in text files from breaking CSS selectors
replace_selector: replace_selector.trim().to_string(),
scraper,
style,
}
}
else {
panic!(
"Template content not found in '{}': Directory must contain either script.js or template.html",
template_path
);
}
}
+2
View File
@@ -0,0 +1,2 @@
pub mod compiler;
pub mod models;
+37
View File
@@ -0,0 +1,37 @@
pub struct Project {
pub pages: Vec<Page>,
}
pub struct Page {
pub route: PageRoute,
pub style: Option<String>,
pub content: Vec<Template>,
}
pub enum PageRoute {
/// File `route.regexpr`
Regexpr(String),
/// File `route`
Static(String),
/// File `route.js`
CustomScript(String),
}
pub enum Template {
CustomScript {
script: String,
style: Option<String>,
},
TemplateInjector {
template: String,
replace_selector: String,
scraper: Option<String>,
style: Option<String>
},
}
#[path = "models_impl.rs"]
mod models_impl;
+168
View File
@@ -0,0 +1,168 @@
use indoc::indoc;
use crate::css::scope_css;
use crate::compiler::models::{PageRoute, Project, Template};
use crate::js::minify_javascript;
impl Project {
pub fn compile_script(&self) -> String {
let template = indoc! { r#"
(function() {
if (typeof Handlebars === 'undefined') {
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/handlebars@latest/dist/handlebars.js';
document.head.appendChild(script);
}
const tempDefine = window.define;
window.define = undefined;
const script = document.createElement('script');
script.src = 'https://cdn.jsdelivr.net/npm/handlebars@latest/dist/handlebars.js';
script.onload = function() {
window.define = tempDefine;
{pages_script}
};
document.head.appendChild(script);
})()
"# };
let mut script = String::new();
for page in &self.pages {
let template_script = indoc! { r#"
if (eval({page_route})) {
{content_script}
}
"# };
let route_script = page.route.as_script();
let content_script = page
.content
.iter()
.map(|template| template.as_script())
.collect::<Vec<String>>()
.join("\n");
script.push_str(
&template_script
.replace("{page_route}", &format!("{:?}", minify_javascript(&route_script)))
.replace("{content_script}", &content_script),
);
}
minify_javascript(&template.replace("{pages_script}", &script))
}
}
impl PageRoute {
fn as_script(&self) -> String {
match self {
PageRoute::Regexpr(expr) => {
let template = indoc! { r#"
(function() {
const regex = new RegExp({expr});
return regex.test(window.location.pathname);
})()
"# };
template.replace("{expr}", &format!("{:?}", expr.trim()))
}
PageRoute::Static(route) => {
let template = indoc! { r#"
(function() {
return window.location.pathname === {route};
})()
"# };
template.replace("{route}", &format!("{:?}", route))
}
PageRoute::CustomScript(script) => script.clone(),
}
}
}
impl Template {
pub fn as_script(&self) -> String {
match self {
Template::CustomScript { script, style } => {
if style.is_none() || style.as_ref().unwrap().trim().is_empty() {
return script.clone();
}
let style_script = style.as_ref().map_or(String::new(), |s| {
format!(
r#"
const styleElement = document.createElement('style');
styleElement.textContent = {style};
document.head.appendChild(styleElement);
"#,
style = format!("{:?}", s)
)
});
format!("{}\n{}", style_script, script)
}
Template::TemplateInjector {
template,
replace_selector,
scraper,
style,
} => {
let scope_class = format!("template-style-{}", rand::random::<u32>());
let style_script = if let Some(style) = style {
if style.trim().is_empty() {
String::new()
} else {
format!(
indoc! {r#"
const styleElement = document.createElement('style');
styleElement.textContent = {style};
document.head.appendChild(styleElement);
"# },
style = &scope_css(&style, &scope_class)
)
}
} else {
String::new()
};
let script_template = indoc! { r#"
(function() {
{style_script}
const parsedData = eval({scraper} || "(() => ({}))");
const template = Handlebars.compile({template});
const rendered = template(parsedData);
console.log("Rendered template:", rendered);
const targetElement = document.querySelector({replace_selector});
const temp = document.createElement('template');
temp.innerHTML = rendered;
const newElement = temp.content.firstElementChild;
targetElement.replaceWith(newElement);
newElement.classList.add({scope_class});
})();
"# };
script_template
.replace("{style_script}", &style_script)
.replace("{template}", &format!("{:?}", template.trim()))
.replace("{replace_selector}", &format!("{:?}", replace_selector))
.replace("{scope_class}", &format!("{:?}", scope_class))
.replace(
"{scraper}",
&format!("{:?}", minify_javascript(scraper.as_deref().unwrap_or(""))),
)
}
}
}
}
+36
View File
@@ -0,0 +1,36 @@
use lightningcss::{
stylesheet::{ParserFlags, ParserOptions, PrinterOptions, StyleSheet},
targets::{Browsers, Targets},
};
pub fn scope_css(raw_css: &str, scope_class: &str) -> String {
let nested_css = format!(".{} {{\n{}\n}}", scope_class, raw_css);
let stylesheet = StyleSheet::parse(
&nested_css,
ParserOptions {
flags: ParserFlags::NESTING,
..ParserOptions::default()
},
)
.expect("Failed to parse CSS");
let targets = Targets {
browsers: Some(Browsers {
// Chrome 90 predates native CSS nesting support
chrome: Some(90 << 16),
..Browsers::default()
}),
..Targets::default()
};
let printed = stylesheet
.to_css(PrinterOptions {
minify: true,
targets,
..PrinterOptions::default()
})
.expect("Failed to serialize CSS");
printed.code
}
+16
View File
@@ -0,0 +1,16 @@
use minify_js::{minify, Session, TopLevelMode};
pub fn minify_javascript(raw_js: &str) -> String {
let session = Session::new();
let mut out = Vec::new();
minify(
&session,
TopLevelMode::Global,
raw_js.as_bytes(),
&mut out,
)
.expect("Failed to minify JavaScript");
String::from_utf8(out).expect("Minifier produced invalid UTF-8")
}
+39
View File
@@ -0,0 +1,39 @@
use clap::{ArgGroup, Parser};
use crate::compiler::compiler::compile;
mod compiler;
pub mod css;
pub mod js;
#[derive(Parser, Debug)]
#[command(
name = "template-compiler",
version = env!("CARGO_PKG_VERSION"),
about = "Template compiler for FastCentrik templates.",
author = "Jakub Žitník",
group = ArgGroup::new("command")
.args(&["new", "compile"])
.multiple(false)
)]
struct Cli {
#[arg(short = 'n', long = "new", help = "Lists all the saved mount points.")]
new: bool,
#[arg(
short = 'c',
long = "compile",
help = "Compiles the project into single JS file.",
value_name = "NAME"
)]
compile: bool,
}
fn main() {
let cli = Cli::parse();
if cli.compile {
compile();
}
}