@mandujs/core 0.54.1 β†’ 0.54.2

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.
@@ -19,7 +19,7 @@ export function generateResourceSlot(definition: ResourceDefinition): string {
19
19
  const endpoints = getEnabledEndpoints(definition);
20
20
 
21
21
  // Generate endpoint handlers
22
- const handlers = generateHandlers(definition, endpoints, pascalName);
22
+ const handlers = generateHandlers(definition, endpoints, pascalName, pluralName);
23
23
 
24
24
  return `// πŸ₯Ÿ Mandu Filling - ${resourceName} Resource
25
25
  // Pattern: /api/${pluralName}
@@ -46,32 +46,33 @@ ${handlers}
46
46
  /**
47
47
  * Generate handlers for enabled endpoints
48
48
  */
49
- function generateHandlers(
50
- definition: ResourceDefinition,
51
- endpoints: string[],
52
- pascalName: string
53
- ): string {
54
- const handlers: string[] = [];
55
-
56
- if (endpoints.includes("list")) {
57
- handlers.push(generateListHandler(definition, pascalName));
58
- }
59
-
60
- if (endpoints.includes("get")) {
61
- handlers.push(generateGetHandler(definition, pascalName));
62
- }
63
-
64
- if (endpoints.includes("create")) {
65
- handlers.push(generateCreateHandler(definition, pascalName));
66
- }
67
-
68
- if (endpoints.includes("update")) {
69
- handlers.push(generateUpdateHandler(definition, pascalName));
70
- }
71
-
72
- if (endpoints.includes("delete")) {
73
- handlers.push(generateDeleteHandler(definition, pascalName));
74
- }
49
+ function generateHandlers(
50
+ definition: ResourceDefinition,
51
+ endpoints: string[],
52
+ pascalName: string,
53
+ pluralName: string
54
+ ): string {
55
+ const handlers: string[] = [];
56
+
57
+ if (endpoints.includes("list")) {
58
+ handlers.push(generateListHandler(pascalName, pluralName));
59
+ }
60
+
61
+ if (endpoints.includes("get")) {
62
+ handlers.push(generateGetHandler(pascalName, pluralName));
63
+ }
64
+
65
+ if (endpoints.includes("create")) {
66
+ handlers.push(generateCreateHandler(pascalName, pluralName));
67
+ }
68
+
69
+ if (endpoints.includes("update")) {
70
+ handlers.push(generateUpdateHandler(pascalName, pluralName));
71
+ }
72
+
73
+ if (endpoints.includes("delete")) {
74
+ handlers.push(generateDeleteHandler(pascalName, pluralName));
75
+ }
75
76
 
76
77
  return handlers.join("\n\n");
77
78
  }
@@ -79,16 +80,16 @@ function generateHandlers(
79
80
  /**
80
81
  * Generate LIST handler (GET /api/resources)
81
82
  */
82
- function generateListHandler(definition: ResourceDefinition, pascalName: string): string {
83
- return ` // πŸ“‹ List ${pascalName}s
84
- .get(async (ctx) => {
85
- const input = await ctx.input(contract, "GET", ctx.params);
86
- const { page, limit } = input;
87
-
88
- // TODO: Implement database query
89
- // const offset = (page - 1) * limit;
90
- // const items = await db.select().from(${definition.name}s).limit(limit).offset(offset);
91
- // const total = await db.select({ count: count() }).from(${definition.name}s);
83
+ function generateListHandler(pascalName: string, pluralName: string): string {
84
+ return ` // πŸ“‹ List ${pascalName}s
85
+ .get(async (ctx) => {
86
+ const input = await ctx.input(contract, "GET", ctx.params);
87
+ const { page, limit } = input;
88
+
89
+ // TODO: Implement database query
90
+ // const offset = (page - 1) * limit;
91
+ // const items = await db.select().from(${pluralName}).limit(limit).offset(offset);
92
+ // const total = await db.select({ count: count() }).from(${pluralName});
92
93
 
93
94
  const mockData = {
94
95
  data: [], // Replace with actual data
@@ -106,14 +107,14 @@ function generateListHandler(definition: ResourceDefinition, pascalName: string)
106
107
  /**
107
108
  * Generate GET handler (GET /api/resources/:id)
108
109
  */
109
- function generateGetHandler(definition: ResourceDefinition, pascalName: string): string {
110
- return ` // πŸ“„ Get Single ${pascalName}
111
- .get(async (ctx) => {
112
- const { id } = ctx.params;
113
-
114
- // TODO: Implement database query
115
- // const item = await db.select().from(${definition.name}s).where(eq(${definition.name}s.id, id)).limit(1);
116
- // if (!item) return ctx.notFound("${pascalName} not found");
110
+ function generateGetHandler(pascalName: string, pluralName: string): string {
111
+ return ` // πŸ“„ Get Single ${pascalName}
112
+ .get(async (ctx) => {
113
+ const { id } = ctx.params;
114
+
115
+ // TODO: Implement database query
116
+ // const item = await db.select().from(${pluralName}).where(eq(${pluralName}.id, id)).limit(1);
117
+ // if (!item) return ctx.notFound("${pascalName} not found");
117
118
 
118
119
  const mockData = {
119
120
  data: { id, message: "${pascalName} details" }, // Replace with actual data
@@ -126,13 +127,13 @@ function generateGetHandler(definition: ResourceDefinition, pascalName: string):
126
127
  /**
127
128
  * Generate CREATE handler (POST /api/resources)
128
129
  */
129
- function generateCreateHandler(definition: ResourceDefinition, pascalName: string): string {
130
- return ` // βž• Create ${pascalName}
131
- .post(async (ctx) => {
132
- const input = await ctx.input(contract, "POST", ctx.params);
133
-
134
- // TODO: Implement database insertion
135
- // const [created] = await db.insert(${definition.name}s).values(input).returning();
130
+ function generateCreateHandler(pascalName: string, pluralName: string): string {
131
+ return ` // βž• Create ${pascalName}
132
+ .post(async (ctx) => {
133
+ const input = await ctx.input(contract, "POST", ctx.params);
134
+
135
+ // TODO: Implement database insertion
136
+ // const [created] = await db.insert(${pluralName}).values(input).returning();
136
137
 
137
138
  const mockData = {
138
139
  data: { id: "new-id", ...input }, // Replace with actual created data
@@ -145,17 +146,17 @@ function generateCreateHandler(definition: ResourceDefinition, pascalName: strin
145
146
  /**
146
147
  * Generate UPDATE handler (PUT /api/resources/:id)
147
148
  */
148
- function generateUpdateHandler(definition: ResourceDefinition, pascalName: string): string {
149
- return ` // ✏️ Update ${pascalName}
150
- .put(async (ctx) => {
151
- const { id } = ctx.params;
152
- const input = await ctx.input(contract, "PUT", ctx.params);
153
-
154
- // TODO: Implement database update
155
- // const [updated] = await db.update(${definition.name}s)
156
- // .set(input)
157
- // .where(eq(${definition.name}s.id, id))
158
- // .returning();
149
+ function generateUpdateHandler(pascalName: string, pluralName: string): string {
150
+ return ` // ✏️ Update ${pascalName}
151
+ .put(async (ctx) => {
152
+ const { id } = ctx.params;
153
+ const input = await ctx.input(contract, "PUT", ctx.params);
154
+
155
+ // TODO: Implement database update
156
+ // const [updated] = await db.update(${pluralName})
157
+ // .set(input)
158
+ // .where(eq(${pluralName}.id, id))
159
+ // .returning();
159
160
  // if (!updated) return ctx.notFound("${pascalName} not found");
160
161
 
161
162
  const mockData = {
@@ -169,14 +170,14 @@ function generateUpdateHandler(definition: ResourceDefinition, pascalName: strin
169
170
  /**
170
171
  * Generate DELETE handler (DELETE /api/resources/:id)
171
172
  */
172
- function generateDeleteHandler(definition: ResourceDefinition, pascalName: string): string {
173
- return ` // πŸ—‘οΈ Delete ${pascalName}
174
- .delete(async (ctx) => {
175
- const { id } = ctx.params;
176
-
177
- // TODO: Implement database deletion
178
- // const deleted = await db.delete(${definition.name}s).where(eq(${definition.name}s.id, id));
179
- // if (!deleted) return ctx.notFound("${pascalName} not found");
173
+ function generateDeleteHandler(pascalName: string, pluralName: string): string {
174
+ return ` // πŸ—‘οΈ Delete ${pascalName}
175
+ .delete(async (ctx) => {
176
+ const { id } = ctx.params;
177
+
178
+ // TODO: Implement database deletion
179
+ // const deleted = await db.delete(${pluralName}).where(eq(${pluralName}.id, id));
180
+ // if (!deleted) return ctx.notFound("${pascalName} not found");
180
181
 
181
182
  return ctx.output(contract, 200, { data: { message: "${pascalName} deleted" } });
182
183
  })`;
@@ -237,19 +237,27 @@ function validateField(resourceName: string, fieldName: string, field: ResourceF
237
237
  /**
238
238
  * Get plural name for resource
239
239
  */
240
- export function getPluralName(definition: ResourceDefinition): string {
241
- if (definition.options?.pluralName) {
242
- return definition.options.pluralName;
243
- }
244
-
245
- if (definition.options?.autoPlural === false) {
246
- return definition.name;
247
- }
248
-
249
- // Simple pluralization: add 's'
250
- // TODO: Add more sophisticated pluralization rules if needed
251
- return `${definition.name}s`;
252
- }
240
+ export function getPluralName(definition: ResourceDefinition): string {
241
+ if (definition.options?.pluralName) {
242
+ return definition.options.pluralName;
243
+ }
244
+
245
+ if (definition.options?.autoPlural === false) {
246
+ return definition.name;
247
+ }
248
+
249
+ return pluralize(definition.name);
250
+ }
251
+
252
+ function pluralize(singular: string): string {
253
+ if (/[^aeiou]y$/i.test(singular)) {
254
+ return singular.slice(0, -1) + "ies";
255
+ }
256
+ if (/(?:s|x|z|ch|sh)$/i.test(singular)) {
257
+ return singular + "es";
258
+ }
259
+ return singular + "s";
260
+ }
253
261
 
254
262
  /**
255
263
  * Get enabled endpoints
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import type { RoutesManifest } from "../../spec/schema";
3
+ import {
4
+ createRuntimeDevtoolsAdapter,
5
+ shouldRecordRuntimeRequest,
6
+ type RuntimeDevtoolsAdapter,
7
+ } from "../devtools-adapter";
8
+
9
+ const manifest: RoutesManifest = {
10
+ version: 1,
11
+ routes: [],
12
+ };
13
+
14
+ describe("runtime devtools adapter", () => {
15
+ let adapter: RuntimeDevtoolsAdapter | null = null;
16
+
17
+ afterEach(() => {
18
+ adapter?.stop();
19
+ adapter = null;
20
+ });
21
+
22
+ it("stays disabled outside dev mode", async () => {
23
+ adapter = createRuntimeDevtoolsAdapter({
24
+ isDev: false,
25
+ rootDir: process.cwd(),
26
+ manifest,
27
+ guardConfig: null,
28
+ });
29
+
30
+ expect(adapter.kitchen).toBeNull();
31
+ expect(adapter.dashboardPath).toBeNull();
32
+ const response = await adapter.handleRequest(
33
+ new Request("http://localhost:3000/__kitchen"),
34
+ "/__kitchen"
35
+ );
36
+ expect(response).toBeNull();
37
+ });
38
+
39
+ it("dispatches Kitchen requests in dev mode", async () => {
40
+ adapter = createRuntimeDevtoolsAdapter({
41
+ isDev: true,
42
+ rootDir: process.cwd(),
43
+ manifest,
44
+ guardConfig: null,
45
+ });
46
+
47
+ expect(adapter.kitchen).not.toBeNull();
48
+ expect(adapter.dashboardPath).toBe("/__kitchen");
49
+ await expect(
50
+ adapter.handleRequest(new Request("http://localhost:3000/api/ping"), "/api/ping")
51
+ ).resolves.toBeNull();
52
+
53
+ const response = await adapter.handleRequest(
54
+ new Request("http://localhost:3000/__kitchen"),
55
+ "/__kitchen"
56
+ );
57
+ expect(response?.status).toBe(200);
58
+ expect(await response?.text()).toContain("Mandu Kitchen");
59
+ });
60
+
61
+ it("keeps framework-internal request paths out of the Kitchen request log", () => {
62
+ expect(shouldRecordRuntimeRequest("/api/ping")).toBe(true);
63
+ expect(shouldRecordRuntimeRequest("/_mandu/heap")).toBe(true);
64
+ expect(shouldRecordRuntimeRequest("/.mandu/client/runtime.js")).toBe(false);
65
+ expect(shouldRecordRuntimeRequest("/__kitchen/api/events")).toBe(false);
66
+ expect(shouldRecordRuntimeRequest("/__mandu/events/recent")).toBe(false);
67
+ });
68
+ });
@@ -0,0 +1,103 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ INTERNAL_EVENTS_ENDPOINT,
4
+ createRuntimeObservabilityLifecycle,
5
+ } from "../observability-lifecycle";
6
+ import { eventBus } from "../../observability/event-bus";
7
+ import {
8
+ resetTracer,
9
+ type Span,
10
+ type SpanExporter,
11
+ } from "../../observability/tracing";
12
+
13
+ class CaptureExporter implements SpanExporter {
14
+ readonly spans: Span[] = [];
15
+ export(spans: Span[]): void {
16
+ this.spans.push(...spans);
17
+ }
18
+ }
19
+
20
+ afterEach(() => {
21
+ resetTracer();
22
+ });
23
+
24
+ describe("runtime observability lifecycle", () => {
25
+ test("serves heap snapshots with perf data when exposed", async () => {
26
+ const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
27
+ const response = lifecycle.handleEndpoint(
28
+ new Request("http://localhost/_mandu/heap"),
29
+ "/_mandu/heap"
30
+ );
31
+
32
+ expect(response?.status).toBe(200);
33
+ const body = await response!.json() as {
34
+ process: { heapUsed: number };
35
+ perf: unknown;
36
+ };
37
+ expect(body.process.heapUsed).toBeGreaterThan(0);
38
+ expect(body.perf).toBeDefined();
39
+ });
40
+
41
+ test("keeps metrics hidden in production unless explicitly enabled", () => {
42
+ const hidden = createRuntimeObservabilityLifecycle({ isDev: false });
43
+ const shown = createRuntimeObservabilityLifecycle({
44
+ isDev: false,
45
+ metricsEndpoint: true,
46
+ });
47
+
48
+ expect(hidden.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")).toBeNull();
49
+ expect(shown.handleEndpoint(new Request("http://localhost/_mandu/metrics"), "/_mandu/metrics")?.status).toBe(200);
50
+ });
51
+
52
+ test("serves recent EventBus snapshots from the lifecycle endpoint", async () => {
53
+ const lifecycle = createRuntimeObservabilityLifecycle({ isDev: true });
54
+ const source = `observability-lifecycle-${Date.now()}`;
55
+ eventBus.emit({
56
+ type: "http",
57
+ severity: "info",
58
+ source,
59
+ message: "GET /observed 200",
60
+ });
61
+
62
+ const response = lifecycle.handleEndpoint(
63
+ new Request(`http://localhost${INTERNAL_EVENTS_ENDPOINT}/recent?source=${source}`),
64
+ `${INTERNAL_EVENTS_ENDPOINT}/recent`
65
+ );
66
+
67
+ expect(response?.status).toBe(200);
68
+ const body = await response!.json() as {
69
+ events: Array<{ source: string; message: string }>;
70
+ };
71
+ expect(body.events.some((event) => event.source === source)).toBe(true);
72
+ });
73
+
74
+ test("wraps requests in a root tracing span", async () => {
75
+ const exporter = new CaptureExporter();
76
+ const lifecycle = createRuntimeObservabilityLifecycle({
77
+ isDev: false,
78
+ tracing: {
79
+ enabled: true,
80
+ customExporter: exporter,
81
+ },
82
+ });
83
+ const incomingTraceId = "4bf92f3577b34da6a3ce929d0e0e4736";
84
+ const incomingSpanId = "00f067aa0ba902b7";
85
+ const response = await lifecycle.runRequest(
86
+ new Request("https://example.test/users", {
87
+ headers: {
88
+ traceparent: `00-${incomingTraceId}-${incomingSpanId}-01`,
89
+ },
90
+ }),
91
+ Date.now(),
92
+ "corr-1",
93
+ async () => new Response("created", { status: 201 })
94
+ );
95
+
96
+ expect(response.status).toBe(201);
97
+ expect(exporter.spans).toHaveLength(1);
98
+ expect(exporter.spans[0].traceId).toBe(incomingTraceId);
99
+ expect(exporter.spans[0].parentSpanId).toBe(incomingSpanId);
100
+ expect(exporter.spans[0].attributes["http.status_code"]).toBe(201);
101
+ expect(exporter.spans[0].status).toBe("ok");
102
+ });
103
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import React from "react";
3
+ import { renderPageResponse } from "../page-render-response";
4
+
5
+ describe("runtime page render response orchestration", () => {
6
+ it("pre-resolves async components on the non-streaming path", async () => {
7
+ async function AsyncPage() {
8
+ return React.createElement("main", null, "resolved-page");
9
+ }
10
+
11
+ const response = await renderPageResponse({
12
+ app: React.createElement(AsyncPage),
13
+ useStreaming: false,
14
+ title: "Async Page",
15
+ headTags: "",
16
+ isDev: false,
17
+ routeId: "page/async",
18
+ routePattern: "/async",
19
+ loaderData: { ok: true },
20
+ transitions: false,
21
+ prefetch: false,
22
+ spa: false,
23
+ devtools: false,
24
+ });
25
+
26
+ expect(response.status).toBe(200);
27
+ const html = await response.text();
28
+ expect(html).toContain("resolved-page");
29
+ expect(html).toContain("Async Page");
30
+ });
31
+
32
+ it("uses the streaming renderer when requested", async () => {
33
+ const response = await renderPageResponse({
34
+ app: React.createElement("main", null, "stream-page"),
35
+ useStreaming: true,
36
+ title: "Stream Page",
37
+ headTags: "",
38
+ isDev: false,
39
+ routeId: "page/stream",
40
+ routePattern: "/stream",
41
+ loaderData: { ok: true },
42
+ transitions: false,
43
+ prefetch: false,
44
+ spa: false,
45
+ devtools: false,
46
+ });
47
+
48
+ expect(response.status).toBe(200);
49
+ expect(response.headers.get("X-Accel-Buffering")).toBe("no");
50
+ const html = await response.text();
51
+ expect(html).toContain("stream-page");
52
+ expect(html).toContain("Stream Page");
53
+ });
54
+ });
@@ -0,0 +1,70 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { defineMiddleware } from "../../middleware/define";
3
+ import {
4
+ buildRequestMiddlewareChain,
5
+ runRequestMiddleware,
6
+ } from "../request-middleware";
7
+
8
+ const req = new Request("https://example.test/api/echo");
9
+
10
+ describe("runtime request middleware wiring", () => {
11
+ it("keeps the no-middleware path unset for hot-path passthrough", () => {
12
+ expect(buildRequestMiddlewareChain(undefined)).toBeUndefined();
13
+ expect(buildRequestMiddlewareChain([])).toBeUndefined();
14
+ });
15
+
16
+ it("returns undefined when there is no chain or middleware is skipped", async () => {
17
+ let finalHits = 0;
18
+ const finalHandler = async (): Promise<Response> => {
19
+ finalHits++;
20
+ return new Response("ok");
21
+ };
22
+ const middlewareChain = buildRequestMiddlewareChain([
23
+ defineMiddleware({
24
+ name: "marker",
25
+ handler: async (_req, next) => next(),
26
+ }),
27
+ ]);
28
+
29
+ await expect(
30
+ runRequestMiddleware({ req, middlewareChain: undefined, finalHandler })
31
+ ).resolves.toBeUndefined();
32
+ await expect(
33
+ runRequestMiddleware({
34
+ req,
35
+ middlewareChain,
36
+ finalHandler,
37
+ skipMiddleware: true,
38
+ })
39
+ ).resolves.toBeUndefined();
40
+ expect(finalHits).toBe(0);
41
+ });
42
+
43
+ it("dispatches through the composed chain when configured", async () => {
44
+ const trace: string[] = [];
45
+ const middlewareChain = buildRequestMiddlewareChain([
46
+ defineMiddleware({
47
+ name: "outer",
48
+ handler: async (_req, next) => {
49
+ trace.push("before");
50
+ const response = await next();
51
+ trace.push("after");
52
+ return response;
53
+ },
54
+ }),
55
+ ]);
56
+
57
+ const response = await runRequestMiddleware({
58
+ req,
59
+ middlewareChain,
60
+ finalHandler: async () => {
61
+ trace.push("final");
62
+ return new Response("ok");
63
+ },
64
+ });
65
+
66
+ expect(response?.status).toBe(200);
67
+ expect(await response?.text()).toBe("ok");
68
+ expect(trace).toEqual(["before", "final", "after"]);
69
+ });
70
+ });
@@ -0,0 +1,68 @@
1
+ import type { RoutesManifest } from "../spec/schema";
2
+ import type { GuardConfig } from "../guard/types";
3
+ import {
4
+ KITCHEN_PREFIX,
5
+ KitchenHandler,
6
+ recordRequest,
7
+ type RequestEntry,
8
+ } from "../kitchen/kitchen-handler";
9
+
10
+ export type RuntimeKitchenHandler = KitchenHandler;
11
+
12
+ export interface RuntimeDevtoolsAdapter {
13
+ readonly kitchen: RuntimeKitchenHandler | null;
14
+ readonly dashboardPath: string | null;
15
+ start(): void;
16
+ stop(): void;
17
+ updateManifest(manifest: RoutesManifest): void;
18
+ handleRequest(req: Request, pathname: string): Promise<Response | null>;
19
+ }
20
+
21
+ export interface CreateRuntimeDevtoolsAdapterOptions {
22
+ isDev: boolean;
23
+ rootDir: string;
24
+ manifest: RoutesManifest;
25
+ guardConfig: GuardConfig | null;
26
+ }
27
+
28
+ export function createRuntimeDevtoolsAdapter(
29
+ options: CreateRuntimeDevtoolsAdapterOptions
30
+ ): RuntimeDevtoolsAdapter {
31
+ const kitchen = options.isDev
32
+ ? new KitchenHandler({
33
+ rootDir: options.rootDir,
34
+ manifest: options.manifest,
35
+ guardConfig: options.guardConfig,
36
+ })
37
+ : null;
38
+
39
+ return {
40
+ kitchen,
41
+ dashboardPath: kitchen ? KITCHEN_PREFIX : null,
42
+ start() {
43
+ if (kitchen) void kitchen.start();
44
+ },
45
+ stop() {
46
+ kitchen?.stop();
47
+ },
48
+ updateManifest(manifest: RoutesManifest) {
49
+ kitchen?.updateManifest(manifest);
50
+ },
51
+ async handleRequest(req: Request, pathname: string): Promise<Response | null> {
52
+ if (!kitchen || !pathname.startsWith(KITCHEN_PREFIX)) return null;
53
+ return await kitchen.handle(req, pathname);
54
+ },
55
+ };
56
+ }
57
+
58
+ export function shouldRecordRuntimeRequest(pathname: string): boolean {
59
+ return (
60
+ !pathname.startsWith("/.mandu/") &&
61
+ !pathname.startsWith(KITCHEN_PREFIX) &&
62
+ !pathname.startsWith("/__mandu/")
63
+ );
64
+ }
65
+
66
+ export function recordRuntimeRequest(entry: RequestEntry): void {
67
+ recordRequest(entry);
68
+ }
@@ -3,12 +3,40 @@
3
3
  * <title>, <p> λ“± ν…μŠ€νŠΈ λ…Έλ“œμ— λ“€μ–΄κ°ˆ λ¬Έμžμ—΄μ„ μ•ˆμ „ν•˜κ²Œ 처리.
4
4
  * 속성값과 달리 " ' λŠ” μ΄μŠ€μΌ€μ΄ν”„ λΆˆν•„μš”.
5
5
  */
6
- export function escapeHtmlText(value: string): string {
7
- return value
8
- .replace(/&/g, "&amp;")
9
- .replace(/</g, "&lt;")
10
- .replace(/>/g, "&gt;");
11
- }
6
+ export function escapeHtmlText(value: string): string {
7
+ return value
8
+ .replace(/&/g, "&amp;")
9
+ .replace(/</g, "&lt;")
10
+ .replace(/>/g, "&gt;");
11
+ }
12
+
13
+ /**
14
+ * Decode the small HTML entity set React can emit inside text metadata.
15
+ * Callers should still escape the returned string before writing HTML.
16
+ */
17
+ export function decodeHtmlText(value: string): string {
18
+ return value.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|#39);/gi, (match, entity: string) => {
19
+ const normalized = entity.toLowerCase();
20
+ if (normalized === "amp") return "&";
21
+ if (normalized === "lt") return "<";
22
+ if (normalized === "gt") return ">";
23
+ if (normalized === "quot") return '"';
24
+ if (normalized === "apos" || normalized === "#39") return "'";
25
+ if (normalized.startsWith("#x")) {
26
+ const codePoint = Number.parseInt(normalized.slice(2), 16);
27
+ return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
28
+ }
29
+ if (normalized.startsWith("#")) {
30
+ const codePoint = Number.parseInt(normalized.slice(1), 10);
31
+ return isValidCodePoint(codePoint) ? String.fromCodePoint(codePoint) : match;
32
+ }
33
+ return match;
34
+ });
35
+ }
36
+
37
+ function isValidCodePoint(value: number): boolean {
38
+ return Number.isInteger(value) && value >= 0 && value <= 0x10ffff;
39
+ }
12
40
 
13
41
  /**
14
42
  * HTML 속성값 μ΄μŠ€μΌ€μ΄ν”„