Initial commit
This commit is contained in:
commit
8e833c85c8
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
__pycache__
|
||||
*.pyc
|
38
htmlcc/__main__.py
Normal file
38
htmlcc/__main__.py
Normal 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()
|
73
htmlcc/emitter/__init__.py
Normal file
73
htmlcc/emitter/__init__.py
Normal 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
42
htmlcc/emitter/cgit.py
Normal 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
185
htmlcc/parser.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user