Compare commits

...

No commits in common. "main" and "render_fixes" have entirely different histories.

42 changed files with 4133 additions and 864 deletions

View file

@ -1,63 +0,0 @@
when:
- event: tag
clone:
git:
image: codefloe.com/crow-plugins/clone:1.1.0
settings:
tags: true
variables:
- &zig_image 'ghcr.io/rust-cross/cargo-zigbuild:0.23'
steps:
- name: build-linux-x86_64
image: *zig_image
commands:
# - zig build -Dtarget=x86_64-linux-musl -Doptimize=ReleaseSafe --prefix zig-out-linux-x86_64
- zig build -Dtarget=x86_64-linux-musl --prefix zig-out-linux-x86_64
- cp zig-out-linux-x86_64/bin/htmlua htmlua-linux-x86_64
- cp zig-out-linux-x86_64/bin/htmlua-cgi htmlua-cgi-linux-x86_64
- name: build-linux-aarch64
image: *zig_image
commands:
# - zig build -Dtarget=aarch64-linux-musl -Doptimize=ReleaseSafe --prefix zig-out-linux-aarch64
- zig build -Dtarget=aarch64-linux-musl --prefix zig-out-linux-aarch64
- cp zig-out-linux-aarch64/bin/htmlua htmlua-linux-aarch64
- cp zig-out-linux-aarch64/bin/htmlua-cgi htmlua-cgi-linux-aarch64
- name: build-macos-x86_64
image: *zig_image
commands:
# - zig build -Dtarget=x86_64-macos -Doptimize=ReleaseSafe --prefix zig-out-macos-x86_64 --sysroot /opt/MacOSX11.3.sdk
- zig build -Dtarget=x86_64-macos --prefix zig-out-macos-x86_64 --sysroot /opt/MacOSX11.3.sdk
- cp zig-out-macos-x86_64/bin/htmlua htmlua-macos-x86_64
- cp zig-out-macos-x86_64/bin/htmlua-cgi htmlua-cgi-macos-x86_64
- name: build-macos-aarch64
image: *zig_image
commands:
# - zig build -Dtarget=aarch64-macos -Doptimize=ReleaseSafe --prefix zig-out-macos-aarch64 --sysroot /opt/MacOSX11.3.sdk
- zig build -Dtarget=aarch64-macos --prefix zig-out-macos-aarch64 --sysroot /opt/MacOSX11.3.sdk
- cp zig-out-macos-aarch64/bin/htmlua htmlua-macos-aarch64
- cp zig-out-macos-aarch64/bin/htmlua-cgi htmlua-cgi-macos-aarch64
- name: release
image: woodpeckerci/plugin-release
depends_on: [build-linux-x86_64, build-linux-aarch64, build-macos-x86_64, build-macos-aarch64]
settings:
api_key:
from_secret: forgejo_token
files:
- htmlua-linux-x86_64
- htmlua-linux-aarch64
- htmlua-macos-x86_64
- htmlua-macos-aarch64
- htmlua-cgi-linux-x86_64
- htmlua-cgi-linux-aarch64
- htmlua-cgi-macos-x86_64
- htmlua-cgi-macos-aarch64
checksum: sha256
file_exists: overwrite

View file

@ -1,23 +0,0 @@
# This file is a bit of a hack in order to run the real release CI
when:
- event: push
branch: ${CI_REPO_DEFAULT_BRANCH}
clone:
git:
image: codefloe.com/crow-plugins/clone:1.1.0
settings:
tags: true
steps:
- name: retag-latest
image: alpine:3.20
environment:
FORGEJO_TOKEN:
from_secret: forgejo_token
commands:
- apk add --no-cache git
- git remote set-url origin "https://oauth2:$${FORGEJO_TOKEN}@$${CI_FORGE_URL#https://}/$${CI_REPO}.git"
- git tag -f latest
- git push -f origin latest

38
.gitignore vendored Executable file → Normal file
View file

@ -1,19 +1,21 @@
# This file is for zig-specific build artifacts.
# If you have OS-specific or editor-specific files to ignore,
# such as *.swp or .DS_Store, put those in your global
# ~/.gitignore and put this in your ~/.gitconfig:
#
# [core]
# excludesfile = ~/.gitignore
#
# Cheers!
# -andrewrk
# Generated by Cargo
# will have compiled files and executables
debug
target
.zig-cache/
zig-out/
zig-pkg/
/release/
/debug/
/build/
/build-*/
/docgen_tmp/
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Generated by cargo mutants
# Contains mutation testing data
**/mutants.out*/
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

13
.rustfmt.toml Normal file
View file

@ -0,0 +1,13 @@
unstable_features = true
max_width = 120
fn_params_layout = "Compressed"
fn_single_line = true
fn_call_width = 120
where_single_line = true
brace_style = "PreferSameLine"
blank_lines_upper_bound = 5
combine_control_expr = false
wrap_comments = false
group_imports = "StdExternalCrate"
imports_granularity = "Crate"

2934
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

5
Cargo.toml Normal file
View file

@ -0,0 +1,5 @@
[workspace]
members = [ "htmlua-apache-cgi","htmlua-parser", "htmlua-server"]
resolver = "2"

19
LICENSE
View file

@ -1,19 +0,0 @@
Copyright 2026 Grace Yoder
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the “Software”), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,9 +0,0 @@
# HTMLua
### An HTML templating engine with (not yet reimplemented) Lua scripting abilities
See in action at [grace.pink](https://grace.pink/index.htmlua)
This is currently an incomplete rewrite. If you want to have the old rust
version you are able to checkout the `rust` tag.

112
build.zig
View file

@ -1,112 +0,0 @@
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const ezts = b.dependency("eazy_zig_tree_sitter", .{
.target = target,
.optimize = optimize,
.use_all_langs = true,
.use_third_party = true,
});
const ts = b.dependency("tree_sitter", .{
.target = target,
.optimize = optimize,
});
const mod = b.addModule("htmlua", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.imports = &.{
.{ .name = "easy-zig-tree-sitter", .module = ezts.module("eazy_zig_tree_sitter") },
.{ .name = "tree-sitter", .module = ts.module("tree_sitter") },
},
});
// Debug bin
const exe = b.addExecutable(.{
.name = "htmlua",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "htmlua", .module = mod },
},
}),
});
b.installArtifact(exe);
const run_step = b.step("run", "Run the app");
const run_cmd = b.addRunArtifact(exe);
run_step.dependOn(&run_cmd.step);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
// CGI bin
const cgi = b.addExecutable(.{
.name = "htmlua-cgi",
.root_module = b.createModule(.{
.root_source_file = b.path("src/cgi.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "htmlua", .module = mod },
},
}),
});
b.installArtifact(cgi);
const mod_tests = b.addTest(.{
.root_module = mod,
});
const run_mod_tests = b.addRunArtifact(mod_tests);
const exe_tests = b.addTest(.{
.root_module = exe.root_module,
});
const run_exe_tests = b.addRunArtifact(exe_tests);
const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_mod_tests.step);
test_step.dependOn(&run_exe_tests.step);
// https://zigtools.org/zls/guides/build-on-save/
const exe_check = b.addExecutable(.{
.name = "htmlua",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "htmlua", .module = mod },
},
}),
});
const cgi_check = b.addExecutable(.{
.name = "htmlua-cgi",
.root_module = b.createModule(.{
.root_source_file = b.path("src/cgi.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "htmlua", .module = mod },
},
}),
});
const check = b.step("check", "Check if htmlua compiles");
check.dependOn(&exe_check.step);
check.dependOn(&cgi_check.step);
}

