@codemeall/create-word-pages 0.1.1 → 0.1.3
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/package.json
CHANGED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const baseUrlPattern = /^[A-Za-z0-9.-]+(\/[A-Za-z0-9._~-]+)*$/
|
|
2
|
+
|
|
3
|
+
export const requiredSiteFields = ["title", "githubUsername", "repositoryName", "baseUrl"]
|
|
4
|
+
|
|
5
|
+
export function validateWordPagesConfig(config) {
|
|
6
|
+
const fieldErrors = {}
|
|
7
|
+
const site = config?.site ?? {}
|
|
8
|
+
|
|
9
|
+
for (const field of requiredSiteFields) {
|
|
10
|
+
if (!String(site[field] ?? "").trim()) {
|
|
11
|
+
fieldErrors[field] = "Required"
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const baseUrl = String(site.baseUrl ?? "").trim()
|
|
16
|
+
if (baseUrl) {
|
|
17
|
+
if (/^[a-z][a-z\d+.-]*:\/\//i.test(baseUrl)) {
|
|
18
|
+
fieldErrors.baseUrl = "Use the Pages URL without https://"
|
|
19
|
+
} else if (/\s/.test(baseUrl)) {
|
|
20
|
+
fieldErrors.baseUrl = "Remove spaces from the Pages URL"
|
|
21
|
+
} else if (baseUrl.startsWith("/") || baseUrl.endsWith("/")) {
|
|
22
|
+
fieldErrors.baseUrl = "Do not use leading or trailing slashes"
|
|
23
|
+
} else if (!baseUrlPattern.test(baseUrl)) {
|
|
24
|
+
fieldErrors.baseUrl = "Use a value like octocat.github.io/my-site"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
ok: Object.keys(fieldErrors).length === 0,
|
|
30
|
+
fieldErrors
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import React, { useEffect, useMemo, useState } from "react"
|
|
2
2
|
import { createRoot } from "react-dom/client"
|
|
3
|
+
import { validateWordPagesConfig } from "./configValidation.js"
|
|
3
4
|
import "./styles.css"
|
|
4
5
|
|
|
6
|
+
type SaveState = "idle" | "dirty" | "saving" | "saved" | "failed"
|
|
7
|
+
|
|
5
8
|
type WordPagesConfig = {
|
|
6
9
|
site: {
|
|
7
10
|
title: string
|
|
@@ -21,6 +24,8 @@ type WordPagesConfig = {
|
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
26
|
|
|
27
|
+
type SiteFieldErrors = Partial<Record<keyof WordPagesConfig["site"], string>>
|
|
28
|
+
|
|
24
29
|
function deriveBaseUrl(username: string, repositoryName: string) {
|
|
25
30
|
if (!username || !repositoryName) return ""
|
|
26
31
|
if (repositoryName.toLowerCase() === `${username.toLowerCase()}.github.io`) return `${username}.github.io`
|
|
@@ -34,15 +39,26 @@ function displayPagesUrl(baseUrl: string) {
|
|
|
34
39
|
function App() {
|
|
35
40
|
const [config, setConfig] = useState<WordPagesConfig | null>(null)
|
|
36
41
|
const [status, setStatus] = useState("Loading")
|
|
42
|
+
const [saveState, setSaveState] = useState<SaveState>("idle")
|
|
43
|
+
const [message, setMessage] = useState("Load the wizard, edit settings, then save.")
|
|
44
|
+
const [lastSavedAt, setLastSavedAt] = useState("")
|
|
37
45
|
|
|
38
46
|
useEffect(() => {
|
|
39
47
|
fetch("/api/config")
|
|
40
|
-
.then((response) =>
|
|
48
|
+
.then((response) => {
|
|
49
|
+
if (!response.ok) throw new Error("Could not load word-pages.config.json")
|
|
50
|
+
return response.json()
|
|
51
|
+
})
|
|
41
52
|
.then((data) => {
|
|
42
53
|
setConfig(data)
|
|
43
54
|
setStatus("Ready")
|
|
55
|
+
setMessage("Configuration loaded from word-pages.config.json.")
|
|
56
|
+
})
|
|
57
|
+
.catch((error) => {
|
|
58
|
+
setStatus("Could not load configuration")
|
|
59
|
+
setSaveState("failed")
|
|
60
|
+
setMessage(error instanceof Error ? error.message : "Restart npm run wizard from the site root.")
|
|
44
61
|
})
|
|
45
|
-
.catch(() => setStatus("Could not load configuration"))
|
|
46
62
|
}, [])
|
|
47
63
|
|
|
48
64
|
const pageUrl = useMemo(() => {
|
|
@@ -50,8 +66,23 @@ function App() {
|
|
|
50
66
|
return displayPagesUrl(deriveBaseUrl(config.site.githubUsername, config.site.repositoryName))
|
|
51
67
|
}, [config])
|
|
52
68
|
|
|
69
|
+
const fieldErrors = useMemo<SiteFieldErrors>(() => {
|
|
70
|
+
if (!config) return {}
|
|
71
|
+
return validateWordPagesConfig(config).fieldErrors
|
|
72
|
+
}, [config])
|
|
73
|
+
const canSave = Object.keys(fieldErrors).length === 0 && saveState !== "saving"
|
|
74
|
+
|
|
53
75
|
if (!config) {
|
|
54
|
-
return
|
|
76
|
+
return (
|
|
77
|
+
<main className="shell">
|
|
78
|
+
<section className={`status-panel ${saveState}`} role="status" aria-live="polite">
|
|
79
|
+
<div>
|
|
80
|
+
<strong>{status}</strong>
|
|
81
|
+
<p>{message}</p>
|
|
82
|
+
</div>
|
|
83
|
+
</section>
|
|
84
|
+
</main>
|
|
85
|
+
)
|
|
55
86
|
}
|
|
56
87
|
|
|
57
88
|
function updateSite<K extends keyof WordPagesConfig["site"]>(key: K, value: WordPagesConfig["site"][K]) {
|
|
@@ -68,16 +99,47 @@ function App() {
|
|
|
68
99
|
: current.site.baseUrl
|
|
69
100
|
}
|
|
70
101
|
})
|
|
102
|
+
setSaveState("dirty")
|
|
103
|
+
setMessage("Unsaved changes. Click Save changes to write word-pages.config.json.")
|
|
71
104
|
}
|
|
72
105
|
|
|
73
106
|
async function save() {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
const validation = validateWordPagesConfig(config)
|
|
108
|
+
if (!validation.ok) {
|
|
109
|
+
setStatus("Required fields missing")
|
|
110
|
+
setSaveState("failed")
|
|
111
|
+
setMessage("Complete the required fields before saving word-pages.config.json.")
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
setStatus("Saving")
|
|
117
|
+
setSaveState("saving")
|
|
118
|
+
setMessage("Saving word-pages.config.json...")
|
|
119
|
+
|
|
120
|
+
const response = await fetch("/api/config", {
|
|
121
|
+
method: "POST",
|
|
122
|
+
headers: { "Content-Type": "application/json" },
|
|
123
|
+
body: JSON.stringify(config)
|
|
124
|
+
})
|
|
125
|
+
const result = await response.json().catch(() => ({ message: "No response body" }))
|
|
126
|
+
if (!response.ok || result.ok === false) {
|
|
127
|
+
if (result.fieldErrors) {
|
|
128
|
+
throw new Error(result.message ?? "Complete the required fields before saving.")
|
|
129
|
+
}
|
|
130
|
+
throw new Error(result.message ?? "Save failed")
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const savedAt = new Date().toLocaleTimeString()
|
|
134
|
+
setStatus("Saved")
|
|
135
|
+
setSaveState("saved")
|
|
136
|
+
setLastSavedAt(savedAt)
|
|
137
|
+
setMessage(`Saved to word-pages.config.json at ${savedAt}. Run npm run preview in another terminal to rebuild the site.`)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
setStatus("Save failed")
|
|
140
|
+
setSaveState("failed")
|
|
141
|
+
setMessage(error instanceof Error ? error.message : "Save failed")
|
|
142
|
+
}
|
|
81
143
|
}
|
|
82
144
|
|
|
83
145
|
return (
|
|
@@ -88,13 +150,24 @@ function App() {
|
|
|
88
150
|
<h1>Configure your Obsidian-powered site.</h1>
|
|
89
151
|
<p className="lede">This wizard writes local configuration only. It does not ask for GitHub credentials and does not upload your vault.</p>
|
|
90
152
|
</div>
|
|
91
|
-
<button onClick={save}>
|
|
153
|
+
<button type="button" onClick={save} disabled={!canSave}>
|
|
154
|
+
{saveState === "saving" ? "Saving..." : "Save changes"}
|
|
155
|
+
</button>
|
|
156
|
+
</section>
|
|
157
|
+
|
|
158
|
+
<section className={`status-panel ${saveState}`} role="status" aria-live="polite">
|
|
159
|
+
<div>
|
|
160
|
+
<strong>{status}</strong>
|
|
161
|
+
<p>{message}</p>
|
|
162
|
+
</div>
|
|
163
|
+
{lastSavedAt && <span>Last saved {lastSavedAt}</span>}
|
|
92
164
|
</section>
|
|
93
165
|
|
|
94
166
|
<section className="grid">
|
|
95
167
|
<label>
|
|
96
168
|
Site title
|
|
97
|
-
<input value={config.site.title} onChange={(event) => updateSite("title", event.target.value)} />
|
|
169
|
+
<input value={config.site.title} onChange={(event) => updateSite("title", event.target.value)} aria-invalid={Boolean(fieldErrors.title)} />
|
|
170
|
+
{fieldErrors.title && <span className="field-error">{fieldErrors.title}</span>}
|
|
98
171
|
</label>
|
|
99
172
|
<label>
|
|
100
173
|
Author or organization
|
|
@@ -106,11 +179,13 @@ function App() {
|
|
|
106
179
|
</label>
|
|
107
180
|
<label>
|
|
108
181
|
GitHub username
|
|
109
|
-
<input value={config.site.githubUsername} onChange={(event) => updateSite("githubUsername", event.target.value)} />
|
|
182
|
+
<input value={config.site.githubUsername} onChange={(event) => updateSite("githubUsername", event.target.value)} aria-invalid={Boolean(fieldErrors.githubUsername)} />
|
|
183
|
+
{fieldErrors.githubUsername && <span className="field-error">{fieldErrors.githubUsername}</span>}
|
|
110
184
|
</label>
|
|
111
185
|
<label>
|
|
112
186
|
Repository name
|
|
113
|
-
<input value={config.site.repositoryName} onChange={(event) => updateSite("repositoryName", event.target.value)} />
|
|
187
|
+
<input value={config.site.repositoryName} onChange={(event) => updateSite("repositoryName", event.target.value)} aria-invalid={Boolean(fieldErrors.repositoryName)} />
|
|
188
|
+
{fieldErrors.repositoryName && <span className="field-error">{fieldErrors.repositoryName}</span>}
|
|
114
189
|
</label>
|
|
115
190
|
<label>
|
|
116
191
|
Repository visibility
|
|
@@ -122,7 +197,8 @@ function App() {
|
|
|
122
197
|
</label>
|
|
123
198
|
<label>
|
|
124
199
|
GitHub Pages base URL
|
|
125
|
-
<input value={config.site.baseUrl} onChange={(event) => updateSite("baseUrl", event.target.value)} />
|
|
200
|
+
<input value={config.site.baseUrl} onChange={(event) => updateSite("baseUrl", event.target.value)} aria-invalid={Boolean(fieldErrors.baseUrl)} />
|
|
201
|
+
{fieldErrors.baseUrl && <span className="field-error">{fieldErrors.baseUrl}</span>}
|
|
126
202
|
</label>
|
|
127
203
|
</section>
|
|
128
204
|
|
|
@@ -133,6 +209,27 @@ function App() {
|
|
|
133
209
|
<p><strong>Expected Pages URL:</strong> {pageUrl}</p>
|
|
134
210
|
</section>
|
|
135
211
|
|
|
212
|
+
<section className="next-steps">
|
|
213
|
+
<h2>Next steps</h2>
|
|
214
|
+
<div className="steps-grid">
|
|
215
|
+
<div>
|
|
216
|
+
<span>1</span>
|
|
217
|
+
<h3>Edit content</h3>
|
|
218
|
+
<p>Open <code>content/</code> in Obsidian and update pages, posts, and notes.</p>
|
|
219
|
+
</div>
|
|
220
|
+
<div>
|
|
221
|
+
<span>2</span>
|
|
222
|
+
<h3>Preview locally</h3>
|
|
223
|
+
<p>Run <code>npm run preview</code> in a second terminal. Quartz serves the site at its local preview URL.</p>
|
|
224
|
+
</div>
|
|
225
|
+
<div>
|
|
226
|
+
<span>3</span>
|
|
227
|
+
<h3>Publish</h3>
|
|
228
|
+
<p>Commit, push to GitHub, and enable Pages with GitHub Actions.</p>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
</section>
|
|
232
|
+
|
|
136
233
|
<footer>
|
|
137
234
|
<span>{status}</span>
|
|
138
235
|
<span>Next: open <code>content/</code> in Obsidian, then run <code>npm run preview</code>.</span>
|
|
@@ -59,6 +59,48 @@ button {
|
|
|
59
59
|
cursor: pointer;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
button:disabled {
|
|
63
|
+
cursor: not-allowed;
|
|
64
|
+
opacity: 0.72;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.status-panel {
|
|
68
|
+
display: flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
justify-content: space-between;
|
|
71
|
+
gap: 16px;
|
|
72
|
+
margin: 24px 0 0;
|
|
73
|
+
border: 1px solid #d8d0c0;
|
|
74
|
+
border-left: 5px solid #8a8376;
|
|
75
|
+
border-radius: 8px;
|
|
76
|
+
padding: 16px;
|
|
77
|
+
background: #fffdf8;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.status-panel p {
|
|
81
|
+
margin: 4px 0 0;
|
|
82
|
+
color: #5f594f;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.status-panel.saved {
|
|
86
|
+
border-left-color: #28786f;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.status-panel.dirty,
|
|
90
|
+
.status-panel.saving {
|
|
91
|
+
border-left-color: #8d5c2c;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.status-panel.failed {
|
|
95
|
+
border-left-color: #b83232;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.status-panel > span {
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
color: #6f675c;
|
|
101
|
+
font-size: 0.92rem;
|
|
102
|
+
}
|
|
103
|
+
|
|
62
104
|
.grid {
|
|
63
105
|
display: grid;
|
|
64
106
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
@@ -89,6 +131,17 @@ textarea {
|
|
|
89
131
|
font: inherit;
|
|
90
132
|
}
|
|
91
133
|
|
|
134
|
+
input[aria-invalid="true"] {
|
|
135
|
+
border-color: #b83232;
|
|
136
|
+
outline-color: #b83232;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.field-error {
|
|
140
|
+
color: #9f2525;
|
|
141
|
+
font-size: 0.88rem;
|
|
142
|
+
font-weight: 700;
|
|
143
|
+
}
|
|
144
|
+
|
|
92
145
|
textarea {
|
|
93
146
|
min-height: 104px;
|
|
94
147
|
resize: vertical;
|
|
@@ -105,6 +158,44 @@ textarea {
|
|
|
105
158
|
margin-top: 0;
|
|
106
159
|
}
|
|
107
160
|
|
|
161
|
+
.next-steps {
|
|
162
|
+
margin-top: 24px;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.steps-grid {
|
|
166
|
+
display: grid;
|
|
167
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
168
|
+
gap: 16px;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.steps-grid > div {
|
|
172
|
+
border: 1px solid #ded8ca;
|
|
173
|
+
border-radius: 8px;
|
|
174
|
+
padding: 18px;
|
|
175
|
+
background: #fffdf8;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.steps-grid span {
|
|
179
|
+
display: inline-grid;
|
|
180
|
+
width: 28px;
|
|
181
|
+
height: 28px;
|
|
182
|
+
place-items: center;
|
|
183
|
+
border-radius: 50%;
|
|
184
|
+
color: white;
|
|
185
|
+
background: #28786f;
|
|
186
|
+
font-size: 0.9rem;
|
|
187
|
+
font-weight: 800;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.steps-grid h3 {
|
|
191
|
+
margin: 14px 0 8px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.steps-grid p {
|
|
195
|
+
margin: 0;
|
|
196
|
+
color: #5f594f;
|
|
197
|
+
}
|
|
198
|
+
|
|
108
199
|
code {
|
|
109
200
|
border-radius: 4px;
|
|
110
201
|
padding: 2px 5px;
|
|
@@ -121,12 +212,18 @@ footer {
|
|
|
121
212
|
|
|
122
213
|
@media (max-width: 720px) {
|
|
123
214
|
.hero,
|
|
124
|
-
footer
|
|
215
|
+
footer,
|
|
216
|
+
.status-panel {
|
|
125
217
|
align-items: stretch;
|
|
126
218
|
flex-direction: column;
|
|
127
219
|
}
|
|
128
220
|
|
|
129
|
-
.grid
|
|
221
|
+
.grid,
|
|
222
|
+
.steps-grid {
|
|
130
223
|
grid-template-columns: 1fr;
|
|
131
224
|
}
|
|
225
|
+
|
|
226
|
+
.status-panel > span {
|
|
227
|
+
white-space: normal;
|
|
228
|
+
}
|
|
132
229
|
}
|
|
@@ -2,8 +2,23 @@ import { defineConfig } from "vite"
|
|
|
2
2
|
import react from "@vitejs/plugin-react"
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises"
|
|
4
4
|
import path from "node:path"
|
|
5
|
+
import { validateWordPagesConfig } from "./src/configValidation.js"
|
|
5
6
|
|
|
6
7
|
const configPath = path.resolve(process.cwd(), "word-pages.config.json")
|
|
8
|
+
let printedCompletionHandoff = false
|
|
9
|
+
|
|
10
|
+
function printCompletionHandoff() {
|
|
11
|
+
if (printedCompletionHandoff) return
|
|
12
|
+
printedCompletionHandoff = true
|
|
13
|
+
console.log(`
|
|
14
|
+
Word Pages setup saved.
|
|
15
|
+
|
|
16
|
+
Next steps:
|
|
17
|
+
1. Open content/ in Obsidian and edit your pages, posts, and notes.
|
|
18
|
+
2. In another terminal, run: npm run preview
|
|
19
|
+
3. When ready, commit, push to GitHub, and enable Pages with GitHub Actions.
|
|
20
|
+
`)
|
|
21
|
+
}
|
|
7
22
|
|
|
8
23
|
export default defineConfig({
|
|
9
24
|
root: "wizard",
|
|
@@ -13,29 +28,61 @@ export default defineConfig({
|
|
|
13
28
|
name: "word-pages-config-api",
|
|
14
29
|
configureServer(server) {
|
|
15
30
|
server.middlewares.use("/api/config", async (req, res) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
res.end(body)
|
|
20
|
-
return
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (req.method === "POST") {
|
|
24
|
-
let raw = ""
|
|
25
|
-
req.on("data", (chunk) => {
|
|
26
|
-
raw += chunk
|
|
27
|
-
})
|
|
28
|
-
req.on("end", async () => {
|
|
29
|
-
const parsed = JSON.parse(raw)
|
|
30
|
-
await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
31
|
+
try {
|
|
32
|
+
if (req.method === "GET") {
|
|
33
|
+
const body = await readFile(configPath, "utf8")
|
|
31
34
|
res.setHeader("Content-Type", "application/json")
|
|
32
|
-
res.end(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
35
|
+
res.end(body)
|
|
36
|
+
return
|
|
37
|
+
}
|
|
36
38
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
if (req.method === "POST") {
|
|
40
|
+
let raw = ""
|
|
41
|
+
req.on("data", (chunk) => {
|
|
42
|
+
raw += chunk
|
|
43
|
+
})
|
|
44
|
+
req.on("end", async () => {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = JSON.parse(raw)
|
|
47
|
+
const validation = validateWordPagesConfig(parsed)
|
|
48
|
+
if (!validation.ok) {
|
|
49
|
+
res.statusCode = 400
|
|
50
|
+
res.setHeader("Content-Type", "application/json")
|
|
51
|
+
res.end(JSON.stringify({
|
|
52
|
+
ok: false,
|
|
53
|
+
message: "Complete the required fields before saving.",
|
|
54
|
+
fieldErrors: validation.fieldErrors
|
|
55
|
+
}))
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
await writeFile(configPath, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
60
|
+
printCompletionHandoff()
|
|
61
|
+
res.setHeader("Content-Type", "application/json")
|
|
62
|
+
res.end(JSON.stringify({ ok: true, path: configPath }))
|
|
63
|
+
} catch (error) {
|
|
64
|
+
res.statusCode = 500
|
|
65
|
+
res.setHeader("Content-Type", "application/json")
|
|
66
|
+
res.end(JSON.stringify({
|
|
67
|
+
ok: false,
|
|
68
|
+
message: error instanceof Error ? error.message : "Could not save configuration"
|
|
69
|
+
}))
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
res.statusCode = 405
|
|
76
|
+
res.setHeader("Content-Type", "application/json")
|
|
77
|
+
res.end(JSON.stringify({ ok: false, message: "Method not allowed" }))
|
|
78
|
+
} catch (error) {
|
|
79
|
+
res.statusCode = 500
|
|
80
|
+
res.setHeader("Content-Type", "application/json")
|
|
81
|
+
res.end(JSON.stringify({
|
|
82
|
+
ok: false,
|
|
83
|
+
message: error instanceof Error ? error.message : "Configuration API failed"
|
|
84
|
+
}))
|
|
85
|
+
}
|
|
39
86
|
})
|
|
40
87
|
}
|
|
41
88
|
}
|