@mandujs/core 0.25.2 → 0.26.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.
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Metadata Route Types
3
+ *
4
+ * File-convention types for Next.js-style metadata routes. Each type
5
+ * matches the Next.js shape so developers familiar with that ecosystem
6
+ * can drop their existing contract in verbatim.
7
+ *
8
+ * Exposed via `@mandujs/core/routes`. For the legacy free-form SEO
9
+ * metadata types (Metadata, Icons, OpenGraph, ...) see `@mandujs/core`
10
+ * / `packages/core/src/seo/types.ts` — this module is scoped strictly
11
+ * to the file-convention routes (sitemap.ts, robots.ts, llms.txt.ts,
12
+ * manifest.ts) and their runtime handlers.
13
+ *
14
+ * @module routes/types
15
+ */
16
+ import { z } from "zod";
17
+
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+ // Sitemap (sitemap.ts → /sitemap.xml)
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+
22
+ /**
23
+ * Change frequency hint from the Sitemaps XML schema.
24
+ * @see https://www.sitemaps.org/protocol.html#changefreqdef
25
+ */
26
+ export type ChangeFrequency =
27
+ | "always"
28
+ | "hourly"
29
+ | "daily"
30
+ | "weekly"
31
+ | "monthly"
32
+ | "yearly"
33
+ | "never";
34
+
35
+ /** Alternate language URLs for a sitemap entry (hreflang). */
36
+ export interface SitemapAlternates {
37
+ languages?: Record<string, string>;
38
+ }
39
+
40
+ /**
41
+ * Single sitemap entry. Next.js shape — `url` is required, everything
42
+ * else is optional. `lastModified` accepts a `Date` or ISO string so
43
+ * users can return e.g. `fs.statSync(...).mtime` directly.
44
+ */
45
+ export interface SitemapEntry {
46
+ /** Absolute URL of the page. */
47
+ url: string;
48
+ /** Last modification time of the page. */
49
+ lastModified?: string | Date;
50
+ /** Crawl frequency hint for search engines. */
51
+ changeFrequency?: ChangeFrequency;
52
+ /** Priority hint [0.0, 1.0]. */
53
+ priority?: number;
54
+ /** Alternate URLs for i18n / hreflang. */
55
+ alternates?: SitemapAlternates;
56
+ /** Optional image URLs associated with the page. */
57
+ images?: string[];
58
+ }
59
+
60
+ /** Full sitemap: an array of SitemapEntry. */
61
+ export type Sitemap = SitemapEntry[];
62
+
63
+ // ═══════════════════════════════════════════════════════════════════════════
64
+ // Robots (robots.ts → /robots.txt)
65
+ // ═══════════════════════════════════════════════════════════════════════════
66
+
67
+ /**
68
+ * Single rule group in robots.txt. Each group applies to one or more
69
+ * user agents; `allow` / `disallow` accept a single string or array.
70
+ */
71
+ export interface RobotsRule {
72
+ /** User agent string ("*", "Googlebot", ...). */
73
+ userAgent: string | string[];
74
+ /** Paths explicitly permitted. */
75
+ allow?: string | string[];
76
+ /** Paths explicitly denied. */
77
+ disallow?: string | string[];
78
+ /** Crawl delay in seconds. */
79
+ crawlDelay?: number;
80
+ }
81
+
82
+ /**
83
+ * Complete robots.txt definition. Matches Next.js `MetadataRoute.Robots`.
84
+ */
85
+ export interface Robots {
86
+ /** One or more rule groups. */
87
+ rules: RobotsRule | RobotsRule[];
88
+ /** Sitemap URL(s) to announce. */
89
+ sitemap?: string | string[];
90
+ /** Host directive (Yandex). */
91
+ host?: string;
92
+ }
93
+
94
+ // ═══════════════════════════════════════════════════════════════════════════
95
+ // Web App Manifest (manifest.ts → /manifest.webmanifest)
96
+ // ═══════════════════════════════════════════════════════════════════════════
97
+
98
+ export type DisplayMode = "fullscreen" | "standalone" | "minimal-ui" | "browser";
99
+ export type Orientation =
100
+ | "any"
101
+ | "natural"
102
+ | "landscape"
103
+ | "landscape-primary"
104
+ | "landscape-secondary"
105
+ | "portrait"
106
+ | "portrait-primary"
107
+ | "portrait-secondary";
108
+
109
+ /**
110
+ * Icon descriptor in a web app manifest. `src` + `sizes` + `type` is
111
+ * the minimal W3C contract; `purpose` is optional but recommended for
112
+ * maskable icons.
113
+ */
114
+ export interface WebAppManifestIcon {
115
+ src: string;
116
+ sizes?: string;
117
+ type?: string;
118
+ purpose?: "any" | "maskable" | "monochrome" | string;
119
+ }
120
+
121
+ /** Shortcut entry (jumplist) in a web app manifest. */
122
+ export interface WebAppManifestShortcut {
123
+ name: string;
124
+ short_name?: string;
125
+ description?: string;
126
+ url: string;
127
+ icons?: WebAppManifestIcon[];
128
+ }
129
+
130
+ /**
131
+ * Web app manifest (application/manifest+json). We type the common
132
+ * subset defined by the W3C spec; unknown keys pass through via the
133
+ * index signature so callers can add experimental fields.
134
+ */
135
+ export interface WebAppManifest {
136
+ /** Full name of the application (required per W3C). */
137
+ name: string;
138
+ /** Short name for home-screen / launcher. */
139
+ short_name: string;
140
+ /** Description. */
141
+ description?: string;
142
+ /** Starting URL. */
143
+ start_url?: string;
144
+ /** Application scope (URL prefix). */
145
+ scope?: string;
146
+ /** Display mode. */
147
+ display?: DisplayMode;
148
+ /** Orientation preference. */
149
+ orientation?: Orientation;
150
+ /** Primary theme color (toolbar). */
151
+ theme_color?: string;
152
+ /** Background color (splash). */
153
+ background_color?: string;
154
+ /** Icons — at least one is required by the spec. */
155
+ icons: WebAppManifestIcon[];
156
+ /** Language tag (e.g. "en-US"). */
157
+ lang?: string;
158
+ /** Text direction. */
159
+ dir?: "ltr" | "rtl" | "auto";
160
+ /** Categories (taxonomy). */
161
+ categories?: string[];
162
+ /** Shortcuts shown in the app launcher. */
163
+ shortcuts?: WebAppManifestShortcut[];
164
+ /** Additional W3C fields pass through unchanged. */
165
+ [key: string]: unknown;
166
+ }
167
+
168
+ // ═══════════════════════════════════════════════════════════════════════════
169
+ // Default export function signatures
170
+ // ═══════════════════════════════════════════════════════════════════════════
171
+
172
+ /**
173
+ * Default-export contract for `app/sitemap.ts`. Return `Sitemap` or a
174
+ * Promise — Mandu awaits the result during the request.
175
+ */
176
+ export type SitemapFn = () => Sitemap | Promise<Sitemap>;
177
+
178
+ /**
179
+ * Default-export contract for `app/robots.ts`.
180
+ */
181
+ export type RobotsFn = () => Robots | Promise<Robots>;
182
+
183
+ /**
184
+ * Default-export contract for `app/llms.txt.ts`. Return the complete
185
+ * text body; Mandu serves it verbatim with `text/plain` content type.
186
+ */
187
+ export type LlmsTxtFn = () => string | Promise<string>;
188
+
189
+ /**
190
+ * Default-export contract for `app/manifest.ts`.
191
+ */
192
+ export type ManifestFn = () => WebAppManifest | Promise<WebAppManifest>;
193
+
194
+ // ═══════════════════════════════════════════════════════════════════════════
195
+ // Metadata route kinds (for manifest / fs-scanner)
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+
198
+ /**
199
+ * Discriminator used in `RouteSpec.kind === "metadata"` entries and in
200
+ * `ScannedFile.metadataKind` when fs-scanner detects one of the four
201
+ * metadata files.
202
+ */
203
+ export type MetadataRouteKind = "sitemap" | "robots" | "llms-txt" | "manifest";
204
+
205
+ /**
206
+ * Static table mapping each metadata route kind to the file name the
207
+ * scanner recognizes, the URL pattern that serves the content, and the
208
+ * Content-Type header. Kept deliberately flat so both fs-scanner and
209
+ * the runtime dispatcher can read from the same source of truth.
210
+ */
211
+ export const METADATA_ROUTES: Record<
212
+ MetadataRouteKind,
213
+ { fileBase: string; pattern: string; contentType: string }
214
+ > = {
215
+ sitemap: {
216
+ fileBase: "sitemap",
217
+ pattern: "/sitemap.xml",
218
+ contentType: "application/xml; charset=utf-8",
219
+ },
220
+ robots: {
221
+ fileBase: "robots",
222
+ pattern: "/robots.txt",
223
+ contentType: "text/plain; charset=utf-8",
224
+ },
225
+ "llms-txt": {
226
+ // `llms.txt.ts` — filename contains a dot before the extension to
227
+ // mirror the served path (/llms.txt). Detected specifically in
228
+ // `detectMetadataFile()` since it doesn't match the common
229
+ // `<name>.<ext>` shape.
230
+ fileBase: "llms.txt",
231
+ pattern: "/llms.txt",
232
+ contentType: "text/plain; charset=utf-8",
233
+ },
234
+ manifest: {
235
+ fileBase: "manifest",
236
+ pattern: "/manifest.webmanifest",
237
+ contentType: "application/manifest+json; charset=utf-8",
238
+ },
239
+ };
240
+
241
+ // ═══════════════════════════════════════════════════════════════════════════
242
+ // Zod schemas — runtime validation of user-returned values
243
+ // ═══════════════════════════════════════════════════════════════════════════
244
+
245
+ /**
246
+ * Validates a single sitemap entry. `url` must be a URL-like string;
247
+ * we accept both protocol-absolute URLs and site-root-relative paths
248
+ * (`/docs/...`) so users building a sitemap for an internal base can
249
+ * return relative URLs. `lastModified` accepts Date or date-like string.
250
+ */
251
+ export const SitemapEntrySchema = z.object({
252
+ url: z
253
+ .string()
254
+ .min(1, "SitemapEntry.url must not be empty")
255
+ .refine(
256
+ (v) => /^(https?:\/\/|\/)/.test(v),
257
+ "SitemapEntry.url must be absolute (http(s)://...) or site-root-relative (/...)"
258
+ ),
259
+ lastModified: z
260
+ .union([z.date(), z.string().min(1)])
261
+ .optional(),
262
+ changeFrequency: z
263
+ .enum(["always", "hourly", "daily", "weekly", "monthly", "yearly", "never"])
264
+ .optional(),
265
+ priority: z.number().min(0).max(1).optional(),
266
+ alternates: z
267
+ .object({ languages: z.record(z.string()).optional() })
268
+ .optional(),
269
+ images: z.array(z.string().min(1)).optional(),
270
+ });
271
+
272
+ export const SitemapSchema = z.array(SitemapEntrySchema);
273
+
274
+ export const RobotsRuleSchema = z.object({
275
+ userAgent: z.union([z.string().min(1), z.array(z.string().min(1))]),
276
+ allow: z.union([z.string(), z.array(z.string())]).optional(),
277
+ disallow: z.union([z.string(), z.array(z.string())]).optional(),
278
+ crawlDelay: z.number().nonnegative().optional(),
279
+ });
280
+
281
+ export const RobotsSchema = z.object({
282
+ rules: z.union([RobotsRuleSchema, z.array(RobotsRuleSchema)]),
283
+ sitemap: z.union([z.string().min(1), z.array(z.string().min(1))]).optional(),
284
+ host: z.string().optional(),
285
+ });
286
+
287
+ export const WebAppManifestIconSchema = z.object({
288
+ src: z.string().min(1, "WebAppManifest icon.src must not be empty"),
289
+ sizes: z.string().optional(),
290
+ type: z.string().optional(),
291
+ purpose: z.string().optional(),
292
+ });
293
+
294
+ /**
295
+ * Minimum W3C-compliant manifest — `name`, `short_name`, and at least
296
+ * one icon. Unknown keys pass through via `.passthrough()` so users
297
+ * can add experimental manifest fields without fighting the validator.
298
+ */
299
+ export const WebAppManifestSchema = z
300
+ .object({
301
+ name: z.string().min(1, "WebAppManifest.name is required"),
302
+ short_name: z.string().min(1, "WebAppManifest.short_name is required"),
303
+ description: z.string().optional(),
304
+ start_url: z.string().optional(),
305
+ scope: z.string().optional(),
306
+ display: z
307
+ .enum(["fullscreen", "standalone", "minimal-ui", "browser"])
308
+ .optional(),
309
+ orientation: z
310
+ .enum([
311
+ "any",
312
+ "natural",
313
+ "landscape",
314
+ "landscape-primary",
315
+ "landscape-secondary",
316
+ "portrait",
317
+ "portrait-primary",
318
+ "portrait-secondary",
319
+ ])
320
+ .optional(),
321
+ theme_color: z.string().optional(),
322
+ background_color: z.string().optional(),
323
+ icons: z
324
+ .array(WebAppManifestIconSchema)
325
+ .min(1, "WebAppManifest.icons must contain at least one icon"),
326
+ lang: z.string().optional(),
327
+ dir: z.enum(["ltr", "rtl", "auto"]).optional(),
328
+ categories: z.array(z.string()).optional(),
329
+ shortcuts: z
330
+ .array(
331
+ z.object({
332
+ name: z.string().min(1),
333
+ short_name: z.string().optional(),
334
+ description: z.string().optional(),
335
+ url: z.string().min(1),
336
+ icons: z.array(WebAppManifestIconSchema).optional(),
337
+ })
338
+ )
339
+ .optional(),
340
+ })
341
+ .passthrough();
@@ -69,6 +69,7 @@ import { extractShellHtml, createPPRResponse } from "./ppr";
69
69
  import { isRedirectResponse } from "./redirect";