View file

@ -1,24 +0,0 @@
.{
.name = .htmlua,
.version = "1.0.0-alpha",
.fingerprint = 0xb94bab7ca04adb0c,
.minimum_zig_version = "0.16.0",
.dependencies = .{
.tree_sitter = .{
.url = "git+https://github.com/tree-sitter/zig-tree-sitter.git#b562510b5563dcf3b7fcac4a5fe9d60834c84667",
.hash = "tree_sitter-0.26.0-8heIf-vDAQDh0HhAAUVwVZFmLrwSKMZl5gRaVFqtas0W",
},
.eazy_zig_tree_sitter = .{
.url = "git+https://git.gae.moe/grace/eazy-zig-tree-sitter.git#8d1fa80a0e225282f607a1e2b2504cfdbf427cfa",
.hash = "eazy_zig_tree_sitter-0.0.0-QEABqToEAwCwlPVt4G7QS6E6vicz4SISg8N93xRr8t8K",
},
},
.paths = .{
"build.zig",
"build.zig.zon",
"src",
"examples",
"LICENSE",
"README.md",
},
}

12
cspell.json Normal file
View file

@ -0,0 +1,12 @@
{
"words": [
"htmlua",
"headermap",
"reqwest",
"includeelement",
"exportelement"
],
"userWords": [
"mlua"
]
}

View file

@ -1,3 +0,0 @@
<p>included file!</p>
<use part=first></use>
<use part=second></use>

View file

@ -1,4 +0,0 @@
.{
.component_path = "./examples",
.template_path = "./examples",
}

View file

@ -1,21 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>main</title>
</head>
<body>
<h1>hello</h1>
<include path="a.htmlua">
<export part=first>
<p><b>exported from main!</b></p>
</export>
<export part=second>
<p><b>exported from main but again!</b></p>
</export>
</include>
</body>
</html>

View file

@ -0,0 +1,8 @@
[package]
name = "htmlua-apache-cgi"
version = "0.1.0"
edition = "2024"
[dependencies]
htmlua-parser = { path = "../htmlua-parser" }
kuchikiki = "0.8.2"

View file

@ -0,0 +1,12 @@
use std::env;
use htmlua_parser::serve::serve_content;
fn main() {
println!("Content-Type: text/html\n");
let request_uri = env::var("PATH_INFO").unwrap_or_else(|_| "".to_string());
let page = serve_content(request_uri.as_str()).unwrap();
println!("{page}");
}

23
htmlua-parser/Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "htmlua-parser"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.98"
dirs = "6.0.0"
html5ever = "0.35.0"
httptest = "0.16.3"
kuchikiki = "0.8.2"
markup5ever = "0.11.0"
mlua = { version = "0.10.5", features = ["lua54", "vendored", "serialize", "anyhow"] }
pulldown-cmark = "0.13.0"
reqwest = { version = "0.12.22", features = ["blocking", "json"] }
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
syntect = "5.2.0"
tendril = "0.4.3"
toml = "0.9.2"
[lints.clippy]
pedantic = { level = "warn", priority = -1 }

100
htmlua-parser/src/config.rs Normal file
View file

