this removes Flask, reworks a number of library methods accordingly, and adds generators and build commands to process the instance directory (largely unchanged, except config.py is now config.json) and spit out files suitable to be served by a web server such as Nginx. there are probably some rough edges here, but overall this works. also note, as this is no longer server software on a network, the license has changed from AGPLv3 to GPLv3, and the "or any later version" allowance has been removed Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
189 lines
8.7 KiB
Python
189 lines
8.7 KiB
Python
"""Build an instance as a static site suitable for serving via e.g. Nginx.
|
|
|
|
SPDX-FileCopyrightText: © 2022 Brian S. Stephan <bss@incorporeal.org>
|
|
SPDX-License-Identifier: GPL-3.0-only
|
|
"""
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import tempfile
|
|
|
|
from termcolor import cprint
|
|
|
|
from incorporealcms import __version__, init_instance
|
|
from incorporealcms.markdown import handle_markdown_file_path
|
|
|
|
|
|
class StaticSiteGenerator(object):
|
|
"""Generate static site output based on the instance's content."""
|
|
|
|
def __init__(self, instance_dir: str, output_dir: str, extra_config=None):
|
|
"""Create the object to run various operations to generate the static site.
|
|
|
|
Args:
|
|
instance_dir: the directory from which to read an instance format set of content
|
|
output_dir: the directory to write the generated static site to
|
|
"""
|
|
self.instance_dir = instance_dir
|
|
self.output_dir = output_dir
|
|
|
|
instance_dir = os.path.abspath(instance_dir)
|
|
output_dir = os.path.abspath(output_dir)
|
|
|
|
# initialize configuration with the path to the instance
|
|
init_instance(instance_dir, extra_config)
|
|
|
|
def build(self):
|
|
"""Build the whole static site."""
|
|
# putting the temporary directory next to the desired output so we can safely rename it later
|
|
tmp_output_dir = tempfile.mkdtemp(dir=os.path.dirname(self.output_dir))
|
|
cprint(f"creating temporary directory '{tmp_output_dir}' for writing", 'green')
|
|
|
|
# copy core content
|
|
pages_dir = os.path.join(self.instance_dir, 'pages')
|
|
self.build_in_destination(pages_dir, tmp_output_dir)
|
|
|
|
# copy the program's static dir
|
|
program_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static')
|
|
static_output_dir = os.path.join(tmp_output_dir, 'static')
|
|
try:
|
|
os.mkdir(static_output_dir)
|
|
except FileExistsError:
|
|
# already exists
|
|
pass
|
|
self.build_in_destination(program_static_dir, static_output_dir, convert_markdown=False)
|
|
|
|
# copy the instance's static dir --- should I deprecate this since it could just be stuff in pages/static/?
|
|
custom_static_dir = os.path.join(self.instance_dir, 'custom-static')
|
|
self.build_in_destination(custom_static_dir, static_output_dir, convert_markdown=False)
|
|
|
|
# move temporary dir to the destination
|
|
old_output_dir = f'{self.output_dir}-old-{os.path.basename(tmp_output_dir)}'
|
|
if os.path.exists(self.output_dir):
|
|
cprint(f"renaming '{self.output_dir}' to '{old_output_dir}'", 'green')
|
|
os.rename(self.output_dir, old_output_dir)
|
|
cprint(f"renaming '{tmp_output_dir}' to '{self.output_dir}'", 'green')
|
|
os.rename(tmp_output_dir, self.output_dir)
|
|
os.chmod(self.output_dir,
|
|
stat.S_IWUSR | stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
|
|
# TODO: unlink old dir above? arg flag?
|
|
|
|
def build_in_destination(self, source_dir: str, dest_dir: str, convert_markdown: bool = True) -> None:
|
|
"""Walk the source directory and copy and/or convert its contents into the destination.
|
|
|
|
Args:
|
|
source_dir: the directory to copy into the destination
|
|
dest_dir: the directory to place copied/converted files into
|
|
convert_markdown: whether or not to convert Markdown files (or simply copy them)
|
|
"""
|
|
cprint(f"copying files from '{source_dir}' to '{dest_dir}'", 'green')
|
|
os.chdir(source_dir)
|
|
for base_dir, subdirs, files in os.walk(source_dir):
|
|
# remove the absolute path of the directory from the base_dir
|
|
base_dir = os.path.relpath(base_dir, source_dir)
|
|
# create subdirs seen here for subsequent depth
|
|
for subdir in subdirs:
|
|
self.build_subdir_in_destination(source_dir, base_dir, subdir, dest_dir)
|
|
|
|
# process and copy files
|
|
for file_ in files:
|
|
self.build_file_in_destination(source_dir, base_dir, file_, dest_dir, convert_markdown)
|
|
|
|
def build_subdir_in_destination(self, source_dir: str, base_dir: str, subdir: str, dest_dir: str) -> None:
|
|
"""Create a subdir (which might actually be a symlink) in the output dir.
|
|
|
|
Args:
|
|
source_dir: the absolute path of the location in the instance, contains subdir
|
|
base_dir: the relative path of the location in the instance, contains subdir
|
|
subdir: the subdir in the instance to replicate in the output
|
|
dest_dir: the output directory to place the subdir in
|
|
"""
|
|
dst = os.path.join(dest_dir, base_dir, subdir)
|
|
if os.path.islink(os.path.join(base_dir, subdir)):
|
|
# keep the link relative to the output directory
|
|
src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, subdir))
|
|
print(f"creating directory symlink '{dst}' -> '{src}'")
|
|
os.symlink(src, dst, target_is_directory=True)
|
|
else:
|
|
print(f"creating directory '{dst}'")
|
|
try:
|
|
os.mkdir(dst)
|
|
except FileExistsError:
|
|
# already exists
|
|
pass
|
|
|
|
def build_file_in_destination(self, source_dir: str, base_dir: str, file_: str, dest_dir: str,
|
|
convert_markdown=False) -> None:
|
|
"""Create a file (which might actually be a symlink) in the output dir.
|
|
|
|
Args:
|
|
source_dir: the absolute path of the location in the instance, contains subdir
|
|
base_dir: the relative path of the location in the instance, contains subdir
|
|
file_: the file in the instance to replicate in the output
|
|
dest_dir: the output directory to place the subdir in
|
|
"""
|
|
dst = os.path.join(dest_dir, base_dir, file_)
|
|
if os.path.islink(os.path.join(base_dir, file_)):
|
|
# keep the link relative to the output directory
|
|
src = self.symlink_to_relative_dest(source_dir, os.path.join(base_dir, file_))
|
|
print(f"creating symlink '{dst}' -> '{src}'")
|
|
os.symlink(src, dst, target_is_directory=False)
|
|
else:
|
|
src = os.path.join(base_dir, file_)
|
|
print(f"copying file '{src}' -> '{dst}'")
|
|
shutil.copy2(src, dst)
|
|
|
|
# render markdown as HTML
|
|
if src.endswith('.md') and convert_markdown:
|
|
rendered_file = dst.removesuffix('.md') + '.html'
|
|
try:
|
|
content = handle_markdown_file_path(src)
|
|
except UnicodeDecodeError:
|
|
# perhaps this isn't a markdown file at all for some reason; we
|
|
# copied it above so stick with tha
|
|
cprint(f"{src} has invalid bytes! skipping", 'yellow')
|
|
else:
|
|
with open(rendered_file, 'w') as dst_file:
|
|
dst_file.write(content)
|
|
|
|
def symlink_to_relative_dest(self, base_dir: str, source: str) -> str:
|
|
"""Given a symlink, make sure it points to something inside the instance and provide its real destination.
|
|
|
|
Args:
|
|
base_dir: the full absolute path of the instance's pages dir, which the symlink destination must be in.
|
|
source: the symlink to check
|
|
Returns:
|
|
what the symlink points at
|
|
"""
|
|
if not os.path.realpath(source).startswith(base_dir):
|
|
raise ValueError(f"symlink destination {os.path.realpath(source)} is outside the instance!")
|
|
# this symlink points to realpath inside base_dir, so relative to base_dir, the symlink dest is...
|
|
return os.path.relpath(os.path.realpath(source), base_dir)
|
|
|
|
|
|
def build():
|
|
"""Build the static site generated against an instance directory."""
|
|
parser = argparse.ArgumentParser(
|
|
description="Build the static site generated against an instance directory.",
|
|
)
|
|
parser.add_argument(
|
|
'instance_dir', help="path to instance directory root (NOTE: the program will go into pages/)"
|
|
)
|
|
parser.add_argument(
|
|
'output_dir', help="path to directory to output to (NOTE: the program must be able to write into its parent!)"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
cprint(f"incorporealcms-build v{__version__} Copyright (C) 2025 Brian S. Stephan <bss@incorporeal.org>", 'green')
|
|
# check output path before doing work
|
|
if not os.path.isdir(args.output_dir):
|
|
# if it doesn't exist, great, we'll just move the temporary dir later;
|
|
# if it exists and is a dir, that's fine, but if it's a file, we should error
|
|
if os.path.exists(args.output_dir):
|
|
raise ValueError(f"specified output path '{args.output_dir}' exists as a file!")
|
|
|
|
site_gen = StaticSiteGenerator(args.instance_dir, args.output_dir)
|
|
site_gen.build()
|