lektor-ai-chatbot 1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Asyntai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: lektor-ai-chatbot
3
+ Version: 1.0.0
4
+ Summary: Add the Asyntai AI chat assistant to your Lektor site. It answers visitor questions from your own content.
5
+ Author-email: Asyntai <hello@asyntai.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://asyntai.com/documentation/integrations/lektor/
8
+ Project-URL: Documentation, https://asyntai.com/documentation/integrations/lektor/
9
+ Project-URL: Source, https://github.com/asyntai/lektor-ai-chatbot
10
+ Project-URL: Issues, https://github.com/asyntai/lektor-ai-chatbot/issues
11
+ Keywords: lektor,lektor-plugin,chatbot,ai,chat,support,asyntai
12
+ Classifier: Framework :: Lektor
13
+ Classifier: Environment :: Plugins
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
16
+ Classifier: Topic :: Communications :: Chat
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # lektor-ai-chatbot
23
+
24
+ Add the [Asyntai](https://asyntai.com) AI chat assistant to your Lektor site.
25
+ It answers visitor questions from your own content, day and night.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ lektor plugins add lektor-ai-chatbot
31
+ ```
32
+
33
+ Then create the file `configs/ai-chatbot.ini` in your project:
34
+
35
+ ```ini
36
+ widget_id = asyntai_1a2b3c4d5e6f
37
+ ```
38
+
39
+ Build the site. The chat bubble is on every page.
40
+
41
+ ## Where the widget ID comes from
42
+
43
+ Open the Install page in your [Asyntai dashboard](https://asyntai.com/dashboard).
44
+ The widget ID is the value of `data-asyntai-id` in the snippet shown there. It
45
+ starts with `asyntai_`.
46
+
47
+ Creating an account is free, and the Free plan is enough to try the widget.
48
+
49
+ ## Options
50
+
51
+ All options go in `configs/ai-chatbot.ini`.
52
+
53
+ | Option | Default | What it does |
54
+ | --- | --- | --- |
55
+ | `widget_id` | required | Your Asyntai widget ID |
56
+ | `enable_on_server` | `true` | Set to `false` to keep the widget off the site during `lektor server` |
57
+ | `script_url` | the Asyntai CDN | Advanced. Load the widget from another URL |
58
+
59
+ A missing or malformed `widget_id` stops the build with a message that says how
60
+ to fix it.
61
+
62
+ ## What the plugin does
63
+
64
+ - It adds one `<script>` tag in front of the closing body tag of every HTML
65
+ page that Lektor builds, including paginated pages and the 404 page.
66
+ - It marks the tag `async`, so your pages render first.
67
+ - It changes nothing else. No templates are overridden and no CSS is added.
68
+ - Feeds, sitemaps, CSS and images stay as they are. An HTML file in `assets/`
69
+ gets the widget only when it is a full page.
70
+ - If a page already has the Asyntai snippet, for example in a template, the
71
+ plugin leaves that page alone.
72
+
73
+ ## Answering from your content
74
+
75
+ Asyntai reads your published pages. Add your site URL in the dashboard, and the
76
+ assistant answers from the same pages the widget sits on. You can also add
77
+ documents, and question and answer pairs.
78
+
79
+ ## Removing it
80
+
81
+ Run `lektor plugins remove lektor-ai-chatbot` and build again. The widget is
82
+ gone.
83
+
84
+ ## Support
85
+
86
+ Email [hello@asyntai.com](mailto:hello@asyntai.com), or read the
87
+ [Lektor guide](https://asyntai.com/documentation/integrations/lektor/).
88
+
89
+ ## Licence
90
+
91
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,70 @@
1
+ # lektor-ai-chatbot
2
+
3
+ Add the [Asyntai](https://asyntai.com) AI chat assistant to your Lektor site.
4
+ It answers visitor questions from your own content, day and night.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ lektor plugins add lektor-ai-chatbot
10
+ ```
11
+
12
+ Then create the file `configs/ai-chatbot.ini` in your project:
13
+
14
+ ```ini
15
+ widget_id = asyntai_1a2b3c4d5e6f
16
+ ```
17
+
18
+ Build the site. The chat bubble is on every page.
19
+
20
+ ## Where the widget ID comes from
21
+
22
+ Open the Install page in your [Asyntai dashboard](https://asyntai.com/dashboard).
23
+ The widget ID is the value of `data-asyntai-id` in the snippet shown there. It
24
+ starts with `asyntai_`.
25
+
26
+ Creating an account is free, and the Free plan is enough to try the widget.
27
+
28
+ ## Options
29
+
30
+ All options go in `configs/ai-chatbot.ini`.
31
+
32
+ | Option | Default | What it does |
33
+ | --- | --- | --- |
34
+ | `widget_id` | required | Your Asyntai widget ID |
35
+ | `enable_on_server` | `true` | Set to `false` to keep the widget off the site during `lektor server` |
36
+ | `script_url` | the Asyntai CDN | Advanced. Load the widget from another URL |
37
+
38
+ A missing or malformed `widget_id` stops the build with a message that says how
39
+ to fix it.
40
+
41
+ ## What the plugin does
42
+
43
+ - It adds one `<script>` tag in front of the closing body tag of every HTML
44
+ page that Lektor builds, including paginated pages and the 404 page.
45
+ - It marks the tag `async`, so your pages render first.
46
+ - It changes nothing else. No templates are overridden and no CSS is added.
47
+ - Feeds, sitemaps, CSS and images stay as they are. An HTML file in `assets/`
48
+ gets the widget only when it is a full page.
49
+ - If a page already has the Asyntai snippet, for example in a template, the
50
+ plugin leaves that page alone.
51
+
52
+ ## Answering from your content
53
+
54
+ Asyntai reads your published pages. Add your site URL in the dashboard, and the
55
+ assistant answers from the same pages the widget sits on. You can also add
56
+ documents, and question and answer pairs.
57
+
58
+ ## Removing it
59
+
60
+ Run `lektor plugins remove lektor-ai-chatbot` and build again. The widget is
61
+ gone.
62
+
63
+ ## Support
64
+
65
+ Email [hello@asyntai.com](mailto:hello@asyntai.com), or read the
66
+ [Lektor guide](https://asyntai.com/documentation/integrations/lektor/).
67
+
68
+ ## Licence
69
+
70
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: lektor-ai-chatbot
3
+ Version: 1.0.0
4
+ Summary: Add the Asyntai AI chat assistant to your Lektor site. It answers visitor questions from your own content.
5
+ Author-email: Asyntai <hello@asyntai.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://asyntai.com/documentation/integrations/lektor/
8
+ Project-URL: Documentation, https://asyntai.com/documentation/integrations/lektor/
9
+ Project-URL: Source, https://github.com/asyntai/lektor-ai-chatbot
10
+ Project-URL: Issues, https://github.com/asyntai/lektor-ai-chatbot/issues
11
+ Keywords: lektor,lektor-plugin,chatbot,ai,chat,support,asyntai
12
+ Classifier: Framework :: Lektor
13
+ Classifier: Environment :: Plugins
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
16
+ Classifier: Topic :: Communications :: Chat
17
+ Requires-Python: >=3.8
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # lektor-ai-chatbot
23
+
24
+ Add the [Asyntai](https://asyntai.com) AI chat assistant to your Lektor site.
25
+ It answers visitor questions from your own content, day and night.
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ lektor plugins add lektor-ai-chatbot
31
+ ```
32
+
33
+ Then create the file `configs/ai-chatbot.ini` in your project:
34
+
35
+ ```ini
36
+ widget_id = asyntai_1a2b3c4d5e6f
37
+ ```
38
+
39
+ Build the site. The chat bubble is on every page.
40
+
41
+ ## Where the widget ID comes from
42
+
43
+ Open the Install page in your [Asyntai dashboard](https://asyntai.com/dashboard).
44
+ The widget ID is the value of `data-asyntai-id` in the snippet shown there. It
45
+ starts with `asyntai_`.
46
+
47
+ Creating an account is free, and the Free plan is enough to try the widget.
48
+
49
+ ## Options
50
+
51
+ All options go in `configs/ai-chatbot.ini`.
52
+
53
+ | Option | Default | What it does |
54
+ | --- | --- | --- |
55
+ | `widget_id` | required | Your Asyntai widget ID |
56
+ | `enable_on_server` | `true` | Set to `false` to keep the widget off the site during `lektor server` |
57
+ | `script_url` | the Asyntai CDN | Advanced. Load the widget from another URL |
58
+
59
+ A missing or malformed `widget_id` stops the build with a message that says how
60
+ to fix it.
61
+
62
+ ## What the plugin does
63
+
64
+ - It adds one `<script>` tag in front of the closing body tag of every HTML
65
+ page that Lektor builds, including paginated pages and the 404 page.
66
+ - It marks the tag `async`, so your pages render first.
67
+ - It changes nothing else. No templates are overridden and no CSS is added.
68
+ - Feeds, sitemaps, CSS and images stay as they are. An HTML file in `assets/`
69
+ gets the widget only when it is a full page.
70
+ - If a page already has the Asyntai snippet, for example in a template, the
71
+ plugin leaves that page alone.
72
+
73
+ ## Answering from your content
74
+
75
+ Asyntai reads your published pages. Add your site URL in the dashboard, and the
76
+ assistant answers from the same pages the widget sits on. You can also add
77
+ documents, and question and answer pairs.
78
+
79
+ ## Removing it
80
+
81
+ Run `lektor plugins remove lektor-ai-chatbot` and build again. The widget is
82
+ gone.
83
+
84
+ ## Support
85
+
86
+ Email [hello@asyntai.com](mailto:hello@asyntai.com), or read the
87
+ [Lektor guide](https://asyntai.com/documentation/integrations/lektor/).
88
+
89
+ ## Licence
90
+
91
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ lektor_ai_chatbot.py
4
+ pyproject.toml
5
+ setup.py
6
+ lektor_ai_chatbot.egg-info/PKG-INFO
7
+ lektor_ai_chatbot.egg-info/SOURCES.txt
8
+ lektor_ai_chatbot.egg-info/dependency_links.txt
9
+ lektor_ai_chatbot.egg-info/entry_points.txt
10
+ lektor_ai_chatbot.egg-info/top_level.txt
11
+ tests/test_plugin.py
@@ -0,0 +1,2 @@
1
+ [lektor.plugins]
2
+ ai-chatbot = lektor_ai_chatbot:AiChatbotPlugin
@@ -0,0 +1 @@
1
+ lektor_ai_chatbot
@@ -0,0 +1,149 @@
1
+ """
2
+ Asyntai AI chatbot for Lektor.
3
+
4
+ Adds the Asyntai chat widget to every HTML page that Lektor builds. The plugin
5
+ writes the ordinary embed snippet in front of the closing body tag of each
6
+ page. Nothing else about the site changes: no templates are overridden and no
7
+ CSS is added.
8
+
9
+ Settings live in configs/ai-chatbot.ini:
10
+
11
+ widget_id = asyntai_1a2b3c4d5e6f
12
+ """
13
+
14
+ import os
15
+ import re
16
+ import threading
17
+
18
+ import click
19
+ from lektor.assets import Asset
20
+ from lektor.pluginsystem import Plugin
21
+
22
+ __version__ = "1.0.0"
23
+
24
+ DEFAULT_SCRIPT_URL = "https://widget.asyntai.com/static/js/chat-widget.js"
25
+ WIDGET_ID_PATTERN = re.compile(r"^asyntai_[A-Za-z0-9]+$")
26
+ HTML_SUFFIXES = (".html", ".htm")
27
+
28
+ # The last closing body tag of the page. A page can quote "</body>" inside a
29
+ # code block, so only the final one is the real end of the document.
30
+ _BODY_END = re.compile(rb"</body\s*>", re.IGNORECASE)
31
+ _MARKER = b"data-asyntai-id"
32
+
33
+ _FALSE = ("0", "false", "no", "off")
34
+
35
+
36
+ def build_tag(widget_id, script_url):
37
+ return '<script async src="{src}" data-asyntai-id="{wid}"></script>'.format(
38
+ src=script_url.replace("&", "&amp;").replace('"', "&quot;"),
39
+ wid=widget_id,
40
+ )
41
+
42
+
43
+ def insert_tag(html, tag):
44
+ """Put the tag in front of the last </body>, or at the end if there is none.
45
+
46
+ Works on bytes, so the page keeps its own encoding and line endings.
47
+ """
48
+ tag = tag.encode("utf-8")
49
+ matches = list(_BODY_END.finditer(html))
50
+ if not matches:
51
+ return html + tag
52
+ end = matches[-1].start()
53
+ return html[:end] + tag + b"\n" + html[end:]
54
+
55
+
56
+ def add_to_file(path, tag, full_page_only=False):
57
+ """Add the tag to one built HTML file. Returns True if the file changed.
58
+
59
+ With full_page_only, a file without a closing body tag stays as it is.
60
+ """
61
+ try:
62
+ with open(path, "rb") as f:
63
+ html = f.read()
64
+ except OSError:
65
+ return False
66
+ # The site owner already pasted the snippet in a template. Two loaders
67
+ # would only fight over one chat bubble, so keep theirs. This also makes
68
+ # a second pass over the same file do nothing.
69
+ if _MARKER in html:
70
+ return False
71
+ if full_page_only and not _BODY_END.search(html):
72
+ return False
73
+ new_html = insert_tag(html, tag)
74
+ tmp = path + ".asyntai-tmp"
75
+ with open(tmp, "wb") as f:
76
+ f.write(new_html)
77
+ os.replace(tmp, path)
78
+ return True
79
+
80
+
81
+ def read_settings(config):
82
+ """Check the ini values and return (tag, enable_on_server)."""
83
+ widget_id = (config.get("widget_id") or "").strip()
84
+ if not widget_id:
85
+ raise click.ClickException(
86
+ 'lektor-ai-chatbot: "widget_id" is missing in configs/ai-chatbot.ini. '
87
+ "Copy it from the Install page of your Asyntai dashboard, for "
88
+ "example: widget_id = asyntai_1a2b3c4d5e6f"
89
+ )
90
+ if not WIDGET_ID_PATTERN.match(widget_id):
91
+ raise click.ClickException(
92
+ 'lektor-ai-chatbot: "{0}" does not look like a widget ID. It starts '
93
+ "with asyntai_, for example asyntai_1a2b3c4d5e6f.".format(widget_id)
94
+ )
95
+ script_url = (config.get("script_url") or "").strip() or DEFAULT_SCRIPT_URL
96
+ on_server = (config.get("enable_on_server") or "true").strip().lower()
97
+ return build_tag(widget_id, script_url), on_server not in _FALSE
98
+
99
+
100
+ def _in_dev_server():
101
+ # `lektor server` builds in a BackgroundBuilder thread. Checking the
102
+ # thread is reliable even for the very first build, which starts before
103
+ # the server-spawn event.
104
+ try:
105
+ from lektor.devserver import BackgroundBuilder
106
+ except ImportError: # pragma: no cover
107
+ return False
108
+ return isinstance(threading.current_thread(), BackgroundBuilder)
109
+
110
+
111
+ class AiChatbotPlugin(Plugin):
112
+ name = "AI Chatbot by Asyntai"
113
+ description = (
114
+ "Adds the Asyntai AI chat assistant to every page. It answers visitor "
115
+ "questions from your own content."
116
+ )
117
+
118
+ def __init__(self, env, id):
119
+ super().__init__(env, id)
120
+ self._server = False
121
+
122
+ def _settings(self):
123
+ # Read fresh each time, so an edit to the ini file during
124
+ # `lektor server` takes effect on the next build.
125
+ return read_settings(self.get_config(fresh=True))
126
+
127
+ def on_setup_env(self, **extra):
128
+ # Stop `lektor build` at once, with a clear message, when the ID is
129
+ # missing or wrong. Nothing is written in that case.
130
+ self._settings()
131
+
132
+ def on_server_spawn(self, **extra):
133
+ self._server = True
134
+
135
+ def on_after_build(self, builder, build_state, source, prog, **extra):
136
+ tag, on_server = self._settings()
137
+ if not on_server and (self._server or _in_dev_server()):
138
+ return
139
+ for artifact in build_state.updated_artifacts:
140
+ filename = artifact.dst_filename
141
+ if not filename.lower().endswith(HTML_SUFFIXES):
142
+ continue
143
+ # A file copied from assets/ can be an HTML fragment that a script
144
+ # loads into another page. Only a full page gets the widget.
145
+ add_to_file(
146
+ filename,
147
+ tag,
148
+ full_page_only=isinstance(artifact.source_obj, Asset),
149
+ )
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lektor-ai-chatbot"
7
+ version = "1.0.0"
8
+ description = "Add the Asyntai AI chat assistant to your Lektor site. It answers visitor questions from your own content."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{name = "Asyntai", email = "hello@asyntai.com"}]
13
+ requires-python = ">=3.8"
14
+ dependencies = []
15
+ keywords = ["lektor", "lektor-plugin", "chatbot", "ai", "chat", "support", "asyntai"]
16
+ classifiers = [
17
+ "Framework :: Lektor",
18
+ "Environment :: Plugins",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
21
+ "Topic :: Communications :: Chat",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://asyntai.com/documentation/integrations/lektor/"
26
+ Documentation = "https://asyntai.com/documentation/integrations/lektor/"
27
+ Source = "https://github.com/asyntai/lektor-ai-chatbot"
28
+ Issues = "https://github.com/asyntai/lektor-ai-chatbot/issues"
29
+
30
+ [project.entry-points."lektor.plugins"]
31
+ ai-chatbot = "lektor_ai_chatbot:AiChatbotPlugin"
32
+
33
+ [tool.setuptools]
34
+ py-modules = ["lektor_ai_chatbot"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ # Lektor 3.3 installs a local plugin from packages/ only when setup.py
2
+ # exists. All metadata is in pyproject.toml.
3
+ from setuptools import setup
4
+
5
+ setup()
@@ -0,0 +1,401 @@
1
+ """
2
+ Tests for lektor-ai-chatbot.
3
+
4
+ They build small real Lektor sites with `lektor build` and `lektor server`.
5
+ Install Lektor and this package first:
6
+
7
+ pip install lektor .
8
+ python tests/test_plugin.py
9
+ """
10
+
11
+ import os
12
+ import shutil
13
+ import socket
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ import time
18
+ import urllib.request
19
+
20
+ HERE = os.path.dirname(os.path.abspath(__file__))
21
+ sys.path.insert(0, os.path.dirname(HERE))
22
+
23
+ import lektor_ai_chatbot as plugin # noqa: E402
24
+
25
+ WIDGET_ID = "asyntai_1a2b3c4d5e6f"
26
+ TAG = plugin.build_tag(WIDGET_ID, plugin.DEFAULT_SCRIPT_URL)
27
+ LEKTOR = [sys.executable, "-m", "lektor"]
28
+
29
+ PAGE_TEMPLATE = """<!doctype html>
30
+ <html>
31
+ <head><meta charset="utf-8"><title>{{ this.title }}</title></head>
32
+ <body>
33
+ <h1>{{ this.title }}</h1>
34
+ {{ this.body }}
35
+ </body>
36
+ </html>
37
+ """
38
+
39
+ # A template that already carries the snippet, as a site owner would paste it.
40
+ OWN_SNIPPET_TEMPLATE = """<!doctype html>
41
+ <html>
42
+ <head><title>{{ this.title }}</title></head>
43
+ <body>
44
+ {{ this.body }}
45
+ <script async src="https://widget.asyntai.com/static/js/chat-widget.js" data-asyntai-id="asyntai_owner1"></script>
46
+ </body>
47
+ </html>
48
+ """
49
+
50
+ FEED_TEMPLATE = """<?xml version="1.0" encoding="utf-8"?>
51
+ <feed><title>{{ this.title }}</title><entry>&lt;/body&gt;</entry></feed>
52
+ """
53
+
54
+ BLOG_TEMPLATE = """<!doctype html>
55
+ <html><head><title>Blog</title></head>
56
+ <body>
57
+ {% for child in this.pagination.items %}<p>{{ child.title }}</p>{% endfor %}
58
+ </body>
59
+ </html>
60
+ """
61
+
62
+ PAGE_MODEL = """[model]
63
+ name = Page
64
+ label = {{ this.title }}
65
+
66
+ [fields.title]
67
+ type = string
68
+
69
+ [fields.body]
70
+ type = markdown
71
+ """
72
+
73
+ BLOG_MODEL = """[model]
74
+ name = Blog
75
+ label = Blog
76
+
77
+ [fields.title]
78
+ type = string
79
+
80
+ [children]
81
+ model = page
82
+ order_by = title
83
+
84
+ [pagination]
85
+ enabled = yes
86
+ per_page = 2
87
+ """
88
+
89
+
90
+ def write(path, text, mode="w"):
91
+ os.makedirs(os.path.dirname(path), exist_ok=True)
92
+ with open(path, mode, encoding=None if "b" in mode else "utf-8", newline="") as f:
93
+ f.write(text)
94
+
95
+
96
+ def page(title, body="", template=None, slug=None):
97
+ parts = ["title: " + title, "body:\n\n" + body]
98
+ if template:
99
+ parts.append("_template: " + template)
100
+ if slug:
101
+ parts.append("_slug: " + slug)
102
+ return "\n---\n".join(parts) + "\n"
103
+
104
+
105
+ def make_site(root, ini=None):
106
+ write(os.path.join(root, "site.lektorproject"), "[project]\nname = Test\n")
107
+ write(os.path.join(root, "models", "page.ini"), PAGE_MODEL)
108
+ write(os.path.join(root, "models", "blog.ini"), BLOG_MODEL)
109
+ t = os.path.join(root, "templates")
110
+ write(os.path.join(t, "page.html"), PAGE_TEMPLATE)
111
+ write(os.path.join(t, "own.html"), OWN_SNIPPET_TEMPLATE)
112
+ write(os.path.join(t, "feed.xml"), FEED_TEMPLATE)
113
+ write(os.path.join(t, "blog.html"), BLOG_TEMPLATE)
114
+ c = os.path.join(root, "content")
115
+ write(os.path.join(c, "contents.lr"), page("Home", "Welcome."))
116
+ write(os.path.join(c, "about", "contents.lr"), page("About", "About us."))
117
+ write(
118
+ os.path.join(c, "code", "contents.lr"),
119
+ page("Code", "Close the page with:\n\n </body>\n\nThat is all."),
120
+ )
121
+ write(
122
+ os.path.join(c, "unicode", "contents.lr"),
123
+ page("Příliš žluťoučký kůň", "日本語のテキスト. Ünïcödé."),
124
+ )
125
+ write(os.path.join(c, "own", "contents.lr"), page("Own", "Mine.", template="own.html"))
126
+ write(
127
+ os.path.join(c, "feed", "contents.lr"),
128
+ page("Feed", template="feed.xml", slug="feed.xml"),
129
+ )
130
+ write(os.path.join(c, "404", "contents.lr"), page("Not found", "Gone.", slug="404.html"))
131
+ write(os.path.join(c, "blog", "contents.lr"), "_model: blog\n---\ntitle: Blog\n")
132
+ for n in range(1, 6):
133
+ write(
134
+ os.path.join(c, "blog", "post%d" % n, "contents.lr"),
135
+ page("Post %d" % n, "Text %d." % n),
136
+ )
137
+ a = os.path.join(root, "assets")
138
+ write(os.path.join(a, "static", "style.css"), "body { color: black; }\n")
139
+ write(os.path.join(a, "fragment.html"), "<div>Loaded by a script.</div>\n")
140
+ write(
141
+ os.path.join(a, "standalone.html"),
142
+ "<html><body><p>Static page.</p></body></html>\n",
143
+ )
144
+ if ini is not None:
145
+ write(os.path.join(root, "configs", "ai-chatbot.ini"), ini)
146
+
147
+
148
+ def build(root, out):
149
+ return subprocess.run(
150
+ LEKTOR + ["--project", root, "build", "-O", out],
151
+ capture_output=True,
152
+ text=True,
153
+ encoding="utf-8",
154
+ errors="replace",
155
+ )
156
+
157
+
158
+ def read(out, *parts):
159
+ with open(os.path.join(out, *parts), "rb") as f:
160
+ return f.read().decode("utf-8")
161
+
162
+
163
+ def free_port():
164
+ with socket.socket() as s:
165
+ s.bind(("127.0.0.1", 0))
166
+ return s.getsockname()[1]
167
+
168
+
169
+ def fetch(url, timeout=60):
170
+ end = time.time() + timeout
171
+ last = None
172
+ while time.time() < end:
173
+ try:
174
+ with urllib.request.urlopen(url, timeout=5) as r:
175
+ return r.read().decode("utf-8")
176
+ except Exception as e: # server still starting or still building
177
+ last = e
178
+ time.sleep(0.5)
179
+ raise AssertionError("no answer from %s: %s" % (url, last))
180
+
181
+
182
+ class Server:
183
+ def __init__(self, root, out):
184
+ self.port = free_port()
185
+ self.out = out
186
+ self.proc = subprocess.Popen(
187
+ LEKTOR
188
+ + ["--project", root, "server", "-h", "127.0.0.1", "-p", str(self.port), "-O", out],
189
+ stdout=subprocess.DEVNULL,
190
+ stderr=subprocess.DEVNULL,
191
+ )
192
+
193
+ def url(self, path):
194
+ return "http://127.0.0.1:%d%s" % (self.port, path)
195
+
196
+ def wait_for_file(self, *parts, timeout=60):
197
+ path = os.path.join(self.out, *parts)
198
+ end = time.time() + timeout
199
+ while time.time() < end:
200
+ if os.path.isfile(path):
201
+ time.sleep(0.5)
202
+ return
203
+ time.sleep(0.3)
204
+ raise AssertionError("server never wrote " + path)
205
+
206
+ def stop(self):
207
+ self.proc.terminate()
208
+ try:
209
+ self.proc.wait(10)
210
+ except subprocess.TimeoutExpired:
211
+ self.proc.kill()
212
+
213
+
214
+ RESULTS = []
215
+
216
+
217
+ def check(name, cond, detail=""):
218
+ RESULTS.append(bool(cond))
219
+ print(("PASS " if cond else "FAIL ") + name + ("" if cond else " " + detail))
220
+
221
+
222
+ def run():
223
+ tmp = tempfile.mkdtemp(prefix="lektor-ai-chatbot-")
224
+ try:
225
+ # ---- unit checks ------------------------------------------------
226
+ check(
227
+ "insert_tag puts the tag before the last </body>",
228
+ plugin.insert_tag(b"<p></body></p></body>", "X") == b"<p></body></p>X\n</body>",
229
+ )
230
+ check("insert_tag appends when there is no </body>", plugin.insert_tag(b"<p>", "X") == b"<p>X")
231
+ check(
232
+ "build_tag escapes the script URL",
233
+ 'src="https://x.test/w.js?a=1&amp;b=&quot;2"'
234
+ in plugin.build_tag(WIDGET_ID, 'https://x.test/w.js?a=1&b="2'),
235
+ )
236
+
237
+ # ---- a normal build ---------------------------------------------
238
+ root = os.path.join(tmp, "site")
239
+ out = os.path.join(tmp, "out")
240
+ make_site(root, "widget_id = %s\n" % WIDGET_ID)
241
+ r = build(root, out)
242
+ check("build succeeds", r.returncode == 0, r.stderr[-800:])
243
+
244
+ home = read(out, "index.html")
245
+ check("home page has one widget tag", home.count("data-asyntai-id") == 1 and TAG in home)
246
+ check("tag sits right before </body>", TAG + "\n</body>" in home)
247
+ check("inner page has the tag", TAG in read(out, "about", "index.html"))
248
+ check("404 page has the tag", TAG in read(out, "404.html"))
249
+
250
+ code = read(out, "code", "index.html")
251
+ check(
252
+ "quoted </body> in a code block is not used",
253
+ code.count("data-asyntai-id") == 1 and code.index(TAG) > code.index("&lt;/body&gt;"),
254
+ )
255
+
256
+ uni = read(out, "unicode", "index.html")
257
+ check(
258
+ "non-ASCII text survives unchanged",
259
+ "Příliš žluťoučký kůň" in uni and "日本語のテキスト" in uni and TAG in uni,
260
+ )
261
+
262
+ own = read(out, "own", "index.html")
263
+ check(
264
+ "a page with the owner's own snippet is left alone",
265
+ own.count("data-asyntai-id") == 1 and "asyntai_owner1" in own,
266
+ )
267
+
268
+ check("feed.xml is untouched", "data-asyntai-id" not in read(out, "feed.xml"))
269
+ check("CSS is untouched", read(out, "static", "style.css") == "body { color: black; }\n")
270
+ check(
271
+ "an HTML fragment from assets/ is untouched",
272
+ read(out, "fragment.html") == "<div>Loaded by a script.</div>\n",
273
+ )
274
+ check("a full static page from assets/ gets the tag", TAG in read(out, "standalone.html"))
275
+
276
+ check("blog index page has the tag", TAG in read(out, "blog", "index.html"))
277
+ check("pagination page 2 has the tag", TAG in read(out, "blog", "page", "2", "index.html"))
278
+ check("pagination page 3 has the tag", TAG in read(out, "blog", "page", "3", "index.html"))
279
+ check("blog post has the tag", TAG in read(out, "blog", "post4", "index.html"))
280
+
281
+ # ---- rebuilds ---------------------------------------------------
282
+ r = build(root, out)
283
+ check(
284
+ "second build adds no second tag",
285
+ r.returncode == 0 and read(out, "index.html").count("data-asyntai-id") == 1,
286
+ )
287
+ write(os.path.join(root, "content", "about", "contents.lr"), page("About", "Changed text."))
288
+ r = build(root, out)
289
+ about = read(out, "about", "index.html")
290
+ check(
291
+ "an edited page is rebuilt with exactly one tag",
292
+ r.returncode == 0 and "Changed text." in about and about.count("data-asyntai-id") == 1,
293
+ )
294
+ write(os.path.join(root, "templates", "page.html"), PAGE_TEMPLATE.replace("<h1>", "<h1 class=t>"))
295
+ r = build(root, out)
296
+ check(
297
+ "a template change rebuilds every page with one tag",
298
+ r.returncode == 0
299
+ and 'class=t' in read(out, "blog", "post2", "index.html")
300
+ and read(out, "blog", "post2", "index.html").count("data-asyntai-id") == 1,
301
+ )
302
+ shutil.rmtree(out)
303
+ r = build(root, out)
304
+ check(
305
+ "a fresh build into an empty folder has the tag",
306
+ r.returncode == 0 and TAG in read(out, "index.html"),
307
+ )
308
+
309
+ # ---- settings ---------------------------------------------------
310
+ r = subprocess.run(
311
+ LEKTOR + ["--project", root, "plugins", "list", "-v"],
312
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
313
+ )
314
+ check(
315
+ "lektor plugins list shows the plugin",
316
+ "AI Chatbot by Asyntai (ai-chatbot)" in r.stdout and "version: 1.0.0" in r.stdout,
317
+ r.stdout,
318
+ )
319
+
320
+ custom = os.path.join(tmp, "custom")
321
+ make_site(
322
+ custom,
323
+ "widget_id = %s\nscript_url = https://cdn.example.test/w.js?v=2&x=1\n" % WIDGET_ID,
324
+ )
325
+ r = build(custom, os.path.join(tmp, "custom-out"))
326
+ check(
327
+ "script_url is used and escaped",
328
+ r.returncode == 0
329
+ and 'src="https://cdn.example.test/w.js?v=2&amp;x=1"'
330
+ in read(os.path.join(tmp, "custom-out"), "index.html"),
331
+ )
332
+
333
+ for label, ini in (
334
+ ("no ini file", None),
335
+ ("empty widget_id", "widget_id =\n"),
336
+ ("malformed widget_id", "widget_id = 12345\n"),
337
+ ):
338
+ bad = os.path.join(tmp, "bad-" + label.replace(" ", "-"))
339
+ bad_out = os.path.join(tmp, "bad-out-" + label.replace(" ", "-"))
340
+ make_site(bad, ini)
341
+ r = build(bad, bad_out)
342
+ text = r.stdout + r.stderr
343
+ check(
344
+ label + " stops the build with a clear message",
345
+ r.returncode != 0 and "lektor-ai-chatbot" in text and "Traceback" not in text
346
+ and not os.path.exists(os.path.join(bad_out, "index.html")),
347
+ text[-600:],
348
+ )
349
+
350
+ off = os.path.join(tmp, "off")
351
+ make_site(off, "widget_id = %s\nenable_on_server = false\n" % WIDGET_ID)
352
+ r = build(off, os.path.join(tmp, "off-out"))
353
+ check(
354
+ "enable_on_server = false still adds the tag on lektor build",
355
+ r.returncode == 0 and TAG in read(os.path.join(tmp, "off-out"), "index.html"),
356
+ )
357
+
358
+ # ---- lektor server ----------------------------------------------
359
+ srv_out = os.path.join(tmp, "srv-out")
360
+ s = Server(root, srv_out)
361
+ try:
362
+ s.wait_for_file("about", "index.html")
363
+ html = fetch(s.url("/about/"))
364
+ check("lektor server serves the page with the tag", TAG in html and html.count("data-asyntai-id") == 1)
365
+ write(os.path.join(root, "content", "about", "contents.lr"), page("About", "Live edit."))
366
+ end = time.time() + 30
367
+ html = ""
368
+ while time.time() < end:
369
+ html = fetch(s.url("/about/"))
370
+ if "Live edit." in html:
371
+ break
372
+ time.sleep(0.5)
373
+ check(
374
+ "a live edit during lektor server keeps one tag",
375
+ "Live edit." in html and html.count("data-asyntai-id") == 1,
376
+ html[-300:],
377
+ )
378
+ finally:
379
+ s.stop()
380
+
381
+ off_srv_out = os.path.join(tmp, "off-srv-out")
382
+ s = Server(off, off_srv_out)
383
+ try:
384
+ s.wait_for_file("index.html")
385
+ html = fetch(s.url("/"))
386
+ check(
387
+ "enable_on_server = false keeps the widget off lektor server",
388
+ "Home" in html and "data-asyntai-id" not in html,
389
+ )
390
+ finally:
391
+ s.stop()
392
+ finally:
393
+ shutil.rmtree(tmp, ignore_errors=True)
394
+
395
+ passed = sum(RESULTS)
396
+ print("\n%d/%d checks passed" % (passed, len(RESULTS)))
397
+ return passed == len(RESULTS)
398
+
399
+
400
+ if __name__ == "__main__":
401
+ sys.exit(0 if run() else 1)