Initial commit

This commit is contained in:
RunasSudo 2025-05-14 15:38:38 +10:00
commit 8e833c85c8
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
5 changed files with 340 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
__pycache__
*.pyc

38
htmlcc/__main__.py Normal file
View File

@ -0,0 +1,38 @@
# htmlcc - Statically compiled HTML templates for C
# Copyright (C) 2025 Lee Yingtong Li
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from .emitter import Emitter, known_emitters
from .emitter.cgit import CgitEmitter
from .parser import Parser
import argparse
# Initialise ArgumentParser
arg_parser = argparse.ArgumentParser(prog='htmlcc', description='Statically compiled HTML templates for C')
arg_parser.add_argument('--emitter', default='cgit', choices=list(known_emitters.keys()))
arg_parser.add_argument('filename', nargs='+')
args = arg_parser.parse_args()
emitter = known_emitters[args.emitter]()
emitter.emit_preamble()
# Process files
for filename in args.filename:
with open(filename, 'r') as f:
content = f.read() # Entire file must be read into memory for str.index etc.
parser = Parser(buffer=content, emitter=emitter)
parser.parse()

View File

@ -0,0 +1,73 @@
# htmlcc - Statically compiled HTML templates for C
# Copyright (C) 2025 Lee Yingtong Li
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
class Emitter:
"""Base class for emitters"""
def emit(self, s: str) -> None:
print(s)
def emit_raw_c(self, raw_c: str) -> None:
"""Emit raw C code"""
self.emit(raw_c)
def emit_preamble(self) -> None:
"""Emit the preamble for the output file"""
return
def output_literal_string(self, literal_string: str) -> None:
"""Emit code to output a literal string"""
raise NotImplementedError()
def output_variable_as_attr(self, variable: str) -> None:
"""Emit code to output a variable, escaping for HTML text"""
raise NotImplementedError()
def output_variable_as_text(self, variable: str) -> None:
"""Emit code to output a variable, escaping for HTML text"""
raise NotImplementedError()
def start_page(self, page_name: str) -> None:
"""Called at {% page ... %}"""
self.emit('void ' + page_name + '(void) {')
def end_page(self) -> None:
"""Called at {% endpage %}"""
self.emit('}')
# Utility functions
@staticmethod
def escape_cstr(s: str) -> str:
"""Escape the string as a C string, wrapping with `"` character"""
s = s.replace('\\', '\\\\')
s = s.replace('"', '\\"')
s = s.replace('\n', '\\n')
return f'"{s}"'
# Known emitter registry
known_emitters = {}
def register_emitter(name: str, emitter: type[Emitter]) -> None:
known_emitters[name] = emitter

42
htmlcc/emitter/cgit.py Normal file
View File

@ -0,0 +1,42 @@
# htmlcc - Statically compiled HTML templates for C
# Copyright (C) 2025 Lee Yingtong Li
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from . import Emitter, register_emitter
class CgitEmitter(Emitter):
"""Emitter for the cgit CGI engine"""
def emit_preamble(self) -> None:
self.emit('''#define USE_THE_REPOSITORY_VARIABLE
#include "../cgit.h"
#include "../html.h"
#include "../ui-shared.h"
#include "version.h"''')
def output_literal_string(self, literal_string: str) -> None:
self.emit(f'html({self.escape_cstr(literal_string)});')
def output_variable_as_attr(self, variable: str) -> None:
self.emit(f'html_attr({variable});')
def output_variable_as_text(self, variable: str) -> None:
self.emit(f'html_txt({variable});')
def start_page(self, page_name: str) -> None:
super().start_page(page_name)
self.emit('cgit_print_http_headers();')
register_emitter('cgit', CgitEmitter)

185
htmlcc/parser.py Normal file
View File

