diff --git a/.gitignore b/.gitignore
index 2e161f5..97ee043 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
node_modules
.svelte-kit
build
+offline-dist
+release
.env
.env.*
!.env.example
diff --git a/offline/OfflineApp.svelte b/offline/OfflineApp.svelte
new file mode 100644
index 0000000..15d8f3a
--- /dev/null
+++ b/offline/OfflineApp.svelte
@@ -0,0 +1,127 @@
+
+
+
+
+
+
+
diff --git a/offline/chart-loader.ts b/offline/chart-loader.ts
new file mode 100644
index 0000000..33a1208
--- /dev/null
+++ b/offline/chart-loader.ts
@@ -0,0 +1,24 @@
+import type { ExactSongChart } from '$lib/engine/index.ts';
+
+const chartSources = import.meta.glob('../static/data/charts/*.json', {
+ eager: true,
+ query: '?raw',
+ import: 'default'
+}) as Record;
+
+const sourceByFilename = new Map(
+ Object.entries(chartSources).map(([path, source]) => [path.split('/').at(-1) ?? path, source])
+);
+const parsedCharts = new Map();
+
+export async function loadExactSongChart(path: string): Promise {
+ const filename = path.split('/').at(-1) ?? path;
+ const cached = parsedCharts.get(filename);
+ if (cached) return cached;
+
+ const source = sourceByFilename.get(filename);
+ if (!source) throw new Error(`포함된 채보 데이터를 찾지 못했습니다: ${filename}`);
+ const chart = JSON.parse(source) as ExactSongChart;
+ parsedCharts.set(filename, chart);
+ return chart;
+}
diff --git a/offline/index.html b/offline/index.html
new file mode 100644
index 0000000..bbcf80f
--- /dev/null
+++ b/offline/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 홀로도리 계산기
+
+
+
+
+
+
diff --git a/offline/main.ts b/offline/main.ts
new file mode 100644
index 0000000..ba93d51
--- /dev/null
+++ b/offline/main.ts
@@ -0,0 +1,7 @@
+import { mount } from 'svelte';
+import App from './OfflineApp.svelte';
+import '../src/lib/styles/layout-tokens.css';
+
+mount(App, {
+ target: document.getElementById('app')!
+});
diff --git a/offline/page-store.ts b/offline/page-store.ts
new file mode 100644
index 0000000..a83cce3
--- /dev/null
+++ b/offline/page-store.ts
@@ -0,0 +1,11 @@
+import { writable } from 'svelte/store';
+
+function pageValue(pathname: string) {
+ return { url: new URL(pathname, 'https://offline.holodori.local') };
+}
+
+export const page = writable(pageValue('/'));
+
+export function setOfflinePage(pathname: string) {
+ page.set(pageValue(pathname));
+}
diff --git a/package.json b/package.json
index 7687873..c9af01c 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,8 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0 --port 5173",
"build": "vite build",
+ "build:offline": "vite build --config vite.offline.config.ts && node scripts/finalize-offline-build.mjs",
+ "package:offline": "npm run build:offline && powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-offline.ps1",
"preview": "vite preview --host 0.0.0.0",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test:engine": "node --test src/lib/engine/*.test.ts",
diff --git a/scripts/finalize-offline-build.mjs b/scripts/finalize-offline-build.mjs
new file mode 100644
index 0000000..eedff36
--- /dev/null
+++ b/scripts/finalize-offline-build.mjs
@@ -0,0 +1,56 @@
+import { readFile, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+
+const output = path.resolve('offline-dist');
+const htmlPath = path.join(output, 'index.html');
+const scriptPath = path.join(output, 'assets', 'app.js');
+const stylePath = path.join(output, 'assets', 'app.css');
+const packageJson = JSON.parse(await readFile(path.resolve('package.json'), 'utf8'));
+
+let html = await readFile(htmlPath, 'utf8');
+html = html.replace(/