@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codemeall/create-word-pages",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Scaffold a Word Pages Obsidian-to-GitHub-Pages starter.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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) => response.json())
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 <main className="shell"><p>{status}</p></main>
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
- setStatus("Saving")
75
- const response = await fetch("/api/config", {
76
- method: "POST",
77
- headers: { "Content-Type": "application/json" },
78
- body: JSON.stringify(config)
79
- })
80
- setStatus(response.ok ? "Saved" : "Save failed")
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}>Save</button>
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
- if (req.method === "GET") {
17
- const body = await readFile(configPath, "utf8")
18
- res.setHeader("Content-Type", "application/json")
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(JSON.stringify({ ok: true }))
33
- })
34
- return
35
- }
35
+ res.end(body)
36
+ return
37
+ }
36
38
 
37
- res.statusCode = 405
38
- res.end("Method not allowed")
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
  }