@uiwwsw/hangulscript 0.0.0-stage → 0.5.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/dist/index.js ADDED
@@ -0,0 +1,181 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { compileSource } from './transformer.js';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ let tsModuleCache;
8
+ let defaultCompilerOptions;
9
+
10
+ function loadTypeScriptModule() {
11
+ if (tsModuleCache) {
12
+ return tsModuleCache;
13
+ }
14
+ let typescript;
15
+ try {
16
+ typescript = require('typescript');
17
+ } catch (initialError) {
18
+ const globalCandidate = path.join(process.execPath, '..', '..', 'lib', 'node_modules', 'typescript');
19
+ try {
20
+ typescript = require(globalCandidate);
21
+ } catch (fallbackError) {
22
+ throw new Error('TypeScript 모듈을 불러올 수 없습니다. Bun 또는 Node 환경에서 TypeScript를 설치해주세요.');
23
+ }
24
+ }
25
+ tsModuleCache = typescript.default ?? typescript;
26
+ return tsModuleCache;
27
+ }
28
+
29
+ function getDefaultCompilerOptions(ts) {
30
+ if (!defaultCompilerOptions) {
31
+ defaultCompilerOptions = {
32
+ allowNonTsExtensions: true,
33
+ noEmit: true,
34
+ module: ts.ModuleKind.NodeNext,
35
+ moduleResolution: ts.ModuleResolutionKind.NodeNext,
36
+ target: ts.ScriptTarget.ES2020,
37
+ strict: true,
38
+ esModuleInterop: true
39
+ };
40
+ }
41
+ return defaultCompilerOptions;
42
+ }
43
+
44
+ function createLintHost(ts, compilerOptions, filePath, sourceText) {
45
+ const resolvedFilePath = path.resolve(filePath);
46
+ const baseHost = ts.createCompilerHost(compilerOptions, true);
47
+ const sourceFile = ts.createSourceFile(
48
+ resolvedFilePath,
49
+ sourceText,
50
+ compilerOptions.target ?? ts.ScriptTarget.ES2020,
51
+ true,
52
+ ts.ScriptKind.TS
53
+ );
54
+
55
+ const getSourceFile = baseHost.getSourceFile.bind(baseHost);
56
+ const fileExists = baseHost.fileExists.bind(baseHost);
57
+ const readFile = baseHost.readFile.bind(baseHost);
58
+
59
+ baseHost.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => {
60
+ if (path.resolve(fileName) === resolvedFilePath) {
61
+ return shouldCreateNewSourceFile ? ts.createSourceFile(
62
+ resolvedFilePath,
63
+ sourceText,
64
+ languageVersion,
65
+ true,
66
+ ts.ScriptKind.TS
67
+ ) : sourceFile;
68
+ }
69
+ return getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile);
70
+ };
71
+
72
+ baseHost.fileExists = (fileName) => {
73
+ if (path.resolve(fileName) === resolvedFilePath) {
74
+ return true;
75
+ }
76
+ return fileExists(fileName);
77
+ };
78
+
79
+ baseHost.readFile = (fileName) => {
80
+ if (path.resolve(fileName) === resolvedFilePath) {
81
+ return sourceText;
82
+ }
83
+ return readFile(fileName);
84
+ };
85
+
86
+ return baseHost;
87
+ }
88
+
89
+ function formatDiagnostics(ts, diagnostics) {
90
+ if (diagnostics.length === 0) {
91
+ return '';
92
+ }
93
+ const formatHost = {
94
+ getCanonicalFileName: (fileName) => fileName,
95
+ getCurrentDirectory: () => process.cwd(),
96
+ getNewLine: () => '\n'
97
+ };
98
+ return ts.formatDiagnosticsWithColorAndContext(diagnostics, formatHost);
99
+ }
100
+
101
+ export function compileString(source) {
102
+ return compileSource(source);
103
+ }
104
+
105
+ export async function compileFile(inputPath, outputPath) {
106
+ const source = await readFile(inputPath, 'utf8');
107
+ const compiled = compileSource(source);
108
+ await mkdir(path.dirname(outputPath), { recursive: true });
109
+ await writeFile(outputPath, compiled, 'utf8');
110
+ return compiled;
111
+ }
112
+
113
+ export async function compileFileToDefaultDestination(inputPath) {
114
+ const parsed = path.parse(inputPath);
115
+ const outputPath = path.join(parsed.dir, `${parsed.name}.ts`);
116
+ await compileFile(inputPath, outputPath);
117
+ return outputPath;
118
+ }
119
+
120
+ export function lintSource(source, filePath = 'input.hgs', compilerOptions) {
121
+ const ts = loadTypeScriptModule();
122
+ const options = compilerOptions ?? getDefaultCompilerOptions(ts);
123
+ const compiled = compileSource(source);
124
+ const host = createLintHost(ts, options, filePath, compiled);
125
+ const program = ts.createProgram([path.resolve(filePath)], options, host);
126
+ const diagnostics = ts.getPreEmitDiagnostics(program);
127
+ return {
128
+ diagnostics,
129
+ formattedDiagnostics: formatDiagnostics(ts, diagnostics),
130
+ hasErrors: diagnostics.some((diagnostic) => diagnostic.category === ts.DiagnosticCategory.Error)
131
+ };
132
+ }
133
+
134
+ export async function lintFile(inputPath, compilerOptions) {
135
+ const source = await readFile(inputPath, 'utf8');
136
+ return lintSource(source, inputPath, compilerOptions);
137
+ }
138
+
139
+ export { compileSource };
140
+
141
+ /** Compile through TypeScript once, emitting executable JS and declarations in memory. */
142
+ export function buildSource(source, filePath = 'input.hgs') {
143
+ const ts = loadTypeScriptModule();
144
+ const virtualPath = path.resolve(filePath).replace(/\.(?:hgs|ks)$/i, '') + '.ts';
145
+ const compiled = compileSource(source);
146
+ const options = {
147
+ target: ts.ScriptTarget.ES2022,
148
+ module: ts.ModuleKind.ESNext,
149
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
150
+ strict: true,
151
+ declaration: true,
152
+ noEmitOnError: true,
153
+ skipLibCheck: true
154
+ };
155
+ const host = createLintHost(ts, options, virtualPath, compiled);
156
+ const files = new Map();
157
+ host.writeFile = (name, content) => files.set(name, content);
158
+ const program = ts.createProgram([virtualPath], options, host);
159
+ const diagnostics = ts.getPreEmitDiagnostics(program);
160
+ if (diagnostics.some(d => d.category === ts.DiagnosticCategory.Error)) {
161
+ throw new Error(formatDiagnostics(ts, diagnostics));
162
+ }
163
+ const emitted = program.emit();
164
+ if (emitted.emitSkipped) throw new Error(formatDiagnostics(ts, emitted.diagnostics) || '빌드에 실패했습니다.');
165
+ return {
166
+ javascript: files.get(virtualPath.replace(/\.ts$/, '.js')) ?? '',
167
+ declaration: files.get(virtualPath.replace(/\.ts$/, '.d.ts')) ?? ''
168
+ };
169
+ }
170
+
171
+ export async function buildFile(inputPath, outputDirectory = path.dirname(inputPath)) {
172
+ const source = await readFile(inputPath, 'utf8');
173
+ const result = buildSource(source, inputPath);
174
+ const base = path.parse(inputPath).name;
175
+ await mkdir(outputDirectory, { recursive: true });
176
+ const javascriptPath = path.join(outputDirectory, base + '.js');
177
+ const declarationPath = path.join(outputDirectory, base + '.d.ts');
178
+ await writeFile(javascriptPath, result.javascript, 'utf8');
179
+ await writeFile(declarationPath, result.declaration, 'utf8');
180
+ return { ...result, javascriptPath, declarationPath };
181
+ }
@@ -0,0 +1,103 @@
1
+ import { uiRuntime } from './ui.js';
2
+ // 문장형 v0.1: deterministic indentation grammar, not natural-language inference.
3
+ export function isSentenceSource(source) {
4
+ return source.split(/\r?\n/).some(line => /^\s*(?:문장으로 시작\s*$|[\p{L}_$][\p{L}\p{N}_$]*(?:은|는)\s+.+이다\s*$|각 .+에서:\s*$|[\p{L}_$][\p{L}\p{N}_$]*\(.*\)를 정의한다:\s*$)/u.test(line));
5
+ }
6
+
7
+ export const sentenceRuntime = `${uiRuntime}
8
+
9
+ function 출력(...값: unknown[]): void { console.log(...값); }
10
+ function 길이(값: string | readonly unknown[]): number { return 값.length; }
11
+ function 추가<T>(목록: T[], 값: T): void { 목록.push(값); }
12
+ function 포함<T>(목록: readonly T[], 값: T): boolean { return 목록.includes(값); }
13
+ function 나누기(글: string, 구분: string): string[] { return 글.split(구분); }
14
+ function 잇기(목록: readonly unknown[], 구분 = ""): string { return 목록.join(구분); }
15
+ function 합계(값: readonly number[]): number { return 값.reduce((합, 항목) => 합 + 항목, 0); }
16
+ function 숫자로(값: string | number): number {
17
+ if (typeof 값 === "string" && 값.trim() === "") throw new Error("빈 문자열은 숫자로 바꿀 수 없습니다.");
18
+ const 결과 = Number(값);
19
+ if (!Number.isFinite(결과)) throw new Error("유한한 숫자를 입력하세요.");
20
+ return 결과;
21
+ }
22
+ function 다듬기(글: string): string { return 글.trim(); }
23
+ function 반올림(값: number, 자리 = 0): number {
24
+ if (!Number.isFinite(값) || !Number.isInteger(자리) || 자리 < -10 || 자리 > 10) throw new Error("유한한 수와 -10~10 사이의 정수 자리가 필요합니다.");
25
+ return Math.round(값 * 10 ** 자리) / 10 ** 자리;
26
+ }
27
+ function 평균(목록: readonly number[]): number {
28
+ if (!목록.length) throw new Error("빈 목록의 평균을 구할 수 없습니다.");
29
+ return 합계(목록) / 목록.length;
30
+ }
31
+ function 최솟값(목록: readonly number[]): number {
32
+ if (!목록.length) throw new Error("빈 목록의 최솟값을 구할 수 없습니다.");
33
+ let 결과 = 목록[0]; for (const 항목 of 목록) if (항목 < 결과) 결과 = 항목;
34
+ return 결과;
35
+ }
36
+ function 최댓값(목록: readonly number[]): number {
37
+ if (!목록.length) throw new Error("빈 목록의 최댓값을 구할 수 없습니다.");
38
+ let 결과 = 목록[0]; for (const 항목 of 목록) if (항목 > 결과) 결과 = 항목;
39
+ return 결과;
40
+ }
41
+ function 정렬(값: readonly number[]): number[] { return [...값].sort((가, 나) => 가 - 나); }
42
+ function 범위(시작: number, 끝?: number, 간격 = 1): number[] {
43
+ if (끝 === undefined) { 끝 = 시작; 시작 = 0; }
44
+ if (!Number.isFinite(시작) || !Number.isFinite(끝) || !Number.isFinite(간격) || 간격 === 0) throw new Error("범위에는 유한한 수와 0이 아닌 간격이 필요합니다.");
45
+ const 개수 = Math.max(0, Math.ceil((끝 - 시작) / 간격));
46
+ if (개수 > 100000) throw new Error("범위는 최대 100000개까지 만들 수 있습니다.");
47
+ return Array.from({ length: 개수 }, (_, 순서) => 시작 + 순서 * 간격);
48
+ }
49
+ `;
50
+
51
+ export function compileSentences(source, { includeRuntime = true } = {}) {
52
+ const output = includeRuntime ? [sentenceRuntime] : [];
53
+ const indents = [0];
54
+ let needsIndent = false;
55
+ const lines = source.split(/\r?\n/);
56
+ for (let index = 0; index < lines.length; index++) {
57
+ const raw = lines[index];
58
+ const text = raw.trim();
59
+ if (!text || text === '문장으로 시작' || text.startsWith('#')) continue;
60
+ const fail = message => { throw new SyntaxError(`${index + 1}행: ${message}`); };
61
+ if (/^\s*\t/.test(raw)) fail('들여쓰기는 탭 대신 공백을 사용하세요.');
62
+ const indent = raw.length - raw.trimStart().length;
63
+ if (needsIndent) {
64
+ if (indent <= indents.at(-1)) fail('블록 안의 문장을 들여써 주세요.');
65
+ indents.push(indent);
66
+ } else if (indent > indents.at(-1)) fail('예상하지 못한 들여쓰기입니다.');
67
+ while (indent < indents.at(-1)) { indents.pop(); output.push('}'); }
68
+ if (indent !== indents.at(-1)) fail('이전 블록과 들여쓰기를 맞춰 주세요.');
69
+ needsIndent = false;
70
+ let match;
71
+ if ((match = text.match(/^(.+\))를 정의한다:$/u))) {
72
+ // Unannotated parameters are intentionally dynamic, like Python.
73
+ const signature = match[1].replace(/\((.*)\)$/, (_, params) => '(' + params.split(',').filter(x => x.trim()).map(x => x.includes(':') ? x.trim() : `${x.trim()}: any`).join(', ') + ')');
74
+ output.push(`function ${signature} {`); needsIndent = true;
75
+ } else if ((match = text.match(/^("(?:[^"\\]|\\.)*") 버튼을 누르면:$/u))) {
76
+ output.push(`if (버튼(${match[1]})) {`); needsIndent = true;
77
+ } else if ((match = text.match(/^각 (.+?)(?:을|를) (.+)에서:$/u))) {
78
+ if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(match[1])) fail('반복 변수 이름을 확인하세요.');
79
+ output.push(`for (const ${match[1]} of ${match[2]}) {`); needsIndent = true;
80
+ } else if ((match = text.match(/^만약 (.+) (?:이)?라면:$/u))) {
81
+ output.push(`if (${match[1]}) {`); needsIndent = true;
82
+ } else if ((match = text.match(/^아니고 (.+) (?:이)?라면:$/u))) {
83
+ if (output.at(-1) !== '}') fail('아니고 앞에는 조건 블록이 필요합니다.');
84
+ output.push(`else if (${match[1]}) {`); needsIndent = true;
85
+ } else if (text === '아니면:') {
86
+ if (output.at(-1) !== '}') fail('아니면 앞에는 조건 블록이 필요합니다.');
87
+ output.push('else {'); needsIndent = true;
88
+ } else if ((match = text.match(/^동안 (.+):$/u))) {
89
+ output.push(`while (${match[1]}) {`); needsIndent = true;
90
+ } else if ((match = text.match(/^(.+?)(?:은|는) (.+)이다$/u))) {
91
+ if (!/^[\p{L}_$][\p{L}\p{N}_$]*$/u.test(match[1])) fail('변수 이름을 확인하세요.');
92
+ output.push(`let ${match[1]}${match[2].trim() === '[]' ? ': any[]' : ''} = ${match[2]};`);
93
+ } else if ((match = text.match(/^돌려주기(?: (.+))?$/u))) {
94
+ output.push(`return ${match[1] ?? ''};`);
95
+ } else if (text === '멈추기') output.push('break;');
96
+ else if (text === '계속하기') output.push('continue;');
97
+ else if (text.endsWith(':')) fail('지원하지 않는 블록 문장입니다.');
98
+ else output.push(text + (text.endsWith(';') ? '' : ';'));
99
+ }
100
+ if (needsIndent) throw new SyntaxError('마지막 블록에 실행할 문장이 필요합니다.');
101
+ while (indents.length > 1) { indents.pop(); output.push('}'); }
102
+ return output.join('\n');
103
+ }
@@ -0,0 +1,2 @@
1
+ /** Convert keyword-style or sentence-style .hgs source into TypeScript. */
2
+ export function compileSource(source: string): string;
@@ -0,0 +1,338 @@
1
+ import ts from 'typescript';
2
+ import { compileSentences, isSentenceSource, sentenceRuntime } from './sentences.js';
3
+
4
+ const identifierCharRegex = /[\p{L}\p{N}_$]/u;
5
+
6
+ const KEYWORD_TRANSFORMS = [
7
+ { match: "내보내기 기본내보내기", replacement: "export default", requiresBoundary: true },
8
+ { match: "비동기함수", replacement: "async function", requiresBoundary: true },
9
+ { match: "상수", replacement: "const", requiresBoundary: true },
10
+ { match: "변수", replacement: "let", requiresBoundary: true },
11
+ { match: "함수", replacement: "function", requiresBoundary: true },
12
+ { match: "반환", replacement: "return", requiresBoundary: true },
13
+ { match: "만약", replacement: "if", requiresBoundary: true },
14
+ { match: "아니면", replacement: "else", requiresBoundary: true },
15
+ { match: "반복", replacement: "for", requiresBoundary: true },
16
+ { match: "동안", replacement: "while", requiresBoundary: true },
17
+ { match: "실행", replacement: "do", requiresBoundary: true },
18
+ { match: "참", replacement: "true", requiresBoundary: true },
19
+ { match: "거짓", replacement: "false", requiresBoundary: true },
20
+ { match: "비어있음", replacement: "null", requiresBoundary: true },
21
+ { match: "정의안됨", replacement: "undefined", requiresBoundary: true },
22
+ { match: "가져오기", replacement: "import", requiresBoundary: true },
23
+ { match: "에서", replacement: "from", requiresBoundary: true },
24
+ { match: "프롬", replacement: "from", requiresBoundary: true },
25
+ { match: "오브", replacement: "of", requiresBoundary: true },
26
+ { match: "안에", replacement: "in", requiresBoundary: true },
27
+ { match: "내보내기", replacement: "export", requiresBoundary: true },
28
+ { match: "스위치", replacement: "switch", requiresBoundary: true },
29
+ { match: "선택", replacement: "switch", requiresBoundary: true },
30
+ { match: "케이스", replacement: "case", requiresBoundary: true },
31
+ { match: "경우", replacement: "case", requiresBoundary: true },
32
+ { match: "디폴트", replacement: "default", requiresBoundary: true },
33
+ { match: "기본", replacement: "default", requiresBoundary: true },
34
+ { match: "중단", replacement: "break", requiresBoundary: true },
35
+ { match: "멈춤", replacement: "break", requiresBoundary: true },
36
+ { match: "계속", replacement: "continue", requiresBoundary: true },
37
+ { match: "마침내", replacement: "finally", requiresBoundary: true },
38
+ { match: "타입", replacement: "type", requiresBoundary: true },
39
+ { match: "인터페이스", replacement: "interface", requiresBoundary: true },
40
+ { match: "구현", replacement: "implements", requiresBoundary: true },
41
+ { match: "확장", replacement: "extends", requiresBoundary: true },
42
+ { match: "열거형", replacement: "enum", requiresBoundary: true },
43
+ { match: "클래스", replacement: "class", requiresBoundary: true },
44
+ { match: "추상", replacement: "abstract", requiresBoundary: true },
45
+ { match: "공개", replacement: "public", requiresBoundary: true },
46
+ { match: "보호", replacement: "protected", requiresBoundary: true },
47
+ { match: "비공개", replacement: "private", requiresBoundary: true },
48
+ { match: "정적", replacement: "static", requiresBoundary: true },
49
+ { match: "읽기전용", replacement: "readonly", requiresBoundary: true },
50
+ { match: "얻기", replacement: "get", requiresBoundary: true },
51
+ { match: "설정", replacement: "set", requiresBoundary: true },
52
+ { match: "선언", replacement: "declare", requiresBoundary: true },
53
+ { match: "생성자", replacement: "constructor", requiresBoundary: true },
54
+ { match: "상위", replacement: "super", requiresBoundary: true },
55
+ { match: "이것", replacement: "this", requiresBoundary: true },
56
+ { match: "새로운", replacement: "new", requiresBoundary: true },
57
+ { match: "대기", replacement: "await", requiresBoundary: true },
58
+ { match: "시도", replacement: "try", requiresBoundary: true },
59
+ { match: "잡기", replacement: "catch", requiresBoundary: true },
60
+ { match: "던지기", replacement: "throw", requiresBoundary: true },
61
+ { match: "양보", replacement: "yield", requiresBoundary: true },
62
+ { match: "삭제", replacement: "delete", requiresBoundary: true },
63
+ { match: "타입오브", replacement: "typeof", requiresBoundary: true },
64
+ { match: "키오브", replacement: "keyof", requiresBoundary: true },
65
+ { match: "인퍼", replacement: "infer", requiresBoundary: true },
66
+ { match: "만족", replacement: "satisfies", requiresBoundary: true },
67
+ { match: "애즈", replacement: "as", requiresBoundary: true },
68
+ { match: "처럼", replacement: "as", requiresBoundary: true },
69
+ { match: "이즈", replacement: "is", requiresBoundary: true },
70
+ { match: "이다", replacement: "is", requiresBoundary: true },
71
+ { match: "무효", replacement: "void", requiresBoundary: true },
72
+ { match: "문자열", replacement: "string", requiresBoundary: true },
73
+ { match: "숫자", replacement: "number", requiresBoundary: true },
74
+ { match: "불리언", replacement: "boolean", requiresBoundary: true },
75
+ { match: "아무거나", replacement: "any", requiresBoundary: true },
76
+ { match: "배열", replacement: "Array", requiresBoundary: true },
77
+ { match: "레코드", replacement: "Record", requiresBoundary: true },
78
+ { match: "맵", replacement: "Map", requiresBoundary: true },
79
+ { match: "세트", replacement: "Set", requiresBoundary: true },
80
+ { match: "약한맵", replacement: "WeakMap", requiresBoundary: true },
81
+ { match: "약한세트", replacement: "WeakSet", requiresBoundary: true },
82
+ { match: "프로미스", replacement: "Promise", requiresBoundary: true },
83
+ { match: "객체", replacement: "Object", requiresBoundary: true },
84
+ { match: "날짜", replacement: "Date", requiresBoundary: true },
85
+ { match: "오류", replacement: "Error", requiresBoundary: true },
86
+ { match: "정규식", replacement: "RegExp", requiresBoundary: true },
87
+ { match: "제이슨", replacement: "JSON", requiresBoundary: true },
88
+ { match: "수학", replacement: "Math", requiresBoundary: true },
89
+ { match: "콘솔.출력", replacement: "console.log", requiresBoundary: true }
90
+ ];
91
+
92
+ const transformsByFirst = new Map();
93
+ for (const transform of KEYWORD_TRANSFORMS) {
94
+ const first = transform.match[0];
95
+ if (!transformsByFirst.has(first)) transformsByFirst.set(first, []);
96
+ transformsByFirst.get(first).push(transform);
97
+ }
98
+
99
+ function isIdentifierBoundary(source, index) {
100
+ if (index <= 0 || index >= source.length) {
101
+ return true;
102
+ }
103
+ const char = source[index - 1];
104
+ return !identifierCharRegex.test(char);
105
+ }
106
+
107
+ function isBoundaryAfter(source, index) {
108
+ if (index >= source.length) {
109
+ return true;
110
+ }
111
+ const char = source[index];
112
+ return !identifierCharRegex.test(char);
113
+ }
114
+
115
+ function tryApplyTransform(source, index) {
116
+ for (const transform of transformsByFirst.get(source[index]) ?? []) {
117
+ if (!source.startsWith(transform.match, index)) {
118
+ continue;
119
+ }
120
+ if (transform.requiresBoundary) {
121
+ const beforeOk = isIdentifierBoundary(source, index);
122
+ const afterOk = isBoundaryAfter(source, index + transform.match.length);
123
+ if (!beforeOk || !afterOk) {
124
+ continue;
125
+ }
126
+ }
127
+ return {
128
+ replacement: transform.replacement,
129
+ length: transform.match.length
130
+ };
131
+ }
132
+ return null;
133
+ }
134
+
135
+ function transformKeywords(source) {
136
+ let result = "";
137
+ let index = 0;
138
+ const modeStack = ["code"];
139
+ const templateExpressionStack = [];
140
+
141
+ while (index < source.length) {
142
+ const mode = modeStack[modeStack.length - 1];
143
+ const char = source[index];
144
+
145
+ if (mode === "code" || mode === "templateExpression") {
146
+ if (mode === "templateExpression") {
147
+ if (char === "}") {
148
+ const info = templateExpressionStack[templateExpressionStack.length - 1];
149
+ info.depth -= 1;
150
+ result += char;
151
+ index += 1;
152
+ if (info.depth === 0) {
153
+ templateExpressionStack.pop();
154
+ modeStack.pop();
155
+ }
156
+ continue;
157
+ }
158
+ if (char === "{") {
159
+ const info = templateExpressionStack[templateExpressionStack.length - 1];
160
+ info.depth += 1;
161
+ result += char;
162
+ index += 1;
163
+ continue;
164
+ }
165
+ }
166
+
167
+ if (char === "\"") {
168
+ modeStack.push("doubleQuote");
169
+ result += char;
170
+ index += 1;
171
+ continue;
172
+ }
173
+ if (char === "'") {
174
+ modeStack.push("singleQuote");
175
+ result += char;
176
+ index += 1;
177
+ continue;
178
+ }
179
+ if (char === "`") {
180
+ modeStack.push("template");
181
+ result += char;
182
+ index += 1;
183
+ continue;
184
+ }
185
+ if (char === "/") {
186
+ const next = source[index + 1];
187
+ if (next === "/") {
188
+ modeStack.push("lineComment");
189
+ result += "//";
190
+ index += 2;
191
+ continue;
192
+ }
193
+ if (next === "*") {
194
+ modeStack.push("blockComment");
195
+ result += "/*";
196
+ index += 2;
197
+ continue;
198
+ }
199
+ }
200
+
201
+ const transform = tryApplyTransform(source, index);
202
+ if (transform) {
203
+ result += transform.replacement;
204
+ index += transform.length;
205
+ continue;
206
+ }
207
+
208
+ result += char;
209
+ index += 1;
210
+ continue;
211
+ }
212
+
213
+ if (mode === "singleQuote") {
214
+ result += char;
215
+ index += 1;
216
+ if (char === "\\") {
217
+ if (index < source.length) {
218
+ result += source[index];
219
+ index += 1;
220
+ }
221
+ continue;
222
+ }
223
+ if (char === "'") {
224
+ modeStack.pop();
225
+ }
226
+ continue;
227
+ }
228
+
229
+ if (mode === "doubleQuote") {
230
+ result += char;
231
+ index += 1;
232
+ if (char === "\\") {
233
+ if (index < source.length) {
234
+ result += source[index];
235
+ index += 1;
236
+ }
237
+ continue;
238
+ }
239
+ if (char === "\"") {
240
+ modeStack.pop();
241
+ }
242
+ continue;
243
+ }
244
+
245
+ if (mode === "template") {
246
+ result += char;
247
+ index += 1;
248
+ if (char === "\\") {
249
+ if (index < source.length) {
250
+ result += source[index];
251
+ index += 1;
252
+ }
253
+ continue;
254
+ }
255
+ if (char === "`") {
256
+ modeStack.pop();
257
+ continue;
258
+ }
259
+ if (char === "$" && source[index] === "{") {
260
+ result += "{";
261
+ modeStack.push("templateExpression");
262
+ templateExpressionStack.push({ depth: 1 });
263
+ index += 1;
264
+ }
265
+ continue;
266
+ }
267
+
268
+ if (mode === "lineComment") {
269
+ result += char;
270
+ index += 1;
271
+ if (char === "\n") {
272
+ modeStack.pop();
273
+ }
274
+ continue;
275
+ }
276
+
277
+ if (mode === "blockComment") {
278
+ result += char;
279
+ index += 1;
280
+ if (char === "*" && source[index] === "/") {
281
+ result += "/";
282
+ index += 1;
283
+ modeStack.pop();
284
+ }
285
+ continue;
286
+ }
287
+ }
288
+
289
+ return result;
290
+ }
291
+
292
+ // Scan tokens so literals, comments, and nested function bodies stay untouched.
293
+ function fixClassMethods(source) {
294
+ if (!source.includes('class')) return source;
295
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, ts.LanguageVariant.Standard, source);
296
+ const stack = [];
297
+ const edits = [];
298
+ let pendingClass = false;
299
+ let previous = ts.SyntaxKind.Unknown;
300
+ const templateDepths = [];
301
+ let braces = 0;
302
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
303
+ if (token === ts.SyntaxKind.CloseBraceToken && templateDepths.at(-1) === braces) {
304
+ token = scanner.reScanTemplateToken(false);
305
+ if (token === ts.SyntaxKind.TemplateTail) templateDepths.pop();
306
+ continue;
307
+ }
308
+ if (token === ts.SyntaxKind.TemplateHead) { templateDepths.push(braces); continue; }
309
+ if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia ||
310
+ token === ts.SyntaxKind.WhitespaceTrivia || token === ts.SyntaxKind.NewLineTrivia) continue;
311
+ if (token === ts.SyntaxKind.ClassKeyword) pendingClass = true;
312
+ if (token === ts.SyntaxKind.OpenBraceToken) {
313
+ stack.push(pendingClass ? 'class' : 'block');
314
+ pendingClass = false;
315
+ braces++;
316
+ } else if (token === ts.SyntaxKind.CloseBraceToken) {
317
+ stack.pop(); braces--;
318
+ } else if (token === ts.SyntaxKind.FunctionKeyword && stack.at(-1) === 'class' &&
319
+ [ts.SyntaxKind.OpenBraceToken, ts.SyntaxKind.CloseBraceToken, ts.SyntaxKind.SemicolonToken,
320
+ ts.SyntaxKind.AsyncKeyword, ts.SyntaxKind.PublicKeyword, ts.SyntaxKind.PrivateKeyword,
321
+ ts.SyntaxKind.ProtectedKeyword, ts.SyntaxKind.StaticKeyword].includes(previous)) {
322
+ const start = scanner.getTokenPos();
323
+ let end = scanner.getTextPos();
324
+ while (source[end] === ' ' || source[end] === '\t') end++;
325
+ edits.push([start, end]);
326
+ }
327
+ previous = token;
328
+ }
329
+ for (const [start, end] of edits.reverse()) source = source.slice(0, start) + source.slice(end);
330
+ return source;
331
+ }
332
+
333
+ export function compileSource(source) {
334
+ const sentenceMode = isSentenceSource(source);
335
+ if (sentenceMode) source = compileSentences(source, { includeRuntime: false });
336
+ const transformed = fixClassMethods(transformKeywords(source));
337
+ return sentenceMode ? sentenceRuntime + '\n' + transformed : transformed;
338
+ }
package/dist/ui.js ADDED
@@ -0,0 +1,49 @@
1
+ // A data-only UI protocol: the host renders descriptors, never user HTML or code.
2
+ export function createUIBridge(values = {}, action = '') {
3
+ const elements = [];
4
+ const labels = new Set();
5
+ const text = value => String(value).slice(0, 2000);
6
+ function add(type, label, extra = {}) {
7
+ label = text(label);
8
+ if (!label || labels.has(label)) throw new Error('화면 요소의 이름은 비어 있거나 중복될 수 없습니다.');
9
+ if (elements.length >= 40) throw new Error('화면에는 최대 40개 요소를 만들 수 있습니다.');
10
+ labels.add(label);
11
+ const item = { type, label, ...extra };
12
+ elements.push(item);
13
+ return item;
14
+ }
15
+ return {
16
+ number(label, initial = 0) {
17
+ const raw = Object.hasOwn(values, label) ? values[label] : initial;
18
+ if (String(raw).trim() === '' || !Number.isFinite(Number(raw))) throw new Error(`${text(label)}: 올바른 숫자를 입력하세요.`);
19
+ const value = Number(raw); add('number', label, { value }); return value;
20
+ },
21
+ text(label, initial = '') {
22
+ const value = text(Object.hasOwn(values, label) ? values[label] : initial);
23
+ add('text', label, { value }); return value;
24
+ },
25
+ select(label, options, initial = options?.[0]) {
26
+ if (!Array.isArray(options) || !options.length || options.length > 30) throw new Error('선택 항목은 1~30개가 필요합니다.');
27
+ options = options.map(text);
28
+ const value = text(Object.hasOwn(values, label) ? values[label] : initial);
29
+ if (!options.includes(value)) throw new Error(`${text(label)}: 목록에서 항목을 선택하세요.`);
30
+ add('select', label, { value, options }); return value;
31
+ },
32
+ button(label) { add('button', label); return action === label; },
33
+ show(label, value) { add('result', label, { value: text(value) }); },
34
+ snapshot() { return elements; }
35
+ };
36
+ }
37
+
38
+ export const uiRuntime = `
39
+ function 화면연결(): { number: (이름: string, 기본값?: number) => number; text: (이름: string, 기본값?: string) => string; select: (이름: string, 항목: string[], 기본값?: string) => string; button: (이름: string) => boolean; show: (이름: string, 값: unknown) => void } {
40
+ const 연결 = (globalThis as any).__hangulUI;
41
+ if (!연결) throw new Error("화면 함수는 한글스크립트 UI 실행 환경에서 사용하세요.");
42
+ return 연결;
43
+ }
44
+ function 숫자입력(이름: string, 기본값 = 0): number { return 화면연결().number(이름, 기본값); }
45
+ function 글자입력(이름: string, 기본값 = ""): string { return 화면연결().text(이름, 기본값); }
46
+ function 선택입력(이름: string, 항목: string[], 기본값 = 항목[0]): string { return 화면연결().select(이름, 항목, 기본값); }
47
+ function 버튼(이름: string): boolean { return 화면연결().button(이름); }
48
+ function 보여주기(이름: string, 값: unknown): void { 화면연결().show(이름, 값); }
49
+ `;
package/dist/vite.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Transform .hgs imports at build time. Place before the React or Vue plugin. */
2
+ export default function hangulscript(): {
3
+ name: string;
4
+ enforce: 'pre';
5
+ transform(source: string, id: string): { code: string; map: null } | null;
6
+ };