neuron-dsl 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,259 @@
1
+ <p align="center">
2
+ <img src="assets/banner.png" alt="Neuron" width="100%" />
3
+ </p>
4
+
5
+ # Neuron
6
+
7
+ AI 에이전트를 위한 선언적 웹 앱 DSL 컴파일러.
8
+
9
+ `.neuron` 파일을 작성하면 SPA(Single Page Application)로 컴파일합니다. 프레임워크 없이 순수 HTML/CSS/JS를 생성합니다.
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # 프로젝트 생성
15
+ neuron new my-shop
16
+
17
+ # 빌드
18
+ cd my-shop
19
+ neuron build
20
+ ```
21
+
22
+ `dist/` 폴더에 배포 가능한 SPA가 생성됩니다:
23
+
24
+ ```
25
+ dist/
26
+ ├── index.html ← 모든 페이지 포함 (SPA)
27
+ ├── style.css ← 테마 + 컴포넌트 스타일
28
+ ├── main.js ← 상태 + 라우터 + 컴포넌트 (Pure JS)
29
+ └── assets/
30
+ ```
31
+
32
+ ## DSL 문법
33
+
34
+ 키워드는 4개뿐: `STATE` `ACTION` `API` `PAGE`
35
+
36
+ ### 프로젝트 구조
37
+
38
+ ```
39
+ my-shop/
40
+ ├── app.neuron ← STATE, ACTION 정의
41
+ ├── pages/
42
+ │ ├── home.neuron
43
+ │ ├── cart.neuron
44
+ │ └── checkout.neuron
45
+ ├── apis/
46
+ │ ├── products.neuron
47
+ │ └── orders.neuron
48
+ ├── themes/
49
+ │ └── theme.json
50
+ ├── assets/
51
+ └── neuron.json
52
+ ```
53
+
54
+ ### STATE & ACTION (app.neuron)
55
+
56
+ 앱 전체의 상태와 액션을 정의합니다.
57
+
58
+ ```
59
+ STATE
60
+ cart: []
61
+ products: []
62
+ user: null
63
+
64
+ ---
65
+
66
+ ACTION add-to-cart
67
+ append: product -> cart
68
+
69
+ ACTION remove-from-cart
70
+ remove: cart where id matches
71
+
72
+ ACTION pay
73
+ call: orders
74
+ on_success: -> /complete
75
+ on_error: show-error
76
+ ```
77
+
78
+ ### PAGE (pages/*.neuron)
79
+
80
+ 페이지 하나당 파일 하나. 컴포넌트를 들여쓰기로 배치합니다.
81
+
82
+ ```
83
+ PAGE home "홈" /
84
+
85
+ header
86
+ title: "My Shop"
87
+ links: [상품>/products, 장바구니>/cart]
88
+
89
+ hero
90
+ title: "최고의 쇼핑"
91
+ subtitle: "지금 시작하세요"
92
+ cta: "쇼핑하기" -> /products
93
+
94
+ product-grid
95
+ data: products
96
+ cols: 3
97
+ on_click: add-to-cart
98
+
99
+ footer
100
+ text: "© 2026 My Shop"
101
+ ```
102
+
103
+ 인라인 단축 문법도 지원합니다:
104
+
105
+ ```
106
+ button "결제하기" -> /checkout
107
+ variant: primary
108
+ ```
109
+
110
+ ### API (apis/*.neuron)
111
+
112
+ ```
113
+ API products
114
+ GET /api/products
115
+ on_load: true
116
+ returns: Product[]
117
+
118
+ API orders
119
+ POST /api/orders
120
+ body: cart
121
+ returns: Order
122
+ ```
123
+
124
+ ## 빌트인 컴포넌트
125
+
126
+ ### 레이아웃
127
+
128
+ | 타입 | 설명 | 주요 속성 |
129
+ |------|------|----------|
130
+ | header | 상단 네비게이션 | title, logo, links |
131
+ | footer | 하단 푸터 | text |
132
+ | section | 컨테이너 | - |
133
+ | grid | 그리드 레이아웃 | cols |
134
+ | hero | 풀와이드 배너 | title, subtitle, cta |
135
+
136
+ ### 데이터 표시
137
+
138
+ | 타입 | 설명 | 주요 속성 |
139
+ |------|------|----------|
140
+ | product-grid | 상품 목록 그리드 | data, cols, on_click |
141
+ | list | 범용 리스트 | data, template |
142
+ | table | 테이블 | data, cols |
143
+ | text | 텍스트 | content, size |
144
+ | image | 이미지 | src, alt |
145
+
146
+ ### 상태 연동
147
+
148
+ | 타입 | 설명 | 주요 속성 |
149
+ |------|------|----------|
150
+ | cart-icon | 장바구니 아이콘+뱃지 | state, act |
151
+ | cart-summary | 장바구니 합계 | state |
152
+ | cart-list | 장바구니 목록 | state, on_remove |
153
+
154
+ ### 인터랙션
155
+
156
+ | 타입 | 설명 | 주요 속성 |
157
+ |------|------|----------|
158
+ | button | 버튼 | label, act, variant |
159
+ | form | 폼 | fields, submit |
160
+ | search | 검색창 | placeholder, state, on_change |
161
+ | tabs | 탭 | items |
162
+ | modal | 모달 | state, title |
163
+
164
+ ## 테마 시스템
165
+
166
+ `themes/theme.json`으로 색상, 폰트, 간격을 관리합니다. `.neuron`에서는 variant만 지정합니다.
167
+
168
+ ```json
169
+ {
170
+ "colors": {
171
+ "primary": "#2E86AB",
172
+ "secondary": "#A23B72",
173
+ "danger": "#E84855",
174
+ "bg": "#FFFFFF",
175
+ "text": "#1A1A2E",
176
+ "border": "#E0E0E0"
177
+ },
178
+ "font": {
179
+ "family": "Inter",
180
+ "size": { "sm": 14, "md": 16, "lg": 20, "xl": 28 }
181
+ },
182
+ "radius": 8,
183
+ "shadow": "0 2px 8px rgba(0,0,0,0.1)",
184
+ "spacing": { "sm": 8, "md": 16, "lg": 24, "xl": 48 }
185
+ }
186
+ ```
187
+
188
+ variant 종류: `primary` | `secondary` | `danger` | `ghost`
189
+
190
+ ## 컴파일러 출력
191
+
192
+ `neuron build`는 Svelte 방식으로 컴파일 타임에 상태-DOM 바인딩을 확정합니다:
193
+
194
+ ```javascript
195
+ // 상태 초기화
196
+ const _state = { cart: [], products: [] }
197
+
198
+ // 상태-DOM 바인딩
199
+ const _bindings = {
200
+ cart: [_update_cart_icon, _update_cart_list, _update_cart_summary],
201
+ products: [_update_product_grid]
202
+ }
203
+
204
+ // 상태 변경 → 바인딩된 DOM만 업데이트
205
+ function _setState(key, val) {
206
+ _state[key] = val
207
+ _bindings[key]?.forEach(fn => fn(val))
208
+ }
209
+ ```
210
+
211
+ ## 에러 메시지
212
+
213
+ AI가 이해할 수 있는 명확한 에러를 출력합니다:
214
+
215
+ ```
216
+ [NEURON ERROR] 알 수 없는 컴포넌트: "buttton"
217
+ → 사용 가능: button, form, text, image, header, footer ...
218
+
219
+ [NEURON ERROR] state "wishlist" 가 정의되지 않음
220
+ → app.neuron STATE 섹션에 "wishlist: []" 를 추가하세요
221
+ ```
222
+
223
+ ## 개발
224
+
225
+ ```bash
226
+ # 의존성 설치
227
+ npm install
228
+
229
+ # 테스트
230
+ npm test
231
+
232
+ # 빌드
233
+ npm run build
234
+ ```
235
+
236
+ ## 아키텍처
237
+
238
+ ```
239
+ .neuron 소스 → Lexer(토큰화) → Parser(AST) → Generator(HTML/CSS/JS) → dist/
240
+ ```
241
+
242
+ | 모듈 | 파일 | 역할 |
243
+ |------|------|------|
244
+ | Lexer | `src/lexer.ts` | 소스 → 토큰 |
245
+ | Parser | `src/parser.ts` | 토큰 → AST |
246
+ | HTML Generator | `src/generator/html.ts` | 페이지 → HTML |
247
+ | CSS Generator | `src/generator/css.ts` | 테마 → CSS |
248
+ | JS Generator | `src/generator/js.ts` | 상태/라우터/액션 → JS |
249
+ | Component Registry | `src/components/registry.ts` | 컴포넌트 → HTML 렌더러 |
250
+ | Compiler | `src/compiler.ts` | 파이프라인 오케스트레이션 |
251
+ | CLI | `src/cli.ts` | 명령어 인터페이스 |
252
+
253
+ ## 철학
254
+
255
+ `.neuron` 파일은 AI 에이전트가 읽고 쓰는 파일입니다. 사람이 읽을 필요 없습니다. 컴파일러가 읽을 수 있으면 됩니다. 포맷은 LLM이 가장 정확하게 생성할 수 있는 구조를 우선합니다.
256
+
257
+ ## License
258
+
259
+ MIT
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,886 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { resolve as resolve2, join as join2, basename } from "path";
5
+ import { existsSync, mkdirSync as mkdirSync2, writeFileSync as writeFileSync2, readdirSync, readFileSync as readFileSync4 } from "fs";
6
+
7
+ // src/compiler.ts
8
+ import { readFileSync as readFileSync2 } from "fs";
9
+
10
+ // src/lexer.ts
11
+ function tokenize(input) {
12
+ const lines = input.split("\n");
13
+ const tokens = [];
14
+ for (let i = 0; i < lines.length; i++) {
15
+ const raw = lines[i];
16
+ const lineNum = i + 1;
17
+ if (raw.trim() === "") continue;
18
+ const indent = raw.length - raw.trimStart().length;
19
+ const trimmed = raw.trim();
20
+ if (trimmed === "---") {
21
+ tokens.push({ type: "SEPARATOR", indent, line: lineNum });
22
+ continue;
23
+ }
24
+ if (trimmed.startsWith("- ")) {
25
+ tokens.push({ type: "LIST_ITEM", value: trimmed.slice(2), indent, line: lineNum });
26
+ continue;
27
+ }
28
+ if (trimmed === "STATE") {
29
+ tokens.push({ type: "KEYWORD", value: "STATE", indent, line: lineNum });
30
+ continue;
31
+ }
32
+ const actionMatch = trimmed.match(/^ACTION\s+(\S+)$/);
33
+ if (actionMatch) {
34
+ tokens.push({ type: "KEYWORD", value: "ACTION", name: actionMatch[1], indent, line: lineNum });
35
+ continue;
36
+ }
37
+ const pageMatch = trimmed.match(/^PAGE\s+(\S+)\s+"([^"]+)"\s+(\S+)$/);
38
+ if (pageMatch) {
39
+ tokens.push({ type: "KEYWORD", value: "PAGE", name: pageMatch[1], title: pageMatch[2], route: pageMatch[3], indent, line: lineNum });
40
+ continue;
41
+ }
42
+ const apiMatch = trimmed.match(/^API\s+(\S+)$/);
43
+ if (apiMatch) {
44
+ tokens.push({ type: "KEYWORD", value: "API", name: apiMatch[1], indent, line: lineNum });
45
+ continue;
46
+ }
47
+ const httpMatch = trimmed.match(/^(GET|POST|PUT|DELETE|PATCH)\s+(\S+)$/);
48
+ if (httpMatch) {
49
+ tokens.push({ type: "HTTP_METHOD", method: httpMatch[1], endpoint: httpMatch[2], indent, line: lineNum });
50
+ continue;
51
+ }
52
+ const propMatch = trimmed.match(/^(\w[\w-]*):\s*(.*)$/);
53
+ if (propMatch) {
54
+ tokens.push({ type: "PROPERTY", key: propMatch[1], value: propMatch[2], indent, line: lineNum });
55
+ continue;
56
+ }
57
+ const inlineMatch = trimmed.match(/^(\w[\w-]*)\s+"([^"]+)"\s+->\s+(\S+)$/);
58
+ if (inlineMatch) {
59
+ tokens.push({ type: "COMPONENT", componentType: inlineMatch[1], inlineLabel: inlineMatch[2], inlineAction: inlineMatch[3], indent, line: lineNum });
60
+ continue;
61
+ }
62
+ const compMatch = trimmed.match(/^(\w[\w-]*)$/);
63
+ if (compMatch) {
64
+ tokens.push({ type: "COMPONENT", componentType: compMatch[1], indent, line: lineNum });
65
+ continue;
66
+ }
67
+ }
68
+ return tokens;
69
+ }
70
+
71
+ // src/parser.ts
72
+ function parse(source) {
73
+ const tokens = tokenize(source);
74
+ const ast = {
75
+ states: [],
76
+ actions: [],
77
+ apis: [],
78
+ pages: []
79
+ };
80
+ let i = 0;
81
+ while (i < tokens.length) {
82
+ const token = tokens[i];
83
+ if (token.type === "SEPARATOR") {
84
+ i++;
85
+ continue;
86
+ }
87
+ if (token.type === "KEYWORD") {
88
+ switch (token.value) {
89
+ case "STATE": {
90
+ const [node, next] = parseState(tokens, i);
91
+ ast.states.push(node);
92
+ i = next;
93
+ break;
94
+ }
95
+ case "ACTION": {
96
+ const [node, next] = parseAction(tokens, i);
97
+ ast.actions.push(node);
98
+ i = next;
99
+ break;
100
+ }
101
+ case "API": {
102
+ const [node, next] = parseApi(tokens, i);
103
+ ast.apis.push(node);
104
+ i = next;
105
+ break;
106
+ }
107
+ case "PAGE": {
108
+ const [node, next] = parsePage(tokens, i);
109
+ ast.pages.push(node);
110
+ i = next;
111
+ break;
112
+ }
113
+ }
114
+ } else {
115
+ i++;
116
+ }
117
+ }
118
+ return ast;
119
+ }
120
+ function parseState(tokens, start) {
121
+ const baseIndent = tokens[start].indent;
122
+ const node = { type: "STATE", fields: [] };
123
+ let i = start + 1;
124
+ while (i < tokens.length) {
125
+ const t = tokens[i];
126
+ if (t.type === "KEYWORD" || t.type === "SEPARATOR" || t.indent <= baseIndent) break;
127
+ if (t.type === "PROPERTY") {
128
+ node.fields.push({ name: t.key, defaultValue: t.value });
129
+ }
130
+ i++;
131
+ }
132
+ return [node, i];
133
+ }
134
+ function parseAction(tokens, start) {
135
+ const keyword = tokens[start];
136
+ const baseIndent = keyword.indent;
137
+ const node = { type: "ACTION", name: keyword.name, steps: [] };
138
+ let i = start + 1;
139
+ while (i < tokens.length) {
140
+ const t = tokens[i];
141
+ if (t.type === "KEYWORD" || t.type === "SEPARATOR" || t.indent <= baseIndent) break;
142
+ if (t.type === "PROPERTY") {
143
+ node.steps.push({ key: t.key, value: t.value });
144
+ }
145
+ i++;
146
+ }
147
+ return [node, i];
148
+ }
149
+ function parseApi(tokens, start) {
150
+ const keyword = tokens[start];
151
+ const baseIndent = keyword.indent;
152
+ const node = { type: "API", name: keyword.name, method: "", endpoint: "", options: {} };
153
+ let i = start + 1;
154
+ while (i < tokens.length) {
155
+ const t = tokens[i];
156
+ if (t.type === "KEYWORD" || t.type === "SEPARATOR" || t.indent <= baseIndent) break;
157
+ if (t.type === "HTTP_METHOD") {
158
+ node.method = t.method;
159
+ node.endpoint = t.endpoint;
160
+ } else if (t.type === "PROPERTY") {
161
+ node.options[t.key] = t.value;
162
+ }
163
+ i++;
164
+ }
165
+ return [node, i];
166
+ }
167
+ function parsePage(tokens, start) {
168
+ const keyword = tokens[start];
169
+ const baseIndent = keyword.indent;
170
+ const node = {
171
+ type: "PAGE",
172
+ name: keyword.name,
173
+ title: keyword.title,
174
+ route: keyword.route,
175
+ components: []
176
+ };
177
+ let i = start + 1;
178
+ while (i < tokens.length) {
179
+ const t = tokens[i];
180
+ if (t.type === "KEYWORD" || t.type === "SEPARATOR" || t.indent <= baseIndent) break;
181
+ if (t.type === "COMPONENT") {
182
+ const [comp, next] = parseComponent(tokens, i);
183
+ node.components.push(comp);
184
+ i = next;
185
+ } else {
186
+ i++;
187
+ }
188
+ }
189
+ return [node, i];
190
+ }
191
+ function parseComponent(tokens, start) {
192
+ const t = tokens[start];
193
+ const baseIndent = t.indent;
194
+ const node = {
195
+ type: "COMPONENT",
196
+ componentType: t.componentType,
197
+ inlineLabel: t.inlineLabel,
198
+ inlineAction: t.inlineAction,
199
+ properties: [],
200
+ children: []
201
+ };
202
+ let i = start + 1;
203
+ while (i < tokens.length) {
204
+ const cur = tokens[i];
205
+ if (cur.indent <= baseIndent || cur.type === "KEYWORD" || cur.type === "SEPARATOR") break;
206
+ if (cur.type === "PROPERTY") {
207
+ node.properties.push({ key: cur.key, value: cur.value });
208
+ i++;
209
+ } else if (cur.type === "LIST_ITEM") {
210
+ node.properties.push({ key: "fields_items", value: cur.value });
211
+ i++;
212
+ } else if (cur.type === "COMPONENT") {
213
+ const [child, next] = parseComponent(tokens, i);
214
+ node.children.push(child);
215
+ i = next;
216
+ } else {
217
+ i++;
218
+ }
219
+ }
220
+ return [node, i];
221
+ }
222
+
223
+ // src/components/registry.ts
224
+ function getProp(node, key) {
225
+ const found = node.properties.find((p) => p.key === key);
226
+ return found?.value;
227
+ }
228
+ function unquote(s) {
229
+ if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
230
+ return s.slice(1, -1);
231
+ }
232
+ return s;
233
+ }
234
+ function parseLinks(raw) {
235
+ const inner = raw.replace(/^\[/, "").replace(/\]$/, "");
236
+ return inner.split(",").map((part) => {
237
+ const trimmed = part.trim();
238
+ const sepIdx = trimmed.indexOf(">");
239
+ if (sepIdx === -1) return { label: trimmed, href: "#" };
240
+ return { label: trimmed.slice(0, sepIdx).trim(), href: trimmed.slice(sepIdx + 1).trim() };
241
+ });
242
+ }
243
+ function parseCta(raw) {
244
+ const match = raw.match(/^"([^"]+)"\s*->\s*(.+)$/);
245
+ if (match) return { label: match[1], action: match[2].trim() };
246
+ return { label: raw, action: "#" };
247
+ }
248
+ var renderers = {
249
+ header(node) {
250
+ const title = unquote(getProp(node, "title") ?? "");
251
+ const linksRaw = getProp(node, "links") ?? "";
252
+ const links = linksRaw ? parseLinks(linksRaw) : [];
253
+ const navItems = links.map((l) => `<a href="${l.href}" data-link>${l.label}</a>`).join("");
254
+ return `<header class="neuron-header"><h1>${title}</h1><nav>${navItems}</nav></header>`;
255
+ },
256
+ footer(node) {
257
+ const text = unquote(getProp(node, "text") ?? "");
258
+ return `<footer class="neuron-footer"><p>${text}</p></footer>`;
259
+ },
260
+ section(node) {
261
+ const childrenHtml = node.children.map(renderComponent).join("");
262
+ return `<section class="neuron-section">${childrenHtml}</section>`;
263
+ },
264
+ grid(node) {
265
+ const cols = getProp(node, "cols") ?? "1";
266
+ const childrenHtml = node.children.map(renderComponent).join("");
267
+ return `<div class="neuron-grid" style="grid-template-columns:repeat(${cols},1fr)">${childrenHtml}</div>`;
268
+ },
269
+ hero(node) {
270
+ const title = unquote(getProp(node, "title") ?? "");
271
+ const subtitle = unquote(getProp(node, "subtitle") ?? "");
272
+ const ctaRaw = getProp(node, "cta");
273
+ let ctaHtml = "";
274
+ if (ctaRaw) {
275
+ const cta = parseCta(ctaRaw);
276
+ if (cta.action.startsWith("/")) {
277
+ ctaHtml = `<a href="${cta.action}" class="neuron-btn" data-link>${cta.label}</a>`;
278
+ } else {
279
+ ctaHtml = `<button class="neuron-btn" data-action="${cta.action}">${cta.label}</button>`;
280
+ }
281
+ }
282
+ return `<section class="neuron-hero"><h2>${title}</h2><p>${subtitle}</p>${ctaHtml}</section>`;
283
+ },
284
+ "product-grid"(node) {
285
+ const data = getProp(node, "data") ?? "";
286
+ const cols = getProp(node, "cols") ?? "3";
287
+ const action = getProp(node, "on_click") ?? "";
288
+ return `<div id="product-grid" class="neuron-product-grid" data-source="${data}" data-cols="${cols}" data-action="${action}"></div>`;
289
+ },
290
+ list(node) {
291
+ const data = getProp(node, "data") ?? "";
292
+ return `<div class="neuron-list" data-source="${data}"></div>`;
293
+ },
294
+ table(node) {
295
+ const data = getProp(node, "data") ?? "";
296
+ return `<table class="neuron-table" data-source="${data}"></table>`;
297
+ },
298
+ text(node) {
299
+ const content = unquote(getProp(node, "content") ?? "");
300
+ const size = getProp(node, "size") ?? "md";
301
+ return `<p class="neuron-text neuron-text--${size}">${content}</p>`;
302
+ },
303
+ image(node) {
304
+ const src = getProp(node, "src") ?? "";
305
+ const alt = getProp(node, "alt") ?? "";
306
+ return `<img class="neuron-image" src="${src}" alt="${alt}">`;
307
+ },
308
+ "cart-icon"(node) {
309
+ const state = getProp(node, "state") ?? "";
310
+ return `<div id="cart-icon" class="neuron-cart-icon" data-state="${state}"><span class="badge">0</span></div>`;
311
+ },
312
+ "cart-summary"(node) {
313
+ const state = getProp(node, "state") ?? "";
314
+ return `<div id="cart-summary" class="neuron-cart-summary" data-state="${state}"></div>`;
315
+ },
316
+ "cart-list"(node) {
317
+ const state = getProp(node, "state") ?? "";
318
+ const removeAction = getProp(node, "on_remove") ?? "";
319
+ return `<div id="cart-list" class="neuron-cart-list" data-state="${state}" data-remove-action="${removeAction}"></div>`;
320
+ },
321
+ button(node) {
322
+ const label = node.inlineLabel ?? unquote(getProp(node, "label") ?? "");
323
+ const action = node.inlineAction ?? getProp(node, "action") ?? "#";
324
+ const variant = getProp(node, "variant") ?? "default";
325
+ if (action.startsWith("/")) {
326
+ return `<a href="${action}" class="neuron-btn neuron-btn--${variant}" data-link>${label}</a>`;
327
+ }
328
+ return `<button class="neuron-btn neuron-btn--${variant}" data-action="${action}">${label}</button>`;
329
+ },
330
+ form(node) {
331
+ const submitRaw = getProp(node, "submit");
332
+ let submitHtml = "";
333
+ if (submitRaw) {
334
+ const submit = parseCta(submitRaw);
335
+ submitHtml = `<button type="submit" class="neuron-btn" data-action="${submit.action}">${submit.label}</button>`;
336
+ }
337
+ const fields = node.properties.filter((p) => p.key.startsWith("field")).map((p) => {
338
+ const val = unquote(p.value);
339
+ return `<input class="neuron-input" name="${p.key}" placeholder="${val}">`;
340
+ }).join("");
341
+ return `<form class="neuron-form">${fields}${submitHtml}</form>`;
342
+ },
343
+ search(node) {
344
+ const placeholder = unquote(getProp(node, "placeholder") ?? "");
345
+ const state = getProp(node, "state") ?? "";
346
+ return `<input class="neuron-search" type="search" placeholder="${placeholder}" data-state="${state}">`;
347
+ },
348
+ tabs(_node) {
349
+ return `<div class="neuron-tabs"></div>`;
350
+ },
351
+ modal(node) {
352
+ const state = getProp(node, "state") ?? "";
353
+ const title = unquote(getProp(node, "title") ?? "");
354
+ return `<div class="neuron-modal" data-state="${state}"><h3>${title}</h3><div class="neuron-modal-body"></div></div>`;
355
+ }
356
+ };
357
+ function renderComponent(node) {
358
+ const renderer = renderers[node.componentType];
359
+ if (!renderer) {
360
+ return `<!-- unknown component: ${node.componentType} -->`;
361
+ }
362
+ return renderer(node);
363
+ }
364
+
365
+ // src/generator/html.ts
366
+ function generateHTML(pages, appTitle) {
367
+ const pagesSections = pages.map((page) => {
368
+ const componentsHtml = page.components.map((c) => renderComponent(c)).join("\n ");
369
+ return ` <div class="neuron-page" data-page="${page.name}" data-route="${page.route}" style="display:none">
370
+ ${componentsHtml}
371
+ </div>`;
372
+ }).join("\n");
373
+ return `<!DOCTYPE html>
374
+ <html lang="ko">
375
+ <head>
376
+ <meta charset="UTF-8">
377
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
378
+ <title>${appTitle}</title>
379
+ <link rel="stylesheet" href="style.css">
380
+ </head>
381
+ <body>
382
+ <div id="app">
383
+ ${pagesSections}
384
+ </div>
385
+ <script src="main.js"></script>
386
+ </body>
387
+ </html>`;
388
+ }
389
+
390
+ // src/theme.ts
391
+ import { readFileSync } from "fs";
392
+ var DEFAULT_THEME = {
393
+ colors: {
394
+ primary: "#2E86AB",
395
+ secondary: "#A23B72",
396
+ danger: "#E84855",
397
+ bg: "#FFFFFF",
398
+ text: "#1A1A2E",
399
+ border: "#E0E0E0"
400
+ },
401
+ font: {
402
+ family: "Inter",
403
+ size: { sm: 14, md: 16, lg: 20, xl: 28 }
404
+ },
405
+ radius: 8,
406
+ shadow: "0 2px 8px rgba(0,0,0,0.1)",
407
+ spacing: { sm: 8, md: 16, lg: 24, xl: 48 }
408
+ };
409
+ function loadTheme(path) {
410
+ if (!path) return { ...DEFAULT_THEME };
411
+ const raw = readFileSync(path, "utf-8");
412
+ return JSON.parse(raw);
413
+ }
414
+ function themeToCSS(theme) {
415
+ const lines = [":root {"];
416
+ for (const [name, value] of Object.entries(theme.colors)) {
417
+ lines.push(` --color-${name}: ${value};`);
418
+ }
419
+ lines.push(` --font-family: '${theme.font.family}', sans-serif;`);
420
+ for (const [size, val] of Object.entries(theme.font.size)) {
421
+ lines.push(` --font-size-${size}: ${val}px;`);
422
+ }
423
+ lines.push(` --radius: ${theme.radius}px;`);
424
+ lines.push(` --shadow: ${theme.shadow};`);
425
+ for (const [name, val] of Object.entries(theme.spacing)) {
426
+ lines.push(` --spacing-${name}: ${val}px;`);
427
+ }
428
+ lines.push("}");
429
+ return lines.join("\n");
430
+ }
431
+
432
+ // src/generator/css.ts
433
+ var BASE_STYLES = `
434
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
435
+
436
+ body {
437
+ font-family: var(--font-family);
438
+ font-size: var(--font-size-md);
439
+ color: var(--color-text);
440
+ background: var(--color-bg);
441
+ line-height: 1.6;
442
+ }
443
+
444
+ a { color: var(--color-primary); text-decoration: none; }
445
+
446
+ .neuron-header {
447
+ display: flex; align-items: center; justify-content: space-between;
448
+ padding: var(--spacing-md) var(--spacing-lg);
449
+ border-bottom: 1px solid var(--color-border);
450
+ }
451
+ .neuron-header h1 { font-size: var(--font-size-lg); }
452
+ .neuron-header nav { display: flex; gap: var(--spacing-md); }
453
+ .neuron-header nav a { font-size: var(--font-size-md); }
454
+
455
+ .neuron-footer {
456
+ padding: var(--spacing-lg);
457
+ text-align: center;
458
+ border-top: 1px solid var(--color-border);
459
+ color: #888;
460
+ }
461
+
462
+ .neuron-hero {
463
+ text-align: center;
464
+ padding: var(--spacing-xl) var(--spacing-lg);
465
+ }
466
+ .neuron-hero h2 { font-size: var(--font-size-xl); margin-bottom: var(--spacing-sm); }
467
+ .neuron-hero p { margin-bottom: var(--spacing-md); color: #666; }
468
+
469
+ .neuron-btn {
470
+ display: inline-block;
471
+ padding: var(--spacing-sm) var(--spacing-md);
472
+ border-radius: var(--radius);
473
+ border: 1px solid var(--color-border);
474
+ cursor: pointer;
475
+ font-size: var(--font-size-md);
476
+ text-align: center;
477
+ transition: background 0.2s, color 0.2s;
478
+ }
479
+ .neuron-btn--primary { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
480
+ .neuron-btn--secondary { background: var(--color-secondary); color: #fff; border-color: var(--color-secondary); }
481
+ .neuron-btn--danger { background: var(--color-danger); color: #fff; border-color: var(--color-danger); }
482
+ .neuron-btn--ghost { background: transparent; color: var(--color-primary); border-color: transparent; }
483
+ .neuron-btn--default { background: var(--color-bg); color: var(--color-text); }
484
+
485
+ .neuron-product-grid {
486
+ display: grid;
487
+ gap: var(--spacing-md);
488
+ padding: var(--spacing-lg);
489
+ }
490
+ .neuron-product-grid article {
491
+ border: 1px solid var(--color-border);
492
+ border-radius: var(--radius);
493
+ padding: var(--spacing-md);
494
+ box-shadow: var(--shadow);
495
+ }
496
+
497
+ .neuron-cart-list { padding: var(--spacing-lg); }
498
+ .neuron-cart-list .cart-item {
499
+ display: flex; justify-content: space-between; align-items: center;
500
+ padding: var(--spacing-sm) 0;
501
+ border-bottom: 1px solid var(--color-border);
502
+ }
503
+
504
+ .neuron-cart-summary {
505
+ padding: var(--spacing-lg);
506
+ text-align: right;
507
+ font-size: var(--font-size-lg);
508
+ font-weight: bold;
509
+ }
510
+
511
+ .neuron-form {
512
+ max-width: 480px;
513
+ margin: 0 auto;
514
+ padding: var(--spacing-lg);
515
+ }
516
+ .neuron-form label {
517
+ display: block;
518
+ margin-bottom: var(--spacing-md);
519
+ font-size: var(--font-size-md);
520
+ }
521
+ .neuron-form input {
522
+ display: block;
523
+ width: 100%;
524
+ padding: var(--spacing-sm);
525
+ margin-top: var(--spacing-sm);
526
+ border: 1px solid var(--color-border);
527
+ border-radius: var(--radius);
528
+ font-size: var(--font-size-md);
529
+ }
530
+ .neuron-form button[type="submit"] { width: 100%; margin-top: var(--spacing-md); }
531
+
532
+ .neuron-cart-icon { position: relative; cursor: pointer; }
533
+ .neuron-cart-icon .badge {
534
+ position: absolute; top: -4px; right: -4px;
535
+ background: var(--color-danger); color: #fff;
536
+ font-size: 12px; width: 18px; height: 18px;
537
+ border-radius: 50%; display: flex; align-items: center; justify-content: center;
538
+ }
539
+
540
+ .neuron-search {
541
+ width: 100%; padding: var(--spacing-sm);
542
+ border: 1px solid var(--color-border);
543
+ border-radius: var(--radius);
544
+ font-size: var(--font-size-md);
545
+ }
546
+
547
+ .neuron-modal {
548
+ display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
549
+ background: rgba(0,0,0,0.5); z-index: 100;
550
+ }
551
+ .neuron-modal.active { display: flex; align-items: center; justify-content: center; }
552
+ .neuron-modal-body {
553
+ background: var(--color-bg); padding: var(--spacing-lg);
554
+ border-radius: var(--radius); box-shadow: var(--shadow);
555
+ min-width: 320px;
556
+ }
557
+
558
+ .neuron-text--sm { font-size: var(--font-size-sm); }
559
+ .neuron-text--md { font-size: var(--font-size-md); }
560
+ .neuron-text--lg { font-size: var(--font-size-lg); }
561
+ .neuron-text--xl { font-size: var(--font-size-xl); }
562
+
563
+ .neuron-grid { display: grid; gap: var(--spacing-md); padding: var(--spacing-md); }
564
+ .neuron-list { padding: var(--spacing-md); }
565
+ .neuron-section { padding: var(--spacing-md); }
566
+ `;
567
+ function generateCSS(theme) {
568
+ return themeToCSS(theme) + "\n" + BASE_STYLES;
569
+ }
570
+
571
+ // src/generator/js.ts
572
+ function generateJS(ast) {
573
+ const lines = [];
574
+ lines.push(generateState(ast));
575
+ lines.push(generateBindings(ast));
576
+ lines.push(generateSetState());
577
+ lines.push(generateRouter(ast));
578
+ lines.push(generateActions(ast));
579
+ lines.push(generateFormHandling());
580
+ lines.push(generateAutoLoad(ast));
581
+ lines.push(generateInit());
582
+ return lines.join("\n\n");
583
+ }
584
+ function generateState(ast) {
585
+ const fields = ast.states.flatMap((s) => s.fields);
586
+ const entries = fields.map((f) => ` "${f.name}": ${f.defaultValue}`);
587
+ return `const _state = {
588
+ ${entries.join(",\n")}
589
+ };`;
590
+ }
591
+ function generateBindings(ast) {
592
+ const fields = ast.states.flatMap((s) => s.fields);
593
+ const entries = fields.map((f) => ` "${f.name}": []`);
594
+ return `const _bindings = {
595
+ ${entries.join(",\n")}
596
+ };`;
597
+ }
598
+ function generateSetState() {
599
+ return `function _setState(key, val) {
600
+ _state[key] = val;
601
+ (_bindings[key] || []).forEach(fn => fn(val));
602
+ }`;
603
+ }
604
+ function generateRouter(ast) {
605
+ const routeEntries = ast.pages.map((p) => ` "${p.route}": "${p.name}"`);
606
+ const routeMap = `const _routes = {
607
+ ${routeEntries.join(",\n")}
608
+ };`;
609
+ const navigate = `function _navigate(route) {
610
+ history.pushState(null, '', route);
611
+ _render(route);
612
+ }`;
613
+ const render = `function _render(route) {
614
+ const pageName = _routes[route];
615
+ document.querySelectorAll('[data-page]').forEach(el => {
616
+ el.style.display = el.getAttribute('data-page') === pageName ? '' : 'none';
617
+ });
618
+ }`;
619
+ const initRouter = `function _initRouter() {
620
+ document.addEventListener('click', function(e) {
621
+ const link = e.target.closest('[data-link]');
622
+ if (link) {
623
+ e.preventDefault();
624
+ _navigate(link.getAttribute('data-link') || link.getAttribute('href'));
625
+ }
626
+ const actionEl = e.target.closest('[data-action]');
627
+ if (actionEl) {
628
+ e.preventDefault();
629
+ const name = actionEl.getAttribute('data-action');
630
+ if (_actions[name]) _actions[name]();
631
+ }
632
+ });
633
+ window.addEventListener('popstate', function() {
634
+ _render(location.pathname);
635
+ });
636
+ _render(location.pathname);
637
+ }`;
638
+ return [routeMap, navigate, render, initRouter].join("\n\n");
639
+ }
640
+ function generateActions(ast) {
641
+ const apiMap = /* @__PURE__ */ new Map();
642
+ for (const api of ast.apis) {
643
+ apiMap.set(api.name, api);
644
+ }
645
+ const entries = ast.actions.map((action) => {
646
+ const body = generateActionBody(action, apiMap);
647
+ return ` '${action.name}': ${body}`;
648
+ });
649
+ return `const _actions = {
650
+ ${entries.join(",\n")}
651
+ };`;
652
+ }
653
+ function generateActionBody(action, apiMap) {
654
+ const stepMap = /* @__PURE__ */ new Map();
655
+ for (const step of action.steps) {
656
+ stepMap.set(step.key, step.value);
657
+ }
658
+ if (stepMap.has("append")) {
659
+ const val = stepMap.get("append");
660
+ const parts = val.split("->").map((s) => s.trim());
661
+ const target = parts[1];
662
+ return `function(item) {
663
+ _setState('${target}', [..._state.${target}, item]);
664
+ }`;
665
+ }
666
+ if (stepMap.has("remove")) {
667
+ const val = stepMap.get("remove");
668
+ const target = val.split(" ")[0].trim();
669
+ return `function(id) {
670
+ _setState('${target}', _state.${target}.filter(i => i.id !== id));
671
+ }`;
672
+ }
673
+ if (stepMap.has("call")) {
674
+ const apiName = stepMap.get("call");
675
+ const api = apiMap.get(apiName);
676
+ if (!api) {
677
+ return `function() { console.warn('API ${apiName} not found'); }`;
678
+ }
679
+ const onSuccess = stepMap.get("on_success");
680
+ const onError = stepMap.get("on_error");
681
+ const queryState = stepMap.get("query");
682
+ const targetState = stepMap.get("target");
683
+ let urlExpr;
684
+ if (queryState) {
685
+ urlExpr = `\`${api.endpoint}?q=\${encodeURIComponent(_state.${queryState})}\``;
686
+ } else {
687
+ urlExpr = `'${api.endpoint}'`;
688
+ }
689
+ const fetchOptions = [];
690
+ fetchOptions.push(`method: '${api.method}'`);
691
+ fetchOptions.push(`headers: { 'Content-Type': 'application/json' }`);
692
+ if (api.options.body) {
693
+ fetchOptions.push(`body: JSON.stringify(_state.${api.options.body})`);
694
+ }
695
+ let successCode = "";
696
+ if (onSuccess) {
697
+ if (onSuccess.startsWith("->")) {
698
+ const route = onSuccess.replace("->", "").trim();
699
+ successCode = `_navigate('${route}');`;
700
+ } else {
701
+ successCode = `_setState('${onSuccess}', data);`;
702
+ }
703
+ }
704
+ if (targetState) {
705
+ successCode = `_setState('${targetState}', data);`;
706
+ }
707
+ let errorCode = "";
708
+ if (onError) {
709
+ errorCode = `
710
+ } catch(err) {
711
+ console.error('${onError}', err);`;
712
+ } else {
713
+ errorCode = `
714
+ } catch(err) {
715
+ console.error(err);`;
716
+ }
717
+ return `async function() {
718
+ try {
719
+ const res = await fetch(${urlExpr}, {
720
+ ${fetchOptions.join(",\n ")}
721
+ });
722
+ const data = await res.json();
723
+ ${successCode}${errorCode}
724
+ }
725
+ }`;
726
+ }
727
+ return `function() {}`;
728
+ }
729
+ function generateFormHandling() {
730
+ return `document.addEventListener('submit', function(e) {
731
+ const form = e.target.closest('form[data-action]');
732
+ if (form) {
733
+ e.preventDefault();
734
+ const name = form.getAttribute('data-action');
735
+ const formData = Object.fromEntries(new FormData(form));
736
+ if (_actions[name]) _actions[name](formData);
737
+ }
738
+ });`;
739
+ }
740
+ function generateAutoLoad(ast) {
741
+ const autoApis = ast.apis.filter((a) => a.options.on_load === "true");
742
+ if (autoApis.length === 0) return "function _autoLoad() {}";
743
+ const calls = autoApis.map((api) => {
744
+ return ` fetch('${api.endpoint}')
745
+ .then(res => res.json())
746
+ .then(data => _setState('${api.name}', data))
747
+ .catch(err => console.error('Failed to load ${api.name}', err));`;
748
+ });
749
+ return `function _autoLoad() {
750
+ ${calls.join("\n")}
751
+ }`;
752
+ }
753
+ function generateInit() {
754
+ return `document.addEventListener('DOMContentLoaded', function() {
755
+ _initRouter();
756
+ _autoLoad();
757
+ });`;
758
+ }
759
+
760
+ // src/compiler.ts
761
+ function compile(input) {
762
+ const errors = [];
763
+ const ast = { states: [], actions: [], apis: [], pages: [] };
764
+ try {
765
+ const appSource = readFileSync2(input.appFile, "utf-8");
766
+ const appAst = parse(appSource);
767
+ ast.states.push(...appAst.states);
768
+ ast.actions.push(...appAst.actions);
769
+ } catch (err) {
770
+ errors.push(`Failed to parse app.neuron: ${err}`);
771
+ }
772
+ for (const pageFile of input.pageFiles) {
773
+ try {
774
+ const source = readFileSync2(pageFile, "utf-8");
775
+ const pageAst = parse(source);
776
+ ast.pages.push(...pageAst.pages);
777
+ } catch (err) {
778
+ errors.push(`Failed to parse ${pageFile}: ${err}`);
779
+ }
780
+ }
781
+ for (const apiFile of input.apiFiles) {
782
+ try {
783
+ const source = readFileSync2(apiFile, "utf-8");
784
+ const apiAst = parse(source);
785
+ ast.apis.push(...apiAst.apis);
786
+ } catch (err) {
787
+ errors.push(`Failed to parse ${apiFile}: ${err}`);
788
+ }
789
+ }
790
+ const theme = loadTheme(input.themeFile);
791
+ const html = generateHTML(ast.pages, input.appTitle);
792
+ const css = generateCSS(theme);
793
+ const js = generateJS(ast);
794
+ return { html, css, js, errors };
795
+ }
796
+
797
+ // src/scaffold.ts
798
+ import { mkdirSync, writeFileSync, readFileSync as readFileSync3 } from "fs";
799
+ import { join, resolve } from "path";
800
+ import { fileURLToPath } from "url";
801
+ import { dirname } from "path";
802
+ function scaffold(projectName, targetDir) {
803
+ const projectDir = join(targetDir, projectName);
804
+ const dirs = [
805
+ projectDir,
806
+ join(projectDir, "pages"),
807
+ join(projectDir, "apis"),
808
+ join(projectDir, "components"),
809
+ join(projectDir, "themes"),
810
+ join(projectDir, "assets")
811
+ ];
812
+ for (const d of dirs) mkdirSync(d, { recursive: true });
813
+ const templatesDir = resolve(dirname(fileURLToPath(import.meta.url)), "..", "templates");
814
+ const files = [
815
+ { src: "neuron.json", dest: "neuron.json" },
816
+ { src: "app.neuron", dest: "app.neuron" },
817
+ { src: "theme.json", dest: "themes/theme.json" },
818
+ { src: "pages/home.neuron", dest: "pages/home.neuron" }
819
+ ];
820
+ for (const file of files) {
821
+ let content = readFileSync3(join(templatesDir, file.src), "utf-8");
822
+ content = content.replace(/\{\{PROJECT_NAME\}\}/g, projectName);
823
+ writeFileSync(join(projectDir, file.dest), content);
824
+ }
825
+ }
826
+
827
+ // src/cli.ts
828
+ function run(args) {
829
+ const command = args[0];
830
+ if (command === "new") {
831
+ const projectName = args[1];
832
+ if (!projectName) {
833
+ console.error("Usage: neuron new <project-name>");
834
+ process.exit(1);
835
+ }
836
+ scaffold(projectName, process.cwd());
837
+ console.log(`Created project: ${projectName}/`);
838
+ return;
839
+ }
840
+ if (command === "build") {
841
+ const projectDir = resolve2(process.cwd());
842
+ const appFile = join2(projectDir, "app.neuron");
843
+ if (!existsSync(appFile)) {
844
+ console.error("[NEURON ERROR] app.neuron not found in current directory");
845
+ process.exit(1);
846
+ }
847
+ const pagesDir = join2(projectDir, "pages");
848
+ const pageFiles = existsSync(pagesDir) ? readdirSync(pagesDir).filter((f) => f.endsWith(".neuron")).map((f) => join2(pagesDir, f)) : [];
849
+ const apisDir = join2(projectDir, "apis");
850
+ const apiFiles = existsSync(apisDir) ? readdirSync(apisDir).filter((f) => f.endsWith(".neuron")).map((f) => join2(apisDir, f)) : [];
851
+ const themeFile = join2(projectDir, "themes", "theme.json");
852
+ const themeArg = existsSync(themeFile) ? themeFile : null;
853
+ let appTitle = basename(projectDir);
854
+ const neuronJson = join2(projectDir, "neuron.json");
855
+ if (existsSync(neuronJson)) {
856
+ try {
857
+ const config = JSON.parse(readFileSync4(neuronJson, "utf-8"));
858
+ appTitle = config.name || appTitle;
859
+ } catch {
860
+ }
861
+ }
862
+ const result = compile({ appFile, pageFiles, apiFiles, themeFile: themeArg, appTitle });
863
+ if (result.errors.length > 0) {
864
+ result.errors.forEach((e) => console.error(e));
865
+ }
866
+ const distDir = join2(projectDir, "dist");
867
+ mkdirSync2(distDir, { recursive: true });
868
+ mkdirSync2(join2(distDir, "assets"), { recursive: true });
869
+ writeFileSync2(join2(distDir, "index.html"), result.html);
870
+ writeFileSync2(join2(distDir, "style.css"), result.css);
871
+ writeFileSync2(join2(distDir, "main.js"), result.js);
872
+ console.log(`Build complete \u2192 dist/`);
873
+ console.log(` index.html (${result.html.length} bytes)`);
874
+ console.log(` style.css (${result.css.length} bytes)`);
875
+ console.log(` main.js (${result.js.length} bytes)`);
876
+ return;
877
+ }
878
+ console.log("Neuron DSL Compiler");
879
+ console.log("");
880
+ console.log("Commands:");
881
+ console.log(" neuron new <name> Create a new project");
882
+ console.log(" neuron build Build the current project");
883
+ }
884
+
885
+ // src/index.ts
886
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "neuron-dsl",
3
+ "version": "1.0.0",
4
+ "description": "AI-first declarative web app DSL compiler",
5
+ "type": "module",
6
+ "bin": {
7
+ "neuron": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "templates"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsup src/index.ts --format esm --dts",
15
+ "prepublishOnly": "npm run build",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git@github-second:rayforvideos/neuron.git"
22
+ },
23
+ "keywords": ["neuron", "dsl", "compiler", "spa", "ai"],
24
+ "author": "",
25
+ "license": "MIT",
26
+ "devDependencies": {
27
+ "@types/node": "^25.5.0",
28
+ "tsup": "^8.5.1",
29
+ "typescript": "^6.0.2",
30
+ "vitest": "^4.1.1"
31
+ }
32
+ }
@@ -0,0 +1,7 @@
1
+ STATE
2
+ items: []
3
+
4
+ ---
5
+
6
+ ACTION add-item
7
+ append: item -> items
@@ -0,0 +1,4 @@
1
+ {
2
+ "name": "{{PROJECT_NAME}}",
3
+ "version": "1.0.0"
4
+ }
@@ -0,0 +1,11 @@
1
+ PAGE home "홈" /
2
+
3
+ header
4
+ title: "{{PROJECT_NAME}}"
5
+
6
+ hero
7
+ title: "Welcome"
8
+ subtitle: "Get started with Neuron"
9
+
10
+ footer
11
+ text: "Built with Neuron"
@@ -0,0 +1,17 @@
1
+ {
2
+ "colors": {
3
+ "primary": "#2E86AB",
4
+ "secondary": "#A23B72",
5
+ "danger": "#E84855",
6
+ "bg": "#FFFFFF",
7
+ "text": "#1A1A2E",
8
+ "border": "#E0E0E0"
9
+ },
10
+ "font": {
11
+ "family": "Inter",
12
+ "size": { "sm": 14, "md": 16, "lg": 20, "xl": 28 }
13
+ },
14
+ "radius": 8,
15
+ "shadow": "0 2px 8px rgba(0,0,0,0.1)",
16
+ "spacing": { "sm": 8, "md": 16, "lg": 24, "xl": 48 }
17
+ }