tinyssg 0.0.0__tar.gz

Sign up to get free protection for your applications and to get access to all the features.
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