1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
|
import json, os, re
from app_inventory import add_inventory_apps, apply_default_face_seeds, apply_package_overrides, face_rows
from default_faces import DefaultFaces
from face_data import *
from face_specs import ui_face_spec
HERE=os.path.dirname(os.path.abspath(__file__))
def strip_exports(src):
"""Drop ES-module `export`/`import` lines so the body loads as a classic <script>.
A top-level `export` (or `import`) is a syntax error outside a module, so it
must go before the body is spliced into the page. Imports are stripped too so a
pure module may import a peer for its own unit tests (e.g. app-util.js imports
rl from colormath.js) while the inlined copy relies on the peer already being
in the page. The .mjs inline-integrity tests apply the identical strip and
assert the page carries the result verbatim, so the two copies cannot drift.
NOTE: this is line-based — each export/import statement must stay on a single
line or the continuation lines survive.
"""
return '\n'.join(l for l in src.splitlines()
if not (l.startswith('export') or l.startswith('import'))).rstrip()
# Pure color-math core, inlined verbatim into the page so the browser runs the
# same code the Node tests import (one source of truth).
COLORMATH_BODY=strip_exports(open(os.path.join(HERE,'colormath.js')).read())
# The app's stylesheet and script, kept as real files so they get JS/CSS tooling
# (highlight, brace-check, lint) and so the logic is unit-testable. They are
# inlined into the page the same way colormath.js is: a placeholder in the
# template, filled at generate time. app.js carries the data placeholders
# (MAP_J, PALETTE_J, COLORMATH_J, ...); those are filled after it is spliced in.
STYLES=open(os.path.join(HERE,'styles.css')).read()
APP_BODY=open(os.path.join(HERE,'app.js')).read()
# Pure package-model + dropdown logic, inlined into the page (and unit-tested via
# test-app-core.mjs) the same way colormath.js is.
APP_CORE_BODY=strip_exports(open(os.path.join(HERE,'app-core.js')).read())
# Pure color/UI-boundary helpers (normHex/ratingColor/textOn), unit-tested via
# test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in
# the page from the colormath core).
APP_UTIL_BODY=strip_exports(open(os.path.join(HERE,'app-util.js')).read())
# Palette panel actions and rendering. This is stateful browser code, split from
# app.js because color-column behavior changes often and benefits from locality.
PALETTE_ACTIONS_BODY=strip_exports(open(os.path.join(HERE,'palette-actions.js')).read())
# Browser hash gates, split from app.js so the application code is not buried
# under the test harness while still shipping one self-contained HTML file.
BROWSER_GATES_BODY=strip_exports(open(os.path.join(HERE,'browser-gates.js')).read())
ns={}
src=open(os.path.join(HERE,'samples.py')).read()
exec(src[:src.index('cols=')], ns)
SAMPLES={"Elisp":ns['ELS'],"Go":ns['GOS'],"Python":ns['PYS'],"TypeScript":ns['TSS'],"Java":ns['JAS'],"C":ns['CS'],"C++":ns['CPS'],"Rust":ns['RUSTS'],"Zig":ns['ZIGS'],"Shell":ns['SHS']}
COLS=ns['COLS']
DEFAULT_FACES_PATH=os.path.join(HERE,'emacs-default-faces.json')
DEFAULTS=DefaultFaces.from_path(DEFAULT_FACES_PATH)
MAP={k:'' for k in COLS}; MAP['bg']='#000000'; MAP['p']='#ffffff'
BOLD={k:False for k in COLS}
ITALIC_MAP={k:False for k in COLS}
def column_id(name):
name = name or 'color'
if re.fullmatch(r'color-\d+', name):
return name
name = re.sub(r'[+-]\d+$', '', name)
return re.sub(r'\d+$', '', name) or 'color'
def normalize_palette(palette):
return [[p[0], p[1] if len(p) > 1 else 'color', p[2] if len(p) > 2 else column_id(p[1] if len(p) > 1 else 'color')]
for p in palette]
if DEFAULTS.available:
MAP['bg']=DEFAULTS.color('default','background') or MAP['bg']
MAP['p']=DEFAULTS.color('default','foreground') or MAP['p']
for cat,faces in DEFAULTS.data.get('syntax-map',{}).items():
faces=faces or []
if cat in ('bg','p') or not faces: continue
face=faces[0]
c=DEFAULTS.color(face,'foreground')
if c: MAP[cat]=c
eff=DEFAULTS.face(face,True)
BOLD[cat]=eff.get('weight')=='bold'
ITALIC_MAP[cat]=eff.get('slant')=='italic'
else:
BOLD={k:v[1] for k,v in COLS.items()}
ITALIC_MAP={k:False for k in COLS}
PALETTE=[[MAP['bg'],"bg","ground"],[MAP['p'],"fg","ground"]]
CATS=[["bg","bg (ground)","Aa Bb 123"],["p","fg","other / whitespace"],["kw","keyword","class def if return"],["bi","builtin","len echo printf"],
["pp","preprocessor","#include #define"],["fnd","function · def","resolve push"],
["fnc","function · call","printf rsync get"],["dec","decorator","@dataclass"],
["ty","type / class","int str Order Queue"],["prop","property / field","id name items"],
["con","constant","None nil NULL true"],["num","number","8080 100 -1"],
["str","string",'"dupre" "fmt"'],["esc","escape","\\n \\t"],["re","regexp","/^#[0-9a-f]+/"],
["doc","docstring",'"""..."""'],["cm","comment","# reject nil"],["cmd","comment delim","# // ;;"],
["var","variable / use","value key self"],["op","operator",": = -> =="],
["punc","punctuation","{ } ( ) ;"]]
UI_FACES=[["cursor","cursor","Aa|"],["region","region (selection)","selected text"],
["hl-line","hl-line (current line)","current line"],["highlight","highlight","hover"],
["mode-line","mode-line","status active"],["mode-line-inactive","mode-line-inactive","status idle"],
["fringe","fringe","| |"],["line-number","line-number"," 42"],
["line-number-current-line","line-number-current-line","> 42"],["minibuffer-prompt","minibuffer-prompt","M-x "],
["isearch","isearch (match)","match"],["lazy-highlight","lazy-highlight","other match"],
["isearch-fail","isearch-fail","no match"],["show-paren-match","show-paren-match","( )"],
["show-paren-mismatch","show-paren-mismatch",") ("],["link","link","https://"],
["error","error","error!"],["warning","warning","warning"],
["success","success","ok"],["vertical-border","vertical-border","|"]]
UIMAP={f[0]:ui_face_spec() for f in UI_FACES}
if DEFAULTS.available:
UIMAP={f[0]:ui_face_spec(DEFAULTS.seed(f[0],False)) for f in UI_FACES}
# Optional palette seed: THEME_STUDIO_SEED=<file.json> seeds the tool's starting
# palette / assignments / bold / italic / UI from a theme.json (path relative to
# this dir), instead of the hardcoded defaults above. Unset leaves them unchanged.
# Placed after every default it overrides (notably UIMAP) so the merge has targets.
# Mirrors what the in-page Import does, so reseed and import agree.
LOCKS=[]; ITALIC=[k for k,v in ITALIC_MAP.items() if v]
# THEME_STUDIO_SEED=<file>.json opens an existing theme as the starting point.
# Unset starts empty: only bg/fg are in the palette.
_seed=os.environ.get('THEME_STUDIO_SEED')
_d={}
if _seed:
_d=json.load(open(os.path.join(HERE,_seed)))
if _d.get('palette'): PALETTE=_d['palette']
if _d.get('assignments'): MAP.update(_d['assignments'])
if 'bold' in _d: BOLD={k:(k in _d['bold']) for k in BOLD}
if 'italic' in _d: ITALIC=_d['italic']
if _d.get('ui'):
for _k,_v in _d['ui'].items(): UIMAP[_k]=_v
if 'locks' in _d: LOCKS=_d['locks']
PALETTE=normalize_palette(PALETTE)
if not DEFAULTS.available:
# These faces carry a fixed style in Emacs's built-in definitions. Fallback
# only; normal generation uses emacs-default-faces.json above.
UIMAP["link"]["underline"]=True
for _f in ("lazy-highlight","show-paren-match"): UIMAP[_f]["underline"]=True
for _f in ("error","warning","success"): UIMAP[_f]["bold"]=True
for _f in ("mode-line","mode-line-inactive"): UIMAP[_f]["box"]={"style":"released","width":1,"color":None}
# Bespoke package face lists and seed defaults live in face_data.py.
APPS={"org-mode":{"label":"org-mode","preview":"org","faces":face_rows(ORG_FACES,"org-",ORG_SEED)},
"magit":{"label":"magit","preview":"magit","faces":face_rows(MAGIT_FACES,"magit-",MAGIT_SEED)},
"elfeed":{"label":"elfeed","preview":"elfeed","faces":face_rows(ELFEED_FACES,"elfeed-",ELFEED_SEED)},
"mu4e":{"label":"mu4e","preview":"mu4e","faces":face_rows(MU4E_FACES,"mu4e-",MU4E_SEED)},
"ghostel":{"label":"ghostel","preview":"ghostel","faces":face_rows(GHOSTEL_FACES,"ghostel-",GHOSTEL_SEED)},
"dashboard":{"label":"dashboard","preview":"dashboard","faces":face_rows(DASHBOARD_FACES,"dashboard-",DASHBOARD_SEED)},
"lsp-mode":{"label":"lsp-mode","preview":"lsp","faces":face_rows(LSP_FACES,"lsp-",LSP_SEED)},
"git-gutter":{"label":"git-gutter","preview":"gitgutter","faces":face_rows(GITGUTTER_FACES,"git-gutter:",GITGUTTER_SEED)},
"flycheck":{"label":"flycheck","preview":"flycheck","faces":face_rows(FLYCHECK_FACES,"flycheck-",FLYCHECK_SEED)},
"dired":{"label":"dired","preview":"dired","faces":face_rows(DIRED_FACES,"dired-",DIRED_SEED)},
"dirvish":{"label":"dirvish","preview":"dirvish","faces":face_rows(DIRVISH_FACES,"dirvish-",DIRVISH_SEED)},
"calibredb":{"label":"calibredb","preview":"calibredb","faces":face_rows(CALIBREDB_FACES,"calibredb-",CALIBREDB_SEED)},
"erc":{"label":"erc","preview":"erc","faces":face_rows(ERC_FACES,"erc-",ERC_SEED)},
"org-drill":{"label":"org-drill","preview":"orgdrill","faces":face_rows(ORGDRILL_FACES,"org-drill-",ORGDRILL_SEED)},
"org-noter":{"label":"org-noter","preview":"orgnoter","faces":face_rows(ORGNOTER_FACES,"org-noter-",ORGNOTER_SEED)},
"signel":{"label":"signel","preview":"signel","faces":face_rows(SIGNEL_FACES,"signel-",SIGNEL_SEED)},
"pearl":{"label":"pearl","preview":"pearl","faces":face_rows(PEARL_FACES,"pearl-",PEARL_SEED)},
"slack":{"label":"slack","preview":"slack","faces":face_rows(SLACK_FACES,"slack-",SLACK_SEED)},
"telega":{"label":"telega","preview":"telega","faces":face_rows(TELEGA_FACES,"telega-",TELEGA_SEED)},
"shr":{"label":"shr (HTML: nov/eww/mail)","preview":"shr","faces":face_rows(SHR_FACES,"shr-",SHR_SEED)}}
# Phase 6: merge the generated all-package inventory (refresh with build-inventory.el).
# Bespoke apps stay; every other installed package becomes an editable generic app.
_inv_path=os.path.join(HERE,"package-inventory.json")
add_inventory_apps(APPS, _inv_path)
apply_default_face_seeds(APPS, DEFAULTS)
# Apply seed theme package overrides when THEME_STUDIO_SEED is set: each full
# per-face spec (color + structure) replaces the hardcoded face seed before render.
if _seed:
apply_package_overrides(APPS, _d.get('packages'))
def add_palette_color(value, label=None):
if not value: return
if any((p[0] or '').lower()==str(value).lower() for p in PALETTE): return
name=label or DEFAULTS.label(value,'color-'+str(len(PALETTE)))
base=name
n=2
used={p[1].lower() for p in PALETTE}
while name.lower() in used:
name=base+'-'+str(n); n+=1
PALETTE.append([value,name,column_id(name)])
if DEFAULTS.available:
for _k,_v in MAP.items():
add_palette_color(_v, 'bg' if _k=='bg' else 'fg' if _k=='p' else None)
for _face,_spec in UIMAP.items():
add_palette_color(_spec.get('fg'))
add_palette_color(_spec.get('bg'))
for _app in APPS.values():
for _face,_label,_spec in _app['faces']:
add_palette_color(_spec.get('fg'))
add_palette_color(_spec.get('bg'))
PALETTE=normalize_palette(PALETTE)
HTML=open(os.path.join(HERE,'theme-studio.template.html')).read()
# Fill the data placeholders. str.replace is literal (no backref interpretation),
# so backslashes in the inlined JS survive intact — the escaping-bug class that
# the triple-quoted string used to cause is gone now that app.js is a real file.
# Caveat: these tokens are replaced everywhere they appear, including inside code
# comments. Don't write a placeholder name (COLORMATH_J, APP_CORE_J, ...) in
# prose in any inlined file, or that prose gets the body spliced into it too.
def fill_data(s):
return (s.replace("COLORMATH_J",COLORMATH_BODY)
.replace("APP_CORE_J",APP_CORE_BODY)
.replace("APP_UTIL_J",APP_UTIL_BODY)
.replace("PALETTE_ACTIONS_J",PALETTE_ACTIONS_BODY)
.replace("BROWSER_GATES_J",BROWSER_GATES_BODY)
.replace("SAMPLES_J",json.dumps(SAMPLES))
.replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS))
.replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS))
.replace("BOLD_J",json.dumps(BOLD)).replace("MAP_J",json.dumps(MAP)).replace("LOCKS_J",json.dumps(LOCKS)).replace("ITALIC_J",json.dumps({k:True for k in ITALIC})))
# Splice the stylesheet and script in first, then fill the data placeholders they
# carry. The page contains app.js exactly as fill_data(APP_BODY) renders it —
# APP_FILLED is that rendering, the handle the inline-integrity test asserts on.
HTML=fill_data(HTML.replace("STYLES_CSS",STYLES).replace("APP_JS",APP_BODY))
APP_FILLED=fill_data(APP_BODY)
OUT=os.path.join(HERE,'theme-studio.html')
if __name__=='__main__':
open(OUT,"w").write(HTML)
print("wrote",OUT)
|