70
70
  import { isNotFoundResponse } from "./not-found";
71
71
  import { newId } from "../id";
72
+ import { handleMetadataRoute as dispatchMetadataRoute } from "../routes/metadata-routes";
72
73
 
73
74
  export interface RateLimitOptions {
74
75
  windowMs?: number;
@@ -461,6 +462,15 @@ export interface PageRegistration {
461
462
  */
462
463
  export type PageHandler = () => Promise<PageRegistration>;
463
464
 
465
+ /**
466
+ * Issue #206 — Metadata route handler. A thunk that returns the
467
+ * imported user module (or its `default` export) so the dispatcher
468
+ * in `handleMetadataRoute` can invoke the user-supplied function with
469
+ * the correct Content-Type + cache headers. The indirection keeps dev
470
+ * HMR cheap: the handler only imports on the request that needs it.
471
+ */
472
+ export type MetadataHandler = () => Promise<unknown>;
473
+
464
474
  export interface AppContext {
465
475
  routeId: string;
466
476
  url: string;
@@ -541,6 +551,15 @@ export class ServerRegistry {
541
551
  readonly pageHandlers: Map<string, PageHandler> = new Map();
542
552
  readonly pageFillings: Map<string, ManduFilling<unknown>> = new Map();
543
553
  readonly routeComponents: Map<string, RouteComponent> = new Map();
554
+ /**
555
+ * Issue #206 — metadata-route handlers keyed by `route.id`
556
+ * (`metadata-sitemap` / `metadata-robots` / `metadata-llms-txt` /
557
+ * `metadata-manifest`). Each handler is a thunk that imports the
558
+ * user module lazily so dev HMR can swap it without restarting the
559
+ * server. The dispatcher in `handleMetadataRoute()` extracts the
560
+ * default export and invokes it.
561
+ */
562
+ readonly metadataHandlers: Map<string, MetadataHandler> = new Map();
544
563
  /** Layout 컴포넌트 캐시 (모듈 경로 → 컴포넌트) */
545
564
  readonly layoutComponents: Map<string, LayoutComponent> = new Map();
546
565
  /** Layout 로더 (모듈 경로 → 로더 함수) */
@@ -596,6 +615,17 @@ export class ServerRegistry {
596
615
  this.apiHandlers.set(routeId, handler);
597
616
  }
598
617
 
618
+ /**
619
+ * Issue #206 — register a metadata-route loader. The handler must
620
+ * be a thunk returning either the raw user function or the
621
+ * module namespace (`{ default: fn }`). The dispatcher accepts both
622
+ * shapes so callers don't have to care whether they imported with
623
+ * `import("./sitemap")` or a bundled variant.
624
+ */
625
+ registerMetadataHandler(routeId: string, handler: MetadataHandler): void {
626
+ this.metadataHandlers.set(routeId, handler);
627
+ }
628
+
599
629
  registerPageLoader(routeId: string, loader: PageLoader): void {
600
630
  this.pageLoaders.set(routeId, loader);
601
631
  }
@@ -760,6 +790,7 @@ export class ServerRegistry {
760
790
  this.pageGenerateMetadata.clear();
761
791
  this.layoutMetadata.clear();
762
792
  this.layoutGenerateMetadata.clear();
793
+ this.metadataHandlers.clear();
763
794
  this.createAppFn = null;
764
795
  this.rateLimiter = null;
765
796
  this.notFoundHandler = null;
@@ -792,6 +823,17 @@ export function registerApiHandler(routeId: string, handler: ApiHandler): void {
792
823
  defaultRegistry.registerApiHandler(routeId, handler);
793
824
  }
794
825
 
826
+ /**
827
+ * Issue #206 — register a metadata route handler on the default
828
+ * registry. See {@link ServerRegistry.registerMetadataHandler} for
829
+ * the handler contract. Exposed at module scope so the CLI handlers
830
+ * wiring (`packages/cli/src/util/handlers.ts`) can reach it without
831
+ * threading a registry reference.
832
+ */
833
+ export function registerMetadataHandler(routeId: string, handler: MetadataHandler): void {
834
+ defaultRegistry.registerMetadataHandler(routeId, handler);
835
+ }
836
+
795
837
  export function registerPageLoader(routeId: string, loader: PageLoader): void {
796
838
  defaultRegistry.registerPageLoader(routeId, loader);
797
839
  }
@@ -1369,6 +1411,47 @@ async function handleApiRoute(
1369
1411
  }
1370
1412
  }
1371
1413
 
1414
+ // ---------- Metadata Route Handler (Issue #206) ----------
1415
+
1416
+ /**
1417
+ * Dispatch a metadata route (sitemap / robots / llms.txt / manifest).
1418
+ *
1419
+ * The registry stores a thunk that lazily imports the user module
1420
+ * on the first request. We delegate the actual validation + render
1421
+ * + Response assembly to `dispatchMetadataRoute()` from
1422
+ * `@mandujs/core/routes` so the pipeline stays testable in isolation.
1423
+ *
1424
+ * A missing registration falls through to the framework's
1425
+ * "handler not found" 500 response — typically only reachable if the
1426
+ * scanner detected the file but `registerManifestHandlers` skipped it
1427
+ * (which would be a framework bug).
1428
+ */
1429
+ async function handleMetadataRouteRequest(
1430
+ route: RouteSpec,
1431
+ registry: ServerRegistry
1432
+ ): Promise<Result<Response>> {
1433
+ if (route.kind !== "metadata") {
1434
+ return err(createHandlerNotFoundResponse(route.id, route.pattern));
1435
+ }
1436
+ const handler = registry.metadataHandlers.get(route.id);
1437
+ if (!handler) {
1438
+ return err(createHandlerNotFoundResponse(route.id, route.pattern));
1439
+ }
1440
+
1441
+ try {
1442
+ const userExport = await handler();
1443
+ const response = await dispatchMetadataRoute({
1444
+ kind: route.metadataKind,
1445
+ userExport,
1446
+ sourceFile: route.module,
1447
+ });
1448
+ return ok(response);
1449
+ } catch (errValue) {
1450
+ const error = errValue instanceof Error ? errValue : new Error(String(errValue));
1451
+ return err(createSSRErrorResponse(route.id, route.pattern, error));
1452
+ }
1453
+ }
1454
+
1372
1455
  // ---------- Page Data Loader ----------
1373
1456
 
1374
1457
  /**
@@ -2701,6 +2784,10 @@ async function handleRequestInternal(
2701
2784
  return handlePageRoute(req, url, route, params, registry);
2702
2785
  }
2703
2786
 
2787
+ if (route.kind === "metadata") {
2788
+ return handleMetadataRouteRequest(route, registry);
2789
+ }
2790
+
2704
2791
  // 4. 알 수 없는 라우트 종류 — exhaustiveness check
2705
2792
  const _exhaustive: never = route;
2706
2793
  return err({
@@ -53,9 +53,17 @@ export type LoaderConfig = z.infer<typeof LoaderConfig>;
53
53
 
54
54
  // ========== Route 설정 ==========
55
55
 
56
- export const RouteKind = z.enum(["page", "api"]);
56
+ export const RouteKind = z.enum(["page", "api", "metadata"]);
57
57
  export type RouteKind = z.infer<typeof RouteKind>;
58
58
 
59
+ /**
60
+ * Metadata route kinds — one for each file-convention metadata file
61
+ * detected under `app/`. Kept in sync with
62
+ * `@mandujs/core/routes` → `MetadataRouteKind`.
63
+ */
64
+ export const MetadataRouteKind = z.enum(["sitemap", "robots", "llms-txt", "manifest"]);
65
+ export type MetadataRouteKind = z.infer<typeof MetadataRouteKind>;
66
+
59
67
  export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
60
68
  export type SpecHttpMethod = z.infer<typeof SpecHttpMethod>;
61
69
 
@@ -115,6 +123,27 @@ export const ApiRouteSpec = z.object({
115
123
 
116
124
  export type ApiRouteSpec = z.infer<typeof ApiRouteSpec>;
117
125
 
126
+ // ---- Metadata 라우트 (Issue #206) ----
127
+ // sitemap.ts / robots.ts / llms.txt.ts / manifest.ts — file-convention
128
+ // metadata routes. Dispatched through `@mandujs/core/routes`.
129
+ export const MetadataRouteSpec = z.object({
130
+ ...RouteSpecBase,
131
+ kind: z.literal("metadata"),
132
+ /** Which metadata file this entry represents. */
133
+ metadataKind: MetadataRouteKind,
134
+ /** MIME type used on the served response. */
135
+ contentType: z.string().min(1),
136
+ // Re-declare shared optional fields so downstream consumers that
137
+ // switch on kind can still read them without type widening gymnastics.
138
+ componentModule: z.string().optional(),
139
+ methods: z.array(SpecHttpMethod).optional(),
140
+ layoutChain: z.array(z.string()).optional(),
141
+ loadingModule: z.string().optional(),
142
+ errorModule: z.string().optional(),
143
+ });
144
+
145
+ export type MetadataRouteSpec = z.infer<typeof MetadataRouteSpec>;
146
+
118
147
  // ---- discriminatedUnion ----
119
148
  export const RouteSpec = z.discriminatedUnion("kind", [
120
149
  // PageRouteSpec에 .refine()이 적용되어 있으므로 내부 shape를 직접 사용
@@ -136,6 +165,17 @@ export const RouteSpec = z.discriminatedUnion("kind", [
136
165
  loadingModule: z.string().optional(),
137
166
  errorModule: z.string().optional(),
138
167
  }),
168
+ z.object({
169
+ ...RouteSpecBase,
170
+ kind: z.literal("metadata"),
171
+ metadataKind: MetadataRouteKind,
172
+ contentType: z.string().min(1),
173
+ componentModule: z.string().optional(),
174
+ methods: z.array(SpecHttpMethod).optional(),
175
+ layoutChain: z.array(z.string()).optional(),
176
+ loadingModule: z.string().optional(),
177
+ errorModule: z.string().optional(),
178
+ }),
139
179
  ]);
140
180
 
141
181
  export type RouteSpec = z.infer<typeof RouteSpec>;
@@ -194,6 +234,16 @@ export function assertApiRoute(route: RouteSpec): asserts route is ApiRouteSpec
194
234
  }
195
235
  }
196
236
 
237
+ /**
238
+ * Asserts that the given route is a metadata route.
239
+ * After this call, TypeScript narrows the type to MetadataRouteSpec.
240
+ */
241
+ export function assertMetadataRoute(route: RouteSpec): asserts route is MetadataRouteSpec {
242
+ if (route.kind !== "metadata") {
243
+ throw new Error(`Expected metadata route, got "${route.kind}" (id: ${route.id})`);
244
+ }
245
+ }
246
+
197
247
  // ========== 유틸리티 함수 ==========
198
248
 
199
249
  /**