ophinode 0.0.1a1__py3-none-any.whl
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.
- ophinode/__init__.py +490 -0
- ophinode/__main__.py +94 -0
- ophinode/constants.py +13 -0
- ophinode/exceptions.py +17 -0
- ophinode/nodes/__init__.py +0 -0
- ophinode/nodes/base.py +45 -0
- ophinode/nodes/html.py +826 -0
- ophinode/rendering.py +179 -0
- ophinode/site.py +426 -0
- ophinode-0.0.1a1.dist-info/METADATA +102 -0
- ophinode-0.0.1a1.dist-info/RECORD +13 -0
- ophinode-0.0.1a1.dist-info/WHEEL +4 -0
- ophinode-0.0.1a1.dist-info/licenses/LICENSE +21 -0
ophinode/rendering.py
ADDED
@@ -0,0 +1,179 @@
|
|
1
|
+
import collections
|
2
|
+
from typing import Union
|
3
|
+
|
4
|
+
from .nodes.base import *
|
5
|
+
|
6
|
+
class RenderContext:
|
7
|
+
def __init__(self, site: "ophinode.site.Site"):
|
8
|
+
self._site = site
|
9
|
+
self._current_page = None
|
10
|
+
self._current_page_path = None
|
11
|
+
self._site_data = {}
|
12
|
+
self._page_data = {}
|
13
|
+
self._built_pages = {}
|
14
|
+
self._expanded_pages = {}
|
15
|
+
self._rendered_pages = {}
|
16
|
+
self._exported_files = {}
|
17
|
+
|
18
|
+
@property
|
19
|
+
def site(self):
|
20
|
+
return self._site
|
21
|
+
|
22
|
+
@property
|
23
|
+
def current_page(self):
|
24
|
+
return self._current_page
|
25
|
+
|
26
|
+
@property
|
27
|
+
def current_page_path(self):
|
28
|
+
return self._current_page_path
|
29
|
+
|
30
|
+
@property
|
31
|
+
def site_data(self):
|
32
|
+
return self._site_data
|
33
|
+
|
34
|
+
@property
|
35
|
+
def page_data(self):
|
36
|
+
return self._page_data.get(self._current_page_path)
|
37
|
+
|
38
|
+
def get_page_data(self, path: str = None):
|
39
|
+
return self._page_data.get(
|
40
|
+
path if path is not None else self._current_page_path
|
41
|
+
)
|
42
|
+
|
43
|
+
@property
|
44
|
+
def built_pages(self):
|
45
|
+
return self._built_pages
|
46
|
+
|
47
|
+
@property
|
48
|
+
def expanded_pages(self):
|
49
|
+
return self._expanded_pages
|
50
|
+
|
51
|
+
@property
|
52
|
+
def rendered_pages(self):
|
53
|
+
return self._rendered_pages
|
54
|
+
|
55
|
+
@property
|
56
|
+
def exported_files(self):
|
57
|
+
return self._exported_files
|
58
|
+
|
59
|
+
class RenderNode:
|
60
|
+
def __init__(self, value: Union[OpenRenderable, ClosedRenderable, None]):
|
61
|
+
self._value = value
|
62
|
+
self._children = []
|
63
|
+
self._parent = None
|
64
|
+
|
65
|
+
@property
|
66
|
+
def value(self):
|
67
|
+
return self._value
|
68
|
+
|
69
|
+
@property
|
70
|
+
def children(self):
|
71
|
+
return self._children
|
72
|
+
|
73
|
+
@property
|
74
|
+
def parent(self):
|
75
|
+
return self._parent
|
76
|
+
|
77
|
+
def render(self, context: RenderContext):
|
78
|
+
result = []
|
79
|
+
depth = 0
|
80
|
+
stk = collections.deque()
|
81
|
+
stk.append((self, False))
|
82
|
+
no_auto_newline_count = 0
|
83
|
+
no_auto_indent_count = 0
|
84
|
+
total_text_content_length = 0
|
85
|
+
text_content_length_stk = collections.deque()
|
86
|
+
while stk:
|
87
|
+
render_node, revisited = stk.pop()
|
88
|
+
v, c = render_node._value, render_node._children
|
89
|
+
if isinstance(v, OpenRenderable):
|
90
|
+
if revisited:
|
91
|
+
depth -= 1
|
92
|
+
text_content = v.render_end(context)
|
93
|
+
if not (
|
94
|
+
text_content_length_stk
|
95
|
+
and text_content_length_stk[-1]
|
96
|
+
== total_text_content_length
|
97
|
+
):
|
98
|
+
if (
|
99
|
+
text_content
|
100
|
+
and total_text_content_length
|
101
|
+
and (
|
102
|
+
no_auto_newline_count == 0
|
103
|
+
or no_auto_indent_count == 0
|
104
|
+
)
|
105
|
+
):
|
106
|
+
text_content = "\n" + text_content
|
107
|
+
if no_auto_indent_count == 0 and text_content:
|
108
|
+
text_content = ("\n"+" "*depth).join(
|
109
|
+
text_content.split("\n")
|
110
|
+
)
|
111
|
+
result.append(text_content)
|
112
|
+
total_text_content_length += len(text_content)
|
113
|
+
text_content_length_stk.pop()
|
114
|
+
if not v.auto_newline:
|
115
|
+
no_auto_newline_count -= 1
|
116
|
+
if not v.auto_indent:
|
117
|
+
no_auto_indent_count -= 1
|
118
|
+
else:
|
119
|
+
text_content = v.render_start(context)
|
120
|
+
if text_content and (
|
121
|
+
(
|
122
|
+
total_text_content_length
|
123
|
+
and no_auto_newline_count == 0
|
124
|
+
)
|
125
|
+
or
|
126
|
+
(
|
127
|
+
text_content_length_stk
|
128
|
+
and text_content_length_stk[-1]
|
129
|
+
== total_text_content_length
|
130
|
+
and no_auto_indent_count == 0
|
131
|
+
)
|
132
|
+
):
|
133
|
+
text_content = "\n" + text_content
|
134
|
+
if no_auto_indent_count == 0 and text_content:
|
135
|
+
text_content = ("\n"+" "*depth).join(
|
136
|
+
text_content.split("\n")
|
137
|
+
)
|
138
|
+
result.append(text_content)
|
139
|
+
total_text_content_length += len(text_content)
|
140
|
+
text_content_length_stk.append(total_text_content_length)
|
141
|
+
if not v.auto_newline:
|
142
|
+
no_auto_newline_count += 1
|
143
|
+
if not v.auto_indent:
|
144
|
+
no_auto_indent_count += 1
|
145
|
+
stk.append((render_node, True))
|
146
|
+
depth += 1
|
147
|
+
elif isinstance(v, ClosedRenderable):
|
148
|
+
if revisited:
|
149
|
+
depth -= 1
|
150
|
+
else:
|
151
|
+
text_content = v.render(context)
|
152
|
+
if text_content and (
|
153
|
+
(
|
154
|
+
total_text_content_length
|
155
|
+
and no_auto_newline_count == 0
|
156
|
+
)
|
157
|
+
or
|
158
|
+
(
|
159
|
+
text_content_length_stk
|
160
|
+
and text_content_length_stk[-1]
|
161
|
+
== total_text_content_length
|
162
|
+
and no_auto_indent_count == 0
|
163
|
+
)
|
164
|
+
):
|
165
|
+
text_content = "\n" + text_content
|
166
|
+
if no_auto_indent_count == 0 and text_content:
|
167
|
+
text_content = ("\n"+" "*depth).join(
|
168
|
+
text_content.split("\n")
|
169
|
+
)
|
170
|
+
result.append(text_content)
|
171
|
+
total_text_content_length += len(text_content)
|
172
|
+
stk.append((render_node, True))
|
173
|
+
depth += 1
|
174
|
+
if not revisited and c:
|
175
|
+
for i in reversed(c):
|
176
|
+
stk.append((i, False))
|
177
|
+
result.append("\n")
|
178
|
+
return "".join(result)
|
179
|
+
|
ophinode/site.py
ADDED
@@ -0,0 +1,426 @@
|
|
1
|
+
import json
|
2
|
+
import os.path
|
3
|
+
import pathlib
|
4
|
+
import collections
|
5
|
+
import collections.abc
|
6
|
+
from abc import ABC, abstractmethod
|
7
|
+
from typing import Any, Union, Iterable, Tuple, Callable
|
8
|
+
|
9
|
+
from .exceptions import *
|
10
|
+
from .constants import *
|
11
|
+
from .nodes.base import *
|
12
|
+
from .nodes.html import TextNode, HTML5Layout
|
13
|
+
from .rendering import RenderContext, RenderNode
|
14
|
+
|
15
|
+
class _StackDelimiter:
|
16
|
+
pass
|
17
|
+
|
18
|
+
class Site:
|
19
|
+
def __init__(
|
20
|
+
self,
|
21
|
+
options: Union[collections.abc.Mapping, None] = None,
|
22
|
+
pages: Union[Iterable[Tuple[str, Any]], None] = None,
|
23
|
+
processors: Union[
|
24
|
+
Iterable[Tuple[str, Callable[["RenderContext"], None]]],
|
25
|
+
None
|
26
|
+
] = None,
|
27
|
+
):
|
28
|
+
self._options = {}
|
29
|
+
if options is not None:
|
30
|
+
self.update_options(options)
|
31
|
+
|
32
|
+
self._pages_dict = {}
|
33
|
+
self._pages = []
|
34
|
+
if pages is not None:
|
35
|
+
if not isinstance(pages, collections.abc.Iterable):
|
36
|
+
raise TypeError("pages must be an iterable")
|
37
|
+
for path, page in pages:
|
38
|
+
if not isinstance(path, str):
|
39
|
+
raise ValueError("path to a page must be a str")
|
40
|
+
if path in self._pages_dict:
|
41
|
+
raise ValueError("duplicate page path: " + path)
|
42
|
+
self._pages_dict[path] = page
|
43
|
+
self._pages.append((path, page))
|
44
|
+
|
45
|
+
self._preprocessors_before_site_build = []
|
46
|
+
self._postprocessors_after_site_build = []
|
47
|
+
self._preprocessors_before_page_build_stage = []
|
48
|
+
self._postprocessors_after_page_build_stage = []
|
49
|
+
self._preprocessors_before_page_preparation_stage = []
|
50
|
+
self._postprocessors_after_page_preparation_stage = []
|
51
|
+
self._preprocessors_before_page_expansion_stage = []
|
52
|
+
self._postprocessors_after_page_expansion_stage = []
|
53
|
+
self._preprocessors_before_page_rendering_stage = []
|
54
|
+
self._postprocessors_after_page_rendering_stage = []
|
55
|
+
if processors is not None:
|
56
|
+
if not isinstance(processors, collections.abc.Iterable):
|
57
|
+
raise TypeError("processors must be an iterable")
|
58
|
+
for stage, processor in processors:
|
59
|
+
self.add_processor(stage, processor)
|
60
|
+
|
61
|
+
def update_options(
|
62
|
+
self,
|
63
|
+
options: collections.abc.Mapping,
|
64
|
+
ignore_invalid_keys: bool = False
|
65
|
+
):
|
66
|
+
if not isinstance(options, collections.abc.Mapping):
|
67
|
+
raise TypeError("options must be a mapping")
|
68
|
+
for k, v in options.items():
|
69
|
+
if k == EXPORT_ROOT_PATH_OPTION_KEY:
|
70
|
+
self.set_export_root_path(v)
|
71
|
+
elif k == DEFAULT_LAYOUT_OPTION_KEY:
|
72
|
+
self.set_default_layout(v)
|
73
|
+
elif k == DEFAULT_PAGE_OUTPUT_FILENAME_OPTION_KEY:
|
74
|
+
self.set_default_page_output_filename(v)
|
75
|
+
elif k == PAGE_OUTPUT_FILE_EXTENSION_OPTION_KEY:
|
76
|
+
self.set_page_output_file_extension(v)
|
77
|
+
elif k == AUTO_EXPORT_FILES_OPTION_KEY:
|
78
|
+
self.set_auto_export_files(v)
|
79
|
+
elif not ignore_invalid_keys:
|
80
|
+
raise ValueError("unknown option key: {}".format(k))
|
81
|
+
|
82
|
+
def set_export_root_path(self, export_root_path: str):
|
83
|
+
if not isinstance(export_root_path, str):
|
84
|
+
raise TypeError("export_root_path must be a str")
|
85
|
+
self._options[EXPORT_ROOT_PATH_OPTION_KEY] = os.path.abspath(
|
86
|
+
export_root_path
|
87
|
+
)
|
88
|
+
|
89
|
+
@property
|
90
|
+
def export_root_path(self) -> str:
|
91
|
+
return self._options.get(
|
92
|
+
EXPORT_ROOT_PATH_OPTION_KEY,
|
93
|
+
EXPORT_ROOT_PATH_OPTION_DEFAULT_VALUE
|
94
|
+
)
|
95
|
+
|
96
|
+
def set_default_layout(self, default_layout: Layout):
|
97
|
+
if not isinstance(default_layout, Layout):
|
98
|
+
raise TypeError("default_layout must be a Layout")
|
99
|
+
self._options[DEFAULT_LAYOUT_OPTION_KEY] = default_layout
|
100
|
+
|
101
|
+
@property
|
102
|
+
def default_layout(self) -> Union[Layout, None]:
|
103
|
+
return self._options.get(
|
104
|
+
DEFAULT_LAYOUT_OPTION_KEY,
|
105
|
+
DEFAULT_LAYOUT_OPTION_DEFAULT_VALUE
|
106
|
+
)
|
107
|
+
|
108
|
+
def set_default_page_output_filename(
|
109
|
+
self,
|
110
|
+
default_page_output_filename: str
|
111
|
+
):
|
112
|
+
if not isinstance(default_page_output_filename, str):
|
113
|
+
raise TypeError("default_page_output_filename must be a str")
|
114
|
+
self._options[
|
115
|
+
DEFAULT_PAGE_OUTPUT_FILENAME_OPTION_KEY
|
116
|
+
] = default_page_output_filename
|
117
|
+
|
118
|
+
@property
|
119
|
+
def default_page_output_filename(self) -> str:
|
120
|
+
return self._options.get(
|
121
|
+
DEFAULT_PAGE_OUTPUT_FILENAME_OPTION_KEY,
|
122
|
+
DEFAULT_PAGE_OUTPUT_FILENAME_OPTION_DEFAULT_VALUE
|
123
|
+
)
|
124
|
+
|
125
|
+
def set_page_output_file_extension(
|
126
|
+
self,
|
127
|
+
page_output_file_extension: str
|
128
|
+
):
|
129
|
+
if not isinstance(page_output_file_extension, str):
|
130
|
+
raise TypeError("page_output_file_extension must be a str")
|
131
|
+
self._options[
|
132
|
+
PAGE_OUTPUT_FILE_EXTENSION_OPTION_KEY
|
133
|
+
] = page_output_file_extension
|
134
|
+
|
135
|
+
@property
|
136
|
+
def page_output_file_extension(self) -> str:
|
137
|
+
return self._options.get(
|
138
|
+
PAGE_OUTPUT_FILE_EXTENSION_OPTION_KEY,
|
139
|
+
PAGE_OUTPUT_FILE_EXTENSION_OPTION_DEFAULT_VALUE
|
140
|
+
)
|
141
|
+
|
142
|
+
def set_auto_export_files(self, auto_export_files: bool):
|
143
|
+
self._options[AUTO_EXPORT_FILES_OPTION_KEY] = bool(auto_export_files)
|
144
|
+
|
145
|
+
@property
|
146
|
+
def auto_export_files(self) -> bool:
|
147
|
+
return self._options.get(
|
148
|
+
AUTO_EXPORT_FILES_OPTION_KEY,
|
149
|
+
AUTO_EXPORT_FILES_OPTION_DEFAULT_VALUE
|
150
|
+
)
|
151
|
+
|
152
|
+
def add_processor(
|
153
|
+
self,
|
154
|
+
stage: str,
|
155
|
+
processor: Callable[["RenderContext"], None]
|
156
|
+
):
|
157
|
+
if not isinstance(stage, str):
|
158
|
+
raise ValueError("processor stage must be a str")
|
159
|
+
if not callable(processor):
|
160
|
+
raise TypeError("processor must be a callable")
|
161
|
+
if stage == "pre_site_build":
|
162
|
+
self._preprocessors_before_site_build.append(processor)
|
163
|
+
elif stage == "post_site_build":
|
164
|
+
self._postprocessors_after_site_build.append(processor)
|
165
|
+
elif stage == "pre_page_build":
|
166
|
+
self._preprocessors_before_page_build_stage.append(processor)
|
167
|
+
elif stage == "post_page_build":
|
168
|
+
self._postprocessors_after_page_build_stage.append(processor)
|
169
|
+
elif stage == "pre_page_preparation":
|
170
|
+
self._preprocessors_before_page_preparation_stage.append(processor)
|
171
|
+
elif stage == "post_page_preparation":
|
172
|
+
self._postprocessors_after_page_preparation_stage.append(processor)
|
173
|
+
elif stage == "pre_page_expansion":
|
174
|
+
self._preprocessors_before_page_expansion_stage.append(processor)
|
175
|
+
elif stage == "post_page_expansion":
|
176
|
+
self._postprocessors_after_page_expansion_stage.append(processor)
|
177
|
+
elif stage == "pre_page_rendering":
|
178
|
+
self._preprocessors_before_page_rendering_stage.append(processor)
|
179
|
+
elif stage == "post_page_rendering":
|
180
|
+
self._postprocessors_after_page_rendering_stage.append(processor)
|
181
|
+
else:
|
182
|
+
raise ValueError("invalid rendering stage: '{}'".format(stage))
|
183
|
+
|
184
|
+
def _prepare_site(self) -> RenderContext:
|
185
|
+
context = RenderContext(self)
|
186
|
+
for path, page in self._pages:
|
187
|
+
context._page_data[path] = {}
|
188
|
+
for processor in self._preprocessors_before_site_build:
|
189
|
+
processor(context)
|
190
|
+
return context
|
191
|
+
|
192
|
+
def _build_nodes(self, page: Any, context: RenderContext) -> Iterable:
|
193
|
+
layout = None
|
194
|
+
if hasattr(page, "layout"):
|
195
|
+
layout = page.layout
|
196
|
+
l_src = "layout property of page"
|
197
|
+
if not layout:
|
198
|
+
layout = self.default_layout
|
199
|
+
l_src = "default_layout option of renderer"
|
200
|
+
if not layout:
|
201
|
+
layout = HTML5Layout()
|
202
|
+
l_src = "fallback layout (HTML5Layout)"
|
203
|
+
|
204
|
+
if callable(layout):
|
205
|
+
layout = layout(context)
|
206
|
+
|
207
|
+
if not isinstance(layout, Layout):
|
208
|
+
raise ValueError(
|
209
|
+
"resolved layout (from {}) is not a Layout instance".format(
|
210
|
+
l_src
|
211
|
+
)
|
212
|
+
)
|
213
|
+
|
214
|
+
return layout.build(page, context)
|
215
|
+
|
216
|
+
def _build_page(self, path, page, context):
|
217
|
+
context._current_page_path = path
|
218
|
+
context._current_page = page
|
219
|
+
context.built_pages[path] = self._build_nodes(page, context)
|
220
|
+
context._current_page = None
|
221
|
+
context._current_page_path = None
|
222
|
+
|
223
|
+
def _build_pages(self, context):
|
224
|
+
for processor in self._preprocessors_before_page_build_stage:
|
225
|
+
processor(context)
|
226
|
+
for path, page in self._pages:
|
227
|
+
self._build_page(path, page, context)
|
228
|
+
for processor in self._postprocessors_after_page_build_stage:
|
229
|
+
processor(context)
|
230
|
+
|
231
|
+
def _prepare_nodes(self, page_built: Iterable, context: RenderContext):
|
232
|
+
for node in page_built:
|
233
|
+
if isinstance(node, Preparable):
|
234
|
+
node.prepare(context)
|
235
|
+
|
236
|
+
def _prepare_page(self, path: str, page: Any, context: RenderContext):
|
237
|
+
context._current_page_path = path
|
238
|
+
context._current_page = page
|
239
|
+
self._prepare_nodes(context.built_pages[path], context)
|
240
|
+
context._current_page = None
|
241
|
+
context._current_page_path = None
|
242
|
+
|
243
|
+
def _prepare_pages(self, context: RenderContext):
|
244
|
+
for processor in self._preprocessors_before_page_preparation_stage:
|
245
|
+
processor(context)
|
246
|
+
for path, page in self._pages:
|
247
|
+
self._prepare_page(path, page, context)
|
248
|
+
for processor in self._postprocessors_after_page_preparation_stage:
|
249
|
+
processor(context)
|
250
|
+
|
251
|
+
def _expand_nodes(
|
252
|
+
self,
|
253
|
+
page_built: Iterable,
|
254
|
+
context: RenderContext
|
255
|
+
) -> RenderNode:
|
256
|
+
root_node = RenderNode(None)
|
257
|
+
curr = root_node
|
258
|
+
|
259
|
+
stack = collections.deque()
|
260
|
+
for node in reversed(page_built):
|
261
|
+
stack.append(node)
|
262
|
+
|
263
|
+
while stack:
|
264
|
+
node = stack.pop()
|
265
|
+
if isinstance(node, _StackDelimiter):
|
266
|
+
curr = curr._parent
|
267
|
+
elif isinstance(node, str):
|
268
|
+
render_node = RenderNode(TextNode(node))
|
269
|
+
render_node._parent = curr
|
270
|
+
curr._children.append(render_node)
|
271
|
+
elif callable(node):
|
272
|
+
r = node(context)
|
273
|
+
stack.append(r)
|
274
|
+
elif isinstance(node, collections.abc.Iterable):
|
275
|
+
for n in reversed(node):
|
276
|
+
stack.append(n)
|
277
|
+
elif isinstance(node, Expandable):
|
278
|
+
r = node.expand(context)
|
279
|
+
stack.append(_StackDelimiter())
|
280
|
+
next_render_node = RenderNode(node)
|
281
|
+
next_render_node._parent = curr
|
282
|
+
curr._children.append(next_render_node)
|
283
|
+
curr = next_render_node
|
284
|
+
stack.append(r)
|
285
|
+
else:
|
286
|
+
next_render_node = RenderNode(node)
|
287
|
+
next_render_node._parent = curr
|
288
|
+
curr._children.append(next_render_node)
|
289
|
+
|
290
|
+
return root_node
|
291
|
+
|
292
|
+
def _expand_page(self, path: str, page: Any, context: RenderContext):
|
293
|
+
context._current_page_path = path
|
294
|
+
context._current_page = page
|
295
|
+
root_node = self._expand_nodes(context.built_pages[path], context)
|
296
|
+
context.expanded_pages[path] = root_node
|
297
|
+
context._current_page = None
|
298
|
+
context._current_page_path = None
|
299
|
+
|
300
|
+
def _expand_pages(self, context: RenderContext):
|
301
|
+
for processor in self._preprocessors_before_page_expansion_stage:
|
302
|
+
processor(context)
|
303
|
+
for path, page in self._pages:
|
304
|
+
self._expand_page(path, page, context)
|
305
|
+
for processor in self._postprocessors_after_page_expansion_stage:
|
306
|
+
processor(context)
|
307
|
+
|
308
|
+
def _render_page(self, path: str, page: Any, context: RenderContext):
|
309
|
+
context._current_page_path = path
|
310
|
+
context._current_page = page
|
311
|
+
root_node = context.expanded_pages[path]
|
312
|
+
render_result = root_node.render(context)
|
313
|
+
context.rendered_pages[path] = render_result
|
314
|
+
context._current_page = None
|
315
|
+
context._current_page_path = None
|
316
|
+
|
317
|
+
def _render_pages(self, context: RenderContext):
|
318
|
+
for processor in self._preprocessors_before_page_rendering_stage:
|
319
|
+
processor(context)
|
320
|
+
for path, page in self._pages:
|
321
|
+
self._render_page(path, page, context)
|
322
|
+
for processor in self._postprocessors_after_page_rendering_stage:
|
323
|
+
processor(context)
|
324
|
+
|
325
|
+
def _finalize_site(self, context: RenderContext):
|
326
|
+
for path, render_result in context.rendered_pages.items():
|
327
|
+
page_path = path
|
328
|
+
if path.endswith("/"):
|
329
|
+
page_path += self.default_page_output_filename
|
330
|
+
elif self.page_output_file_extension:
|
331
|
+
fname = path[path.rfind("/")+1:]
|
332
|
+
dot_index = fname.rfind(".")
|
333
|
+
if (
|
334
|
+
(
|
335
|
+
dot_index == -1
|
336
|
+
or fname[dot_index+1:]
|
337
|
+
!= self.page_output_file_extension
|
338
|
+
)
|
339
|
+
and
|
340
|
+
# the rightmost dot is not the first (or last) character
|
341
|
+
(
|
342
|
+
dot_index != 0
|
343
|
+
and (fname and dot_index != len(fname)-1)
|
344
|
+
)
|
345
|
+
):
|
346
|
+
page_path += "." + self.page_output_file_extension
|
347
|
+
page_path = os.path.normpath(page_path)
|
348
|
+
if page_path in context.exported_files:
|
349
|
+
raise ExportPathCollisionError(
|
350
|
+
"attempted to export a page to '{}', but another file is "
|
351
|
+
"already exported to that path".format(page_path)
|
352
|
+
)
|
353
|
+
context.exported_files[page_path] = render_result
|
354
|
+
|
355
|
+
for processor in self._postprocessors_after_site_build:
|
356
|
+
processor(context)
|
357
|
+
|
358
|
+
def _export_files(self, context: RenderContext):
|
359
|
+
# Check if export_root_path has been set
|
360
|
+
if not self.export_root_path:
|
361
|
+
raise RootPathUndefinedError(
|
362
|
+
"failed to export files because export_root_path is empty"
|
363
|
+
)
|
364
|
+
|
365
|
+
# Ensure export_root_path is a directory,
|
366
|
+
# or if it does not exist, create one
|
367
|
+
export_root_path = pathlib.Path(self.export_root_path)
|
368
|
+
if not export_root_path.exists():
|
369
|
+
if export_root_path.is_symlink():
|
370
|
+
raise RootPathIsNotADirectoryError(
|
371
|
+
"failed to export files because export_root_path is a "
|
372
|
+
"broken symlink"
|
373
|
+
)
|
374
|
+
try:
|
375
|
+
export_root_path.mkdir(parents=True)
|
376
|
+
except NotADirectoryError as exc:
|
377
|
+
raise RootPathIsNotADirectoryError(
|
378
|
+
"failed to export files because the parent of "
|
379
|
+
"export_root_path is not a directory, and thus "
|
380
|
+
"export_root_path cannot be a directory"
|
381
|
+
) from exc
|
382
|
+
elif not export_root_path.is_dir():
|
383
|
+
raise RootPathIsNotADirectoryError(
|
384
|
+
"failed to export files because export_root_path is not a "
|
385
|
+
"directory"
|
386
|
+
)
|
387
|
+
|
388
|
+
# Export files
|
389
|
+
for path, file_content in context.exported_files.items():
|
390
|
+
target_path = (
|
391
|
+
pathlib.Path(self.export_root_path) / path.lstrip('/')
|
392
|
+
)
|
393
|
+
target_directory = target_path.parent
|
394
|
+
if not target_directory.exists():
|
395
|
+
target_directory.mkdir(parents=True)
|
396
|
+
if isinstance(file_content, (bytes, bytearray)):
|
397
|
+
with target_path.open(mode="wb") as f:
|
398
|
+
f.write(file_content)
|
399
|
+
elif isinstance(file_content, str):
|
400
|
+
with target_path.open(mode="w", encoding="utf-8") as f:
|
401
|
+
f.write(file_content)
|
402
|
+
else:
|
403
|
+
with target_path.open(mode="w", encoding="utf-8") as f:
|
404
|
+
json.dump(file_content, f, indent=2)
|
405
|
+
|
406
|
+
def build_site(self):
|
407
|
+
context = self._prepare_site()
|
408
|
+
self._build_pages(context)
|
409
|
+
self._prepare_pages(context)
|
410
|
+
self._expand_pages(context)
|
411
|
+
self._render_pages(context)
|
412
|
+
self._finalize_site(context)
|
413
|
+
if self.auto_export_files:
|
414
|
+
self._export_files(context)
|
415
|
+
return context
|
416
|
+
|
417
|
+
def render_page(page: Any, default_layout: Union[Layout, None] = None):
|
418
|
+
options = {
|
419
|
+
EXPORT_ROOT_PATH_OPTION_KEY: "/",
|
420
|
+
AUTO_EXPORT_FILES_OPTION_KEY: False,
|
421
|
+
}
|
422
|
+
if default_layout is not None:
|
423
|
+
options[DEFAULT_LAYOUT_OPTION_KEY] = default_layout
|
424
|
+
site = Site(options, [("/", page)])
|
425
|
+
context = site.build_site()
|
426
|
+
return context.rendered_pages["/"]
|
@@ -0,0 +1,102 @@
|
|
1
|
+
Metadata-Version: 2.4
|
2
|
+
Name: ophinode
|
3
|
+
Version: 0.0.1a1
|
4
|
+
Summary: A static site generator written in Python
|
5
|
+
Project-URL: Homepage, https://github.com/deflatedlatte/ophinode
|
6
|
+
Project-URL: Issues, https://github.com/deflatedlatte/ophinode/issues
|
7
|
+
Author-email: deflatedlatte <deflatedlatte@gmail.com>
|
8
|
+
License-File: LICENSE
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
10
|
+
Classifier: Operating System :: OS Independent
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
12
|
+
Requires-Python: >=3.6
|
13
|
+
Description-Content-Type: text/markdown
|
14
|
+
|
15
|
+
# ophinode
|
16
|
+
`ophinode` is a static site generator written in Python that focuses on being
|
17
|
+
a simple and flexible library for creating websites.
|
18
|
+
|
19
|
+
This project is currently in the initial development stage, and the APIs may
|
20
|
+
change at any time.
|
21
|
+
|
22
|
+
## Example programs
|
23
|
+
|
24
|
+
You can also get these example programs by running
|
25
|
+
`python -m ophinode examples`.
|
26
|
+
|
27
|
+
```python
|
28
|
+
# Example program: render a page without defining a site.
|
29
|
+
#
|
30
|
+
# Running this program prints a HTML document to standard output.
|
31
|
+
#
|
32
|
+
from ophinode import *
|
33
|
+
|
34
|
+
class MainPage:
|
35
|
+
def body(self):
|
36
|
+
return Div(
|
37
|
+
H1("Main Page"),
|
38
|
+
P("Welcome to ophinode!")
|
39
|
+
)
|
40
|
+
|
41
|
+
def head(self):
|
42
|
+
return [
|
43
|
+
Meta(charset="utf-8"),
|
44
|
+
Title("Main Page")
|
45
|
+
]
|
46
|
+
|
47
|
+
render_page(MainPage(), HTML5Layout())
|
48
|
+
|
49
|
+
```
|
50
|
+
|
51
|
+
```python
|
52
|
+
# Example program: create a page in a directory.
|
53
|
+
#
|
54
|
+
# Running this program creates "index.html" in "./out" directory.
|
55
|
+
#
|
56
|
+
from ophinode import *
|
57
|
+
|
58
|
+
class DefaultLayout(Layout):
|
59
|
+
def build(self, page, context):
|
60
|
+
return [
|
61
|
+
HTML5Doctype(),
|
62
|
+
Html(
|
63
|
+
Head(
|
64
|
+
Meta(charset="utf-8"),
|
65
|
+
Title(page.title()),
|
66
|
+
page.head()
|
67
|
+
),
|
68
|
+
Body(
|
69
|
+
page.body()
|
70
|
+
),
|
71
|
+
)
|
72
|
+
]
|
73
|
+
|
74
|
+
class MainPage:
|
75
|
+
@property
|
76
|
+
def layout(self):
|
77
|
+
return DefaultLayout()
|
78
|
+
|
79
|
+
def body(self):
|
80
|
+
return Div(
|
81
|
+
H1("Main Page"),
|
82
|
+
P("Welcome to ophinode!")
|
83
|
+
)
|
84
|
+
|
85
|
+
def head(self):
|
86
|
+
return []
|
87
|
+
|
88
|
+
def title(self):
|
89
|
+
return "Main Page"
|
90
|
+
|
91
|
+
if __name__ == "__main__":
|
92
|
+
site = Site({
|
93
|
+
"default_layout": DefaultLayout(),
|
94
|
+
"export_root_path": "./out",
|
95
|
+
"default_page_output_filename": "index.html",
|
96
|
+
}, [
|
97
|
+
("/", MainPage()),
|
98
|
+
])
|
99
|
+
|
100
|
+
site.build_site()
|
101
|
+
|
102
|
+
```
|