Skip to content

Commit 5723be2

Browse files
authored
Merge pull request #1 from BleedingXiko/dev
Add searchable docs UI, static serving, and browser provider resolver
2 parents 9e2f2ca + 8ae27d8 commit 5723be2

30 files changed

Lines changed: 894 additions & 37 deletions

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,7 @@ sprag new myapp --template=labs # full framework showcase
513513
```bash
514514
sprag dev # dev server with hot reload
515515
sprag dev --port 3000
516+
sprag dev static # serve the pure static build locally
516517
sprag routes # list all routes, mounts, and actions
517518
```
518519

@@ -529,6 +530,7 @@ sprag add content guides # markdown collection + routes
529530

530531
```bash
531532
sprag build # compile to dist/
533+
sprag build static # compile a pure static site
532534
sprag pack # optimize dist for production
533535
sprag pack --zip # optimize + archive
534536
```
@@ -650,4 +652,4 @@ The docs site is itself a SPRAG app built with the `docs` template — dogfoodin
650652
- **Framework** — two runtimes, routes, controllers, stores, auth, uploads, realtime, codegen
651653
- **Specter** — services, schemas, queues (server runtime)
652654
- **Ragot** — components, modules, ui primitives, decorators (browser runtime)
653-
- **Guides** — deployment, forms, background jobs
655+
- **Guides** — deployment, forms, background jobs

docs/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
public/llms.txt
22
public/llms-full.txt
3+
app/static/search-index.json

docs/app/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
from sprag import App, shell
44

55
from app.llms_txt import write_llms_txt
6+
from app.search_index import write_search_index
67

78

89
_PUBLIC_DIR = Path(__file__).resolve().parent.parent / "public"
10+
_STATIC_DIR = Path(__file__).resolve().parent / "static"
911
_SITE_URL = "https://bleedingxiko.github.io/SPRAG"
1012

1113
app_shell = shell(template="app/shell.html", css=["app/shell.css"])
@@ -24,6 +26,8 @@
2426
},
2527
)
2628

27-
# Regenerate /llms.txt + /llms-full.txt on every boot.
28-
# public/ is copied verbatim to the static build output by sprag build.
29+
# Regenerate generated docs artifacts on every boot.
30+
# public/ is copied verbatim to the static build output; app/static/ is
31+
# served at /static/ in dev and copied to dist/static/ in build.
2932
write_llms_txt(_PUBLIC_DIR, site_url=_SITE_URL)
33+
write_search_index(_STATIC_DIR)