@ -0,0 +1,100 @@
use std::{fs, path::PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct Config {
pub paths: PathConfig,
pub server: ServerConfig,
pub syntax_highlighting: SyntaxConfig,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct PathConfig {
pub pages: PathBuf,
pub components: PathBuf,
pub themes: PathBuf,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct ServerConfig {
pub host: String,
pub port: u16,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct SyntaxConfig {
pub default_theme: String,
pub load_custom_themes: bool,
}
impl Default for Config {
fn default() -> Self {
Self {
paths: PathConfig {
pages: PathBuf::from("/var/www/htmlua/pages"),
components: PathBuf::from("/var/www/htmlua/components"),
themes: PathBuf::from("/var/www/htmlua/themes"),
},
server: ServerConfig {
host: "127.0.0.1".to_string(),
port: 8080,
},
syntax_highlighting: SyntaxConfig {
default_theme: "base16-ocean.dark".to_string(),
load_custom_themes: true,
},
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let config_path = Self::get_config_path();
if config_path.exists() {
let config_content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
let config: Config = toml::from_str(&config_content)
.with_context(|| format!("Failed to parse config file: {}", config_path.display()))?;
Ok(config)
} else {
let default_config = Config::default();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let config_content =
toml::to_string_pretty(&default_config).context("Failed to serialize default config")?;
fs::write(&config_path, config_content)
.with_context(|| format!("Failed to write default config to: {}", config_path.display()))?;
Ok(default_config)
}
}
fn get_config_path() -> PathBuf {
if cfg!(windows) {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("htmlua")
.join("config.toml")
} else {
PathBuf::from("/etc/htmlua.toml")
}
}
pub fn save(&self) -> Result<()> {
let config_path = Self::get_config_path();
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create config directory: {}", parent.display()))?;
}
let config_content = toml::to_string_pretty(self).context("Failed to serialize config")?;
fs::write(&config_path, config_content)
.with_context(|| format!("Failed to write config to: {}", config_path.display()))?;
Ok(())
}
#[must_use]
pub fn config_file_path() -> PathBuf { Self::get_config_path() }
}

View file

@ -0,0 +1,33 @@
use std::{
fs::File,
io::{BufRead, BufReader, Read, Seek, SeekFrom},
path::PathBuf,
};
use anyhow::Result;
use kuchikiki::NodeRef;
use markup5ever::{LocalName, QualName, namespace_url, ns};
use tendril::TendrilSink;
pub fn read_doc_from_file(path: PathBuf) -> Result<NodeRef> {
let mut file = File::open(path)?;
let mut reader = BufReader::new(&mut file);
let mut first_line = String::new();
reader.read_line(&mut first_line)?;
let first_line_trimmed = first_line.trim();
let is_whole_doc = first_line_trimmed.eq_ignore_ascii_case("<!DOCTYPE html>");
file.seek(SeekFrom::Start(0))?;
let mut page_string = String::new();
file.read_to_string(&mut page_string)?;
if is_whole_doc {
Ok(kuchikiki::parse_html().one(page_string))
} else {
let ctx_name = QualName::new(None, ns!(), LocalName::from("div"));
Ok(kuchikiki::parse_fragment(ctx_name, Vec::new()).one(page_string))
}
}

View file

@ -0,0 +1,228 @@
use std::{
cell::{LazyCell, RefCell},
collections::HashMap,
fmt::Write,
rc::Rc,
str::FromStr,
time::Duration,
};
use mlua::{Error, Lua, Table, prelude::*};
use reqwest::{
Method, Url,
blocking::{Client, Response},
header::HeaderMap,
};
use serde::{Deserialize, Serialize};
pub fn create_htmlua_stdlib(l: &Lua, stdout: &Rc<RefCell<String>>) -> mlua::Result<Table> {
let t = l.create_table()?;
// This cannot be the best way to do this
let stdout_println = stdout.clone();
t.set(
"println",
l.create_function(move |_, text: String| {
let mut stdout_ref = stdout_println.borrow_mut();
writeln!(stdout_ref, "{text}").map_err(mlua::Error::external)
})?,
)?;
let stdout_print = stdout.clone();
t.set(
"print",
l.create_function(move |_, text: String| {
let mut stdout_ref = stdout_print.borrow_mut();
write!(stdout_ref, "{text}").map_err(mlua::Error::external)
})?,
)?;
t.set("http", create_http_lib(l)?)?;
Ok(t)
}
#[allow(clippy::too_many_lines)]
fn create_http_lib(l: &Lua) -> mlua::Result<Table> {
let t = l.create_table()?;
let http_client: Rc<LazyCell<Client>> = Rc::new(LazyCell::new(|| {
Client::builder()
.timeout(std::time::Duration::from_secs(3))
.user_agent("htmlua/0.1.0")
.build()
.unwrap()
}));
let client = http_client.clone();
t.set(
"get",
l.create_function(move |_, url: String| {
let res = client.get(url).send().map_err(|e| Error::RuntimeError(e.to_string()))?;
let lua_res: LuaHttpResponse = TryFrom::try_from(res)?;
Ok(lua_res)
})?,
)?;
let client = http_client.clone();
t.set(
"post",
l.create_function(move |_, url: String| {
let res = client
.post(url)
.send()
.map_err(|e| Error::RuntimeError(e.to_string()))?;
let lua_res: LuaHttpResponse = TryFrom::try_from(res)?;
Ok(lua_res)
})?,
)?;
let client = http_client.clone();
t.set(
"get_with_data",
l.create_function(move |_, (url, data): (String, HashMap<String, String>)| {
let res = client
.get(url)
.query(&data)
.send()
.map_err(|e| Error::RuntimeError(e.to_string()))?;
let lua_res: LuaHttpResponse = TryFrom::try_from(res)?;
Ok(lua_res)
})?,
)?;
let client = http_client.clone();
t.set(
"post_with_data_form",
l.create_function(move |_, (url, data): (String, HashMap<String, String>)| {
let res = client
.post(url)
.form(&data)
.send()
.map_err(|e| Error::RuntimeError(e.to_string()))?;
let lua_res: LuaHttpResponse = TryFrom::try_from(res)?;
Ok(lua_res)
})?,
)?;
let client = http_client.clone();
t.set(
"post_with_data_json",
l.create_function(move |_, (url, data): (String, HashMap<String, String>)| {
let res = client
.post(url)
.json(&data)
.send()
.map_err(|e| Error::RuntimeError(e.to_string()))?;
let lua_res: LuaHttpResponse = TryFrom::try_from(res)?;
Ok(lua_res)
})?,
)?;
let client = http_client.clone();
t.set(
"request",
l.create_function(move |_, table: mlua::Table| {
let mut request = client.request(
Method::from_bytes(table.get::<String>("method")?.as_bytes())
.map_err(|e| Error::RuntimeError(e.to_string()))?,
Url::from_str(table.get::<String>("url")?.as_str()).map_err(|e| Error::RuntimeError(e.to_string()))?,
);
if let Ok(header_tbl) = table.get::<mlua::Table>("headers") {
request = header_tbl
.pairs::<String, String>()
.filter_map(std::result::Result::ok)
.fold(request, |req, (k, v)| req.header(k, v));
}
if let Ok(basic_auth) = table.get::<mlua::Table>("basic_auth") {
request = request
.basic_auth(basic_auth.get::<String>("username")?, basic_auth.get::<String>("password").ok());
}
if let Ok(bearer_auth) = table.get::<mlua::Table>("bearer_auth") {
request = request.bearer_auth(bearer_auth.get::<String>("token")?);
}
if let Ok(body) = table.get::<String>("body") {
request = request.body(body);
}
if let Ok(json) = table.get::<String>("json") {
request = request.json(&json);
}
if let Ok(timeout) = table.get::<u64>("timeout") {
request = request.timeout(Duration::from_secs(timeout));
}
let res = request.send().map_err(|e| Error::RuntimeError(e.to_string()))?;
let lua_res: LuaHttpResponse = TryFrom::try_from(res)?;
Ok(lua_res)
})?,
)?;
t.set(
"decode_json",
l.create_function(move |l, text: String| {
let table: serde_json::Value =
serde_json::from_str(&text).map_err(|e| Error::RuntimeError(e.to_string()))?;
Ok(l.to_value(&table))
})?,
)?;
Ok(t)
}
#[derive(Serialize, Deserialize)]
struct LuaHttpResponse {
headers: HashMap<String, String>,
body: String,
status: u16,
}
impl TryFrom<Response> for LuaHttpResponse {
fn try_from(value: Response) -> Result<Self, Self::Error> {
Ok(LuaHttpResponse {
headers: headermap_to_hashmap(value.headers()),
status: value.status().as_u16(),
body: value.text().map_err(|e| Error::RuntimeError(e.to_string()))?,
})
}
type Error = Error;
}
fn headermap_to_hashmap(headers: &HeaderMap) -> HashMap<String, String> {
let mut map = HashMap::new();
for (name, value) in headers {
if let Ok(value_str) = value.to_str() {
map.insert(name.as_str().to_string(), value_str.to_string());
}
}
map
}
impl IntoLua for LuaHttpResponse {
fn into_lua(self, lua: &Lua) -> LuaResult<LuaValue> {
let table = lua.create_table()?;
table.set("status", self.status)?;
table.set("body", self.body)?;
let headers_table = lua.create_table()?;
for (key, value) in self.headers {
headers_table.set(key, value)?;
}
table.set("headers", headers_table)?;
Ok(LuaValue::Table(table))
}
}

5
htmlua-parser/src/lib.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod config;
pub mod helpers;
pub mod htmlua_stdlib;
pub mod render;
pub mod serve;

670
htmlua-parser/src/render.rs Normal file
View file

@ -0,0 +1,670 @@
use std::{
cell::{LazyCell, RefCell},
fmt::Write,
path::PathBuf,
rc::Rc,
};
use anyhow::{Result, anyhow};
use kuchikiki::{NodeRef, traits::TendrilSink};
use markup5ever::{LocalName, Namespace, QualName};
use mlua::Lua;
use pulldown_cmark::{Options, Parser, html};
use syntect::{
easy::HighlightLines,
highlighting::{Style, ThemeSet},
html::{IncludeBackground, styled_line_to_highlighted_html},
parsing::SyntaxSet,
util::LinesWithEndings,
};
use crate::{helpers::read_doc_from_file, htmlua_stdlib::create_htmlua_stdlib, serve::get_config};
fn build_lua_with_stdout(stdout: &Rc<RefCell<String>>) -> Result<Lua> {
let lua = Lua::new();
let globals = lua.globals();
let htmlua_table = create_htmlua_stdlib(&lua, stdout).map_err(|e| anyhow!("Failed to create Lua stdlib: {}", e))?;
globals
.set("htmlua", htmlua_table)
.map_err(|e| anyhow!("Failed to set global: {}", e))?;
Ok(lua)
}
pub fn execute_lua(document: NodeRef) -> Result<NodeRef> {
let stdout: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
let lua = LazyCell::new(|| build_lua_with_stdout(&stdout).unwrap_or_default());
let lua_elements: Vec<_> = match document.select("lua") {
Ok(e) => e.collect(),
Err(()) => return Err(anyhow!("Unable to find Lua")),
};
for node in lua_elements {
if let Some(text_node) = node.as_node().first_child() {
if let Some(lua_code) = text_node.as_text() {
stdout.borrow_mut().clear();
lua.load(lua_code.borrow().as_str())
.exec()
.map_err(|e| anyhow!("Failed to execute Lua: {}", e))?;
node.as_node()
.insert_before(NodeRef::new_text(stdout.borrow().as_str()));
node.as_node().detach();
}
}
}
Ok(document)
}
pub fn process_markdown(document: NodeRef) -> Result<NodeRef> {
let markdown_elements: Vec<_> = match document.select("markdown") {
Ok(e) => e.collect(),
Err(()) => return Err(anyhow!("Unable to find markdown elements")),
};
for node in markdown_elements {
if let Some(text_node) = node.as_node().first_child() {
if let Some(markdown_text) = text_node.as_text() {
let borrowed_text = markdown_text.borrow();
let parser = Parser::new_ext(&borrowed_text, Options::all());
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
let html_fragment = kuchikiki::parse_html().one(html_output);
for child in html_fragment.children() {
node.as_node().insert_before(child);
}
// Remove the original markdown node.
node.as_node().detach();
}
}
}
Ok(document)
}
pub fn expand_template(document: NodeRef, component_path: &PathBuf, include_from: Option<&NodeRef>) -> Result<NodeRef> {
if let Some(from_node) = include_from {
for i in document
.select("includeelement")
.map_err(|()| anyhow!("Error finding includeelement"))?
{
if let Some(name) = i.attributes.borrow().get("name") {
let exported = from_node
.select_first(format!("exportelement.{name}").as_str())
.map_err(|()| anyhow!("Error finding exportelement"))?;
exported
.as_node()
.children()
.rev()
.for_each(|c| i.as_node().insert_after(c));
}
}
while let Ok(i) = document.select_first("includeelement") {
i.as_node().detach();
}
}
for i in document
.select("include")
.map_err(|()| anyhow!("Error finding include"))?
.collect::<Vec<_>>()
{
let attrs = match i.as_node().as_element() {
Some(e) => e.attributes.borrow(),
None => continue,
};
if let Some(include_path) = attrs.get("path") {
let mut item_path = component_path.clone();
item_path.push(include_path);
let new_node = read_doc_from_file(item_path)?;
let replaced_node = expand_template(new_node, component_path, Some(i.as_node()))?;
replaced_node
.select_first("html")
.map_err(|()| anyhow!("Error finding html"))?
.as_node()
.children()
.rev()
.for_each(|c| i.as_node().insert_after(c));
}
}
while let Ok(i) = document.select_first("include") {
i.as_node().detach();
}
Ok(document)
}
pub fn process_syntax_highlighting(document: NodeRef) -> Result<NodeRef> {
let config = get_config();
let ps = SyntaxSet::load_defaults_newlines();
let mut ts = ThemeSet::load_defaults();
let syntax_elements: Vec<_> = match document.select("syntaxhighlight") {
Ok(e) => e.collect(),
Err(()) => return Err(anyhow!("Unable to find syntaxhighlight elements")),
};
if !syntax_elements.is_empty() && config.syntax_highlighting.load_custom_themes {
let _ = ts.add_from_folder(&config.paths.themes);
}
for node in syntax_elements {
let attrs = match node.as_node().as_element() {
Some(e) => e.attributes.borrow(),
None => continue,
};
let language = attrs.get("lang").unwrap_or("text");
let theme_name = attrs.get("theme").unwrap_or(&config.syntax_highlighting.default_theme);
if let Some(text_node) = node.as_node().first_child() {
if let Some(code_text) = text_node.as_text() {
let syntax = ps
.find_syntax_by_extension(language)
.or_else(|| ps.find_syntax_by_name(language))
.unwrap_or_else(|| ps.find_syntax_plain_text());
let theme = &ts.themes[theme_name];
let mut h = HighlightLines::new(syntax, theme);
let mut html_output = String::new();
write!(html_output, r#"<pre class="syntax-highlight" data-lang="{language}">"#)?;
html_output.push_str("<code>");
for line in LinesWithEndings::from(&code_text.borrow()) {
let ranges: Vec<(Style, &str)> = h.highlight_line(line, &ps)?;
let escaped = styled_line_to_highlighted_html(&ranges[..], IncludeBackground::No)?;
html_output.push_str(&escaped);
}
html_output.push_str("</code></pre>");
let html_fragment = kuchikiki::parse_html().one(html_output);
for child in html_fragment.children() {
node.as_node().insert_before(child);
}
// Remove the original syntaxhighlight node.
node.as_node().detach();
}
}
}
Ok(document)
}
pub fn generate_footnotes(document: NodeRef) -> Result<NodeRef> {
let Ok(footnote_container) = document.select_first("footnotecontainer") else {
return Ok(document);
};
let ctx_name = QualName::new(None, Namespace::from("http://www.w3.org/1999/xhtml"), LocalName::from("div"));
for (i, footnote) in document
.select("footnote")
.map_err(|()| anyhow!("Failed to get footnote"))?
.enumerate()
{
let i = i + 1;
let fn_text = footnote.text_contents();
let sup_tag = kuchikiki::parse_fragment(ctx_name.clone(), Vec::new())
.one(format!("<a href=#ft-text-{i}><sup id=\"ft-sup-{i}\" title=\"{fn_text}\">{i}</sup></a>"))
.select_first("a")
.map_err(|()| anyhow!("parse err"))?;
footnote.as_node().insert_after(sup_tag.as_node().clone());
let text_tag = kuchikiki::parse_fragment(ctx_name.clone(), Vec::new())
.one(format!("<p id=\"ft-text-{i}\"><a href=#ft-sup-{i}>{i}:</a> {fn_text}</p>"))
.select_first("p")
.map_err(|()| anyhow!("parse err"))?;
footnote_container.as_node().insert_before(text_tag.as_node().clone());
}
while let Ok(i) = document.select_first("footnote") {
i.as_node().detach();
}
footnote_container.as_node().detach();
Ok(document)
}
#[cfg(test)]
mod tests {
use httptest::{Expectation, ServerPool, matchers::*, responders::*};
use markup5ever::{namespace_url, ns};
use super::*;
#[test]
fn basic_lua() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<span><lua>
htmlua.println("Test from Lua!")
</lua></span>
</body>
</html>"#;
let document = kuchikiki::parse_html().one(page);
let d = execute_lua(document).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "Test from Lua!\n");
assert!(d.select_first("lua").is_err());
}
#[test]
fn multiple_lua() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<span id="ta"><lua>
htmlua.println("Test from Lua!")
</lua></span>
<div>
<span id="tb"><lua>htmlua.println("test 2")</lua></span>
</div>
</body>
</html>"#;
let document = kuchikiki::parse_html().one(page);
let d = execute_lua(document).unwrap();
let text = d.select_first("#ta").unwrap().as_node().text_contents();
assert_eq!(text, "Test from Lua!\n");
let text = d.select_first("#tb").unwrap().as_node().text_contents();
assert_eq!(text, "test 2\n");
assert!(d.select_first("lua").is_err());
}
#[test]
fn basic_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<include path="comp1.html" />
<span><lua>
htmlua.println("Test from Lua!")
</lua></span>
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "included1");
assert!(d.select_first("include").is_err());
}
#[test]
fn multiple_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<include path="multi1_1.html"></include>
<include path="multi1_2.html"></include>
<include path="multi1_3.html"></include>
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("#inc1").unwrap().as_node().text_contents();
assert_eq!(text, "included1");
let text = d.select_first("#inc2").unwrap().as_node().text_contents();
assert_eq!(text, "included2");
let text = d.select_first("#inc3").unwrap().as_node().text_contents();
assert_eq!(text, "included3");
assert!(d.select_first("include").is_err());
}
#[test]
fn basic_lua_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a paragraph.</p>
<span><lua>
htmlua.println("Test from Lua!")
</lua></span>
<include path="comp1.html" />
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = execute_lua(expand_template(document, &p, None).unwrap()).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "Test from Lua!\n");
assert!(d.select_first("lua").is_err());
let text = d.select_first("#inc1").unwrap().as_node().text_contents();
assert_eq!(text, "included1");
assert!(d.select_first("include").is_err());
}
#[test]
fn recursive_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<include path="comp2_level1.html" />
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("span").unwrap().as_node().text_contents();
assert_eq!(text, "included_twice");
assert!(d.select_first("include").is_err());
}
#[test]
fn export_element_include() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<h1>Hello World</h1>
<include path="with_include1.html">
<exportelement class="el1"><span id="ta">element 1</span></exportelement>
<exportelement class="el2"><span id="tb">element 2</span></exportelement>
</include>
</body>
</html>"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let document = kuchikiki::parse_html().one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d.select_first("#ta").unwrap().as_node().text_contents();
assert_eq!(text, "element 1");
let text = d.select_first("#tb").unwrap().as_node().text_contents();
assert_eq!(text, "element 2");
assert!(d.select_first("exportelement").is_err());
assert!(d.select_first("includeelement").is_err());
}
#[test]
fn include_base() {
let page = r#"
<include path="include_base.html">
<exportelement class="t"><title>title_test</title></exportelement>
<exportelement class="b"><span id="tb">body_el</span></exportelement>
</include>
"#;
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
p.push("tests/components");
let ctx_name = QualName::new(None, ns!(html), LocalName::from("div"));
let document = kuchikiki::parse_fragment(ctx_name, Vec::new()).one(page);
let d = expand_template(document, &p, None).unwrap();
let text = d
.select_first("head")
.unwrap()
.as_node()
.select_first("title")
.unwrap()
.as_node()
.text_contents();
assert_eq!(text, "title_test");
let text = d.select_first("#tb").unwrap().as_node().text_contents();
assert_eq!(text, "body_el");
assert!(d.select_first("exportelement").is_err());
assert!(d.select_first("includeelement").is_err());
}
#[test]
fn footnotes() {
let page = r"
<!DOCTYPE html>
<html>
<head>
<title>Basic HTML Page</title>
</head>
<body>
<div>
<p>asdf</p>
<p>asdf<footnote>um actually</footnote></p>
<p>asdf</p>
<p>asdf<footnote>no</footnote></p>
</div>
<div>
<footnotecontainer></footnotecontainer>
</div>
</body>
</html>";
let document = kuchikiki::parse_html().one(page);
let d = generate_footnotes(document).unwrap();
let sup1 = d.select_first("#ft-sup-1").unwrap();
assert_eq!(sup1.attributes.borrow().get("title").unwrap(), "um actually");
assert_eq!(sup1.text_contents(), "1");
let sup2 = d.select_first("#ft-sup-2").unwrap();
assert_eq!(sup2.attributes.borrow().get("title").unwrap(), "no");
assert_eq!(sup2.text_contents(), "2");
let ft_text_1 = d.select_first("#ft-text-1").unwrap();
assert!(ft_text_1.text_contents().contains("um actually"));
let ft_text_2 = d.select_first("#ft-text-2").unwrap();
assert!(ft_text_2.text_contents().contains("no"));
assert!(d.select_first("footnote").is_err());
assert!(d.select_first("footnotecontainer").is_err());
}
#[test]
fn basic_markdown() {
let page = r"
<!DOCTYPE html>
<html>
<head>
<title>Markdown Test</title>
</head>
<body>
<h1>Hello World</h1>
<div>
<markdown>
# This is a heading
This is a paragraph with **bold text** and *italic text*.
- Item 1
- Item 2
- Item 3
</markdown>
</div>
</body>
</html>";
let document = kuchikiki::parse_html().one(page);
let d = process_markdown(document).unwrap();
assert!(d.select_first("markdown").is_err());
assert!(d.select_first("p").is_ok());
assert!(d.select_first("ul").is_ok());
}
#[test]
fn basic_syntax_highlighting() {
let page = r#"
<!DOCTYPE html>
<html>
<head>
<title>Syntax Highlighting Test</title>
</head>
<body>
<h1>Code Example</h1>
<syntaxhighlight lang="rust">
fn main() {
println!("Hello, world!");
let x = 42;
let y = "test";
}
</syntaxhighlight>
</body>
</html>"#;
let document = kuchikiki::parse_html().one(page);
let d = process_syntax_highlighting(document).unwrap();
assert!(d.select_first("syntaxhighlight").is_err());
assert!(d.select_first("pre").is_ok());
assert!(d.select_first("code").is_ok());
let pre = d.select_first("pre").unwrap();
let attrs = pre.attributes.borrow();
assert_eq!(attrs.get("data-lang"), Some("rust"));
assert!(attrs.get("class").unwrap().contains("syntax-highlight"));
}
static SERVER_POOL: ServerPool = ServerPool::new(2);
#[test]
fn basic_get() {
let server = SERVER_POOL.get_server();
server.expect(
Expectation::matching(request::method_path("GET", "/test/1")).respond_with(status_code(200).body("ret")),
);
let stdout = Rc::new(RefCell::new(String::new()));
let lua = build_lua_with_stdout(&stdout).unwrap();
let code = format!("htmlua.print(htmlua.http.get(\"{}\").body)", server.url("/test/1"));
lua.load(code).exec().unwrap();
assert_eq!(stdout.borrow().as_str(), "ret");
}
#[test]
fn basic_post() {
let server = SERVER_POOL.get_server();
server.expect(
Expectation::matching(request::method_path("POST", "/test/1")).respond_with(status_code(200).body("ret")),
);
let stdout = Rc::new(RefCell::new(String::new()));
let lua = build_lua_with_stdout(&stdout).unwrap();
let code = format!("htmlua.print(htmlua.http.post(\"{}\").body)", server.url("/test/1"));
lua.load(code).exec().unwrap();
assert_eq!(stdout.borrow().as_str(), "ret");
}
#[test]
fn get_with_data() {
let server = SERVER_POOL.get_server();
server.expect(
Expectation::matching(request::query(url_decoded(contains(("a", "b")))))
.respond_with(status_code(200).body("ret")),
);
let stdout = Rc::new(RefCell::new(String::new()));
let lua = build_lua_with_stdout(&stdout).unwrap();
let code = format!(
"
data = {{}}
data.a = \"b\"
htmlua.print(htmlua.http.get_with_data(\"{}\", data).body)
",
server.url("/test/1")
);
lua.load(code).exec().unwrap();
assert_eq!(stdout.borrow().as_str(), "ret");
}
#[test]
fn post_with_data_form() {
let server = SERVER_POOL.get_server();
server.expect(
Expectation::matching(all_of![request::method_path("POST", "/test/1"), request::body("a=b")])
.respond_with(status_code(200).body("ret")),
);
let stdout = Rc::new(RefCell::new(String::new()));
let lua = build_lua_with_stdout(&stdout).unwrap();
let code = format!(
"
data = {{}}
data.a = \"b\"
htmlua.print(htmlua.http.post_with_data_form(\"{}\", data).body)
",
server.url("/test/1")
);
lua.load(code).exec().unwrap();
assert_eq!(stdout.borrow().as_str(), "ret");
}
#[test]
fn post_with_data_json() {
let server = SERVER_POOL.get_server();
server.expect(
Expectation::matching(all_of![request::method_path("POST", "/test/1"), request::body(r#"{"a":"b"}"#)])
.respond_with(status_code(200).body("ret")),
);
let stdout = Rc::new(RefCell::new(String::new()));
let lua = build_lua_with_stdout(&stdout).unwrap();
let code = format!(
"
data = {{}}
data.a = \"b\"
htmlua.print(htmlua.http.post_with_data_json(\"{}\", data).body)
",
server.url("/test/1")
);
lua.load(code).exec().unwrap();
assert_eq!(stdout.borrow().as_str(), "ret");
}
#[test]
fn table_request() {
let server = SERVER_POOL.get_server();
server.expect(
Expectation::matching(all_of![
request::method_path("GET", "/test/1"),
request::body("cool"),
request::headers(contains(("testhdr", "val"))),
request::headers(contains(("authorization", "Basic dXNlcjpwYXNz"))),
])
.respond_with(status_code(200).body("ret")),
);
let stdout = Rc::new(RefCell::new(String::new()));
let lua = build_lua_with_stdout(&stdout).unwrap();
let code = format!(
"
req = {{}}
req.url = \"{}\"
req.method = \"GET\"
req.headers = {{}}
req.headers.testhdr = \"val\"
req.basic_auth = {{}}
req.basic_auth.username = \"user\"
req.basic_auth.password = \"pass\"
req.body = \"cool\"
htmlua.print(htmlua.http.request(req).body)
",
server.url("/test/1")
);
lua.load(code).exec().unwrap();
assert_eq!(stdout.borrow().as_str(), "ret");
}
}

View file

@ -0,0 +1,35 @@
use std::{path::Path, sync::OnceLock};
use anyhow::{Result, anyhow};
use crate::{
config::Config,
helpers::read_doc_from_file,
render::{execute_lua, expand_template, process_markdown, process_syntax_highlighting},
};
static CONFIG: OnceLock<Config> = OnceLock::new();
pub fn get_config() -> &'static Config {
CONFIG.get_or_init(|| {
Config::load().unwrap_or_else(|e| {
eprintln!("Warning: Failed to load config: {e}");
eprintln!("Using default configuration");
Config::default()
})
})
}
pub fn serve_content(request_uri: &str) -> Result<String> {
let config = get_config();
let safe_path = Path::new(request_uri).strip_prefix("/")?;
let page_path = config.paths.pages.join(safe_path);
let doc = read_doc_from_file(page_path)?
.select_first("html")
.map_err(|()| anyhow!("failed to read doc"))?;
let full_doc = expand_template(doc.as_node().to_owned(), &config.paths.components, None)?;
let markdown_doc = process_markdown(full_doc)?;
let highlighted_doc = process_syntax_highlighting(markdown_doc)?;
let executed_doc = execute_lua(highlighted_doc)?;
Ok(executed_doc.to_string())
}

View file

@ -0,0 +1 @@
<span id="inc1">included1</span>

View file

@ -0,0 +1,4 @@
<div>
<p>test</p>
<include path="comp2_level2.html"/>
</div>

View file

@ -0,0 +1 @@
<span>included_twice</span>

View file

@ -0,0 +1,9 @@
<html>
<head>
<includeelement name="t"></includeelement>
</head>
<body>
<h1>Hello World</h1>
<includeelement name="b"></includeelement>
</body>
</html>

View file

@ -0,0 +1 @@
<span id="inc1">included1</span>

View file

@ -0,0 +1,2 @@
<span id="inc2">included2</span>

View file

@ -0,0 +1 @@
<span id="inc3">included3</span>

View file

@ -0,0 +1,7 @@
<div>
<includeelement name="el1"></includeelement>
<br>
<div>
<includeelement name="el2"></includeelement>
</div>
</div>

6
htmlua-server/Cargo.toml Normal file
View file

@ -0,0 +1,6 @@
[package]
name = "htmlua-server"
version = "0.1.0"
edition = "2024"
[dependencies]

View file

@ -0,0 +1,3 @@
fn main() {
println!("Hello, world!");
}

View file

@ -1,79 +0,0 @@
const std = @import("std");
const Io = std.Io;
const File = Io.File;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ArrayList = std.ArrayList;
const StringArrayHashMap = std.StringArrayHashMapUnmanaged;
const HTMLua = @import("htmlua");
const Config = struct {
component_path: []const u8,
template_path: []const u8,
};
var component_dir: Io.Dir = undefined;
pub fn main(init: std.process.Init) !void {
const io = init.io;
var arena = std.heap.ArenaAllocator.init(init.gpa);
defer arena.deinit();
const alloc = arena.allocator();
var args = init.minimal.args.iterate();
const cwd = std.Io.Dir.cwd();
if (!args.skip()) unreachable;
const config_file_path = args.next().?;
const config_buf = try cwd.readFileAllocOptions(io, config_file_path, alloc, .unlimited, std.mem.Alignment.@"1", '\x00');
const config = try std.zon.parse.fromSliceAlloc(
Config,
alloc,
config_buf,
null,
.{},
);
component_dir = try cwd.openDir(io, config.component_path, .{});
const template_dir = try cwd.openDir(io, config.template_path, .{});
var state: HTMLua.Parser = .{
.tags = &[_]HTMLua.ParserTag{
HTMLua.tags.Include,
HTMLua.tags.Export,
HTMLua.tags.Use,
HTMLua.tags.Syntax,
},
.tag_stack = try ArrayList([]const u8).initCapacity(alloc, 256),
.exported_parts = try StringArrayHashMap([]const u8).init(alloc, &[0][]const u8{}, &[0][]const u8{}),
.component_resolver = struct {
pub fn resolve(arena_inner: *ArenaAllocator, io_inner: Io, path: []const u8) anyerror!*Io.Reader {
const read_buff = try arena_inner.allocator().alloc(u8, 16384);
const clean_path = try HTMLua.utils.sanitizePath(arena_inner.allocator(), path);
var file = try component_dir.openFile(io_inner, clean_path, .{ .mode = .read_only });
const reader = try arena_inner.allocator().create(File.Reader);
reader.* = file.reader(io_inner, read_buff);
return &reader.interface;
}
}.resolve,
};
const request_path = init.environ_map.get("PATH_INFO").?;
const clean_request_path = try HTMLua.utils.sanitizePath(alloc, request_path);
const file = try template_dir.openFile(io, clean_request_path, .{ .mode = .read_only });
defer file.close(io);
const in_buf = try alloc.alloc(u8, 16384);
var in = file.reader(io, in_buf);
const out_buf = try alloc.alloc(u8, 16384);
var out = File.stdout().writer(io, out_buf);
try out.interface.writeAll("Content-type: text/html\n");
try HTMLua.parseTemplate(&arena, io, &state, &in.interface, &out.interface);
try out.flush();
}

View file

@ -1,59 +0,0 @@
const std = @import("std");
const Io = std.Io;
const File = Io.File;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ArrayList = std.ArrayList;
const StringArrayHashMap = std.StringArrayHashMapUnmanaged;
const HTMLua = @import("root.zig");
pub fn main(init: std.process.Init) !void {
const io = init.io;
var arena = std.heap.ArenaAllocator.init(init.gpa);
defer arena.deinit();
const alloc = arena.allocator();
var state: HTMLua.Parser = .{
.component_resolver = &resolver,
.tags = &[_]HTMLua.ParserTag{
HTMLua.tags.Include,
HTMLua.tags.Export,
HTMLua.tags.Use,
},
.tag_stack = try ArrayList([]const u8).initCapacity(alloc, 256),
.exported_parts = try StringArrayHashMap([]const u8).init(alloc, &[0][]const u8{}, &[0][]const u8{}),
};
// I dont like this but this is temporary so its ok
const args = try init.minimal.args.toSlice(alloc);
const file_path = if (args.len != 1) args[1] else null;
const file = if (file_path) |fp|
try Io.Dir.cwd().openFile(io, fp, .{ .mode = .read_only })
else
null;
defer if (file) |f| f.close(io);
const in_buf = try alloc.alloc(u8, 1024);
var in =
if (file) |f| f.reader(io, in_buf) else File.stdin().reader(io, in_buf);
const out_buf = try alloc.alloc(u8, 1024);
var out = File.stdout().writer(io, out_buf);
try HTMLua.parseTemplate(&arena, io, &state, &in.interface, &out.interface);
try out.flush();
}
// https://ziggit.dev/t/zig-0-15-1-reader-writer-dont-make-copies-of-fieldparentptr-based-interfaces/11719
fn resolver(arena: *ArenaAllocator, io: Io, path: []const u8) anyerror!*Io.Reader {
const read_buff = try arena.allocator().alloc(u8, 1024);
const cwd = std.Io.Dir.cwd();
var file = try cwd.openFile(io, path, .{ .mode = .read_only });
const reader = try arena.allocator().create(Io.File.Reader);
reader.* = file.reader(io, read_buff);
return &reader.interface;
}

View file

@ -1,76 +0,0 @@
const std = @import("std");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const SplitIterator = std.mem.SplitIterator;
const DelimiterType = std.mem.DelimiterType;
const ArrayList = std.ArrayList;
const StringArrayHashMap = std.StringArrayHashMapUnmanaged;
pub const tags = @import("tags.zig");
pub const utils = @import("utils.zig");
pub const ParserFn =
fn (
arena: *ArenaAllocator,
io: Io,
state: *Parser,
reader: *Io.Reader,
writer: *Io.Writer,
attrs: *SplitIterator(u8, DelimiterType.scalar),
) anyerror!void;
pub const ParserTag = struct {
name: []const u8,
parse_fn: *const ParserFn,
};
pub const Parser = struct {
component_resolver: *const fn (alloc: *ArenaAllocator, io: Io, path: []const u8) anyerror!*Io.Reader,
exported_parts: StringArrayHashMap([]const u8),
tags: []const ParserTag,
tag_stack: ArrayList([]const u8),
};
pub fn parseTemplate(arena: *ArenaAllocator, io: Io, state: *Parser, reader: *Io.Reader, writer: *Io.Writer) !void {
outer: while (true) {
_ = reader.streamDelimiter(writer, '<') catch |err| {
if (err == Io.Reader.Error.EndOfStream) break else return err;
};
// consume '<'
_ = try reader.takeByte();
if (try reader.peekByte() == '/') {
// consume '/'
_ = try reader.takeByte();
const last_tag = state.tag_stack.getLastOrNull() orelse "";
// if reached closing tag, send execution back up the stack
if (std.mem.eql(u8, last_tag, try reader.peekDelimiterExclusive('>'))) {
// consume closing tag
_ = try reader.takeDelimiterInclusive('>');
return;
} else {
try writer.writeAll("</");
continue :outer;
}
}
const inside = reader.takeDelimiterExclusive('>') catch |err| {
if (err == error.StreamTooLong) {
try writer.writeByte('<');
break :outer;
} else return err;
};
var it = std.mem.splitScalar(u8, inside, ' ');
const tag_name = it.first();
for (state.tags) |tag| {
if (std.mem.eql(u8, tag.name, tag_name) == true) {
// consume '>'
_ = try reader.takeByte();
try tag.parse_fn(arena, io, state, reader, writer, &it);
continue :outer;
}
}
try writer.writeByte('<');
try writer.writeAll(inside);
}
}

View file

@ -1,4 +0,0 @@
pub const Include = @import("tags/Include.zig").Include;
pub const Export = @import("tags/Export.zig").Export;
pub const Use = @import("tags/Use.zig").Use;
pub const Syntax = @import("tags/Syntax.zig").Syntax;

View file

@ -1,52 +0,0 @@
const std = @import("std");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const SplitIterator = std.mem.SplitIterator;
const DelimiterType = std.mem.DelimiterType;
const parser = @import("../root.zig");
const Parser = parser.Parser;
pub const ExportTagError = error{
NoPartAttribute,
};
pub const Export: parser.ParserTag = .{
.name = "export",
.parse_fn = &parse,
};
fn parse(
arena: *ArenaAllocator,
io: Io,
state: *Parser,
reader: *Io.Reader,
writer: *Io.Writer,
attrs: *SplitIterator(u8, DelimiterType.scalar),
) anyerror!void {
_ = writer;
const raw_part_attr: []const u8 = while (attrs.next()) |attr| {
var it = std.mem.splitScalar(u8, attr, '=');
if (std.mem.eql(u8, it.first(), "part")) {
break it.next() orelse return ExportTagError.NoPartAttribute;
}
} else return ExportTagError.NoPartAttribute;
const part = try arena.allocator().dupe(u8, raw_part_attr);
var part_buffer = try Io.Writer.Allocating.initCapacity(arena.allocator(), 1024);
try state.tag_stack.append(arena.allocator(), "export");
try parser.parseTemplate(
arena,
io,
state,
reader,
&part_buffer.writer,
);
_ = state.tag_stack.pop();
try state.exported_parts.put(arena.allocator(), part, try part_buffer.toOwnedSlice());
}

View file

@ -1,58 +0,0 @@
const std = @import("std");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const SplitIterator = std.mem.SplitIterator;
const DelimiterType = std.mem.DelimiterType;
const parser = @import("../root.zig");
const Parser = parser.Parser;
pub const IncludeTagError = error{
NoPathAttribute,
PathOpenFailure,
};
pub const Include: parser.ParserTag = .{
.name = "include",
.parse_fn = &parse,
};
fn parse(
arena: *ArenaAllocator,
io: Io,
state: *Parser,
reader: *Io.Reader,
writer: *Io.Writer,
attrs: *SplitIterator(u8, DelimiterType.scalar),
) anyerror!void {
const raw_path_attr: []const u8 = while (attrs.next()) |attr| {
var it = std.mem.splitScalar(u8, attr, '=');
if (std.mem.eql(u8, it.first(), "path")) {
const raw_path = it.next() orelse return IncludeTagError.NoPathAttribute;
if (raw_path.len == 0) return IncludeTagError.NoPathAttribute;
if ((raw_path[0] == '"' and raw_path[raw_path.len - 1] == '"') or
(raw_path[0] == '\'' and raw_path[raw_path.len - 1] == '\''))
{
if (raw_path.len <= 2) return IncludeTagError.NoPathAttribute;
break raw_path[1 .. raw_path.len - 1];
} else {
break raw_path;
}
}
} else return IncludeTagError.NoPathAttribute;
const path = try arena.allocator().dupe(u8, raw_path_attr);
// parse through to gather <export> tags
try state.tag_stack.append(arena.allocator(), "include");
try parser.parseTemplate(arena, io, state, reader, writer);
_ = state.tag_stack.pop();
const included_reader = state.component_resolver(arena, io, path) catch {
std.log.err("Include Tag: failed to open file {s}", .{path});
return IncludeTagError.PathOpenFailure;
};
try parser.parseTemplate(arena, io, state, included_reader, writer);
}

View file

@ -1,169 +0,0 @@
const std = @import("std");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const SplitIterator = std.mem.SplitIterator;
const DelimiterType = std.mem.DelimiterType;
const ezts = @import("easy-zig-tree-sitter");
const ts = @import("tree-sitter");
const parser = @import("../root.zig");
const Parser = parser.Parser;
pub const SyntaxTagError = error{InvalidLanguage};
pub const Syntax: parser.ParserTag = .{
.name = "syntax",
.parse_fn = &parse,
};
fn parse(
arena: *ArenaAllocator,
io: Io,
state: *Parser,
reader: *Io.Reader,
writer: *Io.Writer,
attrs: *SplitIterator(u8, DelimiterType.scalar),
) anyerror!void {
_ = state;
_ = io;
const raw_lang_attr: []const u8 = while (attrs.next()) |attr| {
var it = std.mem.splitScalar(u8, attr, '=');
if (std.mem.eql(u8, it.first(), "lang")) {
break it.next() orelse "";
}
} else "";
const lang_key = try arena.allocator().dupe(u8, raw_lang_attr);
_ = try reader.takeDelimiterInclusive('\n');
const first_line = try reader.peekDelimiterInclusive('\n');
const whitespace_chr = for (first_line, 0..) |chr, idx| {
if (!(chr == ' ' or chr == '\t')) break idx;
} else 0;
var code = try std.ArrayList(u8).initCapacity(arena.allocator(), 1024);
while (true) {
const line = try reader.takeDelimiterInclusive('\n');
if (std.mem.indexOf(u8, line, "</syntax>")) |idx| {
try code.appendSlice(arena.allocator(), line[0..idx]);
// TODO: tags at the end will break the parser
break;
}
const whitespace_lem = @min(line.len, whitespace_chr);
if (std.mem.countScalar(u8, line[0..whitespace_lem], ' ') + std.mem.countScalar(u8, line[0..whitespace_lem], '\t') < whitespace_lem) {
try code.appendSlice(arena.allocator(), line);
} else {
try code.appendSlice(arena.allocator(), line[whitespace_lem..line.len]);
}
}
// Alignment issues?
// ts.setAllocator(arena.allocator());
// TODO: TS Thread Mutex
// TODO: null handeling
var hlp = HLParser.create(lang_key, code.items) orelse {
std.log.warn("Failed to make Parser for {s}", .{lang_key});
return;
};
defer hlp.destroy();
var next_hl = hlp.nextHL();
var end_stack = try std.ArrayList(u32).initCapacity(arena.allocator(), 1024);
var code_idx: u32 = 0;
try writer.writeAll("<pre class=\"hl\"><code>");
while (code_idx != code.items.len) {
var next_idx: u32 = @intCast(code.items.len);
if (end_stack.getLastOrNull()) |last| {
if (last == code_idx) {
try writer.writeAll("</span>");
_ = end_stack.pop();
continue;
} else if (last < next_idx) {
next_idx = last;
}
}
if (next_hl) |hl| {
if (hl.start == code_idx) {
try writer.writeAll("<span class=\"hl");
// no '.' in css class names
var it = std.mem.splitScalar(u8, hl.name, '.');
while (it.next()) |n| {
try writer.writeByte('-');
try writer.writeAll(n);
}
try writer.writeAll("\">");
try end_stack.append(arena.allocator(), hl.end);
next_hl = hlp.nextHL();
continue;
} else if (hl.start < next_idx) {
next_idx = hl.start;
}
}
try writer.writeAll(code.items[code_idx..next_idx]);
code_idx = next_idx;
}
try writer.writeAll("</code></pre>");
}
const HLGroup = struct {
name: []const u8,
start: u32,
end: u32,
};
const HLParser = struct {
lang: *ts.Language,
tree: *ts.Tree,
query: *ts.Query,
cursor: *ts.QueryCursor,
fn create(lang_key: []const u8, code: []const u8) ?HLParser {
const lang = ezts.getLang(lang_key) orelse return null;
const query_string = ezts.getQuery(lang_key, "highlights") orelse return null;
const ps = ts.Parser.create();
ps.setLanguage(lang) catch return null;
const tree = ps.parseString(code, null) orelse return null;
var error_offset: u32 = 0;
const query = ts.Query.create(lang, query_string, &error_offset) catch return null;
const cursor = ts.QueryCursor.create();
cursor.exec(query, tree.rootNode());
return .{
.lang = lang,
.tree = tree,
.query = query,
.cursor = cursor,
};
}
fn destroy(self: *HLParser) void {
self.cursor.destroy();
self.query.destroy();
self.tree.destroy();
self.lang.destroy();
}
fn nextHL(self: *HLParser) ?HLGroup {
if (self.cursor.nextCapture()) |data| {
const idx, const match = data;
const c = match.captures[idx];
return .{
.start = c.node.startByte(),
.end = c.node.endByte(),
.name = self.query.captureNameForId(c.index) orelse return null,
};
} else return null;
}
};

View file

@ -1,48 +0,0 @@
const std = @import("std");
const Io = std.Io;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const SplitIterator = std.mem.SplitIterator;
const DelimiterType = std.mem.DelimiterType;
const parser = @import("../root.zig");
const Parser = parser.Parser;
pub const UseTagError = error{ NoPartAttribute, NoPartExported };
pub const Use: parser.ParserTag = .{
.name = "use",
.parse_fn = &parse,
};
fn parse(
arena: *ArenaAllocator,
io: Io,
state: *Parser,
reader: *Io.Reader,
writer: *Io.Writer,
attrs: *SplitIterator(u8, DelimiterType.scalar),
) anyerror!void {
_ = io;
const raw_part_attr: []const u8 = while (attrs.next()) |attr| {
var it = std.mem.splitScalar(u8, attr, '=');
if (std.mem.eql(u8, it.first(), "part")) {
break it.next() orelse return UseTagError.NoPartAttribute;
}
} else return UseTagError.NoPartAttribute;
const part = try arena.allocator().dupe(u8, raw_part_attr);
const part_text = state.exported_parts.get(part) orelse return UseTagError.NoPartExported;
var part_reader = Io.Reader.fixed(part_text);
_ = try part_reader.streamRemaining(writer);
while (true) {
_ = try reader.discardDelimiterInclusive('<');
const inside = try reader.takeDelimiterInclusive('>');
if (std.mem.eql(u8, "/use>", inside)) return;
}
}

View file

@ -1,23 +0,0 @@
const std = @import("std");
const Io = std.Io;
const File = Io.File;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const ArrayList = std.ArrayList;
const StringArrayHashMap = std.StringArrayHashMapUnmanaged;
pub fn sanitizePath(alloc: Allocator, path: []const u8) ![]const u8 {
const clean = try alloc.alloc(u8, path.len);
var list = ArrayList(u8).initBuffer(clean);
var pit = std.mem.splitScalar(u8, path, '/');
while (pit.next()) |part| {
if (part.len == 0) continue;
if (std.mem.eql(u8, part, "..")) continue;
// This should never actually allocate
try list.appendSlice(alloc, part);
try list.append(alloc, '/');
}
const new_len = list.items.len - 1;
_ = alloc.resize(clean, new_len);
return clean[0..new_len];
}