cumstack 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/LICENSE +21 -0
- package/README.md +3 -0
- package/cli/build.js +19 -0
- package/cli/builder.js +172 -0
- package/cli/create.js +36 -0
- package/cli/dev.js +109 -0
- package/cli/index.js +65 -0
- package/index.js +22 -0
- package/package.json +67 -0
- package/src/app/client/Twink.js +57 -0
- package/src/app/client/components.js +28 -0
- package/src/app/client/hmr.js +161 -0
- package/src/app/client/index.js +144 -0
- package/src/app/client.js +599 -0
- package/src/app/index.js +8 -0
- package/src/app/server/hono-utils.js +292 -0
- package/src/app/server/index.js +457 -0
- package/src/app/server/jsx.js +168 -0
- package/src/app/server.js +373 -0
- package/src/app/shared/i18n.js +271 -0
- package/src/app/shared/language-codes.js +199 -0
- package/src/app/shared/reactivity.js +259 -0
- package/src/app/shared/router.js +153 -0
- package/src/app/shared/utils.js +127 -0
- package/templates/monorepo/README.md +27 -0
- package/templates/monorepo/api/package.json +13 -0
- package/templates/monorepo/app/package.json +19 -0
- package/templates/monorepo/app/src/entry.client.jsx +4 -0
- package/templates/monorepo/app/src/entry.server.jsx +14 -0
- package/templates/monorepo/app/src/main.css +7 -0
- package/templates/monorepo/app/src/pages/404.jsx +9 -0
- package/templates/monorepo/app/src/pages/Home.jsx +8 -0
- package/templates/monorepo/app/wrangler.toml +35 -0
- package/templates/monorepo/package.json +18 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// iso 639-1 language code lookup table
|
|
2
|
+
// maps 2-letter language codes to their native names
|
|
3
|
+
|
|
4
|
+
export const LANGUAGE_CODES = {
|
|
5
|
+
aa: 'Afaraf',
|
|
6
|
+
ab: 'Аҧсуа',
|
|
7
|
+
ae: 'Avesta',
|
|
8
|
+
af: 'Afrikaans',
|
|
9
|
+
ak: 'Akan',
|
|
10
|
+
am: 'አማርኛ',
|
|
11
|
+
an: 'Aragonés',
|
|
12
|
+
ar: 'العربية',
|
|
13
|
+
as: 'অসমীয়া',
|
|
14
|
+
av: 'Авар',
|
|
15
|
+
ay: 'Aymar',
|
|
16
|
+
az: 'Azərbaycan',
|
|
17
|
+
ba: 'Башҡорт',
|
|
18
|
+
be: 'Беларуская',
|
|
19
|
+
bg: 'Български',
|
|
20
|
+
bh: 'भोजपुरी',
|
|
21
|
+
bi: 'Bislama',
|
|
22
|
+
bm: 'Bamanankan',
|
|
23
|
+
bn: 'বাংলা',
|
|
24
|
+
bo: 'བོད་ཡིག',
|
|
25
|
+
br: 'Brezhoneg',
|
|
26
|
+
bs: 'Bosanski',
|
|
27
|
+
ca: 'Català',
|
|
28
|
+
ce: 'Нохчийн',
|
|
29
|
+
ch: 'Chamoru',
|
|
30
|
+
co: 'Corsu',
|
|
31
|
+
cr: 'ᓀᐦᐃᔭᐍᐏᐣ',
|
|
32
|
+
cs: 'Čeština',
|
|
33
|
+
cu: 'Словѣньскъ',
|
|
34
|
+
cv: 'Чӑваш',
|
|
35
|
+
cy: 'Cymraeg',
|
|
36
|
+
da: 'Dansk',
|
|
37
|
+
de: 'Deutsch',
|
|
38
|
+
dv: 'ދިވެހި',
|
|
39
|
+
dz: 'རྫོང་ཁ',
|
|
40
|
+
ee: 'Eʋegbe',
|
|
41
|
+
el: 'Ελληνικά',
|
|
42
|
+
en: 'English',
|
|
43
|
+
eo: 'Esperanto',
|
|
44
|
+
es: 'Español',
|
|
45
|
+
et: 'Eesti',
|
|
46
|
+
eu: 'Euskara',
|
|
47
|
+
fa: 'فارسی',
|
|
48
|
+
ff: 'Fulfulde',
|
|
49
|
+
fi: 'Suomi',
|
|
50
|
+
fj: 'Vosa Vakaviti',
|
|
51
|
+
fo: 'Føroyskt',
|
|
52
|
+
fr: 'Français',
|
|
53
|
+
fy: 'Frysk',
|
|
54
|
+
ga: 'Gaeilge',
|
|
55
|
+
gd: 'Gàidhlig',
|
|
56
|
+
gl: 'Galego',
|
|
57
|
+
gn: "Avañe'ẽ",
|
|
58
|
+
gu: 'ગુજરાતી',
|
|
59
|
+
gv: 'Gaelg',
|
|
60
|
+
ha: 'هَوُسَ',
|
|
61
|
+
he: 'עברית',
|
|
62
|
+
hi: 'हिन्दी',
|
|
63
|
+
ho: 'Hiri Motu',
|
|
64
|
+
hr: 'Hrvatski',
|
|
65
|
+
ht: 'Kreyòl',
|
|
66
|
+
hu: 'Magyar',
|
|
67
|
+
hy: 'Հայերեն',
|
|
68
|
+
hz: 'Otjiherero',
|
|
69
|
+
ia: 'Interlingua',
|
|
70
|
+
id: 'Bahasa Indonesia',
|
|
71
|
+
ie: 'Interlingue',
|
|
72
|
+
ig: 'Igbo',
|
|
73
|
+
ii: 'ꆈꌠꉙ',
|
|
74
|
+
ik: 'Iñupiatun',
|
|
75
|
+
io: 'Ido',
|
|
76
|
+
is: 'Íslenska',
|
|
77
|
+
it: 'Italiano',
|
|
78
|
+
iu: 'ᐃᓄᒃᑎᑐᑦ',
|
|
79
|
+
ja: '日本語',
|
|
80
|
+
jv: 'Basa Jawa',
|
|
81
|
+
ka: 'ქართული',
|
|
82
|
+
kg: 'Kikongo',
|
|
83
|
+
ki: 'Gĩkũyũ',
|
|
84
|
+
kj: 'Kuanyama',
|
|
85
|
+
kk: 'Қазақша',
|
|
86
|
+
kl: 'Kalaallisut',
|
|
87
|
+
km: 'ភាសាខ្មែរ',
|
|
88
|
+
kn: 'ಕನ್ನಡ',
|
|
89
|
+
ko: '한국어',
|
|
90
|
+
kr: 'Kanuri',
|
|
91
|
+
ks: 'कश्मीरी',
|
|
92
|
+
ku: 'Kurdî',
|
|
93
|
+
kv: 'Коми',
|
|
94
|
+
kw: 'Kernewek',
|
|
95
|
+
ky: 'Кыргызча',
|
|
96
|
+
la: 'Latina',
|
|
97
|
+
lb: 'Lëtzebuergesch',
|
|
98
|
+
lg: 'Luganda',
|
|
99
|
+
li: 'Limburgs',
|
|
100
|
+
ln: 'Lingála',
|
|
101
|
+
lo: 'ລາວ',
|
|
102
|
+
lt: 'Lietuvių',
|
|
103
|
+
lu: 'Tshiluba',
|
|
104
|
+
lv: 'Latviešu',
|
|
105
|
+
mg: 'Malagasy',
|
|
106
|
+
mh: 'Kajin M̧ajeļ',
|
|
107
|
+
mi: 'Te Reo Māori',
|
|
108
|
+
mk: 'Македонски',
|
|
109
|
+
ml: 'മലയാളം',
|
|
110
|
+
mn: 'Монгол',
|
|
111
|
+
mr: 'मराठी',
|
|
112
|
+
ms: 'Bahasa Melayu',
|
|
113
|
+
mt: 'Malti',
|
|
114
|
+
my: 'ဗမာစာ',
|
|
115
|
+
na: 'Dorerin Naoero',
|
|
116
|
+
nb: 'Norsk Bokmål',
|
|
117
|
+
nd: 'isiNdebele',
|
|
118
|
+
ne: 'नेपाली',
|
|
119
|
+
ng: 'Owambo',
|
|
120
|
+
nl: 'Nederlands',
|
|
121
|
+
nn: 'Norsk Nynorsk',
|
|
122
|
+
no: 'Norsk',
|
|
123
|
+
nr: 'isiNdebele',
|
|
124
|
+
nv: 'Diné bizaad',
|
|
125
|
+
ny: 'ChiCheŵa',
|
|
126
|
+
oc: 'Occitan',
|
|
127
|
+
oj: 'ᐊᓂᔑᓈᐯᒧᐎᓐ',
|
|
128
|
+
om: 'Oromoo',
|
|
129
|
+
or: 'ଓଡ଼ିଆ',
|
|
130
|
+
os: 'Ирон',
|
|
131
|
+
pa: 'ਪੰਜਾਬੀ',
|
|
132
|
+
pi: 'पाऴि',
|
|
133
|
+
pl: 'Polski',
|
|
134
|
+
ps: 'پښتو',
|
|
135
|
+
pt: 'Português',
|
|
136
|
+
qu: 'Runa Simi',
|
|
137
|
+
rm: 'Rumantsch',
|
|
138
|
+
rn: 'Ikirundi',
|
|
139
|
+
ro: 'Română',
|
|
140
|
+
ru: 'Русский',
|
|
141
|
+
rw: 'Ikinyarwanda',
|
|
142
|
+
sa: 'संस्कृतम्',
|
|
143
|
+
sc: 'Sardu',
|
|
144
|
+
sd: 'سنڌي',
|
|
145
|
+
se: 'Davvisámegiella',
|
|
146
|
+
sg: 'Sängö',
|
|
147
|
+
si: 'සිංහල',
|
|
148
|
+
sk: 'Slovenčina',
|
|
149
|
+
sl: 'Slovenščina',
|
|
150
|
+
sm: 'Gagana Samoa',
|
|
151
|
+
sn: 'chiShona',
|
|
152
|
+
so: 'Soomaali',
|
|
153
|
+
sq: 'Shqip',
|
|
154
|
+
sr: 'Српски',
|
|
155
|
+
ss: 'SiSwati',
|
|
156
|
+
st: 'Sesotho',
|
|
157
|
+
su: 'Basa Sunda',
|
|
158
|
+
sv: 'Svenska',
|
|
159
|
+
sw: 'Kiswahili',
|
|
160
|
+
ta: 'தமிழ்',
|
|
161
|
+
te: 'తెలుగు',
|
|
162
|
+
tg: 'Тоҷикӣ',
|
|
163
|
+
th: 'ไทย',
|
|
164
|
+
ti: 'ትግርኛ',
|
|
165
|
+
tk: 'Türkmençe',
|
|
166
|
+
tl: 'Tagalog',
|
|
167
|
+
tn: 'Setswana',
|
|
168
|
+
to: 'Lea Faka-Tonga',
|
|
169
|
+
tr: 'Türkçe',
|
|
170
|
+
ts: 'Xitsonga',
|
|
171
|
+
tt: 'Татарча',
|
|
172
|
+
tw: 'Twi',
|
|
173
|
+
ty: 'Reo Tahiti',
|
|
174
|
+
ug: 'ئۇيغۇرچە',
|
|
175
|
+
uk: 'Українська',
|
|
176
|
+
ur: 'اردو',
|
|
177
|
+
uz: 'Oʻzbekcha',
|
|
178
|
+
ve: 'Tshivenḓa',
|
|
179
|
+
vi: 'Tiếng Việt',
|
|
180
|
+
vo: 'Volapük',
|
|
181
|
+
wa: 'Walon',
|
|
182
|
+
wo: 'Wolof',
|
|
183
|
+
xh: 'isiXhosa',
|
|
184
|
+
yi: 'ייִדיש',
|
|
185
|
+
yo: 'Yorùbá',
|
|
186
|
+
za: 'Saɯ cueŋƅ',
|
|
187
|
+
zh: '中文',
|
|
188
|
+
zu: 'isiZulu',
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// check if language code is valid
|
|
192
|
+
export function isValidLanguageCode(code) {
|
|
193
|
+
return code.length === 2 && LANGUAGE_CODES.hasOwnProperty(code);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// get native name for language code
|
|
197
|
+
export function getLanguageName(code) {
|
|
198
|
+
return LANGUAGE_CODES[code] || code;
|
|
199
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack Reactivity System
|
|
3
|
+
* signal-based fine-grained reactivity (shared between client and server)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let currentEffect = null;
|
|
7
|
+
const effectStack = [];
|
|
8
|
+
let batchDepth = 0;
|
|
9
|
+
let pending = null;
|
|
10
|
+
let schedule = null;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* create a reactive signal
|
|
14
|
+
* @template T
|
|
15
|
+
* @param {T} initial - Initial value
|
|
16
|
+
* @returns {[() => T, (next: T | ((prev: T) => T)) => void]} Tuple of [getter, setter]
|
|
17
|
+
*/
|
|
18
|
+
export function createMoan(initial) {
|
|
19
|
+
let value = initial;
|
|
20
|
+
const subscribers = new Set();
|
|
21
|
+
const read = () => {
|
|
22
|
+
if (currentEffect) subscribers.add(currentEffect);
|
|
23
|
+
return value;
|
|
24
|
+
};
|
|
25
|
+
const write = (next) => {
|
|
26
|
+
const newValue = typeof next === 'function' ? next(value) : next;
|
|
27
|
+
if (Object.is(value, newValue)) return;
|
|
28
|
+
value = newValue;
|
|
29
|
+
const effects = [...subscribers];
|
|
30
|
+
for (const effect of effects) if (subscribers.has(effect)) (schedule || ((e) => e()))(effect);
|
|
31
|
+
};
|
|
32
|
+
return [read, write];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* create an effect that runs when dependencies change
|
|
37
|
+
* @param {() => void} fn - Effect function
|
|
38
|
+
* @returns {() => void} Dispose function to stop the effect
|
|
39
|
+
*/
|
|
40
|
+
export function onClimax(fn) {
|
|
41
|
+
let disposed = false;
|
|
42
|
+
const cleanup = [];
|
|
43
|
+
const effect = () => {
|
|
44
|
+
if (disposed) return;
|
|
45
|
+
effectStack.push(effect);
|
|
46
|
+
currentEffect = effect;
|
|
47
|
+
try {
|
|
48
|
+
// run any previous cleanup
|
|
49
|
+
cleanup.forEach((c) => c());
|
|
50
|
+
cleanup.length = 0;
|
|
51
|
+
const result = fn();
|
|
52
|
+
if (typeof result === 'function') cleanup.push(result);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('Effect error:', err);
|
|
55
|
+
} finally {
|
|
56
|
+
effectStack.pop();
|
|
57
|
+
currentEffect = effectStack[effectStack.length - 1] || null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
batch(effect);
|
|
61
|
+
return () => {
|
|
62
|
+
disposed = true;
|
|
63
|
+
cleanup.forEach((c) => c());
|
|
64
|
+
cleanup.length = 0;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* create a computed value
|
|
70
|
+
* @template T
|
|
71
|
+
* @param {() => T} fn - Computation function
|
|
72
|
+
* @returns {() => T} Getter for the computed value
|
|
73
|
+
*/
|
|
74
|
+
export function knotMemo(fn) {
|
|
75
|
+
let initialized = false;
|
|
76
|
+
let cached;
|
|
77
|
+
const [signal, setSignal] = createMoan();
|
|
78
|
+
onClimax(() => {
|
|
79
|
+
const result = fn();
|
|
80
|
+
if (!initialized || !Object.is(result, cached)) {
|
|
81
|
+
cached = result;
|
|
82
|
+
untrack(() => batch(() => setSignal(result)));
|
|
83
|
+
initialized = true;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return signal;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* create a resource for async data fetching
|
|
91
|
+
* @template T
|
|
92
|
+
* @param {(signal: AbortSignal) => Promise<T>} fetcher - Async function to fetch data
|
|
93
|
+
* @param {(() => any) | null} deps - Optional signal to trigger refetch
|
|
94
|
+
* @param {T | null} initialValue - Initial value before data loads
|
|
95
|
+
* @returns {Object} Resource object with data, loading, error states and methods
|
|
96
|
+
*/
|
|
97
|
+
export function loadShot(fetcher, deps = null, initialValue = null) {
|
|
98
|
+
const [data, setData] = createMoan(initialValue);
|
|
99
|
+
const [loading, setLoading] = createMoan(false);
|
|
100
|
+
const [error, setError] = createMoan(null);
|
|
101
|
+
let controller = null;
|
|
102
|
+
const refetch = async () => {
|
|
103
|
+
if (controller) controller.abort();
|
|
104
|
+
controller = new AbortController();
|
|
105
|
+
const signal = controller.signal;
|
|
106
|
+
batch(() => {
|
|
107
|
+
setLoading(true);
|
|
108
|
+
setError(null);
|
|
109
|
+
});
|
|
110
|
+
try {
|
|
111
|
+
const result = await fetcher(signal);
|
|
112
|
+
batch(() => {
|
|
113
|
+
if (!signal.aborted) setData(result);
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
batch(() => {
|
|
117
|
+
if (!signal.aborted) setError(err);
|
|
118
|
+
});
|
|
119
|
+
} finally {
|
|
120
|
+
batch(() => {
|
|
121
|
+
if (!signal.aborted) setLoading(false);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
if (deps) {
|
|
126
|
+
onClimax(() => {
|
|
127
|
+
const _ = deps();
|
|
128
|
+
refetch();
|
|
129
|
+
});
|
|
130
|
+
} else queueMicrotask(refetch);
|
|
131
|
+
const dispose = () => controller?.abort();
|
|
132
|
+
return {
|
|
133
|
+
data,
|
|
134
|
+
loading,
|
|
135
|
+
error,
|
|
136
|
+
refetch,
|
|
137
|
+
dispose,
|
|
138
|
+
idle: () => !loading() && data() === initialValue,
|
|
139
|
+
success: () => !loading() && !error(),
|
|
140
|
+
hasError: () => !!error(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* batch multiple updates together
|
|
146
|
+
* @param {Function} fn - Function to run in batch
|
|
147
|
+
*/
|
|
148
|
+
export function batch(fn) {
|
|
149
|
+
if (batchDepth++ === 0) pending = new Set();
|
|
150
|
+
const prev = schedule;
|
|
151
|
+
schedule = (e) => pending.add(e);
|
|
152
|
+
try {
|
|
153
|
+
fn(schedule);
|
|
154
|
+
} finally {
|
|
155
|
+
schedule = prev;
|
|
156
|
+
if (--batchDepth === 0) {
|
|
157
|
+
const effects = pending;
|
|
158
|
+
pending = null;
|
|
159
|
+
for (const e of effects) e();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// external scheduling
|
|
165
|
+
batch.schedule = (fn) => {
|
|
166
|
+
if (batchDepth > 0) pending.add(fn);
|
|
167
|
+
else fn();
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* run function without tracking dependencies
|
|
172
|
+
* @template T
|
|
173
|
+
* @param {() => T} fn - Function to run without tracking
|
|
174
|
+
* @returns {T} Result of the function
|
|
175
|
+
*/
|
|
176
|
+
export const untrack = (fn) => {
|
|
177
|
+
const prev = currentEffect;
|
|
178
|
+
currentEffect = null;
|
|
179
|
+
try {
|
|
180
|
+
return fn();
|
|
181
|
+
} finally {
|
|
182
|
+
currentEffect = prev;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* get current location (pathname, search, hash)
|
|
188
|
+
* returns a reactive signal that updates on navigation
|
|
189
|
+
* @returns {Object} Location object with location signal, active function, and dispose method
|
|
190
|
+
*/
|
|
191
|
+
let locationInstance = null;
|
|
192
|
+
export function useLocation() {
|
|
193
|
+
if (typeof window === 'undefined') {
|
|
194
|
+
return {
|
|
195
|
+
location: () => ({ pathname: '/', search: '', hash: '' }),
|
|
196
|
+
active: () => false,
|
|
197
|
+
dispose: () => {},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// return singleton instance if already created
|
|
202
|
+
if (locationInstance) {
|
|
203
|
+
locationInstance.refCount++;
|
|
204
|
+
return {
|
|
205
|
+
location: locationInstance.location,
|
|
206
|
+
active: locationInstance.active,
|
|
207
|
+
dispose: () => {
|
|
208
|
+
if (--locationInstance.refCount === 0) {
|
|
209
|
+
locationInstance.dispose();
|
|
210
|
+
locationInstance = null;
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// patch history methods once
|
|
217
|
+
if (!window.__locationPatched) {
|
|
218
|
+
['pushState', 'replaceState'].forEach((method) => {
|
|
219
|
+
const orig = history[method];
|
|
220
|
+
history[method] = function (...args) {
|
|
221
|
+
const result = orig.apply(this, args);
|
|
222
|
+
window.dispatchEvent(new Event(method.toLowerCase()));
|
|
223
|
+
return result;
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
window.__locationPatched = true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const getLoc = () => ({
|
|
230
|
+
pathname: window.location.pathname,
|
|
231
|
+
search: window.location.search,
|
|
232
|
+
hash: window.location.hash,
|
|
233
|
+
});
|
|
234
|
+
const [location, setLocation] = createMoan(getLoc());
|
|
235
|
+
const update = () => batch(() => setLocation(getLoc()));
|
|
236
|
+
const events = ['popstate', 'pushstate', 'replacestate'];
|
|
237
|
+
events.forEach((e) => window.addEventListener(e, update));
|
|
238
|
+
|
|
239
|
+
const active = (path) => location().pathname === path;
|
|
240
|
+
const dispose = () => events.forEach((e) => window.removeEventListener(e, update));
|
|
241
|
+
|
|
242
|
+
locationInstance = {
|
|
243
|
+
location,
|
|
244
|
+
active,
|
|
245
|
+
dispose,
|
|
246
|
+
refCount: 1,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
location,
|
|
251
|
+
active,
|
|
252
|
+
dispose: () => {
|
|
253
|
+
if (--locationInstance.refCount === 0) {
|
|
254
|
+
locationInstance.dispose();
|
|
255
|
+
locationInstance = null;
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cumstack Router
|
|
3
|
+
* simple manual routing with params and navigation
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createMoan } from './reactivity.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* parse route pattern to regex with params
|
|
10
|
+
* @param {string} pattern - Route pattern like "/user/:id"
|
|
11
|
+
* @returns {{ regex: RegExp, params: string[] }}
|
|
12
|
+
*/
|
|
13
|
+
function parseRoute(pattern) {
|
|
14
|
+
const params = [];
|
|
15
|
+
const regexPattern = pattern.replace(/\*/g, '.*').replace(/:(\w+)/g, (_, param) => {
|
|
16
|
+
params.push(param);
|
|
17
|
+
return '([^/]+)';
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
regex: new RegExp(`^${regexPattern}$`),
|
|
21
|
+
params,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* match a path against a pattern
|
|
27
|
+
* @param {string} path - Current path
|
|
28
|
+
* @param {string} pattern - Route pattern
|
|
29
|
+
* @returns {{ matched: boolean, params: Record<string, string> }}
|
|
30
|
+
*/
|
|
31
|
+
export function matchRoute(path, pattern) {
|
|
32
|
+
const { regex, params: paramNames } = parseRoute(pattern);
|
|
33
|
+
const match = path.match(regex);
|
|
34
|
+
if (!match) return { matched: false, params: {} };
|
|
35
|
+
const params = {};
|
|
36
|
+
paramNames.forEach((name, index) => (params[name] = match[index + 1]));
|
|
37
|
+
return { matched: true, params };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* create a router instance
|
|
42
|
+
*/
|
|
43
|
+
export function createRouter() {
|
|
44
|
+
const routes = new Map();
|
|
45
|
+
const [currentPath, setCurrentPath] = createMoan(typeof window !== 'undefined' ? window.location.pathname : '/');
|
|
46
|
+
const [currentParams, setCurrentParams] = createMoan({});
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* register a route
|
|
50
|
+
* @param {string} pattern - Route pattern
|
|
51
|
+
* @param {Function} handler - Route handler
|
|
52
|
+
*/
|
|
53
|
+
function register(pattern, handler) {
|
|
54
|
+
routes.set(pattern, handler);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* navigate to a path
|
|
59
|
+
* @param {string} path - Target path
|
|
60
|
+
* @param {Object} options - Navigation options
|
|
61
|
+
*/
|
|
62
|
+
function navigate(path, options = {}) {
|
|
63
|
+
if (typeof window === 'undefined') return;
|
|
64
|
+
const { replace = false, state = null } = options;
|
|
65
|
+
if (replace) window.history.replaceState(state, '', path);
|
|
66
|
+
else window.history.pushState(state, '', path);
|
|
67
|
+
setCurrentPath(path);
|
|
68
|
+
matchCurrentRoute();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* match current route and extract params
|
|
73
|
+
*/
|
|
74
|
+
function matchCurrentRoute() {
|
|
75
|
+
const path = currentPath();
|
|
76
|
+
for (const [pattern, handler] of routes) {
|
|
77
|
+
const { matched, params } = matchRoute(path, pattern);
|
|
78
|
+
if (matched) {
|
|
79
|
+
setCurrentParams(params);
|
|
80
|
+
return { handler, params };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* handle browser navigation events
|
|
88
|
+
*/
|
|
89
|
+
function handlePopState() {
|
|
90
|
+
setCurrentPath(window.location.pathname);
|
|
91
|
+
matchCurrentRoute();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* initialize router
|
|
96
|
+
*/
|
|
97
|
+
function init() {
|
|
98
|
+
if (typeof window !== 'undefined') {
|
|
99
|
+
window.addEventListener('popstate', handlePopState);
|
|
100
|
+
matchCurrentRoute();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
register,
|
|
106
|
+
navigate,
|
|
107
|
+
init,
|
|
108
|
+
currentPath,
|
|
109
|
+
currentParams,
|
|
110
|
+
matchRoute: matchCurrentRoute,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* create a link component helper
|
|
116
|
+
* @param {string} href - Twink href
|
|
117
|
+
* @param {Object} options - Twink options
|
|
118
|
+
*/
|
|
119
|
+
export function createTwink(href, options = {}) {
|
|
120
|
+
return {
|
|
121
|
+
href,
|
|
122
|
+
onClick: (e) => {
|
|
123
|
+
if (options.onClick) options.onClick(e);
|
|
124
|
+
if (!e.defaultPrevented && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.button === 0) {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
options.navigate?.(href);
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* parse query string
|
|
134
|
+
* @param {string} search - Query string
|
|
135
|
+
* @returns {Record<string, string>}
|
|
136
|
+
*/
|
|
137
|
+
export function parseQuery(search) {
|
|
138
|
+
const params = new URLSearchParams(search);
|
|
139
|
+
const result = {};
|
|
140
|
+
for (const [key, value] of params) result[key] = value;
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* build query string
|
|
146
|
+
* @param {Record<string, string>} params - Query params
|
|
147
|
+
* @returns {string}
|
|
148
|
+
*/
|
|
149
|
+
export function buildQuery(params) {
|
|
150
|
+
const searchParams = new URLSearchParams(params);
|
|
151
|
+
const query = searchParams.toString();
|
|
152
|
+
return query ? `?${query}` : '';
|
|
153
|
+
}
|