docs/app/content/docs/framework/routes.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ my_page = page(
5252
| `modules` | No | JS import aliases: `{"alias": "path/to/module.js"}` |
5353
| `static_paths` | No | Function returning path params for static builds |
5454
| `metadata` | No | Dict of metadata (title, description, etc.) |
55+
| `providers` | No | Browser provider Modules for this page |
5556

5657
### Metadata
5758

@@ -139,6 +140,38 @@ Merge order: **app metadata → page metadata → `__sprag_meta__`** (last wins)
139140

140141
If you want a browser-owned client app instead of a page route, use `mount(...)` under `app/mounts/`. Mounts are separate from page modes.
141142

143+
## Browser providers
144+
145+
Use `providers` when a page or mount needs a browser Module that starts before the hydrated or mounted Module code.
146+
147+
```python
148+
from sprag import Module, page
149+
150+
151+
class ToastProvider(Module):
152+
def on_start(self):
153+
self.last_message = ""
154+
155+
def push(self, message):
156+
self.last_message = message
157+
158+
159+
class InboxModule(Module):
160+
def on_start(self):
161+
toast = self.provider("toast")
162+
toast.push("Inbox ready")
163+
164+
165+
inbox = page(
166+
path="/inbox",
167+
controller=InboxController,
168+
screen=InboxScreen,
169+
providers={"toast": ToastProvider},
170+
)
171+
```
172+
173+
`App.providers` are server-side services resolved from controllers with `self.service(...)`. `page.providers` and `mount.providers` are browser-side Modules resolved from browser Modules with `self.provider(...)`.
174+
142175
## Dynamic routes
143176

144177
Use brackets in directory names for dynamic segments:

docs/app/content/docs/ragot/modules.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,3 +192,33 @@ class MyModule(Module):
192192
```
193193

194194
Auto-cleaned on `on_stop()`.
195+
196+
## Page and Mount Providers
197+
198+
`page(..., providers={...})` and `mount(..., providers={...})` start browser provider Modules before the page hydrates or the mount starts. Resolve them from another browser Module with `self.provider(key)`.
199+
200+
```python
201+
class ToastProvider(Module):
202+
def on_start(self):
203+
self.last_message = ""
204+
205+
def push(self, message):
206+
self.last_message = message
207+
208+
209+
class InboxModule(Module):
210+
def on_start(self):
211+
toast = self.provider("toast")
212+
toast.push("Inbox ready")
213+
```
214+
215+
Declare the provider on the surface:
216+
217+
```python
218+
inbox = page(
219+
path="/inbox",
220+
controller=InboxController,
221+
screen=InboxScreen,
222+
providers={"toast": ToastProvider},
223+
)
224+
```

docs/app/content/docs/reference/cli.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,12 @@ Build a local preview, watch the project for changes, and start the dev server.
3737
**Usage**
3838

3939
```bash
40-
sprag dev [--port 8000] [--host 127.0.0.1] [--interval 1.0] [--server-mode wsgi|websocket]
40+
sprag dev [static] [--port 8000] [--host 127.0.0.1] [--interval 1.0] [--server-mode wsgi|websocket]
4141
```
4242

4343
**Flags**
4444

45+
- `static` — optional mode that builds and serves a pure static site preview
4546
- `--port` — port to bind
4647
- `--host` — host/interface to bind
4748
- `--interval` — file-watch polling interval in seconds
@@ -54,10 +55,13 @@ sprag dev [--port 8000] [--host 127.0.0.1] [--interval 1.0] [--server-mode wsgi|
5455

5556
```bash
5657
sprag dev --host 0.0.0.0 --port 9000 --server-mode websocket
58+
sprag dev static --port 9000
5759
```
5860

5961
If you use websocket mode from a `spragkit` install, also install `gevent-websocket`.
6062

63+
`sprag dev static` serves only the generated static files, matching hosts such as GitHub Pages. It does not expose SPRAG action, websocket, or dev reload endpoints.
64+
6165
## `sprag build`
6266

6367
Build the app into a deployable artifact.

docs/app/mounts/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""SPRAG client app mounts."""

docs/app/mounts/search/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""search client app mount."""

docs/app/mounts/search/modules.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
from sprag import Module, debounce, dom
2+
3+
4+
class SearchModule(Module):
5+
def __init__(self, screen=None, state=None):
6+
super().__init__(screen=screen, state=state or {})
7+
self._docs = []
8+
self._loaded = False
9+
10+
def on_start(self):
11+
initial = self._read_query_param()
12+
input_el = dom.query("[data-role='search-input']", self.element)
13+
if input_el and initial:
14+
input_el.value = initial
15+
self.delegate(self.element, "input", "[data-role='search-input']", self.on_input)
16+
self._set_status("Loading search index…")
17+
self.load_index(initial)
18+
19+
async def load_index(self, initial_query):
20+
try:
21+
response = await browser.fetch("../static/search-index.json")
22+
payload = await response.json()
23+
self._docs = self._prepare(payload["docs"])
24+
self._loaded = True
25+
self._run(initial_query)
26+
except Exception as err:
27+
self._render_results([], [])
28+
self._set_status("Couldn't load the search index.")
29+
30+
@debounce(0.12)
31+
def on_input(self, event, target):
32+
self._run(target.value)
33+
34+
def _run(self, query):
35+
if not self._loaded:
36+
return None
37+
trimmed = query.strip()
38+
if not trimmed:
39+
self._render_results([], [])
40+
self._set_status("Type to search the docs.")
41+
return None
42+
tokens = self._tokenize(trimmed.lower())
43+
if len(tokens) == 0:
44+
self._render_results([], [])
45+
self._set_status("Type to search the docs.")
46+
return None
47+
results = self._score(tokens)
48+
if len(results) == 0:
49+
self._render_results([], [])
50+
self._set_status("No results for “" + trimmed + "”.")
51+
return None
52+
self._render_results(results, tokens)
53+
self._set_status(str(len(results)) + " result" + ("" if len(results) == 1 else "s") + " for “" + trimmed + "”")
54+
55+
def _set_status(self, text):
56+
el = dom.query("[data-role='search-status']", self.element)
57+
if el:
58+
el.textContent = text
59+
60+
def _render_results(self, items, tokens):
61+
container = dom.query("[data-role='search-results']", self.element)
62+
if not container:
63+
return None
64+
dom.clear(container)
65+
doc = browser.document
66+
for item in items:
67+
li = doc.createElement("li")
68+
li.className = "search-result"
69+
a = doc.createElement("a")
70+
a.href = item["url"]
71+
a.className = "search-result-link"
72+
section = doc.createElement("div")
73+
section.className = "search-result-section"
74+
section.textContent = item["section"]
75+
a.appendChild(section)
76+
title = doc.createElement("div")
77+
title.className = "search-result-title"
78+
self._highlight(title, item["title"], tokens)
79+
a.appendChild(title)
80+
if item["snippet"]:
81+
snippet = doc.createElement("div")
82+
snippet.className = "search-result-snippet"
83+
self._highlight(snippet, item["snippet"], tokens)
84+
a.appendChild(snippet)
85+
li.appendChild(a)
86+
container.appendChild(li)
87+
88+
def _highlight(self, parent, text, tokens):
89+
if not text:
90+
return None
91+
if len(tokens) == 0:
92+
parent.textContent = text
93+
return None
94+
doc = browser.document
95+
text_lc = text.lower()
96+
n = len(text)
97+
i = 0
98+
plain_start = 0
99+
while i < n:
100+
matched_len = 0
101+
for token in tokens:
102+
tlen = len(token)
103+
if text_lc.slice(i, i + tlen) == token:
104+
matched_len = tlen
105+
break
106+
if matched_len > 0:
107+
if i > plain_start:
108+
parent.appendChild(doc.createTextNode(text.slice(plain_start, i)))
109+
mark = doc.createElement("mark")
110+
mark.textContent = text.slice(i, i + matched_len)
111+
parent.appendChild(mark)
112+
i = i + matched_len
113+
plain_start = i
114+
else:
115+
i = i + 1
116+
if plain_start < n:
117+
parent.appendChild(doc.createTextNode(text.slice(plain_start, n)))
118+
119+
def _tokenize(self, query_lc):
120+
tokens = []
121+
for word in query_lc.split(" "):
122+
cleaned = word.strip()
123+
if cleaned:
124+
tokens.push(cleaned)
125+
return tokens
126+
127+
def _score(self, tokens):
128+
matches = []
129+
for doc in self._docs:
130+
score = 0
131+
all_hit = True
132+
for token in tokens:
133+
if token in doc["title_lc"]:
134+
score += 10
135+
elif token in doc["headings_lc"]:
136+
score += 4
137+
elif token in doc["description_lc"]:
138+
score += 3
139+
elif token in doc["body_lc"]:
140+
score += 1
141+
else:
142+
all_hit = False
143+
break
144+
if all_hit and score > 0:
145+
matches.push({
146+
"title": doc["title"],
147+
"url": doc["url"],
148+
"section": doc["section"],
149+
"snippet": self._snippet(doc, tokens),
150+
"score": score,
151+
})
152+
153+
matches.sort(lambda a, b: b["score"] - a["score"])
154+
return matches.slice(0, 30)
155+
156+
def _snippet(self, doc, tokens):
157+
body = doc["body"]
158+
body_lc = doc["body_lc"]
159+
if not body:
160+
return doc["description"] or ""
161+
162+
pos = -1
163+
for token in tokens:
164+
i = body_lc.indexOf(token)
165+
if i >= 0:
166+
pos = i
167+
break
168+
169+
if pos < 0:
170+
tail = "…" if len(body) > 160 else ""
171+
return body.slice(0, 160) + tail
172+
173+
start = pos - 60
174+
if start < 0:
175+
start = 0
176+
end = pos + 120
177+
prefix = "…" if start > 0 else ""
178+
suffix = "…" if end < len(body) else ""
179+
return prefix + body.slice(start, end) + suffix
180+
181+
def _prepare(self, docs):
182+
prepared = []
183+
for d in docs:
184+
headings_joined = d["headings"].join(" ")
185+
prepared.push({
186+
"title": d["title"],
187+
"title_lc": d["title"].lower(),
188+
"url": d["url"],
189+
"section": d["section"],
190+
"description": d["description"],
191+
"description_lc": d["description"].lower(),
192+
"headings_lc": headings_joined.lower(),
193+
"body": d["body"],
194+
"body_lc": d["body"].lower(),
195+
})
196+
return prepared
197+
198+
def _read_query_param(self):
199+
raw = browser.location.search
200+
if not raw or len(raw) < 3:
201+
return ""
202+
body = raw.slice(1)
203+
for pair in body.split("&"):
204+
if pair.slice(0, 2) == "q=":
205+
value = pair.slice(2).replace_all("+", " ")
206+
return browser.decodeURIComponent(value)
207+
return ""

docs/app/mounts/search/mount.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from sprag import mount
2+
3+
from .modules import SearchModule
4+
from .server import SearchBoot
5+
from .web import SearchApp
6+
7+
8+
search = mount(
9+
path="/search",
10+
component=SearchApp,
11+
module=SearchModule,
12+
boot=SearchBoot,
13+
metadata={"title": "Search"},
14+
)

0 commit comments

Comments
 (0)