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/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
+ ```