codebread 1.0.0__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.
@@ -0,0 +1,407 @@
1
+ """Connection mapper: turns per-file facts into a single graph.
2
+
3
+ Node kinds: file, function (incl. methods), table.
4
+ Edge kinds:
5
+ call — function A calls function B
6
+ api — frontend fetch/axios/requests call matched to a backend route
7
+ db — function reads/writes a table
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import posixpath
12
+ import re
13
+ from typing import Dict, List, Optional, Tuple
14
+
15
+ from .models import FileInfo, FunctionInfo, TableInfo
16
+
17
+ COMMON_NAMES = { # never resolve these across files by name alone
18
+ "main", "run", "init", "setup", "get", "set", "update", "handle",
19
+ "start", "stop", "index", "render", "log", "test", "create", "close",
20
+ "open", "load", "save", "send", "next", "call", "apply", "toString",
21
+ "connect", "execute", "process", "add", "remove", "push", "pop",
22
+ }
23
+
24
+ # never flag these as orphans by name alone — common framework entry points
25
+ # (constructors, lifecycle hooks, test functions) that are called implicitly
26
+ ENTRY_NAMES = {
27
+ "main", "__init__", "__main__", "__new__", "__call__", "setup", "setUp",
28
+ "tearDown", "run", "handler", "index", "render", "constructor",
29
+ }
30
+
31
+
32
+ def _norm_path(p: str) -> List[str]:
33
+ """Normalize an URL/route path into comparable segments.
34
+ ':id', '{id}', '<id>', '${...}', '{param}' all become '*'."""
35
+ p = re.sub(r"^[a-z]+://[^/]*", "", p.strip())
36
+ p = p.split("?")[0].split("#")[0]
37
+ segs = []
38
+ for s in p.split("/"):
39
+ s = s.strip()
40
+ if not s:
41
+ continue
42
+ if (s.startswith(":") or s.startswith("{") or s.startswith("<")
43
+ or "${" in s or "{param}" in s or s.startswith("*")):
44
+ segs.append("*")
45
+ else:
46
+ segs.append(s.lower())
47
+ return segs
48
+
49
+
50
+ def _paths_match(a: List[str], b: List[str]) -> bool:
51
+ if len(a) != len(b):
52
+ # allow route prefixes mounted elsewhere: match on trailing segments
53
+ if len(a) > len(b):
54
+ a = a[-len(b):] if b else a
55
+ else:
56
+ b = b[-len(a):] if a else b
57
+ if len(a) != len(b) or not a:
58
+ return False
59
+ return all(x == y or x == "*" or y == "*" for x, y in zip(a, b))
60
+
61
+
62
+ def _methods_match(call_m: str, route_m: str) -> bool:
63
+ if call_m == "ANY" or route_m == "ANY":
64
+ return True
65
+ return bool(set(call_m.split("/")) & set(route_m.split("/")))
66
+
67
+
68
+ _RESOLVE_EXTS = (".js", ".ts", ".jsx", ".tsx", ".mjs", ".vue", ".svelte",
69
+ ".py", ".php", ".rb")
70
+
71
+
72
+ def _resolve_file_ref(base_file: str, target: str, file_set) -> Optional[str]:
73
+ """Resolve an import/include/link string to a scanned file path."""
74
+ target = target.replace("\\", "/").strip()
75
+ if not target or "://" in target:
76
+ return None
77
+ base_dir = posixpath.dirname(base_file)
78
+ candidates = [
79
+ posixpath.normpath(posixpath.join(base_dir, target)),
80
+ posixpath.normpath(target.lstrip("/")),
81
+ target.lstrip("./"),
82
+ ]
83
+ # python dotted modules: models / app.utils -> models.py / app/utils.py
84
+ if "/" not in target and "." not in posixpath.basename(target):
85
+ candidates.append(target.replace(".", "/"))
86
+ candidates.append(posixpath.join(base_dir, target.replace(".", "/")))
87
+ for cand in candidates:
88
+ if cand.startswith(".."):
89
+ continue
90
+ if cand in file_set:
91
+ return cand
92
+ for ext in _RESOLVE_EXTS:
93
+ if cand + ext in file_set:
94
+ return cand + ext
95
+ idx = posixpath.join(cand, "index" + ext)
96
+ if idx in file_set:
97
+ return idx
98
+ return None
99
+
100
+
101
+ def _find_cycles(adj: Dict[str, List[str]]) -> List[List[str]]:
102
+ """Iterative DFS cycle detection over a directed graph (call edges).
103
+ Returns one representative cycle (as a list of node ids) per strongly
104
+ connected loop found — not an exhaustive enumeration of every cycle,
105
+ but enough to flag circular call chains without recursion-depth risk
106
+ on large codebases."""
107
+ WHITE, GRAY, BLACK = 0, 1, 2
108
+ color: Dict[str, int] = {}
109
+ cycles: List[List[str]] = []
110
+ seen_keys = set()
111
+
112
+ for start in adj:
113
+ if color.get(start, WHITE) != WHITE:
114
+ continue
115
+ stack = [(start, iter(adj.get(start, [])))]
116
+ path = [start]
117
+ color[start] = GRAY
118
+ while stack:
119
+ node, it = stack[-1]
120
+ advanced = False
121
+ for nxt in it:
122
+ c = color.get(nxt, WHITE)
123
+ if c == WHITE:
124
+ color[nxt] = GRAY
125
+ stack.append((nxt, iter(adj.get(nxt, []))))
126
+ path.append(nxt)
127
+ advanced = True
128
+ break
129
+ elif c == GRAY:
130
+ idx = path.index(nxt)
131
+ cyc = path[idx:]
132
+ key = frozenset(cyc)
133
+ if len(cyc) > 1 and key not in seen_keys:
134
+ seen_keys.add(key)
135
+ cycles.append(cyc)
136
+ if not advanced:
137
+ color[stack[-1][0]] = BLACK
138
+ stack.pop()
139
+ path.pop()
140
+ return cycles
141
+
142
+
143
+ def _mark_orphans_and_cycles(nodes: List[Dict], edges: List[Dict]) -> List[Dict]:
144
+ """Flag functions/methods with no detected callers (orphans) and
145
+ functions involved in a circular call chain. Returns the cycle list
146
+ (each entry: {"nodes": [...], "labels": [...]}) for the UI panel."""
147
+ call_adj: Dict[str, List[str]] = {}
148
+ in_call_count: Dict[str, int] = {}
149
+ for e in edges:
150
+ if e["kind"] == "call":
151
+ call_adj.setdefault(e["source"], []).append(e["target"])
152
+ if e["kind"] in ("call", "api"):
153
+ in_call_count[e["target"]] = in_call_count.get(e["target"], 0) + 1
154
+
155
+ cycles = _find_cycles(call_adj)
156
+ cycle_node_ids = {nid for cyc in cycles for nid in cyc}
157
+ node_by_id = {n["id"]: n for n in nodes}
158
+
159
+ for n in nodes:
160
+ if n["id"] in cycle_node_ids:
161
+ n["cycle"] = True
162
+ if n["kind"] not in ("function", "method"):
163
+ continue
164
+ if n.get("routes"):
165
+ continue
166
+ label = n["label"]
167
+ if label in ENTRY_NAMES or label.startswith("test_") or label.startswith("Test"):
168
+ continue
169
+ if in_call_count.get(n["id"], 0) == 0:
170
+ n["orphan"] = True
171
+
172
+ for e in edges:
173
+ if e["kind"] == "call" and e["source"] in cycle_node_ids \
174
+ and e["target"] in cycle_node_ids:
175
+ e["cycle"] = True
176
+
177
+ return [{"nodes": cyc,
178
+ "labels": [node_by_id[nid]["label"] for nid in cyc if nid in node_by_id]}
179
+ for cyc in cycles]
180
+
181
+
182
+ def build_graph(files: List[FileInfo], tree: Dict,
183
+ warnings: List[Dict]) -> Dict:
184
+ nodes: List[Dict] = []
185
+ edges: List[Dict] = []
186
+ edge_seen = set()
187
+
188
+ def add_edge(src: str, dst: str, kind: str, label: str = ""):
189
+ if src == dst:
190
+ return
191
+ key = (src, dst, kind)
192
+ if key in edge_seen:
193
+ return
194
+ edge_seen.add(key)
195
+ edges.append({"source": src, "target": dst, "kind": kind,
196
+ "label": label})
197
+
198
+ # ---- nodes -------------------------------------------------------
199
+ fn_nodes: Dict[Tuple[str, str], str] = {} # (file, fn name) -> node id
200
+ name_index: Dict[str, List[Tuple[str, FileInfo, FunctionInfo]]] = {}
201
+ model_to_table: Dict[str, str] = {} # ORM class name -> table node id
202
+ table_ids: Dict[str, str] = {}
203
+
204
+ for f in files:
205
+ file_id = f.path
206
+ nodes.append({
207
+ "id": file_id, "kind": "file", "label": f.path.split("/")[-1],
208
+ "file": f.path, "layer": f.layer, "language": f.language,
209
+ "loc": f.loc, "warnings": f.warnings,
210
+ "nFunctions": len(f.functions),
211
+ "imports": f.imports[:30], "dbConfig": f.db_config,
212
+ "source": f.source,
213
+ })
214
+ for fn in f.functions:
215
+ nid = f"{f.path}::{fn.name}@{fn.line}"
216
+ fn_nodes[(f.path, fn.name)] = nid
217
+ name_index.setdefault(fn.name, []).append((nid, f, fn))
218
+ nodes.append({
219
+ "id": nid, "kind": fn.kind, "label": fn.name,
220
+ "file": f.path, "layer": f.layer, "line": fn.line,
221
+ "endLine": fn.end_line, "params": fn.params,
222
+ "returns": fn.returns, "description": fn.description,
223
+ "parentClass": fn.parent_class, "index": fn.index,
224
+ "language": f.language, "code": fn.code,
225
+ "routes": [{"method": r.method, "path": r.path}
226
+ for r in fn.routes],
227
+ })
228
+
229
+ def table_node(t_name: str, t: Optional[TableInfo] = None) -> str:
230
+ key = t_name.lower()
231
+ if key in table_ids:
232
+ nid = table_ids[key]
233
+ if t is not None: # enrich a placeholder with real definition
234
+ for n in nodes:
235
+ if n["id"] == nid:
236
+ if t.fields:
237
+ n["fields"] = t.fields
238
+ if t.model:
239
+ n["model"] = t.model
240
+ if t.source:
241
+ n["file"] = t.source
242
+ break
243
+ return nid
244
+ nid = f"table::{key}"
245
+ table_ids[key] = nid
246
+ nodes.append({
247
+ "id": nid, "kind": "table", "label": t_name,
248
+ "layer": "database",
249
+ "file": t.source if t else "",
250
+ "fields": t.fields if t else [],
251
+ "model": t.model if t else "",
252
+ })
253
+ return nid
254
+
255
+ for f in files:
256
+ for t in f.tables:
257
+ nid = table_node(t.name, t)
258
+ if t.model:
259
+ model_to_table[t.model.lower()] = nid
260
+
261
+ # ---- call edges --------------------------------------------------
262
+ for f in files:
263
+ local = {fn.name for fn in f.functions}
264
+ imported_mods = {i.split("/")[-1].split(".")[-1] for i in f.imports}
265
+ for fn in f.functions:
266
+ src = fn_nodes[(f.path, fn.name)]
267
+ for callee in fn.calls:
268
+ if callee == fn.name:
269
+ continue
270
+ if callee in local:
271
+ add_edge(src, fn_nodes[(f.path, callee)], "call",
272
+ f"{fn.name}() → {callee}()")
273
+ continue
274
+ candidates = name_index.get(callee, [])
275
+ if not candidates or callee in COMMON_NAMES:
276
+ continue
277
+ if len(candidates) == 1:
278
+ add_edge(src, candidates[0][0], "call",
279
+ f"{fn.name}() → {callee}()")
280
+ else:
281
+ # prefer a candidate whose file is imported by this file
282
+ hits = [c for c in candidates
283
+ if c[1].path.split("/")[-1].rsplit(".", 1)[0]
284
+ in imported_mods]
285
+ if len(hits) == 1:
286
+ add_edge(src, hits[0][0], "call",
287
+ f"{fn.name}() → {callee}()")
288
+
289
+ # ---- file-level connections ---------------------------------------
290
+ file_set = {f.path for f in files}
291
+ for f in files:
292
+ fname = f.path.split("/")[-1]
293
+ local = {fn.name: fn_nodes[(f.path, fn.name)] for fn in f.functions}
294
+ # top-level calls: page/script code calling into functions
295
+ for callee in f.calls:
296
+ if callee in local:
297
+ continue # a file already implicitly contains its own fns
298
+ candidates = name_index.get(callee, [])
299
+ if not candidates or callee in COMMON_NAMES:
300
+ continue
301
+ if len(candidates) == 1:
302
+ add_edge(f.path, candidates[0][0], "call",
303
+ f"{fname} → {callee}()")
304
+ # includes / imports that resolve to a scanned file
305
+ for imp in f.imports:
306
+ tgt = _resolve_file_ref(f.path, imp, file_set)
307
+ if tgt and tgt != f.path:
308
+ add_edge(f.path, tgt, "include",
309
+ f"{fname} includes {tgt.split('/')[-1]}")
310
+ # page navigation links (redirect/href/action)
311
+ for ln in f.links:
312
+ tgt = _resolve_file_ref(f.path, ln, file_set)
313
+ if tgt and tgt != f.path:
314
+ add_edge(f.path, tgt, "link",
315
+ f"{fname} links to {tgt.split('/')[-1]}")
316
+
317
+ # ---- route index (backend endpoints) -----------------------------
318
+ # (method, segments, handler node id, pretty label)
319
+ route_index: List[Tuple[str, List[str], str, str]] = []
320
+ for f in files:
321
+ for fn in f.functions:
322
+ nid = fn_nodes[(f.path, fn.name)]
323
+ for r in fn.routes:
324
+ route_index.append((r.method, _norm_path(r.path), nid,
325
+ f"{r.method} {r.path}"))
326
+ for r in f.routes: # file-level routes referencing a handler by name
327
+ handler_id = None
328
+ if r.handler and (f.path, r.handler) in fn_nodes:
329
+ handler_id = fn_nodes[(f.path, r.handler)]
330
+ elif r.handler and len(name_index.get(r.handler, [])) == 1:
331
+ handler_id = name_index[r.handler][0][0]
332
+ target = handler_id or f.path
333
+ route_index.append((r.method, _norm_path(r.path), target,
334
+ f"{r.method} {r.path}"))
335
+ if handler_id:
336
+ # mark handler node with its route
337
+ for n in nodes:
338
+ if n["id"] == handler_id:
339
+ n.setdefault("routes", []).append(
340
+ {"method": r.method, "path": r.path})
341
+ break
342
+
343
+ # ---- api edges (frontend call -> backend route) -------------------
344
+ def match_route(method: str, url: str) -> Optional[Tuple[str, str]]:
345
+ segs = _norm_path(url)
346
+ if not segs:
347
+ return None
348
+ best = None
349
+ for r_method, r_segs, target, label in route_index:
350
+ if not _methods_match(method, r_method):
351
+ continue
352
+ if _paths_match(segs, r_segs):
353
+ exact = sum(1 for x, y in zip(segs, r_segs) if x == y)
354
+ if best is None or exact > best[0]:
355
+ best = (exact, target, label)
356
+ return (best[1], best[2]) if best else None
357
+
358
+ for f in files:
359
+ callers = [(fn_nodes[(f.path, fn.name)], fn.api_calls)
360
+ for fn in f.functions]
361
+ callers.append((f.path, f.api_calls))
362
+ for src, api_calls in callers:
363
+ for call in api_calls:
364
+ hit = match_route(call.method, call.url)
365
+ if hit:
366
+ add_edge(src, hit[0], "api",
367
+ f"{call.method} {call.url} → {hit[1]}")
368
+
369
+ # ---- db edges ------------------------------------------------------
370
+ for f in files:
371
+ for fn in f.functions:
372
+ src = fn_nodes[(f.path, fn.name)]
373
+ for ref in fn.db_refs:
374
+ tid = model_to_table.get(ref.table.lower()) \
375
+ or table_ids.get(ref.table.lower())
376
+ if tid is None:
377
+ # unknown table: create it only for confident refs
378
+ if ref.via == "sql" or ref.table.lower() in table_ids:
379
+ tid = table_node(ref.table)
380
+ elif ref.table[0].isupper() and model_to_table:
381
+ continue # unmatched model name in a modeled project
382
+ else:
383
+ tid = table_node(ref.table)
384
+ verb = {"query": "queries", "select": "reads",
385
+ "insert": "inserts into", "update": "updates",
386
+ "delete": "deletes from",
387
+ "define": "defines"}.get(ref.op, ref.op)
388
+ add_edge(src, tid, "db", f"{fn.name}() {verb} {ref.table}")
389
+
390
+ # ---- orphans + circular dependencies --------------------------------
391
+ cycles = _mark_orphans_and_cycles(nodes, edges)
392
+
393
+ # ---- stats ---------------------------------------------------------
394
+ n_fns = sum(1 for n in nodes if n["kind"] in ("function", "method"))
395
+ n_orphans = sum(1 for n in nodes if n.get("orphan"))
396
+ stats = {
397
+ "files": len(files),
398
+ "functions": n_fns,
399
+ "tables": len(table_ids),
400
+ "connections": len(edges),
401
+ "routes": len(route_index),
402
+ "warnings": len(warnings) + sum(len(f.warnings) for f in files),
403
+ "orphans": n_orphans,
404
+ "cycles": len(cycles),
405
+ }
406
+ return {"nodes": nodes, "edges": edges, "tree": tree,
407
+ "warnings": warnings, "cycles": cycles, "stats": stats}
codebread/diff.py ADDED
@@ -0,0 +1,123 @@
1
+ """Diff two exported graph JSONs to see what changed between scans."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Dict, List, Tuple
5
+
6
+
7
+ def _index_files(graph: Dict) -> Dict[str, Dict]:
8
+ return {n["id"]: n for n in graph.get("nodes", []) if n["kind"] == "file"}
9
+
10
+
11
+ def _index_functions(graph: Dict) -> Dict[str, Dict]:
12
+ out = {}
13
+ for n in graph.get("nodes", []):
14
+ if n["kind"] in ("function", "method"):
15
+ out[f"{n.get('file', '')}::{n.get('label', '')}"] = n
16
+ return out
17
+
18
+
19
+ def _index_tables(graph: Dict) -> Dict[str, Dict]:
20
+ return {n["label"].lower(): n for n in graph.get("nodes", []) if n["kind"] == "table"}
21
+
22
+
23
+ def compute_diff(old: Dict, new: Dict) -> Dict:
24
+ """Compare two graph dicts (as produced by analyzer.analyze / loaded
25
+ from export_json) and return what was added, removed and changed."""
26
+ old_files, new_files = _index_files(old), _index_files(new)
27
+ old_fns, new_fns = _index_functions(old), _index_functions(new)
28
+ old_tables, new_tables = _index_tables(old), _index_tables(new)
29
+
30
+ files_added = sorted(set(new_files) - set(old_files))
31
+ files_removed = sorted(set(old_files) - set(new_files))
32
+
33
+ fn_added = sorted(set(new_fns) - set(old_fns))
34
+ fn_removed = sorted(set(old_fns) - set(new_fns))
35
+ fn_changed: List[Tuple[str, List[str]]] = []
36
+ for key in sorted(set(old_fns) & set(new_fns)):
37
+ a, b = old_fns[key], new_fns[key]
38
+ diffs = []
39
+ if a.get("params") != b.get("params"):
40
+ diffs.append(f"params {a.get('params')} -> {b.get('params')}")
41
+ if a.get("returns") != b.get("returns"):
42
+ diffs.append(f"returns {a.get('returns')!r} -> {b.get('returns')!r}")
43
+ if a.get("line") != b.get("line"):
44
+ diffs.append(f"moved line {a.get('line')} -> {b.get('line')}")
45
+ if diffs:
46
+ fn_changed.append((key, diffs))
47
+
48
+ tables_added = sorted(set(new_tables) - set(old_tables))
49
+ tables_removed = sorted(set(old_tables) - set(new_tables))
50
+ tables_changed: List[Tuple[str, List[str], List[str]]] = []
51
+ for key in sorted(set(old_tables) & set(new_tables)):
52
+ a, b = old_tables[key], new_tables[key]
53
+ af, bf = set(a.get("fields") or []), set(b.get("fields") or [])
54
+ if af != bf:
55
+ tables_changed.append((key, sorted(bf - af), sorted(af - bf)))
56
+
57
+ old_stats, new_stats = old.get("stats", {}), new.get("stats", {})
58
+ stats_delta = {k: new_stats.get(k, 0) - old_stats.get(k, 0)
59
+ for k in set(old_stats) | set(new_stats)}
60
+
61
+ return {
62
+ "files_added": files_added, "files_removed": files_removed,
63
+ "functions_added": fn_added, "functions_removed": fn_removed,
64
+ "functions_changed": fn_changed,
65
+ "tables_added": tables_added, "tables_removed": tables_removed,
66
+ "tables_changed": tables_changed,
67
+ "stats_delta": stats_delta,
68
+ "old_meta": old.get("meta", {}), "new_meta": new.get("meta", {}),
69
+ }
70
+
71
+
72
+ def format_diff_report(d: Dict) -> str:
73
+ lines = []
74
+ om, nm = d["old_meta"], d["new_meta"]
75
+ lines.append(f"Diff: {om.get('name', 'old')} ({om.get('scannedAt', '?')}) -> "
76
+ f"{nm.get('name', 'new')} ({nm.get('scannedAt', '?')})")
77
+ lines.append("")
78
+
79
+ def bucket(title: str, added: List[str], removed: List[str]) -> None:
80
+ if not added and not removed:
81
+ return
82
+ lines.append(title)
83
+ for x in added:
84
+ lines.append(f" + {x}")
85
+ for x in removed:
86
+ lines.append(f" - {x}")
87
+ lines.append("")
88
+
89
+ bucket("Files:", d["files_added"], d["files_removed"])
90
+ bucket("Functions:", d["functions_added"], d["functions_removed"])
91
+ if d["functions_changed"]:
92
+ lines.append("Functions changed:")
93
+ for key, diffs in d["functions_changed"]:
94
+ lines.append(f" ~ {key}")
95
+ for line in diffs:
96
+ lines.append(f" {line}")
97
+ lines.append("")
98
+ bucket("Tables:", d["tables_added"], d["tables_removed"])
99
+ if d["tables_changed"]:
100
+ lines.append("Table fields changed:")
101
+ for key, added, removed in d["tables_changed"]:
102
+ bits = []
103
+ if added:
104
+ bits.append("+" + ",".join(added))
105
+ if removed:
106
+ bits.append("-" + ",".join(removed))
107
+ lines.append(f" ~ {key}: {'; '.join(bits)}")
108
+ lines.append("")
109
+
110
+ nonzero = {k: v for k, v in d["stats_delta"].items() if v}
111
+ if nonzero:
112
+ lines.append("Stats delta:")
113
+ for k, v in sorted(nonzero.items()):
114
+ sign = "+" if v > 0 else ""
115
+ lines.append(f" {k}: {sign}{v}")
116
+ lines.append("")
117
+
118
+ if not any([d["files_added"], d["files_removed"], d["functions_added"],
119
+ d["functions_removed"], d["functions_changed"],
120
+ d["tables_added"], d["tables_removed"], d["tables_changed"]]):
121
+ lines.append("No structural changes detected.")
122
+
123
+ return "\n".join(lines)
codebread/export.py ADDED
@@ -0,0 +1,45 @@
1
+ """Exports: graph JSON (re-loadable) and a single-file static HTML."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from typing import Dict
7
+
8
+ WEB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web")
9
+
10
+
11
+ def export_json(graph: Dict, out_path: str) -> str:
12
+ out_path = os.path.abspath(out_path)
13
+ with open(out_path, "w", encoding="utf-8") as f:
14
+ json.dump(graph, f, ensure_ascii=False, indent=1)
15
+ return out_path
16
+
17
+
18
+ def load_json(path: str) -> Dict:
19
+ with open(path, "r", encoding="utf-8") as f:
20
+ return json.load(f)
21
+
22
+
23
+ def export_html(graph: Dict, out_path: str) -> str:
24
+ """Inline CSS+JS+data into one shareable HTML file."""
25
+ with open(os.path.join(WEB_DIR, "index.html"), encoding="utf-8") as f:
26
+ html = f.read()
27
+ with open(os.path.join(WEB_DIR, "style.css"), encoding="utf-8") as f:
28
+ css = f.read()
29
+ with open(os.path.join(WEB_DIR, "app.js"), encoding="utf-8") as f:
30
+ js = f.read()
31
+
32
+ data = json.dumps(graph, ensure_ascii=False)
33
+ data = data.replace("</", "<\\/") # keep </script> out of the payload
34
+ html = html.replace(
35
+ '<link rel="stylesheet" href="style.css">',
36
+ f"<style>\n{css}\n</style>")
37
+ html = html.replace(
38
+ '<script src="app.js"></script>',
39
+ f"<script>window.CODEBREAD_DATA = {data};</script>\n"
40
+ f"<script>\n{js}\n</script>")
41
+
42
+ out_path = os.path.abspath(out_path)
43
+ with open(out_path, "w", encoding="utf-8") as f:
44
+ f.write(html)
45
+ return out_path