@etamong-playground/legal 0.3.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 etamong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @etamong-playground/legal
2
+
3
+ > **About** — One of several small shared libraries used across a personal "fleet" of small apps (error handling · audit logging · encryption-at-rest · i18n · UI · …). Authored and maintained with [Claude Code](https://www.anthropic.com/claude-code) (Anthropic's agentic CLI). Each README documents the design rationale behind the library.
4
+ >
5
+ > **This is a public repository** — keep internal infrastructure details (hostnames, secret/Vault paths, private URLs, internal issue/MR references) out of code, comments, and commit messages.
6
+
7
+ Shared, versioned legal-document toolkit for etamong-playground apps — one model,
8
+ renderers, and advance-notice logic for Terms (이용약관) / Privacy (개인정보처리방침),
9
+ so each app stops re-implementing its own.
10
+
11
+ **Headless & styleless.** Ships a model + helpers + renderers that emit
12
+ `legal-*` class names only. Theme via the optional stylesheet's CSS variables,
13
+ or style the classes yourself. No router, no bundled content — each app owns its
14
+ own version data in its own repo (so the legal audit trail stays in git).
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ pnpm add @etamong-playground/legal
20
+ ```
21
+
22
+ Resolving `@etamong-playground/*` from GitHub Packages requires the registry in `.npmrc`:
23
+
24
+ ```
25
+ @etamong-playground:registry=https://npm.pkg.github.com
26
+ //npm.pkg.github.com/:_authToken=<your-github-token>
27
+ ```
28
+
29
+ ## Model
30
+
31
+ ```ts
32
+ interface LegalVersion {
33
+ version: string; // "1.0"
34
+ publishedDate: string; // 공고일 YYYY-MM-DD
35
+ effectiveDate: string; // 시행일 YYYY-MM-DD
36
+ summary?: string;
37
+ adverse?: boolean; // 불리/중대 → 30일 사전공지 (else 7일)
38
+ sections: { title: string; content: ReactNode }[];
39
+ changes?: VersionChange[]; // diff vs previous version
40
+ }
41
+ interface LegalDocMeta { kind: "terms" | "privacy"; title: string; versions: LegalVersion[] }
42
+ ```
43
+
44
+ Newest version first. Publishing a change = append a version with a future
45
+ `effectiveDate`. Advance-notice (공시) and the effective-date flip are pure date
46
+ functions — no scheduled job.
47
+
48
+ ## Helpers
49
+
50
+ - `effectiveVersion(versions, today?)` — the version in force.
51
+ - `upcomingVersion(versions, today?)` — soonest announced-but-not-yet-effective
52
+ version, or null. Guarded by `publishedDate <= today` so drafts don't trigger
53
+ a public banner.
54
+ - `todayISO(timeZone = "Asia/Seoul")` — today as YYYY-MM-DD in the jurisdiction.
55
+
56
+ ## Components
57
+
58
+ | Component | Purpose |
59
+ |---|---|
60
+ | `<LegalDocument version>` | Renders a version's titled sections. |
61
+ | `<VersionDiff changes fromVersion toVersion>` | 3-column 변경 전/후 table; `==highlight==` markup. |
62
+ | `<VersionHistory versions selectedIdx onSelect>` | Controlled version picker. |
63
+ | `<UpcomingNotice docTitle version onPreview?>` | Per-doc 변경 예정 card. |
64
+ | `<PolicyNotice docs hrefFor renderLink?>` | Site-wide dismissible 사전공시 banner. |
65
+
66
+ `PolicyNotice` is router-agnostic — pass `renderLink={(href, kids) => <Link to={href}>{kids}</Link>}`
67
+ to use your app's Link. Interactive components use state; mount them inside a
68
+ client boundary (Next.js `"use client"`).
69
+
70
+ ## Theming
71
+
72
+ ```ts
73
+ import "@etamong-playground/legal/styles.css";
74
+ ```
75
+
76
+ Override CSS variables for theme / dark mode:
77
+
78
+ ```css
79
+ .dark {
80
+ --legal-fg: #d4d4d8;
81
+ --legal-border: #3f3f46;
82
+ --legal-surface: #27272a;
83
+ --legal-notice-bg: #1e3a8a33;
84
+ /* ... see src/styles.css for the full list */
85
+ }
86
+ ```
87
+
88
+ ## Releasing
89
+
90
+ Bump `version` in `package.json`, commit, then push a matching tag:
91
+
92
+ ```sh
93
+ git tag v0.1.0 && git push origin v0.1.0
94
+ ```
95
+
96
+ CI publishes to GitHub Packages on tags matching `vX.Y.Z`.
97
+
98
+ ## Acknowledgements
99
+
100
+ Built for [React](https://react.dev) (peer dependency, MIT).
101
+
102
+ ## License
103
+
104
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,22 @@
1
+ // src/helpers.ts
2
+ function todayISO(timeZone = "Asia/Seoul") {
3
+ return new Intl.DateTimeFormat("en-CA", {
4
+ timeZone,
5
+ year: "numeric",
6
+ month: "2-digit",
7
+ day: "2-digit"
8
+ }).format(/* @__PURE__ */ new Date());
9
+ }
10
+ function effectiveVersion(versions, today = todayISO()) {
11
+ const inForce = versions.filter((v) => v.effectiveDate <= today).sort((a, b) => b.effectiveDate.localeCompare(a.effectiveDate));
12
+ return inForce[0] ?? versions[versions.length - 1];
13
+ }
14
+ function upcomingVersion(versions, today = todayISO()) {
15
+ const announced = versions.filter((v) => v.publishedDate <= today && v.effectiveDate > today).sort((a, b) => a.effectiveDate.localeCompare(b.effectiveDate));
16
+ return announced[0] ?? null;
17
+ }
18
+ function policyInForce(effectiveDate, now = todayISO()) {
19
+ return now >= effectiveDate;
20
+ }
21
+
22
+ export { effectiveVersion, policyInForce, todayISO, upcomingVersion };
@@ -0,0 +1,152 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The kind of legal document. Apps typically ship terms + privacy; `identity`
5
+ * is the app-agnostic SSO/identity statement (L1) used as the single privacy URL
6
+ * on a Google OAuth consent screen — it never enumerates downstream apps.
7
+ */
8
+ type LegalDocKind = "terms" | "privacy" | "identity";
9
+ /**
10
+ * Whether a document is exposed on the public hub. Anything not explicitly
11
+ * `public` stays off the hub; create flows default to `internal`.
12
+ */
13
+ type LegalVisibility = "public" | "internal";
14
+ /**
15
+ * One row of a version-to-version diff table (변경 전 / 변경 후).
16
+ * `before`/`after` support `==highlight==` markup — the renderer wraps the
17
+ * delimited span in a <mark>.
18
+ */
19
+ interface VersionChange {
20
+ /** Section label this change belongs to (e.g. "제4조 (보존)"). */
21
+ section: string;
22
+ type: "added" | "removed" | "changed";
23
+ /** Prior text. Omit for `added`. May contain `==highlight==` spans. */
24
+ before?: string;
25
+ /** New text. Omit for `removed`. May contain `==highlight==` spans. */
26
+ after?: string;
27
+ }
28
+ /**
29
+ * A titled section of a legal document.
30
+ *
31
+ * `id` is a stable deep-link slug — the public hub renders the anchor
32
+ * `#${docAnchor}-${id}`; assign it once and never rename or reuse it (app
33
+ * footers link to it). `body` is serializable Markdown, which the server-side
34
+ * hub renders (it has no app bundle). `content` (app JSX) is a deprecated
35
+ * fallback the React `LegalDocument` uses only when `body` is empty.
36
+ */
37
+ interface LegalSection {
38
+ /** Stable slug for deep-linking. Hub anchor is `${docAnchor}-${id}`. */
39
+ id: string;
40
+ title: string;
41
+ /** Serializable Markdown — rendered by the hub and as the LegalDocument default. */
42
+ body: string;
43
+ /** @deprecated In-app JSX. Ignored by the hub; LegalDocument falls back to it when `body` is empty. */
44
+ content?: ReactNode;
45
+ }
46
+ /**
47
+ * A single dated version of a legal document. Newest first in the array.
48
+ *
49
+ * Publish a change by appending a new entry with a future `effectiveDate`.
50
+ * While `publishedDate <= today < effectiveDate` the version is "announced"
51
+ * (공시) — `upcomingVersion()` returns it and the notice/banner show it. On
52
+ * `effectiveDate` it silently becomes the one `effectiveVersion()` returns; no
53
+ * second commit or scheduled job is needed (both are pure date functions).
54
+ *
55
+ * Korean law: 이용약관 개정은 시행 7일 전(이용자에게 불리하면 30일 전) 공지,
56
+ * 개인정보처리방침은 변경 내용 공개 + 이전 버전 열람. `adverse` marks the 30-day case.
57
+ */
58
+ interface LegalVersion {
59
+ /** e.g. "1.0". Must be unique within a document. */
60
+ version: string;
61
+ /** 공고일 (YYYY-MM-DD). A version only counts as announced once this is reached. */
62
+ publishedDate: string;
63
+ /** 시행일 (YYYY-MM-DD). The version takes force on this date. */
64
+ effectiveDate: string;
65
+ /** 변경 요약 — shown in the advance-notice card. */
66
+ summary?: string;
67
+ /** 이용자에게 불리/중대한 변경 → 30일 사전공지(아니면 7일). */
68
+ adverse?: boolean;
69
+ sections: LegalSection[];
70
+ /** Diff vs the immediately-previous version, for the 3-column table. */
71
+ changes?: VersionChange[];
72
+ }
73
+ /**
74
+ * The data controller (개인정보처리자) surfaced to users, plus the privacy
75
+ * officer (개인정보 보호책임자) contact. For an L2 doc this is the service; for
76
+ * the L1 identity doc it is the identity broker (authentik).
77
+ */
78
+ interface DataController {
79
+ /** 개인정보처리자 명 (e.g. service or operator name). */
80
+ name: string;
81
+ /** 개인정보 보호책임자 연락처 (reachable email). */
82
+ contactEmail: string;
83
+ }
84
+ /** A complete legal document: its identity plus every version (newest first). */
85
+ interface LegalDocMeta {
86
+ kind: LegalDocKind;
87
+ /** Display title, e.g. "이용약관". */
88
+ title: string;
89
+ /** Versions, newest first. */
90
+ versions: LegalVersion[];
91
+ /**
92
+ * Standalone slug owned by the legal store (key `legal:doc:${serviceId}:${kind}`).
93
+ * NOT a hostname — it does not join the host-keyed maintenance registry.
94
+ * Use "_identity" for the L1 document.
95
+ */
96
+ serviceId: string;
97
+ /** Stable per-document hub anchor. Section anchors are `${docAnchor}-${section.id}`. */
98
+ docAnchor: string;
99
+ /** Gates hub rendering and PolicyNotice. Defaults to "internal" when authored. */
100
+ visibility: LegalVisibility;
101
+ /** Controller surfaced to users (L2 = the service; L1 = the identity broker). */
102
+ controller: DataController;
103
+ }
104
+
105
+ /**
106
+ * Today as YYYY-MM-DD in a given IANA timezone (default Asia/Seoul).
107
+ *
108
+ * Effective/published dates are legal calendar dates, so they must be compared
109
+ * against the calendar day in the service's jurisdiction — not UTC, which can
110
+ * be a day off for KST users near midnight.
111
+ */
112
+ declare function todayISO(timeZone?: string): string;
113
+ /**
114
+ * The version currently in force: newest whose `effectiveDate` is on/before
115
+ * `today`. Falls back to the oldest version if none is effective yet (so a
116
+ * brand-new doc still renders something).
117
+ */
118
+ declare function effectiveVersion(versions: LegalVersion[], today?: string): LegalVersion;
119
+ /**
120
+ * The soonest *announced* version not yet in force: `publishedDate <= today <
121
+ * effectiveDate`. Returns null when nothing is pending.
122
+ *
123
+ * The `publishedDate <= today` guard is deliberate — it prevents a draft entry
124
+ * with a future publish date from prematurely lighting up the public "변경 예정"
125
+ * banner (see planning concepts/user-facing-content-hygiene: no fake notices).
126
+ */
127
+ declare function upcomingVersion(versions: LegalVersion[], today?: string): LegalVersion | null;
128
+ /**
129
+ * Date-gate check for `legal-deploy-gate` consumers (see planning concept page
130
+ * of the same name): "is the policy that takes effect on `effectiveDate`
131
+ * currently in force?"
132
+ *
133
+ * Returns `true` once `now` (KST calendar day) is on or after `effectiveDate`,
134
+ * so the new-policy code path activates automatically on 시행일 and stays on
135
+ * forever after. Pure date comparison — no hub fetch, no I/O.
136
+ *
137
+ * Typical use:
138
+ *
139
+ * if (policyInForce("2026-07-01")) {
140
+ * // new-policy behavior
141
+ * } else {
142
+ * // old-policy behavior (still required during the notice window)
143
+ * }
144
+ *
145
+ * `effectiveDate` MUST be the YYYY-MM-DD string from the legal version it
146
+ * matches. Hardcoding is preferred — published versions are immutable so the
147
+ * date won't move out from under the gate, and grepping for the literal is
148
+ * how the branch gets cleaned up after the migration window closes.
149
+ */
150
+ declare function policyInForce(effectiveDate: string, now?: string): boolean;
151
+
152
+ export { type DataController as D, type LegalDocMeta as L, type VersionChange as V, type LegalVersion as a, type LegalDocKind as b, type LegalSection as c, type LegalVisibility as d, effectiveVersion as e, policyInForce as p, todayISO as t, upcomingVersion as u };
@@ -0,0 +1,152 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /**
4
+ * The kind of legal document. Apps typically ship terms + privacy; `identity`
5
+ * is the app-agnostic SSO/identity statement (L1) used as the single privacy URL
6
+ * on a Google OAuth consent screen — it never enumerates downstream apps.
7
+ */
8
+ type LegalDocKind = "terms" | "privacy" | "identity";
9
+ /**
10
+ * Whether a document is exposed on the public hub. Anything not explicitly
11
+ * `public` stays off the hub; create flows default to `internal`.
12
+ */
13
+ type LegalVisibility = "public" | "internal";
14
+ /**
15
+ * One row of a version-to-version diff table (변경 전 / 변경 후).
16
+ * `before`/`after` support `==highlight==` markup — the renderer wraps the
17
+ * delimited span in a <mark>.
18
+ */
19
+ interface VersionChange {
20
+ /** Section label this change belongs to (e.g. "제4조 (보존)"). */
21
+ section: string;
22
+ type: "added" | "removed" | "changed";
23
+ /** Prior text. Omit for `added`. May contain `==highlight==` spans. */
24
+ before?: string;
25
+ /** New text. Omit for `removed`. May contain `==highlight==` spans. */
26
+ after?: string;
27
+ }
28
+ /**
29
+ * A titled section of a legal document.
30
+ *
31
+ * `id` is a stable deep-link slug — the public hub renders the anchor
32
+ * `#${docAnchor}-${id}`; assign it once and never rename or reuse it (app
33
+ * footers link to it). `body` is serializable Markdown, which the server-side
34
+ * hub renders (it has no app bundle). `content` (app JSX) is a deprecated
35
+ * fallback the React `LegalDocument` uses only when `body` is empty.
36
+ */
37
+ interface LegalSection {
38
+ /** Stable slug for deep-linking. Hub anchor is `${docAnchor}-${id}`. */
39
+ id: string;
40
+ title: string;
41
+ /** Serializable Markdown — rendered by the hub and as the LegalDocument default. */
42
+ body: string;
43
+ /** @deprecated In-app JSX. Ignored by the hub; LegalDocument falls back to it when `body` is empty. */
44
+ content?: ReactNode;
45
+ }
46
+ /**
47
+ * A single dated version of a legal document. Newest first in the array.
48
+ *
49
+ * Publish a change by appending a new entry with a future `effectiveDate`.
50
+ * While `publishedDate <= today < effectiveDate` the version is "announced"
51
+ * (공시) — `upcomingVersion()` returns it and the notice/banner show it. On
52
+ * `effectiveDate` it silently becomes the one `effectiveVersion()` returns; no
53
+ * second commit or scheduled job is needed (both are pure date functions).
54
+ *
55
+ * Korean law: 이용약관 개정은 시행 7일 전(이용자에게 불리하면 30일 전) 공지,
56
+ * 개인정보처리방침은 변경 내용 공개 + 이전 버전 열람. `adverse` marks the 30-day case.
57
+ */
58
+ interface LegalVersion {
59
+ /** e.g. "1.0". Must be unique within a document. */
60
+ version: string;
61
+ /** 공고일 (YYYY-MM-DD). A version only counts as announced once this is reached. */
62
+ publishedDate: string;
63
+ /** 시행일 (YYYY-MM-DD). The version takes force on this date. */
64
+ effectiveDate: string;
65
+ /** 변경 요약 — shown in the advance-notice card. */
66
+ summary?: string;
67
+ /** 이용자에게 불리/중대한 변경 → 30일 사전공지(아니면 7일). */
68
+ adverse?: boolean;
69
+ sections: LegalSection[];
70
+ /** Diff vs the immediately-previous version, for the 3-column table. */
71
+ changes?: VersionChange[];
72
+ }
73
+ /**
74
+ * The data controller (개인정보처리자) surfaced to users, plus the privacy
75
+ * officer (개인정보 보호책임자) contact. For an L2 doc this is the service; for
76
+ * the L1 identity doc it is the identity broker (authentik).
77
+ */
78
+ interface DataController {
79
+ /** 개인정보처리자 명 (e.g. service or operator name). */
80
+ name: string;
81
+ /** 개인정보 보호책임자 연락처 (reachable email). */
82
+ contactEmail: string;
83
+ }
84
+ /** A complete legal document: its identity plus every version (newest first). */
85
+ interface LegalDocMeta {
86
+ kind: LegalDocKind;
87
+ /** Display title, e.g. "이용약관". */
88
+ title: string;
89
+ /** Versions, newest first. */
90
+ versions: LegalVersion[];
91
+ /**
92
+ * Standalone slug owned by the legal store (key `legal:doc:${serviceId}:${kind}`).
93
+ * NOT a hostname — it does not join the host-keyed maintenance registry.
94
+ * Use "_identity" for the L1 document.
95
+ */
96
+ serviceId: string;
97
+ /** Stable per-document hub anchor. Section anchors are `${docAnchor}-${section.id}`. */
98
+ docAnchor: string;
99
+ /** Gates hub rendering and PolicyNotice. Defaults to "internal" when authored. */
100
+ visibility: LegalVisibility;
101
+ /** Controller surfaced to users (L2 = the service; L1 = the identity broker). */
102
+ controller: DataController;
103
+ }
104
+
105
+ /**
106
+ * Today as YYYY-MM-DD in a given IANA timezone (default Asia/Seoul).
107
+ *
108
+ * Effective/published dates are legal calendar dates, so they must be compared
109
+ * against the calendar day in the service's jurisdiction — not UTC, which can
110
+ * be a day off for KST users near midnight.
111
+ */
112
+ declare function todayISO(timeZone?: string): string;
113
+ /**
114
+ * The version currently in force: newest whose `effectiveDate` is on/before
115
+ * `today`. Falls back to the oldest version if none is effective yet (so a
116
+ * brand-new doc still renders something).
117
+ */
118
+ declare function effectiveVersion(versions: LegalVersion[], today?: string): LegalVersion;
119
+ /**
120
+ * The soonest *announced* version not yet in force: `publishedDate <= today <
121
+ * effectiveDate`. Returns null when nothing is pending.
122
+ *
123
+ * The `publishedDate <= today` guard is deliberate — it prevents a draft entry
124
+ * with a future publish date from prematurely lighting up the public "변경 예정"
125
+ * banner (see planning concepts/user-facing-content-hygiene: no fake notices).
126
+ */
127
+ declare function upcomingVersion(versions: LegalVersion[], today?: string): LegalVersion | null;
128
+ /**
129
+ * Date-gate check for `legal-deploy-gate` consumers (see planning concept page
130
+ * of the same name): "is the policy that takes effect on `effectiveDate`
131
+ * currently in force?"
132
+ *
133
+ * Returns `true` once `now` (KST calendar day) is on or after `effectiveDate`,
134
+ * so the new-policy code path activates automatically on 시행일 and stays on
135
+ * forever after. Pure date comparison — no hub fetch, no I/O.
136
+ *
137
+ * Typical use:
138
+ *
139
+ * if (policyInForce("2026-07-01")) {
140
+ * // new-policy behavior
141
+ * } else {
142
+ * // old-policy behavior (still required during the notice window)
143
+ * }
144
+ *
145
+ * `effectiveDate` MUST be the YYYY-MM-DD string from the legal version it
146
+ * matches. Hardcoding is preferred — published versions are immutable so the
147
+ * date won't move out from under the gate, and grepping for the literal is
148
+ * how the branch gets cleaned up after the migration window closes.
149
+ */
150
+ declare function policyInForce(effectiveDate: string, now?: string): boolean;
151
+
152
+ export { type DataController as D, type LegalDocMeta as L, type VersionChange as V, type LegalVersion as a, type LegalDocKind as b, type LegalSection as c, type LegalVisibility as d, effectiveVersion as e, policyInForce as p, todayISO as t, upcomingVersion as u };
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // src/helpers.ts
4
+ function todayISO(timeZone = "Asia/Seoul") {
5
+ return new Intl.DateTimeFormat("en-CA", {
6
+ timeZone,
7
+ year: "numeric",
8
+ month: "2-digit",
9
+ day: "2-digit"
10
+ }).format(/* @__PURE__ */ new Date());
11
+ }
12
+ function effectiveVersion(versions, today = todayISO()) {
13
+ const inForce = versions.filter((v) => v.effectiveDate <= today).sort((a, b) => b.effectiveDate.localeCompare(a.effectiveDate));
14
+ return inForce[0] ?? versions[versions.length - 1];
15
+ }
16
+ function upcomingVersion(versions, today = todayISO()) {
17
+ const announced = versions.filter((v) => v.publishedDate <= today && v.effectiveDate > today).sort((a, b) => a.effectiveDate.localeCompare(b.effectiveDate));
18
+ return announced[0] ?? null;
19
+ }
20
+ function policyInForce(effectiveDate, now = todayISO()) {
21
+ return now >= effectiveDate;
22
+ }
23
+
24
+ exports.effectiveVersion = effectiveVersion;
25
+ exports.policyInForce = policyInForce;
26
+ exports.todayISO = todayISO;
27
+ exports.upcomingVersion = upcomingVersion;
@@ -0,0 +1,2 @@
1
+ export { e as effectiveVersion, p as policyInForce, t as todayISO, u as upcomingVersion } from './helpers-CRGM1DHb.cjs';
2
+ import 'react';
@@ -0,0 +1,2 @@
1
+ export { e as effectiveVersion, p as policyInForce, t as todayISO, u as upcomingVersion } from './helpers-CRGM1DHb.js';
2
+ import 'react';
@@ -0,0 +1 @@
1
+ export { effectiveVersion, policyInForce, todayISO, upcomingVersion } from './chunk-MDFCRQ5H.js';
package/dist/index.cjs ADDED
@@ -0,0 +1,195 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+ var react = require('react');
5
+
6
+ // src/helpers.ts
7
+ function todayISO(timeZone = "Asia/Seoul") {
8
+ return new Intl.DateTimeFormat("en-CA", {
9
+ timeZone,
10
+ year: "numeric",
11
+ month: "2-digit",
12
+ day: "2-digit"
13
+ }).format(/* @__PURE__ */ new Date());
14
+ }
15
+ function effectiveVersion(versions, today = todayISO()) {
16
+ const inForce = versions.filter((v) => v.effectiveDate <= today).sort((a, b) => b.effectiveDate.localeCompare(a.effectiveDate));
17
+ return inForce[0] ?? versions[versions.length - 1];
18
+ }
19
+ function upcomingVersion(versions, today = todayISO()) {
20
+ const announced = versions.filter((v) => v.publishedDate <= today && v.effectiveDate > today).sort((a, b) => a.effectiveDate.localeCompare(b.effectiveDate));
21
+ return announced[0] ?? null;
22
+ }
23
+ function policyInForce(effectiveDate, now = todayISO()) {
24
+ return now >= effectiveDate;
25
+ }
26
+ function renderHighlighted(text) {
27
+ return text.split(/(==.*?==)/g).map((part, i) => {
28
+ if (part.startsWith("==") && part.endsWith("==") && part.length >= 4) {
29
+ return /* @__PURE__ */ jsxRuntime.jsx("mark", { className: "legal-mark", children: part.slice(2, -2) }, i);
30
+ }
31
+ if (part.includes("\n")) {
32
+ const lines = part.split("\n");
33
+ return lines.map((line, j) => /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
34
+ line,
35
+ j < lines.length - 1 && /* @__PURE__ */ jsxRuntime.jsx("br", {})
36
+ ] }, `${i}-${j}`));
37
+ }
38
+ return part;
39
+ });
40
+ }
41
+ function LegalDocument({
42
+ doc,
43
+ version,
44
+ className
45
+ }) {
46
+ const v = version ?? effectiveVersion(doc.versions);
47
+ return /* @__PURE__ */ jsxRuntime.jsx(
48
+ "article",
49
+ {
50
+ id: doc.docAnchor,
51
+ className: className ? `legal-document ${className}` : "legal-document",
52
+ children: v.sections.map((section) => /* @__PURE__ */ jsxRuntime.jsxs("section", { className: "legal-section", children: [
53
+ /* @__PURE__ */ jsxRuntime.jsx("h2", { id: `${doc.docAnchor}-${section.id}`, className: "legal-section-title", children: section.title }),
54
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "legal-section-body", children: section.content ?? section.body })
55
+ ] }, section.id))
56
+ }
57
+ );
58
+ }
59
+ var badgeLabel = {
60
+ added: "\uCD94\uAC00",
61
+ removed: "\uC0AD\uC81C",
62
+ changed: "\uBCC0\uACBD"
63
+ };
64
+ function VersionDiff({
65
+ changes,
66
+ fromVersion,
67
+ toVersion,
68
+ className
69
+ }) {
70
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: className ? `legal-diff ${className}` : "legal-diff", children: [
71
+ /* @__PURE__ */ jsxRuntime.jsxs("h3", { className: "legal-diff-title", children: [
72
+ "\uC8FC\uC694 \uBCC0\uACBD \uC0AC\uD56D (v",
73
+ fromVersion,
74
+ " \u2192 v",
75
+ toVersion,
76
+ ")"
77
+ ] }),
78
+ /* @__PURE__ */ jsxRuntime.jsx("div", { className: "legal-diff-scroll", children: /* @__PURE__ */ jsxRuntime.jsxs("table", { className: "legal-diff-table", children: [
79
+ /* @__PURE__ */ jsxRuntime.jsx("thead", { children: /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
80
+ /* @__PURE__ */ jsxRuntime.jsx("th", { children: "\uAD6C\uBD84" }),
81
+ /* @__PURE__ */ jsxRuntime.jsx("th", { children: "\uBCC0\uACBD \uC804" }),
82
+ /* @__PURE__ */ jsxRuntime.jsx("th", { children: "\uBCC0\uACBD \uD6C4" })
83
+ ] }) }),
84
+ /* @__PURE__ */ jsxRuntime.jsx("tbody", { children: changes.map((c, i) => /* @__PURE__ */ jsxRuntime.jsxs("tr", { children: [
85
+ /* @__PURE__ */ jsxRuntime.jsxs("td", { className: "legal-diff-section", children: [
86
+ /* @__PURE__ */ jsxRuntime.jsx("span", { children: c.section }),
87
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: `legal-badge legal-badge--${c.type}`, children: badgeLabel[c.type] })
88
+ ] }),
89
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: c.type === "added" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "legal-diff-empty", children: "\u2014" }) : renderHighlighted(c.before ?? "") }),
90
+ /* @__PURE__ */ jsxRuntime.jsx("td", { children: c.type === "removed" ? /* @__PURE__ */ jsxRuntime.jsx("span", { className: "legal-diff-empty", children: "\u2014" }) : renderHighlighted(c.after ?? "") })
91
+ ] }, i)) })
92
+ ] }) })
93
+ ] });
94
+ }
95
+ function VersionHistory({
96
+ versions,
97
+ selectedIdx,
98
+ onSelect,
99
+ className
100
+ }) {
101
+ if (versions.length <= 1) return null;
102
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: className ? `legal-history ${className}` : "legal-history", children: [
103
+ /* @__PURE__ */ jsxRuntime.jsx("h3", { className: "legal-history-title", children: "\uC774\uC804 \uBC84\uC804" }),
104
+ /* @__PURE__ */ jsxRuntime.jsx("ul", { className: "legal-history-list", children: versions.map((v, i) => /* @__PURE__ */ jsxRuntime.jsx("li", { children: /* @__PURE__ */ jsxRuntime.jsxs(
105
+ "button",
106
+ {
107
+ type: "button",
108
+ onClick: () => onSelect(i),
109
+ "aria-current": i === selectedIdx ? "true" : void 0,
110
+ className: i === selectedIdx ? "legal-history-item is-selected" : "legal-history-item",
111
+ children: [
112
+ "v",
113
+ v.version,
114
+ " (",
115
+ v.effectiveDate,
116
+ ")"
117
+ ]
118
+ }
119
+ ) }, v.version)) })
120
+ ] });
121
+ }
122
+ function UpcomingNotice({
123
+ docTitle,
124
+ version,
125
+ onPreview,
126
+ className
127
+ }) {
128
+ return /* @__PURE__ */ jsxRuntime.jsx("div", { className: className ? `legal-notice ${className}` : "legal-notice", children: /* @__PURE__ */ jsxRuntime.jsxs("p", { className: "legal-notice-text", children: [
129
+ "\u{1F4E2} ",
130
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: docTitle }),
131
+ " \uBCC0\uACBD \uC608\uC815 \u2014 ",
132
+ /* @__PURE__ */ jsxRuntime.jsx("b", { children: version.effectiveDate }),
133
+ " \uC2DC\uD589 (v",
134
+ version.version,
135
+ ").",
136
+ version.summary ? ` ${version.summary}` : "",
137
+ version.adverse ? " \uC774\uC6A9\uC790\uC5D0\uAC8C \uBD88\uB9AC/\uC911\uB300\uD55C \uBCC0\uACBD\uC73C\uB85C \uC2DC\uD589 30\uC77C \uC804\uBD80\uD130 \uACF5\uC9C0\uD569\uB2C8\uB2E4." : ` (\uACF5\uACE0\uC77C ${version.publishedDate})`,
138
+ onPreview && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
139
+ " ",
140
+ /* @__PURE__ */ jsxRuntime.jsx("button", { type: "button", className: "legal-notice-preview", onClick: onPreview, children: "\uBCC0\uACBD \uC608\uC815\uBCF8 \uBCF4\uAE30" })
141
+ ] })
142
+ ] }) });
143
+ }
144
+ var defaultRenderLink = (href, children) => /* @__PURE__ */ jsxRuntime.jsx("a", { href, children });
145
+ function PolicyNotice({
146
+ docs,
147
+ hrefFor,
148
+ today,
149
+ renderLink = defaultRenderLink,
150
+ storage,
151
+ className
152
+ }) {
153
+ const store = storage ?? (typeof localStorage !== "undefined" ? localStorage : void 0);
154
+ const pending = docs.filter((doc) => doc.visibility === "public").map((doc) => ({ doc, up: upcomingVersion(doc.versions, today) })).filter((x) => x.up != null);
155
+ const dismissKey = "legal-policy-notice:" + pending.map((x) => `${x.doc.serviceId}:${x.doc.kind}@${x.up.version}`).join(",");
156
+ const [dismissed, setDismissed] = react.useState(() => store?.getItem(dismissKey) === "1");
157
+ if (pending.length === 0 || dismissed) return null;
158
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: className ? `legal-policy-notice ${className}` : "legal-policy-notice", children: [
159
+ /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "legal-policy-notice-text", children: [
160
+ "\u{1F4E2}",
161
+ " ",
162
+ pending.map((x, i) => /* @__PURE__ */ jsxRuntime.jsxs("span", { children: [
163
+ i > 0 ? " \xB7 " : "",
164
+ renderLink(hrefFor(x.doc), x.doc.title),
165
+ " ",
166
+ x.up.effectiveDate,
167
+ "\uBD80\uD130 \uBCC0\uACBD \uC608\uC815"
168
+ ] }, `${x.doc.serviceId}:${x.doc.kind}`))
169
+ ] }),
170
+ /* @__PURE__ */ jsxRuntime.jsx(
171
+ "button",
172
+ {
173
+ type: "button",
174
+ className: "legal-policy-notice-dismiss",
175
+ "aria-label": "\uB2EB\uAE30",
176
+ onClick: () => {
177
+ store?.setItem(dismissKey, "1");
178
+ setDismissed(true);
179
+ },
180
+ children: "\u2715"
181
+ }
182
+ )
183
+ ] });
184
+ }
185
+
186
+ exports.LegalDocument = LegalDocument;
187
+ exports.PolicyNotice = PolicyNotice;
188
+ exports.UpcomingNotice = UpcomingNotice;
189
+ exports.VersionDiff = VersionDiff;
190
+ exports.VersionHistory = VersionHistory;
191
+ exports.effectiveVersion = effectiveVersion;
192
+ exports.policyInForce = policyInForce;
193
+ exports.renderHighlighted = renderHighlighted;
194
+ exports.todayISO = todayISO;
195
+ exports.upcomingVersion = upcomingVersion;
@@ -0,0 +1,89 @@
1
+ import { L as LegalDocMeta, a as LegalVersion, V as VersionChange } from './helpers-CRGM1DHb.cjs';
2
+ export { D as DataController, b as LegalDocKind, c as LegalSection, d as LegalVisibility, e as effectiveVersion, p as policyInForce, t as todayISO, u as upcomingVersion } from './helpers-CRGM1DHb.cjs';
3
+ import * as react from 'react';
4
+ import { ReactNode } from 'react';
5
+
6
+ /**
7
+ * Render text with `==highlight==` spans wrapped in <mark class="legal-mark">,
8
+ * and bare "\n" turned into <br/>. Used by the diff table to call out the exact
9
+ * words that changed between two versions.
10
+ */
11
+ declare function renderHighlighted(text: string): ReactNode;
12
+
13
+ /**
14
+ * Renders the titled sections of a legal document's effective version, with
15
+ * stable deep-link anchors: `${doc.docAnchor}` on the article and
16
+ * `${doc.docAnchor}-${section.id}` on each heading. Pass `version` to render a
17
+ * specific version instead of the effective one.
18
+ *
19
+ * Each section renders its Markdown `body` as text; the deprecated `content`
20
+ * (app JSX) is used only as a fallback when `body` is empty. Markdown is not
21
+ * parsed here — the public hub renders Markdown server-side; this component is
22
+ * for in-app JSX docs.
23
+ */
24
+ declare function LegalDocument({ doc, version, className, }: {
25
+ doc: LegalDocMeta;
26
+ version?: LegalVersion;
27
+ className?: string;
28
+ }): react.JSX.Element;
29
+
30
+ /**
31
+ * 3-column "변경 전 / 변경 후" table for the diff between two versions.
32
+ * `==highlight==` spans in before/after text are marked. Styleless — themed via
33
+ * the `legal-*` classes / CSS variables in the shipped stylesheet.
34
+ */
35
+ declare function VersionDiff({ changes, fromVersion, toVersion, className, }: {
36
+ changes: VersionChange[];
37
+ fromVersion: string;
38
+ toVersion: string;
39
+ className?: string;
40
+ }): react.JSX.Element;
41
+
42
+ /**
43
+ * Version picker / 이전 버전 list. Controlled: the parent owns `selectedIdx`
44
+ * and updates it from `onSelect`. Renders nothing for a single-version doc.
45
+ */
46
+ declare function VersionHistory({ versions, selectedIdx, onSelect, className, }: {
47
+ versions: LegalVersion[];
48
+ selectedIdx: number;
49
+ onSelect: (idx: number) => void;
50
+ className?: string;
51
+ }): react.JSX.Element | null;
52
+
53
+ /**
54
+ * Per-document advance-notice card (공시): "v2.0이 YYYY-MM-DD부터 시행됩니다".
55
+ * Render it on a document page when `upcomingVersion(doc.versions)` is non-null.
56
+ * `onPreview` (optional) lets the page switch the shown version to the upcoming one.
57
+ */
58
+ declare function UpcomingNotice({ docTitle, version, onPreview, className, }: {
59
+ docTitle: string;
60
+ version: LegalVersion;
61
+ onPreview?: () => void;
62
+ className?: string;
63
+ }): react.JSX.Element;
64
+
65
+ /** Router-agnostic link renderer. Defaults to a plain anchor. */
66
+ type RenderLink = (href: string, children: ReactNode) => ReactNode;
67
+ /**
68
+ * Site-wide advance-notice banner (사이트 공통 사전공시 배너): shows when any
69
+ * document has an announced-but-not-yet-effective version. Dismissible per
70
+ * pending-version-set (so a new announcement re-shows it). Renders nothing when
71
+ * nothing is pending.
72
+ *
73
+ * Router-agnostic: pass `renderLink` to use the app's Link (next/link,
74
+ * react-router) instead of a plain <a>.
75
+ *
76
+ * Interactive (uses state) — mount inside a client boundary (Next.js "use client").
77
+ */
78
+ declare function PolicyNotice({ docs, hrefFor, today, renderLink, storage, className, }: {
79
+ docs: LegalDocMeta[];
80
+ /** Maps a document to its route, e.g. (doc) => `/${doc.kind}`. */
81
+ hrefFor: (doc: LegalDocMeta) => string;
82
+ today?: string;
83
+ renderLink?: RenderLink;
84
+ /** Persistence for the dismissed flag. Defaults to localStorage when available. */
85
+ storage?: Pick<Storage, "getItem" | "setItem">;
86
+ className?: string;
87
+ }): react.JSX.Element | null;
88
+
89
+ export { LegalDocMeta, LegalDocument, LegalVersion, PolicyNotice, type RenderLink, UpcomingNotice, VersionChange, VersionDiff, VersionHistory, renderHighlighted };
@@ -0,0 +1,89 @@
1
+ import { L as LegalDocMeta, a as LegalVersion, V as VersionChange } from './helpers-CRGM1DHb.js';
2
+ export { D as DataController, b as LegalDocKind, c as LegalSection, d as LegalVisibility, e as effectiveVersion, p as policyInForce, t as todayISO, u as upcomingVersion } from './helpers-CRGM1DHb.js';
3
+ import * as react from 'react';
4
+ import { ReactNode } from 'react';
5
+
6
+ /**
7
+ * Render text with `==highlight==` spans wrapped in <mark class="legal-mark">,
8
+ * and bare "\n" turned into <br/>. Used by the diff table to call out the exact
9
+ * words that changed between two versions.
10
+ */
11
+ declare function renderHighlighted(text: string): ReactNode;
12
+
13
+ /**
14
+ * Renders the titled sections of a legal document's effective version, with
15
+ * stable deep-link anchors: `${doc.docAnchor}` on the article and
16
+ * `${doc.docAnchor}-${section.id}` on each heading. Pass `version` to render a
17
+ * specific version instead of the effective one.
18
+ *
19
+ * Each section renders its Markdown `body` as text; the deprecated `content`
20
+ * (app JSX) is used only as a fallback when `body` is empty. Markdown is not
21
+ * parsed here — the public hub renders Markdown server-side; this component is
22
+ * for in-app JSX docs.
23
+ */
24
+ declare function LegalDocument({ doc, version, className, }: {
25
+ doc: LegalDocMeta;
26
+ version?: LegalVersion;
27
+ className?: string;
28
+ }): react.JSX.Element;
29
+
30
+ /**
31
+ * 3-column "변경 전 / 변경 후" table for the diff between two versions.
32
+ * `==highlight==` spans in before/after text are marked. Styleless — themed via
33
+ * the `legal-*` classes / CSS variables in the shipped stylesheet.
34
+ */
35
+ declare function VersionDiff({ changes, fromVersion, toVersion, className, }: {
36
+ changes: VersionChange[];
37
+ fromVersion: string;
38
+ toVersion: string;
39
+ className?: string;
40
+ }): react.JSX.Element;
41
+
42
+ /**
43
+ * Version picker / 이전 버전 list. Controlled: the parent owns `selectedIdx`
44
+ * and updates it from `onSelect`. Renders nothing for a single-version doc.
45
+ */
46
+ declare function VersionHistory({ versions, selectedIdx, onSelect, className, }: {
47
+ versions: LegalVersion[];
48
+ selectedIdx: number;
49
+ onSelect: (idx: number) => void;
50
+ className?: string;
51
+ }): react.JSX.Element | null;
52
+
53
+ /**
54
+ * Per-document advance-notice card (공시): "v2.0이 YYYY-MM-DD부터 시행됩니다".
55
+ * Render it on a document page when `upcomingVersion(doc.versions)` is non-null.
56
+ * `onPreview` (optional) lets the page switch the shown version to the upcoming one.
57
+ */
58
+ declare function UpcomingNotice({ docTitle, version, onPreview, className, }: {
59
+ docTitle: string;
60
+ version: LegalVersion;
61
+ onPreview?: () => void;
62
+ className?: string;
63
+ }): react.JSX.Element;
64
+
65
+ /** Router-agnostic link renderer. Defaults to a plain anchor. */
66
+ type RenderLink = (href: string, children: ReactNode) => ReactNode;
67
+ /**
68
+ * Site-wide advance-notice banner (사이트 공통 사전공시 배너): shows when any
69
+ * document has an announced-but-not-yet-effective version. Dismissible per
70
+ * pending-version-set (so a new announcement re-shows it). Renders nothing when
71
+ * nothing is pending.
72
+ *
73
+ * Router-agnostic: pass `renderLink` to use the app's Link (next/link,
74
+ * react-router) instead of a plain <a>.
75
+ *
76
+ * Interactive (uses state) — mount inside a client boundary (Next.js "use client").
77
+ */
78
+ declare function PolicyNotice({ docs, hrefFor, today, renderLink, storage, className, }: {
79
+ docs: LegalDocMeta[];
80
+ /** Maps a document to its route, e.g. (doc) => `/${doc.kind}`. */
81
+ hrefFor: (doc: LegalDocMeta) => string;
82
+ today?: string;
83
+ renderLink?: RenderLink;
84
+ /** Persistence for the dismissed flag. Defaults to localStorage when available. */
85
+ storage?: Pick<Storage, "getItem" | "setItem">;
86
+ className?: string;
87
+ }): react.JSX.Element | null;
88
+
89
+ export { LegalDocMeta, LegalDocument, LegalVersion, PolicyNotice, type RenderLink, UpcomingNotice, VersionChange, VersionDiff, VersionHistory, renderHighlighted };
package/dist/index.js ADDED
@@ -0,0 +1,166 @@
1
+ import { effectiveVersion, upcomingVersion } from './chunk-MDFCRQ5H.js';
2
+ export { effectiveVersion, policyInForce, todayISO, upcomingVersion } from './chunk-MDFCRQ5H.js';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+ import { useState } from 'react';
5
+
6
+ function renderHighlighted(text) {
7
+ return text.split(/(==.*?==)/g).map((part, i) => {
8
+ if (part.startsWith("==") && part.endsWith("==") && part.length >= 4) {
9
+ return /* @__PURE__ */ jsx("mark", { className: "legal-mark", children: part.slice(2, -2) }, i);
10
+ }
11
+ if (part.includes("\n")) {
12
+ const lines = part.split("\n");
13
+ return lines.map((line, j) => /* @__PURE__ */ jsxs("span", { children: [
14
+ line,
15
+ j < lines.length - 1 && /* @__PURE__ */ jsx("br", {})
16
+ ] }, `${i}-${j}`));
17
+ }
18
+ return part;
19
+ });
20
+ }
21
+ function LegalDocument({
22
+ doc,
23
+ version,
24
+ className
25
+ }) {
26
+ const v = version ?? effectiveVersion(doc.versions);
27
+ return /* @__PURE__ */ jsx(
28
+ "article",
29
+ {
30
+ id: doc.docAnchor,
31
+ className: className ? `legal-document ${className}` : "legal-document",
32
+ children: v.sections.map((section) => /* @__PURE__ */ jsxs("section", { className: "legal-section", children: [
33
+ /* @__PURE__ */ jsx("h2", { id: `${doc.docAnchor}-${section.id}`, className: "legal-section-title", children: section.title }),
34
+ /* @__PURE__ */ jsx("div", { className: "legal-section-body", children: section.content ?? section.body })
35
+ ] }, section.id))
36
+ }
37
+ );
38
+ }
39
+ var badgeLabel = {
40
+ added: "\uCD94\uAC00",
41
+ removed: "\uC0AD\uC81C",
42
+ changed: "\uBCC0\uACBD"
43
+ };
44
+ function VersionDiff({
45
+ changes,
46
+ fromVersion,
47
+ toVersion,
48
+ className
49
+ }) {
50
+ return /* @__PURE__ */ jsxs("div", { className: className ? `legal-diff ${className}` : "legal-diff", children: [
51
+ /* @__PURE__ */ jsxs("h3", { className: "legal-diff-title", children: [
52
+ "\uC8FC\uC694 \uBCC0\uACBD \uC0AC\uD56D (v",
53
+ fromVersion,
54
+ " \u2192 v",
55
+ toVersion,
56
+ ")"
57
+ ] }),
58
+ /* @__PURE__ */ jsx("div", { className: "legal-diff-scroll", children: /* @__PURE__ */ jsxs("table", { className: "legal-diff-table", children: [
59
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsxs("tr", { children: [
60
+ /* @__PURE__ */ jsx("th", { children: "\uAD6C\uBD84" }),
61
+ /* @__PURE__ */ jsx("th", { children: "\uBCC0\uACBD \uC804" }),
62
+ /* @__PURE__ */ jsx("th", { children: "\uBCC0\uACBD \uD6C4" })
63
+ ] }) }),
64
+ /* @__PURE__ */ jsx("tbody", { children: changes.map((c, i) => /* @__PURE__ */ jsxs("tr", { children: [
65
+ /* @__PURE__ */ jsxs("td", { className: "legal-diff-section", children: [
66
+ /* @__PURE__ */ jsx("span", { children: c.section }),
67
+ /* @__PURE__ */ jsx("span", { className: `legal-badge legal-badge--${c.type}`, children: badgeLabel[c.type] })
68
+ ] }),
69
+ /* @__PURE__ */ jsx("td", { children: c.type === "added" ? /* @__PURE__ */ jsx("span", { className: "legal-diff-empty", children: "\u2014" }) : renderHighlighted(c.before ?? "") }),
70
+ /* @__PURE__ */ jsx("td", { children: c.type === "removed" ? /* @__PURE__ */ jsx("span", { className: "legal-diff-empty", children: "\u2014" }) : renderHighlighted(c.after ?? "") })
71
+ ] }, i)) })
72
+ ] }) })
73
+ ] });
74
+ }
75
+ function VersionHistory({
76
+ versions,
77
+ selectedIdx,
78
+ onSelect,
79
+ className
80
+ }) {
81
+ if (versions.length <= 1) return null;
82
+ return /* @__PURE__ */ jsxs("div", { className: className ? `legal-history ${className}` : "legal-history", children: [
83
+ /* @__PURE__ */ jsx("h3", { className: "legal-history-title", children: "\uC774\uC804 \uBC84\uC804" }),
84
+ /* @__PURE__ */ jsx("ul", { className: "legal-history-list", children: versions.map((v, i) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
85
+ "button",
86
+ {
87
+ type: "button",
88
+ onClick: () => onSelect(i),
89
+ "aria-current": i === selectedIdx ? "true" : void 0,
90
+ className: i === selectedIdx ? "legal-history-item is-selected" : "legal-history-item",
91
+ children: [
92
+ "v",
93
+ v.version,
94
+ " (",
95
+ v.effectiveDate,
96
+ ")"
97
+ ]
98
+ }
99
+ ) }, v.version)) })
100
+ ] });
101
+ }
102
+ function UpcomingNotice({
103
+ docTitle,
104
+ version,
105
+ onPreview,
106
+ className
107
+ }) {
108
+ return /* @__PURE__ */ jsx("div", { className: className ? `legal-notice ${className}` : "legal-notice", children: /* @__PURE__ */ jsxs("p", { className: "legal-notice-text", children: [
109
+ "\u{1F4E2} ",
110
+ /* @__PURE__ */ jsx("b", { children: docTitle }),
111
+ " \uBCC0\uACBD \uC608\uC815 \u2014 ",
112
+ /* @__PURE__ */ jsx("b", { children: version.effectiveDate }),
113
+ " \uC2DC\uD589 (v",
114
+ version.version,
115
+ ").",
116
+ version.summary ? ` ${version.summary}` : "",
117
+ version.adverse ? " \uC774\uC6A9\uC790\uC5D0\uAC8C \uBD88\uB9AC/\uC911\uB300\uD55C \uBCC0\uACBD\uC73C\uB85C \uC2DC\uD589 30\uC77C \uC804\uBD80\uD130 \uACF5\uC9C0\uD569\uB2C8\uB2E4." : ` (\uACF5\uACE0\uC77C ${version.publishedDate})`,
118
+ onPreview && /* @__PURE__ */ jsxs(Fragment, { children: [
119
+ " ",
120
+ /* @__PURE__ */ jsx("button", { type: "button", className: "legal-notice-preview", onClick: onPreview, children: "\uBCC0\uACBD \uC608\uC815\uBCF8 \uBCF4\uAE30" })
121
+ ] })
122
+ ] }) });
123
+ }
124
+ var defaultRenderLink = (href, children) => /* @__PURE__ */ jsx("a", { href, children });
125
+ function PolicyNotice({
126
+ docs,
127
+ hrefFor,
128
+ today,
129
+ renderLink = defaultRenderLink,
130
+ storage,
131
+ className
132
+ }) {
133
+ const store = storage ?? (typeof localStorage !== "undefined" ? localStorage : void 0);
134
+ const pending = docs.filter((doc) => doc.visibility === "public").map((doc) => ({ doc, up: upcomingVersion(doc.versions, today) })).filter((x) => x.up != null);
135
+ const dismissKey = "legal-policy-notice:" + pending.map((x) => `${x.doc.serviceId}:${x.doc.kind}@${x.up.version}`).join(",");
136
+ const [dismissed, setDismissed] = useState(() => store?.getItem(dismissKey) === "1");
137
+ if (pending.length === 0 || dismissed) return null;
138
+ return /* @__PURE__ */ jsxs("div", { className: className ? `legal-policy-notice ${className}` : "legal-policy-notice", children: [
139
+ /* @__PURE__ */ jsxs("span", { className: "legal-policy-notice-text", children: [
140
+ "\u{1F4E2}",
141
+ " ",
142
+ pending.map((x, i) => /* @__PURE__ */ jsxs("span", { children: [
143
+ i > 0 ? " \xB7 " : "",
144
+ renderLink(hrefFor(x.doc), x.doc.title),
145
+ " ",
146
+ x.up.effectiveDate,
147
+ "\uBD80\uD130 \uBCC0\uACBD \uC608\uC815"
148
+ ] }, `${x.doc.serviceId}:${x.doc.kind}`))
149
+ ] }),
150
+ /* @__PURE__ */ jsx(
151
+ "button",
152
+ {
153
+ type: "button",
154
+ className: "legal-policy-notice-dismiss",
155
+ "aria-label": "\uB2EB\uAE30",
156
+ onClick: () => {
157
+ store?.setItem(dismissKey, "1");
158
+ setDismissed(true);
159
+ },
160
+ children: "\u2715"
161
+ }
162
+ )
163
+ ] });
164
+ }
165
+
166
+ export { LegalDocument, PolicyNotice, UpcomingNotice, VersionDiff, VersionHistory, renderHighlighted };
@@ -0,0 +1,191 @@
1
+ /*
2
+ * Optional default theme for @etamong-lab/legal.
3
+ *
4
+ * Import once in the app: `import "@etamong-lab/legal/styles.css";`
5
+ * Override the CSS variables to theme (including dark mode) — e.g.
6
+ * .dark { --legal-border: #3f3f46; --legal-muted: #a1a1aa; ... }
7
+ * Or skip this file entirely and style the `legal-*` classes yourself.
8
+ */
9
+ :root {
10
+ --legal-fg: #3f3f46;
11
+ --legal-muted: #71717a;
12
+ --legal-border: #e4e4e7;
13
+ --legal-surface: #fafafa;
14
+ --legal-accent: #2563eb;
15
+ --legal-mark-bg: #fef08a;
16
+ --legal-mark-fg: inherit;
17
+ --legal-notice-bg: #eff6ff;
18
+ --legal-notice-border: #bfdbfe;
19
+ --legal-notice-fg: #1e40af;
20
+ --legal-badge-added-bg: #dcfce7;
21
+ --legal-badge-added-fg: #15803d;
22
+ --legal-badge-removed-bg: #fee2e2;
23
+ --legal-badge-removed-fg: #b91c1c;
24
+ --legal-badge-changed-bg: #fef3c7;
25
+ --legal-badge-changed-fg: #b45309;
26
+ }
27
+
28
+ .legal-document {
29
+ color: var(--legal-fg);
30
+ font-size: 0.875rem;
31
+ line-height: 1.7;
32
+ display: flex;
33
+ flex-direction: column;
34
+ gap: 1.5rem;
35
+ }
36
+ .legal-section-title {
37
+ font-size: 1rem;
38
+ font-weight: 600;
39
+ margin: 0 0 0.5rem;
40
+ }
41
+ .legal-section-body > * + * {
42
+ margin-top: 0.5rem;
43
+ }
44
+
45
+ .legal-mark {
46
+ background: var(--legal-mark-bg);
47
+ color: var(--legal-mark-fg);
48
+ border-radius: 0.25rem;
49
+ padding: 0 0.125rem;
50
+ }
51
+
52
+ /* --- diff table --- */
53
+ .legal-diff {
54
+ margin-top: 2.5rem;
55
+ }
56
+ .legal-diff-title {
57
+ font-size: 0.875rem;
58
+ font-weight: 600;
59
+ margin: 0 0 0.75rem;
60
+ }
61
+ .legal-diff-scroll {
62
+ overflow-x: auto;
63
+ border: 1px solid var(--legal-border);
64
+ border-radius: 0.5rem;
65
+ }
66
+ .legal-diff-table {
67
+ width: 100%;
68
+ border-collapse: collapse;
69
+ font-size: 0.875rem;
70
+ }
71
+ .legal-diff-table th {
72
+ text-align: left;
73
+ font-weight: 500;
74
+ color: var(--legal-muted);
75
+ background: var(--legal-surface);
76
+ padding: 0.5rem 0.75rem;
77
+ border-bottom: 1px solid var(--legal-border);
78
+ }
79
+ .legal-diff-table td {
80
+ vertical-align: top;
81
+ color: var(--legal-muted);
82
+ padding: 0.625rem 0.75rem;
83
+ border-top: 1px solid var(--legal-border);
84
+ }
85
+ .legal-diff-section span:first-child {
86
+ color: var(--legal-fg);
87
+ }
88
+ .legal-diff-empty {
89
+ opacity: 0.5;
90
+ }
91
+ .legal-badge {
92
+ display: inline-block;
93
+ margin-left: 0.375rem;
94
+ padding: 0.0625rem 0.375rem;
95
+ font-size: 0.75rem;
96
+ font-weight: 500;
97
+ border-radius: 0.25rem;
98
+ }
99
+ .legal-badge--added {
100
+ background: var(--legal-badge-added-bg);
101
+ color: var(--legal-badge-added-fg);
102
+ }
103
+ .legal-badge--removed {
104
+ background: var(--legal-badge-removed-bg);
105
+ color: var(--legal-badge-removed-fg);
106
+ }
107
+ .legal-badge--changed {
108
+ background: var(--legal-badge-changed-bg);
109
+ color: var(--legal-badge-changed-fg);
110
+ }
111
+
112
+ /* --- version history --- */
113
+ .legal-history {
114
+ margin-top: 3rem;
115
+ border-top: 1px solid var(--legal-border);
116
+ padding-top: 1.5rem;
117
+ }
118
+ .legal-history-title {
119
+ font-size: 0.875rem;
120
+ font-weight: 600;
121
+ margin: 0 0 0.75rem;
122
+ }
123
+ .legal-history-list {
124
+ list-style: none;
125
+ margin: 0;
126
+ padding: 0;
127
+ display: flex;
128
+ flex-direction: column;
129
+ gap: 0.5rem;
130
+ }
131
+ .legal-history-item {
132
+ background: none;
133
+ border: none;
134
+ padding: 0;
135
+ cursor: pointer;
136
+ font-size: 0.875rem;
137
+ color: var(--legal-accent);
138
+ }
139
+ .legal-history-item.is-selected {
140
+ color: var(--legal-fg);
141
+ font-weight: 500;
142
+ cursor: default;
143
+ }
144
+
145
+ /* --- per-doc upcoming notice --- */
146
+ .legal-notice {
147
+ background: var(--legal-notice-bg);
148
+ border: 1px solid var(--legal-notice-border);
149
+ border-radius: 0.5rem;
150
+ padding: 0.75rem 1rem;
151
+ margin-bottom: 1.5rem;
152
+ }
153
+ .legal-notice-text {
154
+ margin: 0;
155
+ font-size: 0.875rem;
156
+ color: var(--legal-notice-fg);
157
+ }
158
+ .legal-notice-preview {
159
+ background: none;
160
+ border: none;
161
+ padding: 0;
162
+ cursor: pointer;
163
+ font-weight: 500;
164
+ text-decoration: underline;
165
+ color: inherit;
166
+ }
167
+
168
+ /* --- site-wide policy banner --- */
169
+ .legal-policy-notice {
170
+ display: flex;
171
+ align-items: center;
172
+ justify-content: space-between;
173
+ gap: 0.75rem;
174
+ background: var(--legal-notice-bg);
175
+ border-bottom: 1px solid var(--legal-notice-border);
176
+ color: var(--legal-notice-fg);
177
+ padding: 0.5rem 1rem;
178
+ font-size: 0.875rem;
179
+ }
180
+ .legal-policy-notice a {
181
+ color: inherit;
182
+ font-weight: 500;
183
+ }
184
+ .legal-policy-notice-dismiss {
185
+ background: none;
186
+ border: none;
187
+ cursor: pointer;
188
+ color: inherit;
189
+ font-size: 1rem;
190
+ line-height: 1;
191
+ }
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@etamong-playground/legal",
3
+ "version": "0.3.1",
4
+ "description": "Shared versioned legal-document model + headless renderers (Terms/Privacy, version diff, advance-notice) for etamong-lab apps.",
5
+ "type": "module",
6
+ "sideEffects": [
7
+ "*.css"
8
+ ],
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./helpers": {
19
+ "types": "./dist/helpers.d.ts",
20
+ "import": "./dist/helpers.js",
21
+ "require": "./dist/helpers.cjs"
22
+ },
23
+ "./styles.css": "./dist/styles.css"
24
+ },
25
+ "files": [
26
+ "dist"
27
+ ],
28
+ "scripts": {
29
+ "build": "tsup",
30
+ "dev": "tsup --watch",
31
+ "typecheck": "tsc --noEmit"
32
+ },
33
+ "peerDependencies": {
34
+ "react": ">=18"
35
+ },
36
+ "devDependencies": {
37
+ "@types/react": "^18.3.12",
38
+ "react": "^18.3.1",
39
+ "tsup": "^8.3.5",
40
+ "typescript": "^5.6.3"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "license": "UNLICENSED"
46
+ }