@ -0,0 +1,185 @@
# htmlcc - Statically compiled HTML templates for C
# Copyright (C) 2025 Lee Yingtong Li
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from .emitter import Emitter
class Parser:
"""Parser implementation for template files"""
def __init__(self, buffer: str, emitter: Emitter):
self.buffer = buffer
self.emitter = emitter
# Internal state
self.cur_line_contains_handlebars = False
self.cur_line_contains_nonblank_literal = False
self.cur_line_leading_ws = '' # Buffer for leading whitespace
self.in_page = False
def parse(self) -> None:
"""Parse the entire file"""
while self.buffer:
self.parse_toplevel()
def parse_toplevel(self) -> None:
"""Parse a literal string or handlebars"""
if self.buffer.startswith('{{'):
self.parse_handlebars_variable()
elif self.buffer.startswith('{!'):
self.parse_handlebars_raw_c()
elif self.buffer.startswith('{%'):
self.parse_handlebars_keyword()
elif self.buffer.startswith('{#'):
self.parse_handlebars_comment()
else:
self.parse_literal()
def parse_handlebars_comment(self) -> None:
"""Parse {# ... #} handlebars"""
self.cur_line_contains_handlebars = True
# Read until and including #}
stop_reading_idx = self.buffer.index('#}') + 2
self.buffer = self.buffer[stop_reading_idx:]
def parse_handlebars_keyword(self) -> None:
"""Parse {% ... %} handlebars"""
self.cur_line_contains_handlebars = True
# Read until and including %}
stop_reading_idx = self.buffer.index('%}') + 2
s = self.buffer[:stop_reading_idx]
self.buffer = self.buffer[stop_reading_idx:]
command = s[len('{%'):-len('%}')].strip()
if command.startswith('page '):
# {% page ... %}
page_name = command[len('page '):].strip()
self.emitter.start_page(page_name)
self.in_page = True
elif command == 'endpage':
# {% endpage %}
self.emitter.end_page()
self.in_page = False
else:
raise SyntaxError(f'Unknown command "{command}"')
def parse_handlebars_variable(self) -> None:
"""Parse {{ ... }} handlebars"""
self.cur_line_contains_handlebars = True
# Read until and including }}
stop_reading_idx = self.buffer.index('}}') + 2
s = self.buffer[:stop_reading_idx]
self.buffer = self.buffer[stop_reading_idx:]
variable = s[len('{{'):-len('}}')].strip()
# Detect filters
if variable.endswith('attr') and variable[:-len('attr')].rstrip().endswith('|'):
# Output as HTML attribute
variable = variable[:-len('attr')].rstrip()[:-1].rstrip()
self.emitter.output_variable_as_attr(variable)
else:
# No filter - output as text
self.emitter.output_variable_as_text(variable)
def parse_handlebars_raw_c(self) -> None:
"""Parse {! ... !} handlebars"""
self.cur_line_contains_handlebars = True
# Read until and including !}
stop_reading_idx = self.buffer.index('!}') + 2
s = self.buffer[:stop_reading_idx]
self.buffer = self.buffer[stop_reading_idx:]
raw_c = s[len('{!'):-len('!}')].strip()
self.emitter.emit_raw_c(raw_c)
def parse_literal(self) -> None:
"""Parse literal string"""
# Read until the next newline or handlebars or EOF
stop_reading_idx_candidates = [
len(self.buffer), # EOF is backup case
self.buffer.index('\n') + 1 if '\n' in self.buffer else None, # Include the newline character
self.buffer.index('{{') if '{{' in self.buffer else None,
self.buffer.index('{!') if '{!' in self.buffer else None,
self.buffer.index('{%') if '{%' in self.buffer else None,
self.buffer.index('{#') if '{#' in self.buffer else None,
]
stop_reading_idx = min(idx for idx in stop_reading_idx_candidates if idx is not None)
s = self.buffer[:stop_reading_idx]
self.buffer = self.buffer[stop_reading_idx:]
if not self.in_page:
# Not in a page block!
if s.isspace():
# Suppress whitespace outside page blocks
return
raise SyntaxError('Unexpected text outside page block')
if s.isspace():
if not self.cur_line_contains_nonblank_literal:
if '\n' in s:
# End of line which contains only nonblank literals
if self.cur_line_contains_handlebars:
# Do not print the whitespace
self.reset_new_line()
return
else:
# Not sure yet whether we should print the space - add to whitespace buffer
self.cur_line_leading_ws += s
return
else:
self.commit_leading_ws()
self.cur_line_contains_nonblank_literal = True
# Conditionally output the literal string
if not self.cur_line_contains_nonblank_literal and self.cur_line_contains_handlebars:
# If the current line contains handlebars and whitespace only, do not emit literal string
return
self.emitter.output_literal_string(s)
if '\n' in s:
self.reset_new_line()
def reset_new_line(self) -> None:
"""Reset the internal state for a new line"""
self.cur_line_contains_handlebars = False
self.cur_line_contains_nonblank_literal = False
self.cur_line_leading_ws = ''
def commit_leading_ws(self) -> None:
"""Commit cur_line_leading_ws buffer to output"""
if self.cur_line_leading_ws:
self.emitter.output_literal_string(self.cur_line_leading_ws)
self.cur_line_leading_ws = ''
class SyntaxError(Exception):
pass