tinyssg 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tinyssg-0.0.0/PKG-INFO ADDED
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.2
2
+ Name: tinyssg
3
+ Version: 0.0.0
4
+ Summary: A simple static site generator
5
+ Author: Uniras
6
+ Author-email: tkappeng@gmail.com
7
+ License: MIT License
8
+ Project-URL: Homepage, https://github.com/uniras/tinyssg
9
+ Project-URL: Repository, https://github.com/uniras/tinyssg
10
+ Keywords: ssg,static site generator,html
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.7
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: Utilities
26
+ Description-Content-Type: text/markdown
27
+
28
+ # TinySSG
29
+
30
+ ## 概要
31
+
32
+ TinySSGは、ファイルベースルーティングのシンプルな静的サイトジェネレータです。
33
+ ページを簡単な構造のTinySSGPageクラスを継承したPythonコードで記述することで単純さと柔軟性を両立しています。
34
+
35
+ ## インストール
36
+
37
+ ```bash
38
+ pip install tinyssg
39
+ ```
40
+
41
+ ## 使い方
42
+
43
+ ### ディレクトリ構成
44
+
45
+ 以下のようにディレクトリを構成します。ディレクトリ名はオプション引数で変更可能です。
46
+
47
+ ```text
48
+ --- proect
49
+ |-- pages SSGの対象となるPythonファイルを配置します
50
+ |-- libs SSG・デプロイの対象にならないPythonファイルを配置します(ライブラリなど)
51
+ |-- static SSGの対象にならない静的ファイルを配置します(css, 画像など)
52
+ |-- dist SSGの結果が出力されるディレクトリです。このディレクトリの中身をWebサーバに配置することでWebサイトとして公開できます。
53
+ |-- static staticディレクトリはこのディレクトリにコピーされます
54
+ ```
55
+
56
+ pages, libs, staticディレクトリは、開発サーバー起動時に監視され、ファイルが変更されると自動的にサーバーを再起動します。
57
+
58
+ ### ページの作成
59
+
60
+ `Page`ディレクトリ内にPythonファイルを作り、`TinySSGPage`クラスを継承したクラスを作成します。
61
+
62
+ ```python
63
+ from tinyssg import TinySSGPage
64
+
65
+ class IndexPage(TinySSGPage):
66
+ def query(self):
67
+ return {
68
+ 'title': 'Index',
69
+ 'content': 'Hello, World!'
70
+ }
71
+
72
+ def template(self):
73
+ return '''
74
+ <!DOCTYPE html>
75
+ <html>
76
+ <head>
77
+ <meta charset="utf-8" />
78
+ <title>{{ title }}</title>
79
+ </head>
80
+ <body>
81
+ <h1>{{ title }}</h1>
82
+ <p>{{ content }}</p>
83
+ </body>
84
+ </html>'''
85
+ ```
86
+
87
+ `query`メソッドでテンプレートに渡すデータを返し、`template`メソッドでHTMLテンプレートを返します。
88
+ TinySSGは、これらのメソッドの返り値を使ってHTMLを生成します。
89
+
90
+ `query`メソッドが返すデータは、Python辞書形式またはPython辞書のリスト形式である必要があります。
91
+ 辞書形式の場合はpythonファイル名のHTMLファイルが生成され、リスト形式の場合はPythonファイル名と同じディレクトリが作成され、デフォルトでは1からの数字.htmlのファイル名でHTMLファイルが生成されます。
92
+ `return`の際にタプルとしてリストと一緒にキー名を表す文字列を返すと、そのキーに対応する値をファイル名としてHTMLファイルが生成されます。
93
+
94
+ `TinySSG`はデフォルトでは単純にテンプレートの`{{ キー名 }}`で囲まれた部分を`query`メソッドの返り値である辞書のキーに対応する値で単純に置換するだけですが、
95
+ `render`メソッドをオーバーライドすることで、より複雑な処理を行うこともできます。Jinja2などのテンプレートエンジンを使うこともできます。
96
+
97
+ また、`translate`メソッドをオーバーライドすることで、レンダー後のテキストを最終的なHTMLに変換する処理を定義することもできます。
98
+ ここでmarkdownライブラリを使って変換する処理を記述すればテンプレートをHTMLではなくMarkdownで記述することができます。
99
+
100
+ それぞれページごとに定義することになりますが、単純なPythonクラスですので複数のページに適用したい場合は共通部分を定義したクラスを作成し、それを継承することでコードをコピーすることなく簡単に適用することができます。
101
+
102
+ ### HTMLの生成
103
+
104
+ ```bash
105
+ python -m tinyssg gen
106
+ ```
@@ -0,0 +1,79 @@
1
+ # TinySSG
2
+
3
+ ## 概要
4
+
5
+ TinySSGは、ファイルベースルーティングのシンプルな静的サイトジェネレータです。
6
+ ページを簡単な構造のTinySSGPageクラスを継承したPythonコードで記述することで単純さと柔軟性を両立しています。
7
+
8
+ ## インストール
9
+
10
+ ```bash
11
+ pip install tinyssg
12
+ ```
13
+
14
+ ## 使い方
15
+
16
+ ### ディレクトリ構成
17
+
18
+ 以下のようにディレクトリを構成します。ディレクトリ名はオプション引数で変更可能です。
19
+
20
+ ```text
21
+ --- proect
22
+ |-- pages SSGの対象となるPythonファイルを配置します
23
+ |-- libs SSG・デプロイの対象にならないPythonファイルを配置します(ライブラリなど)
24
+ |-- static SSGの対象にならない静的ファイルを配置します(css, 画像など)
25
+ |-- dist SSGの結果が出力されるディレクトリです。このディレクトリの中身をWebサーバに配置することでWebサイトとして公開できます。
26
+ |-- static staticディレクトリはこのディレクトリにコピーされます
27
+ ```
28
+
29
+ pages, libs, staticディレクトリは、開発サーバー起動時に監視され、ファイルが変更されると自動的にサーバーを再起動します。
30
+
31
+ ### ページの作成
32
+
33
+ `Page`ディレクトリ内にPythonファイルを作り、`TinySSGPage`クラスを継承したクラスを作成します。
34
+
35
+ ```python
36
+ from tinyssg import TinySSGPage
37
+
38
+ class IndexPage(TinySSGPage):
39
+ def query(self):
40
+ return {
41
+ 'title': 'Index',
42
+ 'content': 'Hello, World!'
43
+ }
44
+
45
+ def template(self):
46
+ return '''
47
+ <!DOCTYPE html>
48
+ <html>
49
+ <head>
50
+ <meta charset="utf-8" />
51
+ <title>{{ title }}</title>
52
+ </head>
53
+ <body>
54
+ <h1>{{ title }}</h1>
55
+ <p>{{ content }}</p>
56
+ </body>
57
+ </html>'''
58
+ ```
59
+
60
+ `query`メソッドでテンプレートに渡すデータを返し、`template`メソッドでHTMLテンプレートを返します。
61
+ TinySSGは、これらのメソッドの返り値を使ってHTMLを生成します。
62
+
63
+ `query`メソッドが返すデータは、Python辞書形式またはPython辞書のリスト形式である必要があります。
64
+ 辞書形式の場合はpythonファイル名のHTMLファイルが生成され、リスト形式の場合はPythonファイル名と同じディレクトリが作成され、デフォルトでは1からの数字.htmlのファイル名でHTMLファイルが生成されます。
65
+ `return`の際にタプルとしてリストと一緒にキー名を表す文字列を返すと、そのキーに対応する値をファイル名としてHTMLファイルが生成されます。
66
+
67
+ `TinySSG`はデフォルトでは単純にテンプレートの`{{ キー名 }}`で囲まれた部分を`query`メソッドの返り値である辞書のキーに対応する値で単純に置換するだけですが、
68
+ `render`メソッドをオーバーライドすることで、より複雑な処理を行うこともできます。Jinja2などのテンプレートエンジンを使うこともできます。
69
+
70
+ また、`translate`メソッドをオーバーライドすることで、レンダー後のテキストを最終的なHTMLに変換する処理を定義することもできます。
71
+ ここでmarkdownライブラリを使って変換する処理を記述すればテンプレートをHTMLではなくMarkdownで記述することができます。
72
+
73
+ それぞれページごとに定義することになりますが、単純なPythonクラスですので複数のページに適用したい場合は共通部分を定義したクラスを作成し、それを継承することでコードをコピーすることなく簡単に適用することができます。
74
+
75
+ ### HTMLの生成
76
+
77
+ ```bash
78
+ python -m tinyssg gen
79
+ ```
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tinyssg"
7
+ version = "0.0.0"
8
+ description = "A simple static site generator"
9
+ readme = "README.md"
10
+ authors = [
11
+ {name = "Uniras"},
12
+ {email = "tkappeng@gmail.com"}
13
+ ]
14
+ license = {text = "MIT License"}
15
+ keywords = ["ssg", "static site generator", "html"]
16
+ classifiers = [
17
+ "Development Status :: 5 - Production/Stable",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.7",
22
+ "Programming Language :: Python :: 3.8",
23
+ "Programming Language :: Python :: 3.9",
24
+ "Programming Language :: Python :: 3.10",
25
+ "Programming Language :: Python :: 3.11",
26
+ "Programming Language :: Python :: 3.12",
27
+ "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Topic :: Software Development :: Libraries",
30
+ "Topic :: Software Development :: Libraries :: Python Modules",
31
+ "Topic :: Utilities",
32
+ ]
33
+
34
+ [project.urls]
35
+ Homepage = "https://github.com/uniras/tinyssg"
36
+ Repository = "https://github.com/uniras/tinyssg"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,716 @@
1
+ import argparse
2
+ import importlib.util
3
+ import inspect
4
+ import json
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import webbrowser
12
+ from http.server import HTTPServer, SimpleHTTPRequestHandler
13
+
14
+
15
+ class TinySSGPage:
16
+ """
17
+ Base class for HTML page generation
18
+ """
19
+ def render(self, src: str, data: dict) -> str:
20
+ """
21
+ Template rendering process
22
+ """
23
+ return TinySSGUtility.render_variables(src, data)
24
+
25
+ def translate(self, basestr: str) -> str:
26
+ """
27
+ Process to convert rendered text to HTML
28
+ """
29
+ return basestr
30
+
31
+ def query(self) -> dict:
32
+ """
33
+ Data Acquisition Process
34
+ """
35
+ return {}
36
+
37
+ def template(self) -> str:
38
+ """
39
+ Template string
40
+ """
41
+ raise TinySSGException(f"The Page class corresponding to {self.__class__.__name__} does not appear to be implemented correctly.")
42
+
43
+
44
+ class TinySSGException(Exception):
45
+ """
46
+ TinySSG Exception Class
47
+ """
48
+ pass
49
+
50
+
51
+ class TinySSGUtility:
52
+ """
53
+ TinySSG Utility Class
54
+ """
55
+ @classmethod
56
+ def render_variables(cls, src: str, data: dict, start_delimiter: str = r'\{\{\s?', end_delimiter: str = r'\s?\}\}') -> str:
57
+ """
58
+ Replace variables in the template with the values in the dictionary
59
+ """
60
+ result = src
61
+ for key, value in data.items():
62
+ result = re.sub(start_delimiter + re.escape(key) + end_delimiter, str(value), result)
63
+ return result
64
+
65
+ @classmethod
66
+ def get_fullpath(cls, args: dict, pathkey: str) -> str:
67
+ """
68
+ Get the full path from the relative path
69
+ """
70
+ if isinstance(args['curdir'], str) and len(args['curdir']) > 0:
71
+ return os.path.join(args['curdir'], args[pathkey])
72
+ else:
73
+ return os.path.join(os.getcwd(), args[pathkey])
74
+
75
+ @classmethod
76
+ def clear_output(cls, output_full_path: str) -> None:
77
+ """
78
+ Delete the output directory
79
+ """
80
+ if os.path.exists(output_full_path):
81
+ shutil.rmtree(output_full_path)
82
+
83
+ @classmethod
84
+ def clear_start(cls, args: dict) -> None:
85
+ """
86
+ Delete the output directory
87
+ """
88
+ output_full_path = cls.get_fullpath(args, 'output')
89
+ cls.clear_output(output_full_path)
90
+
91
+ @classmethod
92
+ def log_print(cls, message: str) -> None:
93
+ """
94
+ Output log message (Console Execution Only)
95
+ """
96
+ try:
97
+ from IPython import get_ipython # type: ignore
98
+ env = get_ipython().__class__.__name__
99
+ if env == 'ZMQInteractiveShell':
100
+ return
101
+ except: # noqa: E722
102
+ pass
103
+
104
+ print(message)
105
+
106
+ def error_print(cla, message: str) -> None:
107
+ """
108
+ Output error message
109
+ """
110
+ print(message)
111
+
112
+
113
+ class TinySSGGenerator:
114
+ """
115
+ Generator
116
+ """
117
+ @classmethod
118
+ def check_duplicate_name(cls, files: list, dirs: list) -> list:
119
+ """
120
+ Check for duplicate names in files and directories
121
+ """
122
+ filenames = {os.path.splitext(f)[0] for f in files}
123
+ dirnames = set(dirs)
124
+
125
+ conflicts = list(filenames & dirnames)
126
+
127
+ return conflicts
128
+
129
+ @classmethod
130
+ def extract_page_classes(cls, root: str, filename: str) -> list:
131
+ """
132
+ Extract page classes from the specified file.
133
+ """
134
+ page_classes = []
135
+ check_base_name = TinySSGPage.__name__
136
+
137
+ if filename.endswith('.py') and filename != '__init__.py':
138
+ module_name = os.path.splitext(filename)[0] # Excluding extensions
139
+ module_path = os.path.join(root, filename)
140
+
141
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
142
+ if spec and spec.loader:
143
+ module = importlib.util.module_from_spec(spec)
144
+ spec.loader.exec_module(module)
145
+ for _, members in inspect.getmembers(module):
146
+ if inspect.isclass(members) and members.__module__ == module_name:
147
+ parents = [m.__name__ for m in members.__mro__]
148
+ if check_base_name in parents:
149
+ page_classes.append(members)
150
+
151
+ return page_classes, module_name, module_path
152
+
153
+ @classmethod
154
+ def check_input_file(cls, relative_path: str, filename: str, input_file: str) -> bool:
155
+ """
156
+ Check if the input file is the same as the file being processed
157
+ """
158
+ convert_filename = os.path.join(relative_path, os.path.splitext(filename)[0]).replace(os.sep, '/')
159
+ convert_input_file = os.path.splitext(re.sub(r'^\./', '', input_file))[0].replace(os.sep, '/')
160
+
161
+ return convert_filename == convert_input_file
162
+
163
+ @classmethod
164
+ def search_route(cls, args: dict) -> dict:
165
+ """
166
+ Search for Page classes in the specified directory
167
+ """
168
+ static_path = args['static']
169
+ input_file = args['input']
170
+
171
+ try:
172
+ prev_dont_write_bytecode = sys.dont_write_bytecode
173
+ sys.dont_write_bytecode = True
174
+
175
+ routes = {}
176
+
177
+ full_pages_path = TinySSGUtility.get_fullpath(args, 'page')
178
+ page_counter = 0
179
+
180
+ for root, dirs, files in os.walk(full_pages_path):
181
+ relative_path = os.path.relpath(root, full_pages_path)
182
+
183
+ conflicts = cls.check_duplicate_name(files, dirs)
184
+ if len(conflicts) > 0:
185
+ raise TinySSGException(f"The following names conflict between files and directories: {', '.join(conflicts)} in {relative_path}")
186
+
187
+ if relative_path == '.':
188
+ relative_path = ''
189
+
190
+ if relative_path == static_path:
191
+ raise TinySSGException(f"Static file directory name conflict: {os.path.join(full_pages_path, relative_path)}")
192
+
193
+ if relative_path.endswith('__pycache__'):
194
+ continue
195
+
196
+ current_dict = routes
197
+
198
+ if relative_path:
199
+ for part in relative_path.split(os.sep):
200
+ if part not in current_dict:
201
+ current_dict[part] = {}
202
+ current_dict = current_dict[part]
203
+
204
+ for filename in files:
205
+ if len(input_file) > 0 and not cls.check_input_file(relative_path, filename, input_file):
206
+ continue
207
+
208
+ if relative_path == '' and filename == f"{static_path}.py":
209
+ raise TinySSGException(f"Static file directory name conflict: {os.path.join(root, filename)}")
210
+
211
+ page_classes, module_name, module_path = cls.extract_page_classes(root, filename)
212
+ page_counter += len(page_classes)
213
+ if len(page_classes) > 1:
214
+ current_dict[module_name] = {c.__name__: c for c in page_classes}
215
+ elif len(page_classes) == 1:
216
+ current_dict[module_name] = page_classes[0]
217
+ else:
218
+ TinySSGUtility.log_print(f"warning: No Page class found in {module_path}")
219
+
220
+ if page_counter == 0:
221
+ raise TinySSGException('No Page classes found.')
222
+ finally:
223
+ sys.dont_write_bytecode = prev_dont_write_bytecode
224
+
225
+ return routes
226
+
227
+ @classmethod
228
+ def create_content(cls, page: TinySSGPage) -> str:
229
+ """
230
+ Generate HTML content from Page class
231
+ """
232
+ basefetch = page.query()
233
+ fetchdata, slugkey = basefetch if isinstance(basefetch, tuple) else (basefetch, None)
234
+ if isinstance(fetchdata, dict):
235
+ baselist = [fetchdata]
236
+ slugkey = None
237
+ single_page = True
238
+ elif isinstance(fetchdata, list):
239
+ if len(fetchdata) == 0:
240
+ return {}
241
+ baselist = fetchdata
242
+ for i in range(len(baselist)):
243
+ if not isinstance(baselist[i], dict):
244
+ raise TinySSGException('The query method must return a dictionary or a list of dictionaries.')
245
+ single_page = False
246
+ else:
247
+ raise TinySSGException('The query method must return a dictionary or a list of dictionaries.')
248
+
249
+ result = {}
250
+
251
+ for i in range(len(baselist)):
252
+ if isinstance(slugkey, str) and slugkey in baselist[i]:
253
+ key = baselist[i][slugkey]
254
+ else:
255
+ key = str(i + 1)
256
+ pagedata = baselist[i]
257
+ pagetemp = page.template()
258
+ basestr = page.render(pagetemp, pagedata).strip() + '\n'
259
+ htmlstr = page.translate(basestr)
260
+ if isinstance(htmlstr, str) and len(htmlstr) > 0:
261
+ result[key] = htmlstr
262
+
263
+ return result['1'] if single_page else result
264
+
265
+ @classmethod
266
+ def traverse_route(cls, route: dict, dict_path: str = '') -> dict:
267
+ """
268
+ Traverse the route dictionary and generate HTML content
269
+ """
270
+ result = {}
271
+
272
+ for key, value in route.items():
273
+
274
+ if isinstance(value, dict):
275
+ current_path = f"{dict_path}/{key}"
276
+ result[key] = cls.traverse_route(value, current_path)
277
+ else:
278
+ page = value()
279
+ page.name = key
280
+ if not isinstance(page.name, str) or len(page.name) == 0:
281
+ raise TinySSGException('The name must be a non-empty string.')
282
+ result[key] = cls.create_content(page)
283
+
284
+ return result
285
+
286
+ @classmethod
287
+ def generate_routes(cls, args: dict) -> dict:
288
+ """
289
+ Generate HTML content dictionary from Page classes
290
+ """
291
+ route = cls.search_route(args)
292
+ return cls.traverse_route(route)
293
+
294
+ @classmethod
295
+ def output_file(cls, data: dict, full_path: str) -> None:
296
+ """
297
+ Output the HTML content dictionary to the file
298
+ """
299
+ for key, value in data.items():
300
+ if isinstance(value, dict) and len(value) > 0:
301
+ relative_path = os.path.join(full_path, key)
302
+ if not os.path.exists(relative_path):
303
+ os.makedirs(relative_path)
304
+ cls.output_file(value, relative_path)
305
+ elif isinstance(value, str):
306
+ with open(os.path.join(full_path, key + '.html'), 'w', encoding='utf-8') as f:
307
+ f.write(value)
308
+
309
+ @classmethod
310
+ def generator_start(cls, args: dict) -> None:
311
+ """
312
+ Generate HTML files from Page classes
313
+ """
314
+ input_full_path = TinySSGUtility.get_fullpath(args, 'page')
315
+
316
+ if not os.path.isdir(input_full_path):
317
+ raise TinySSGException(f"The specified page directory does not exist. ({input_full_path})")
318
+
319
+ page_data = cls.generate_routes(args)
320
+ output_full_path = TinySSGUtility.get_fullpath(args, 'output')
321
+
322
+ if not os.path.exists(output_full_path):
323
+ os.makedirs(output_full_path)
324
+
325
+ cls.output_file(page_data, output_full_path)
326
+
327
+ static_full_path = TinySSGUtility.get_fullpath(args, 'static')
328
+ output_static_full_path = os.path.join(output_full_path, args['static'])
329
+
330
+ if os.path.isdir(static_full_path):
331
+ if not os.path.exists(output_static_full_path):
332
+ os.makedirs(output_static_full_path)
333
+ shutil.copytree(static_full_path, output_static_full_path, dirs_exist_ok=True)
334
+
335
+
336
+ class TinySSGDebugHTTPServer(HTTPServer):
337
+ """
338
+ Custom HTTP server class
339
+ """
340
+ def __init__(self, server_address: tuple, RequestHandlerClass: any, args: dict, route: dict, reload: bool) -> None:
341
+ super().__init__(server_address, RequestHandlerClass)
342
+ self.args = args
343
+ self.route = route
344
+ self.reload = reload
345
+
346
+
347
+ class TinySSGDebugHTTPHandler(SimpleHTTPRequestHandler):
348
+ """
349
+ Custom HTTP request handler
350
+ """
351
+ def __init__(self, *args, **kwargs) -> None:
352
+ try:
353
+ super().__init__(*args, **kwargs)
354
+ except ConnectionResetError:
355
+ pass
356
+
357
+ def end_headers(self):
358
+ self.send_header('Cache-Control', 'no-store')
359
+ return super().end_headers()
360
+
361
+ def log_message(self, format: str, *args: any) -> None:
362
+ TinySSGDebug.print_httpd_log_message(self, self.server, format, *args)
363
+
364
+ def do_GET(self) -> None:
365
+ TinySSGDebug.httpd_get_handler(self, self.server)
366
+
367
+
368
+ class TinySSGDebug:
369
+ """
370
+ Debug Server
371
+ """
372
+ @classmethod
373
+ def watchdog_script(cls) -> str:
374
+ """
375
+ JavaScript code that checks for file updates from a web browser and reloads the file if there are any updates
376
+ """
377
+ return '''
378
+ <script type="module">
379
+ let __reload_check = () => {
380
+ fetch('/change').then(response => response.json()).then(data => {
381
+ if (data.reload) {
382
+ console.log('Change detected. Reloading...');
383
+ location.reload();
384
+ } else {
385
+ setTimeout(__reload_check, 1000);
386
+ }
387
+ });
388
+ };
389
+ setTimeout(__reload_check, 1000);
390
+ </script>'''
391
+
392
+ @classmethod
393
+ def send_ok_response(cls, handler: TinySSGDebugHTTPHandler, content_type: str, content: str = '', add_headers: dict = {}) -> None:
394
+ """
395
+ Send an OK response
396
+ """
397
+ encoded_content = content.encode('utf-8')
398
+ handler.send_response(200)
399
+ handler.send_header('Content-type', content_type)
400
+ handler.send_header('Content-Length', len(encoded_content))
401
+ for key, value in add_headers.items():
402
+ handler.send_header(key, value)
403
+ handler.end_headers()
404
+ handler.wfile.write(encoded_content)
405
+
406
+ @classmethod
407
+ def send_no_ok_response(cls, handler: TinySSGDebugHTTPHandler, status: int, content: str = '', add_headers: dict = {}) -> None:
408
+ """
409
+ Send a non-OK response
410
+ """
411
+ handler.send_response(status)
412
+ for key, value in add_headers.items():
413
+ handler.send_header(key, value)
414
+ if isinstance(content, str) and len(content) > 0:
415
+ encoded_content = content.encode('utf-8')
416
+ handler.send_header('Content-type', 'text/plain')
417
+ handler.send_header('Content-Length', len(encoded_content))
418
+ handler.end_headers()
419
+ handler.wfile.write(encoded_content)
420
+ else:
421
+ handler.end_headers()
422
+
423
+ @classmethod
424
+ def print_httpd_log_message(cls, handler: TinySSGDebugHTTPHandler, server: TinySSGDebugHTTPServer, format: str, *args: any) -> None:
425
+ """
426
+ Output the log message (HTTPServer)
427
+ """
428
+ if not server.args['nolog'] and not str(args[0]).startswith('GET /change'):
429
+ SimpleHTTPRequestHandler.log_message(handler, format, *args)
430
+ sys.stdout.flush()
431
+
432
+ @classmethod
433
+ def httpd_get_handler(cls, handler: TinySSGDebugHTTPHandler, server: TinySSGDebugHTTPServer) -> None:
434
+ """
435
+ Process the GET request
436
+ """
437
+ if handler.path == '/change':
438
+ cls.send_ok_response(handler, 'application/json', json.dumps({'reload': server.reload}))
439
+ server.reload = False
440
+ elif handler.path == '/stop':
441
+ cls.send_ok_response(handler, 'text/plain', 'Server Stopped.')
442
+ server.shutdown()
443
+ elif handler.path.startswith(f"/{server.args['output']}/{server.args['static']}/"):
444
+ redirect_path = re.sub('/' + re.escape(server.args['output']), '', handler.path)
445
+ handler.path = redirect_path
446
+ SimpleHTTPRequestHandler.do_GET(handler)
447
+ elif handler.path == f"/{server.args['output']}":
448
+ cls.send_no_ok_response(handler, 301, '', {'Location': f"/{server.args['output']}/"})
449
+ elif handler.path.startswith(f"/{server.args['output']}/"):
450
+ baselen = len(f"/{server.args['output']}/")
451
+ basename = re.sub(r'\.html$', '', handler.path[baselen:])
452
+ basename = f"{basename}index" if basename.endswith('/') or basename == '' else basename
453
+ output_path = basename.split('/')
454
+ current_route = server.route
455
+
456
+ for path in output_path:
457
+ if not isinstance(current_route, dict) or path not in current_route:
458
+ cls.send_no_ok_response(handler, 404, 'Not Found')
459
+ return
460
+ current_route = current_route[path]
461
+
462
+ if isinstance(current_route, dict):
463
+ cls.send_no_ok_response(handler, 301, '', {'Location': f"{handler.path}/"})
464
+ elif not isinstance(current_route, str):
465
+ TinySSGUtility.error_print(f"Error: The Page class for {handler.path} may not be implemented correctly.")
466
+ cls.send_no_ok_response(handler, 500, 'Internal Server Error')
467
+ else:
468
+ current_route = current_route if server.args['noreload'] else re.sub(r'(\s*</head>)', f"{cls.watchdog_script()}\n\\1", current_route)
469
+ cls.send_ok_response(handler, 'text/html', current_route)
470
+ else:
471
+ cls.send_no_ok_response(handler, 404, 'Not Found')
472
+
473
+ @classmethod
474
+ def stop_server(cls, process: any) -> None:
475
+ """
476
+ Stop the debug server
477
+ """
478
+ process.kill()
479
+
480
+ @classmethod
481
+ def server_stop_output(cls, process) -> None:
482
+ """
483
+ Output the server stop message
484
+ """
485
+ TinySSGUtility.error_print('Server return code:', process.poll())
486
+ TinySSGUtility.error_print('Server Output:\n')
487
+ TinySSGUtility.error_print(process.stdout.read() if process.stdout else '')
488
+ TinySSGUtility.error_print(process.stderr.read() if process.stderr else '')
489
+
490
+ @classmethod
491
+ def server_start(cls, args: dict) -> None:
492
+ """
493
+ Run the debug server
494
+ """
495
+ reload = args['mode'] == 'servreload'
496
+ route = TinySSGGenerator.generate_routes(args)
497
+ server_address = ('', args['port'])
498
+ httpd = TinySSGDebugHTTPServer(server_address, TinySSGDebugHTTPHandler, args, route, reload)
499
+ TinySSGUtility.error_print(f"Starting server on http://localhost:{args['port']}/{args['output']}/")
500
+ httpd.serve_forever()
501
+
502
+
503
+ class TinySSGLauncher:
504
+ """
505
+ Watchdog and Server Launcher
506
+ """
507
+ @classmethod
508
+ def check_for_changes(cls, mod_time: float, args: dict, pathlits: list) -> bool:
509
+ """
510
+ Check for changes in the specified directories
511
+ """
512
+ path_times = []
513
+ new_mod_time = 0
514
+
515
+ try:
516
+ for path in pathlits:
517
+ time_list = [os.path.getmtime(os.path.join(root, file)) for root, _, files in os.walk(path) for file in files]
518
+ if len(time_list) > 0:
519
+ this_path_time = max(time_list)
520
+ path_times.append(this_path_time)
521
+
522
+ if len(path_times) > 0:
523
+ new_mod_time = max(path_times)
524
+
525
+ if new_mod_time > mod_time:
526
+ mod_time = new_mod_time + args['wait']
527
+ return True, mod_time
528
+ except Exception as e:
529
+ TinySSGUtility.log_print(f"update check warning: {e}")
530
+
531
+ return False, mod_time
532
+
533
+ @classmethod
534
+ def launch_server(cls, args: dict, reload: bool) -> None:
535
+ """
536
+ Launch the server
537
+ """
538
+ servcommand = 'serv' if not reload else 'servreload'
539
+
540
+ newargv = args.copy()
541
+ newargv['mode'] = servcommand
542
+
543
+ command = [sys.executable, __file__, '--config', f"{json.dumps(newargv)}", 'config']
544
+
545
+ process = subprocess.Popen(
546
+ command,
547
+ stdout=None if not args['nolog'] else subprocess.PIPE,
548
+ stderr=None if not args['nolog'] else subprocess.PIPE,
549
+ text=True,
550
+ encoding='utf-8'
551
+ )
552
+
553
+ time.sleep(1)
554
+
555
+ if process.poll() is None:
556
+ return process
557
+ else:
558
+ TinySSGUtility.log_print('Server start failed.')
559
+ TinySSGDebug.stop_server(process)
560
+ return None
561
+
562
+ @classmethod
563
+ def open_browser(cls, args: dict) -> None:
564
+ """
565
+ Open the browser or Display Jupyter Iframe
566
+ """
567
+ url = f"http://localhost:{args['port']}/{args['output']}/"
568
+
569
+ try:
570
+ from IPython import get_ipython # type: ignore
571
+ env = get_ipython().__class__.__name__
572
+ if env == 'ZMQInteractiveShell':
573
+ from IPython import display
574
+ display.display(display.IFrame(url, width=args['jwidth'], height=args['jheight']))
575
+ except: # noqa: E722
576
+ webbrowser.open(url)
577
+
578
+ @classmethod
579
+ def launcher_start(cls, args: dict) -> None:
580
+ """
581
+ Launch the debug server and file change detection
582
+ """
583
+ if isinstance(args['curdir'], str) and len(args['curdir']) > 0:
584
+ os.chdir(args['curdir'])
585
+
586
+ cur_dir = os.getcwd()
587
+ page_dir = os.path.join(cur_dir, args['page'])
588
+ static_dir = os.path.join(cur_dir, args['static'])
589
+ lib_dir = os.path.join(cur_dir, args['lib'])
590
+ mod_time = 0.0
591
+ should_reload = False
592
+
593
+ if not os.path.isdir(page_dir):
594
+ raise TinySSGException(f"The specified page directory does not exist. ({page_dir})")
595
+
596
+ check_dirs = [page_dir]
597
+
598
+ if os.path.isdir(static_dir):
599
+ check_dirs.append(static_dir)
600
+
601
+ if os.path.isdir(lib_dir):
602
+ check_dirs.append(lib_dir)
603
+
604
+ if not args['noreload']:
605
+ _, mod_time = cls.check_for_changes(0.0, args, check_dirs)
606
+
607
+ process = cls.launch_server(args, False)
608
+
609
+ if process is None:
610
+ return
611
+
612
+ if not args['noopen']:
613
+ cls.open_browser(args)
614
+
615
+ while True:
616
+ try:
617
+ time.sleep(1)
618
+ if process.poll() is not None:
619
+ TinySSGUtility.log_print('Server stopped.')
620
+ TinySSGDebug.server_stop_output(process)
621
+ break
622
+ if not args['noreload']:
623
+ should_reload, mod_time = cls.check_for_changes(mod_time, args, check_dirs)
624
+ if should_reload:
625
+ TinySSGUtility.log_print('File changed. Reloading...')
626
+ TinySSGDebug.stop_server(process)
627
+ time.sleep(1)
628
+ process = cls.launch_server(args, True)
629
+ except KeyboardInterrupt:
630
+ TinySSGDebug.stop_server(process)
631
+ TinySSGUtility.error_print('Server stopped.')
632
+ TinySSGDebug.server_stop_output(process)
633
+ break
634
+
635
+
636
+ class TinySSG:
637
+ """
638
+ TinySSG Main Class
639
+ """
640
+ @classmethod
641
+ def main(cls, args: dict) -> None:
642
+ """
643
+ Main function
644
+ """
645
+ exitcode = 0
646
+
647
+ try:
648
+ if args['mode'] == 'gen':
649
+ if args['input'] == '':
650
+ TinySSGUtility.clear_start(args)
651
+ TinySSGGenerator.generator_start(args)
652
+ TinySSGUtility.log_print('HTML files generated.')
653
+ elif args['mode'] == 'dev':
654
+ TinySSGLauncher.launcher_start(args)
655
+ elif args['mode'] == 'cls':
656
+ TinySSGUtility.clear_start(args)
657
+ TinySSGUtility.log_print('Output directory cleared.')
658
+ elif args['mode'] == 'serv' or args['mode'] == 'servreload':
659
+ TinySSGDebug.server_start(args)
660
+ elif args['mode'] == 'config':
661
+ config = json.loads(args['config'])
662
+ default_args = cls.get_default_arg_dict()
663
+ for key, value in default_args.items():
664
+ if key not in config:
665
+ config[key] = value
666
+ cls.main(config)
667
+ else:
668
+ raise TinySSGException('Invalid mode.')
669
+ except TinySSGException as e:
670
+ TinySSGUtility.error_print(f"Error: {e}")
671
+ exitcode = 1
672
+
673
+ sys.exit(exitcode)
674
+
675
+ @classmethod
676
+ def get_arg_parser(cls) -> argparse.ArgumentParser:
677
+ """
678
+ Set the argument parser
679
+ """
680
+ parser = argparse.ArgumentParser(description='TinySSG Simple Static Site Generate Tool')
681
+ parser.add_argument('mode', choices=['dev', 'gen', 'cls', 'serv', 'servreload', 'config'], help='Select the mode to run (gen = Generate HTML files, dev = Run the debug server)')
682
+ parser.add_argument('--port', '-P', type=int, default=8000, help='Port number for the debug server')
683
+ parser.add_argument('--page', '-p', type=str, default='pages', help='Page file path')
684
+ parser.add_argument('--static', '-s', type=str, default='static', help='Static file path')
685
+ parser.add_argument('--lib', '-l', type=str, default='libs', help='Library file path')
686
+ parser.add_argument('--input', '-i', type=str, default='', help='Input file name (Used to generate specific files only)')
687
+ parser.add_argument('--output', '-o', type=str, default='dist', help='Output directory path')
688
+ parser.add_argument('--wait', '-w', type=int, default=5, help='Wait time for file change detection')
689
+ parser.add_argument('--nolog', '-n', action='store_true', help='Do not output debug server log')
690
+ parser.add_argument('--noreload', '-r', action='store_true', help='Do not reload the server when the file changes')
691
+ parser.add_argument('--noopen', '-N', action='store_true', help='Do not open the browser when starting the server')
692
+ parser.add_argument('--curdir', '-C', type=str, default='', help='Current directory (For Jupyter)')
693
+ parser.add_argument('--config', '-c', type=str, default='', help='Configuration json string')
694
+ parser.add_argument('--jwidth', '-jw', type=str, default='600', help='Jupyter iframe width')
695
+ parser.add_argument('--jheight', '-jh', type=str, default='600', help='Jupyter iframe height')
696
+
697
+ return parser
698
+
699
+ @classmethod
700
+ def get_default_arg_dict(cls):
701
+ parser = cls.get_arg_parser()
702
+ return vars(parser.parse_args(['dev']))
703
+
704
+ @classmethod
705
+ def cli_main(cls):
706
+ """
707
+ Command line interface
708
+ """
709
+ parser = cls.get_arg_parser()
710
+ parse_args = parser.parse_args()
711
+ args = vars(parse_args)
712
+ cls.main(args)
713
+
714
+
715
+ if __name__ == '__main__':
716
+ TinySSG.cli_main()
@@ -0,0 +1,106 @@
1
+ Metadata-Version: 2.2
2
+ Name: tinyssg
3
+ Version: 0.0.0
4
+ Summary: A simple static site generator
5
+ Author: Uniras
6
+ Author-email: tkappeng@gmail.com
7
+ License: MIT License
8
+ Project-URL: Homepage, https://github.com/uniras/tinyssg
9
+ Project-URL: Repository, https://github.com/uniras/tinyssg
10
+ Keywords: ssg,static site generator,html
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.7
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Topic :: Software Development :: Libraries
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: Utilities
26
+ Description-Content-Type: text/markdown
27
+
28
+ # TinySSG
29
+
30
+ ## 概要
31
+
32
+ TinySSGは、ファイルベースルーティングのシンプルな静的サイトジェネレータです。
33
+ ページを簡単な構造のTinySSGPageクラスを継承したPythonコードで記述することで単純さと柔軟性を両立しています。
34
+
35
+ ## インストール
36
+
37
+ ```bash
38
+ pip install tinyssg
39
+ ```
40
+
41
+ ## 使い方
42
+
43
+ ### ディレクトリ構成
44
+
45
+ 以下のようにディレクトリを構成します。ディレクトリ名はオプション引数で変更可能です。
46
+
47
+ ```text
48
+ --- proect
49
+ |-- pages SSGの対象となるPythonファイルを配置します
50
+ |-- libs SSG・デプロイの対象にならないPythonファイルを配置します(ライブラリなど)
51
+ |-- static SSGの対象にならない静的ファイルを配置します(css, 画像など)
52
+ |-- dist SSGの結果が出力されるディレクトリです。このディレクトリの中身をWebサーバに配置することでWebサイトとして公開できます。
53
+ |-- static staticディレクトリはこのディレクトリにコピーされます
54
+ ```
55
+
56
+ pages, libs, staticディレクトリは、開発サーバー起動時に監視され、ファイルが変更されると自動的にサーバーを再起動します。
57
+
58
+ ### ページの作成
59
+
60
+ `Page`ディレクトリ内にPythonファイルを作り、`TinySSGPage`クラスを継承したクラスを作成します。
61
+
62
+ ```python
63
+ from tinyssg import TinySSGPage
64
+
65
+ class IndexPage(TinySSGPage):
66
+ def query(self):
67
+ return {
68
+ 'title': 'Index',
69
+ 'content': 'Hello, World!'
70
+ }
71
+
72
+ def template(self):
73
+ return '''
74
+ <!DOCTYPE html>
75
+ <html>
76
+ <head>
77
+ <meta charset="utf-8" />
78
+ <title>{{ title }}</title>
79
+ </head>
80
+ <body>
81
+ <h1>{{ title }}</h1>
82
+ <p>{{ content }}</p>
83
+ </body>
84
+ </html>'''
85
+ ```
86
+
87
+ `query`メソッドでテンプレートに渡すデータを返し、`template`メソッドでHTMLテンプレートを返します。
88
+ TinySSGは、これらのメソッドの返り値を使ってHTMLを生成します。
89
+
90
+ `query`メソッドが返すデータは、Python辞書形式またはPython辞書のリスト形式である必要があります。
91
+ 辞書形式の場合はpythonファイル名のHTMLファイルが生成され、リスト形式の場合はPythonファイル名と同じディレクトリが作成され、デフォルトでは1からの数字.htmlのファイル名でHTMLファイルが生成されます。
92
+ `return`の際にタプルとしてリストと一緒にキー名を表す文字列を返すと、そのキーに対応する値をファイル名としてHTMLファイルが生成されます。
93
+
94
+ `TinySSG`はデフォルトでは単純にテンプレートの`{{ キー名 }}`で囲まれた部分を`query`メソッドの返り値である辞書のキーに対応する値で単純に置換するだけですが、
95
+ `render`メソッドをオーバーライドすることで、より複雑な処理を行うこともできます。Jinja2などのテンプレートエンジンを使うこともできます。
96
+
97
+ また、`translate`メソッドをオーバーライドすることで、レンダー後のテキストを最終的なHTMLに変換する処理を定義することもできます。
98
+ ここでmarkdownライブラリを使って変換する処理を記述すればテンプレートをHTMLではなくMarkdownで記述することができます。
99
+
100
+ それぞれページごとに定義することになりますが、単純なPythonクラスですので複数のページに適用したい場合は共通部分を定義したクラスを作成し、それを継承することでコードをコピーすることなく簡単に適用することができます。
101
+
102
+ ### HTMLの生成
103
+
104
+ ```bash
105
+ python -m tinyssg gen
106
+ ```
@@ -0,0 +1,7 @@
1
+ README.md
2
+ pyproject.toml
3
+ tinyssg/__init__.py
4
+ tinyssg.egg-info/PKG-INFO
5
+ tinyssg.egg-info/SOURCES.txt
6
+ tinyssg.egg-info/dependency_links.txt
7
+ tinyssg.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ tinyssg