commit 8e833c85c80ec4c8641a0f4fb511efddf4ad2d9d Author: RunasSudo Date: Wed May 14 15:38:38 2025 +1000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d35cb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +*.pyc diff --git a/htmlcc/__main__.py b/htmlcc/__main__.py new file mode 100644 index 0000000..05b6179 --- /dev/null +++ b/htmlcc/__main__.py @@ -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 . + +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() diff --git a/htmlcc/emitter/__init__.py b/htmlcc/emitter/__init__.py new file mode 100644 index 0000000..daad541 --- /dev/null +++ b/htmlcc/emitter/__init__.py @@ -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 . + +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 diff --git a/htmlcc/emitter/cgit.py b/htmlcc/emitter/cgit.py new file mode 100644 index 0000000..c169921 --- /dev/null +++ b/htmlcc/emitter/cgit.py @@ -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 . + +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) diff --git a/htmlcc/parser.py b/htmlcc/parser.py new file mode 100644 index 0000000..13bcc10 --- /dev/null +++ b/htmlcc/parser.py @@ -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 . + +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