@codemeall/create-word-pages 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cp, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"
4
+ import path from "node:path"
5
+ import { fileURLToPath } from "node:url"
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
8
+ const packageRoot = path.resolve(__dirname, "..")
9
+ const templateRoot = path.join(packageRoot, "template")
10
+
11
+ const rawTarget = process.argv[2] ?? "word-pages-site"
12
+ const targetDir = path.resolve(process.cwd(), rawTarget)
13
+ const projectName = path.basename(targetDir)
14
+
15
+ async function ensureEmptyOrCreate(dir) {
16
+ await mkdir(dir, { recursive: true })
17
+ const entries = await readdir(dir)
18
+ if (entries.length > 0) {
19
+ throw new Error(`Target directory is not empty: ${dir}`)
20
+ }
21
+ }
22
+
23
+ async function replacePlaceholders(filePath) {
24
+ const info = await stat(filePath)
25
+ if (!info.isFile()) return
26
+
27
+ const textExtensions = new Set([
28
+ ".css",
29
+ ".html",
30
+ ".js",
31
+ ".json",
32
+ ".md",
33
+ ".mjs",
34
+ ".ts",
35
+ ".tsx",
36
+ ".txt",
37
+ ".yml"
38
+ ])
39
+
40
+ if (!textExtensions.has(path.extname(filePath))) return
41
+
42
+ const source = await readFile(filePath, "utf8")
43
+ const updated = source.replaceAll("__WORD_PAGES_PROJECT_NAME__", projectName)
44
+ if (updated !== source) {
45
+ await writeFile(filePath, updated)
46
+ }
47
+ }
48
+
49
+ async function walk(dir, visitor) {
50
+ const entries = await readdir(dir, { withFileTypes: true })
51
+ for (const entry of entries) {
52
+ const fullPath = path.join(dir, entry.name)
53
+ if (entry.isDirectory()) {
54
+ await walk(fullPath, visitor)
55
+ } else {
56
+ await visitor(fullPath)
57
+ }
58
+ }
59
+ }
60
+
61
+ try {
62
+ await ensureEmptyOrCreate(targetDir)
63
+ await cp(templateRoot, targetDir, { recursive: true })
64
+ await walk(targetDir, replacePlaceholders)
65
+
66
+ console.log(`Created Word Pages starter in ${targetDir}`)
67
+ console.log("")
68
+ console.log("Next steps:")
69
+ console.log(` cd ${path.relative(process.cwd(), targetDir) || "."}`)
70
+ console.log(" npm install")
71
+ console.log(" npm run wizard")
72
+ console.log(" npm run preview")
73
+ } catch (error) {
74
+ console.error(error instanceof Error ? error.message : error)
75
+ process.exit(1)
76
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@codemeall/create-word-pages",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a Word Pages Obsidian-to-GitHub-Pages starter.",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-word-pages": "./bin/create-word-pages.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "template"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test"
15
+ },
16
+ "engines": {
17
+ "node": ">=22",
18
+ "npm": ">=10.9"
19
+ },
20
+ "license": "MIT"
21
+ }
@@ -0,0 +1,51 @@
1
+ name: Deploy Word Pages
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ pages: write
12
+ id-token: write
13
+
14
+ concurrency:
15
+ group: pages
16
+ cancel-in-progress: false
17
+
18
+ jobs:
19
+ build:
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - name: Checkout
23
+ uses: actions/checkout@v4
24
+
25
+ - name: Setup Node
26
+ uses: actions/setup-node@v4
27
+ with:
28
+ node-version: 22
29
+ cache: npm
30
+
31
+ - name: Install dependencies
32
+ run: npm ci
33
+
34
+ - name: Build
35
+ run: npm run build
36
+
37
+ - name: Upload artifact
38
+ uses: actions/upload-pages-artifact@v3
39
+ with:
40
+ path: .word-pages/quartz/public
41
+
42
+ deploy:
43
+ environment:
44
+ name: github-pages
45
+ url: ${{ steps.deployment.outputs.page_url }}
46
+ runs-on: ubuntu-latest
47
+ needs: build
48
+ steps:
49
+ - name: Deploy
50
+ id: deployment
51
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,54 @@
1
+ # Word Pages Starter
2
+
3
+ Word Pages publishes an Obsidian-authored Markdown vault to GitHub Pages with a Quartz-powered reading experience.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npm install
9
+ npm run wizard
10
+ npm run preview
11
+ ```
12
+
13
+ Open `content/` as an Obsidian vault. Write pages, posts, and notes there. A Markdown file is rendered only when it has `publish: true` in frontmatter.
14
+
15
+ Important: `publish: true` controls whether the generated site renders a page. It does not make committed source Markdown private. If this repository is public, committed files can be visible on GitHub even when they are not rendered.
16
+
17
+ ## Content Contract
18
+
19
+ Use frontmatter to classify content:
20
+
21
+ ```yaml
22
+ ---
23
+ title: "My post"
24
+ type: post
25
+ publish: true
26
+ date: 2026-06-06
27
+ tags:
28
+ - writing
29
+ ---
30
+ ```
31
+
32
+ Supported types:
33
+
34
+ - `page`: routed to `/` when `slug: index` or `slug: home`; otherwise routed to `/:slug/`.
35
+ - `post`: routed to `/posts/:slug/`.
36
+ - `note`: routed to `/notes/:slug/`.
37
+
38
+ Wikilinks, backlinks, graph view, tags, search, and dark/light mode are provided by Quartz.
39
+
40
+ ## GitHub Pages
41
+
42
+ The included workflow builds the site with GitHub Actions and publishes the generated `public/` folder. For project Pages, set your GitHub username and repository name in the wizard so the Quartz `baseUrl` is correct. Quartz expects this value without protocol or slashes, for example `octocat.github.io/my-site`.
43
+
44
+ Custom domains are supported by GitHub Pages, but Word Pages v1 only documents that workflow and does not automate DNS setup.
45
+
46
+ ## Import Jekyll Posts
47
+
48
+ To import an existing Jekyll `_posts` folder:
49
+
50
+ ```bash
51
+ npm run import:jekyll -- /path/to/site/_posts
52
+ ```
53
+
54
+ The importer copies Markdown files into `content/posts`, preserves common frontmatter, adds `type: post`, and adds `publish: true`.
@@ -0,0 +1,19 @@
1
+ ---
2
+ title: "Publishing Model"
3
+ type: note
4
+ slug: publishing-model
5
+ publish: true
6
+ tags:
7
+ - docs
8
+ - github-pages
9
+ ---
10
+
11
+ # Publishing Model
12
+
13
+ Word Pages stages publishable notes into Quartz routes before building the static site.
14
+
15
+ - `page` content becomes top-level pages.
16
+ - `post` content becomes `/posts/:slug/`.
17
+ - `note` content becomes `/notes/:slug/`.
18
+
19
+ Only files with `publish: true` are rendered. Source privacy still depends on repository visibility.
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: "About"
3
+ type: page
4
+ slug: about
5
+ publish: true
6
+ tags:
7
+ - portfolio
8
+ ---
9
+
10
+ # About
11
+
12
+ Add your profile, services, contact links, or agency positioning here.
13
+
14
+ Word Pages keeps the public site static and lets GitHub Pages host it from your repository.
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: "Home"
3
+ type: page
4
+ slug: index
5
+ publish: true
6
+ tags:
7
+ - portfolio
8
+ ---
9
+
10
+ # Word Pages
11
+
12
+ This is your public home page. Open the `content/` folder in Obsidian and edit this note.
13
+
14
+ Use this space for a calm portfolio introduction, featured writing, client knowledge base links, or a personal digital garden.
15
+
16
+ Start with [[notes/publishing-model|the publishing model]] or read the sample [[posts/launching-word-pages|launch post]].
@@ -0,0 +1,16 @@
1
+ ---
2
+ title: "Launching Word Pages"
3
+ type: post
4
+ slug: launching-word-pages
5
+ publish: true
6
+ date: 2026-06-06
7
+ tags:
8
+ - writing
9
+ - word-pages
10
+ ---
11
+
12
+ # Launching Word Pages
13
+
14
+ This sample post shows the blog side of your Obsidian vault.
15
+
16
+ Posts are routed under `/posts/` and can link to notes like [[notes/publishing-model|Publishing Model]].
@@ -0,0 +1,12 @@
1
+ ---
2
+ title: ""
3
+ type: post
4
+ slug: ""
5
+ publish: false
6
+ date: 2026-06-06
7
+ tags: []
8
+ ---
9
+
10
+ # Untitled Post
11
+
12
+ Write in Obsidian, then set `publish: true` when ready.
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "__WORD_PAGES_PROJECT_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "wizard": "vite --host 127.0.0.1 --config wizard/vite.config.ts",
8
+ "prepare:content": "node scripts/prepare-quartz-content.mjs",
9
+ "import:jekyll": "node scripts/import-jekyll-posts.mjs",
10
+ "quartz:install": "node scripts/install-quartz.mjs",
11
+ "build": "npm run prepare:content && npm run quartz:install && node scripts/run-quartz.mjs build -d ../quartz-content -o public",
12
+ "preview": "npm run prepare:content && npm run quartz:install && node scripts/run-quartz.mjs build -d ../quartz-content --serve",
13
+ "test": "node --test"
14
+ },
15
+ "dependencies": {
16
+ "@vitejs/plugin-react": "^5.0.0",
17
+ "vite": "^7.0.0",
18
+ "react": "^19.0.0",
19
+ "react-dom": "^19.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "typescript": "^5.8.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=22",
26
+ "npm": ">=10.9"
27
+ },
28
+ "wordPages": {
29
+ "quartzRepository": "https://github.com/jackyzha0/quartz.git"
30
+ }
31
+ }
@@ -0,0 +1,78 @@
1
+ import { QuartzConfig } from "./quartz/cfg"
2
+ import * as Plugin from "./quartz/plugins"
3
+
4
+ const config: QuartzConfig = {
5
+ configuration: {
6
+ pageTitle: __WORD_PAGES_TITLE__,
7
+ enableSPA: true,
8
+ enablePopovers: true,
9
+ analytics: null,
10
+ locale: "en-US",
11
+ baseUrl: __WORD_PAGES_BASE_URL__,
12
+ ignorePatterns: ["private", "templates", ".obsidian"],
13
+ defaultDateType: "modified",
14
+ theme: {
15
+ fontOrigin: "googleFonts",
16
+ cdnCaching: true,
17
+ typography: {
18
+ header: "Schibsted Grotesk",
19
+ body: "Source Sans 3",
20
+ code: "IBM Plex Mono"
21
+ },
22
+ colors: {
23
+ lightMode: {
24
+ light: "#faf8f3",
25
+ lightgray: "#e5e0d5",
26
+ gray: "#8a8376",
27
+ darkgray: "#3e3a33",
28
+ dark: "#171613",
29
+ secondary: "#28786f",
30
+ tertiary: "#8d5c2c",
31
+ highlight: "rgba(40, 120, 111, 0.14)",
32
+ textHighlight: "#fff0a8"
33
+ },
34
+ darkMode: {
35
+ light: "#181816",
36
+ lightgray: "#2a2a26",
37
+ gray: "#aaa394",
38
+ darkgray: "#ddd8cb",
39
+ dark: "#f8f3e7",
40
+ secondary: "#73c6b6",
41
+ tertiary: "#d39b63",
42
+ highlight: "rgba(115, 198, 182, 0.16)",
43
+ textHighlight: "#5e4d18"
44
+ }
45
+ }
46
+ }
47
+ },
48
+ plugins: {
49
+ transformers: [
50
+ Plugin.FrontMatter(),
51
+ Plugin.CreatedModifiedDate({ priority: ["frontmatter", "filesystem"] }),
52
+ Plugin.SyntaxHighlighting(),
53
+ Plugin.ObsidianFlavoredMarkdown({ enableInHtmlEmbed: false }),
54
+ Plugin.GitHubFlavoredMarkdown(),
55
+ Plugin.CrawlLinks({ markdownLinkResolution: "shortest" }),
56
+ Plugin.Description(),
57
+ Plugin.Latex({ renderEngine: "katex" })
58
+ ],
59
+ filters: [
60
+ Plugin.ExplicitPublish()
61
+ ],
62
+ emitters: [
63
+ Plugin.AliasRedirects(),
64
+ Plugin.ComponentResources(),
65
+ Plugin.ContentPage(),
66
+ Plugin.FolderPage(),
67
+ Plugin.TagPage(),
68
+ Plugin.ContentIndex({
69
+ enableSiteMap: true,
70
+ enableRSS: true
71
+ }),
72
+ Plugin.Assets(),
73
+ Plugin.Static()
74
+ ]
75
+ }
76
+ }
77
+
78
+ export default config
@@ -0,0 +1,98 @@
1
+ export function parseFrontmatter(source) {
2
+ if (!source.startsWith("---\n")) {
3
+ return { data: {}, content: source }
4
+ }
5
+
6
+ const end = source.indexOf("\n---", 4)
7
+ if (end === -1) {
8
+ return { data: {}, content: source }
9
+ }
10
+
11
+ const yaml = source.slice(4, end)
12
+ const content = source.slice(end + 4).replace(/^\n/, "")
13
+ return { data: parseYaml(yaml), content }
14
+ }
15
+
16
+ export function stringifyFrontmatter(content, data) {
17
+ return `---\n${stringifyYaml(data)}---\n\n${content.trimStart()}`
18
+ }
19
+
20
+ function parseYaml(yaml) {
21
+ const data = {}
22
+ const lines = yaml.split("\n")
23
+ let index = 0
24
+
25
+ while (index < lines.length) {
26
+ const line = lines[index]
27
+ if (!line.trim()) {
28
+ index += 1
29
+ continue
30
+ }
31
+
32
+ const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/)
33
+ if (!match) {
34
+ index += 1
35
+ continue
36
+ }
37
+
38
+ const [, key, rawValue] = match
39
+ if (rawValue === "") {
40
+ const values = []
41
+ let cursor = index + 1
42
+ while (cursor < lines.length) {
43
+ const item = lines[cursor].match(/^\s*-\s*(.*)$/)
44
+ if (!item) break
45
+ values.push(parseScalar(item[1]))
46
+ cursor += 1
47
+ }
48
+ data[key] = values
49
+ index = cursor
50
+ continue
51
+ }
52
+
53
+ data[key] = parseScalar(rawValue)
54
+ index += 1
55
+ }
56
+
57
+ return data
58
+ }
59
+
60
+ function parseScalar(raw) {
61
+ const value = raw.trim()
62
+ if (value === "true") return true
63
+ if (value === "false") return false
64
+ if (value === "[]") return []
65
+ if (value.startsWith("[") && value.endsWith("]")) {
66
+ return value
67
+ .slice(1, -1)
68
+ .split(",")
69
+ .map((item) => parseScalar(item))
70
+ .filter((item) => item !== "")
71
+ }
72
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
73
+ return value.slice(1, -1)
74
+ }
75
+ return value
76
+ }
77
+
78
+ function stringifyYaml(data) {
79
+ return Object.entries(data)
80
+ .filter(([, value]) => value !== undefined)
81
+ .map(([key, value]) => {
82
+ if (Array.isArray(value)) {
83
+ if (value.length === 0) return `${key}: []\n`
84
+ return `${key}:\n${value.map((item) => ` - ${formatScalar(item)}`).join("\n")}\n`
85
+ }
86
+ return `${key}: ${formatScalar(value)}\n`
87
+ })
88
+ .join("")
89
+ }
90
+
91
+ function formatScalar(value) {
92
+ if (typeof value === "boolean") return value ? "true" : "false"
93
+ const text = String(value)
94
+ if (!text || /[:#\n{}[\],&*?|<>=!%@`]/.test(text)) {
95
+ return JSON.stringify(text)
96
+ }
97
+ return text
98
+ }
@@ -0,0 +1,55 @@
1
+ import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.mjs"
4
+
5
+ const source = process.argv[2]
6
+ if (!source) {
7
+ console.error("Usage: npm run import:jekyll -- /path/to/site/_posts")
8
+ process.exit(1)
9
+ }
10
+
11
+ const root = process.cwd()
12
+ const destination = path.join(root, "content", "posts")
13
+ await mkdir(destination, { recursive: true })
14
+
15
+ function slugFromJekyllName(fileName) {
16
+ return fileName
17
+ .replace(/^\d{4}-\d{2}-\d{2}-/, "")
18
+ .replace(/\.md$/i, "")
19
+ }
20
+
21
+ const files = (await readdir(source)).filter((file) => file.endsWith(".md"))
22
+ let imported = 0
23
+
24
+ for (const file of files) {
25
+ const fullPath = path.join(source, file)
26
+ const raw = await readFile(fullPath, "utf8")
27
+ const parsed = parseFrontmatter(raw)
28
+ const slug = parsed.data.slug ?? slugFromJekyllName(file)
29
+
30
+ const data = {
31
+ ...parsed.data,
32
+ type: "post",
33
+ publish: true,
34
+ slug,
35
+ title: parsed.data.title ?? slug.replaceAll("-", " "),
36
+ date: parsed.data.date ?? file.slice(0, 10)
37
+ }
38
+
39
+ delete data.layout
40
+ delete data.permalink
41
+
42
+ const outputPath = path.join(destination, `${slug}.md`)
43
+ await writeFile(outputPath, stringifyFrontmatter(parsed.content.trimStart(), data))
44
+ imported += 1
45
+ }
46
+
47
+ const assetSource = path.resolve(source, "..", "assets")
48
+ try {
49
+ await cp(assetSource, path.join(root, "content", "assets"), { recursive: true, force: true })
50
+ console.log("Copied assets into content/assets.")
51
+ } catch {
52
+ console.log("No sibling assets folder copied.")
53
+ }
54
+
55
+ console.log(`Imported ${imported} Jekyll posts into content/posts.`)
@@ -0,0 +1,63 @@
1
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { spawn } from "node:child_process"
3
+ import path from "node:path"
4
+
5
+ const root = process.cwd()
6
+ const quartzDir = path.join(root, ".word-pages", "quartz")
7
+ const npmCache = path.join(root, ".word-pages", "npm-cache")
8
+ const quartzRepo = "https://github.com/jackyzha0/quartz.git"
9
+
10
+ function run(command, args, options = {}) {
11
+ return new Promise((resolve, reject) => {
12
+ const child = spawn(command, args, {
13
+ stdio: "inherit",
14
+ ...options
15
+ })
16
+ child.on("exit", (code) => {
17
+ if (code === 0) resolve()
18
+ else reject(new Error(`${command} ${args.join(" ")} exited with ${code}`))
19
+ })
20
+ })
21
+ }
22
+
23
+ async function exists(filePath) {
24
+ try {
25
+ await access(filePath)
26
+ return true
27
+ } catch {
28
+ return false
29
+ }
30
+ }
31
+
32
+ if (!await exists(path.join(quartzDir, "package.json"))) {
33
+ await mkdir(path.dirname(quartzDir), { recursive: true })
34
+ await run("git", ["clone", "--depth", "1", quartzRepo, quartzDir])
35
+ }
36
+
37
+ if (!await exists(path.join(quartzDir, "node_modules"))) {
38
+ await mkdir(npmCache, { recursive: true })
39
+ await run("npm", ["install"], {
40
+ cwd: quartzDir,
41
+ env: {
42
+ ...process.env,
43
+ npm_config_cache: npmCache
44
+ }
45
+ })
46
+ }
47
+
48
+ const config = JSON.parse(await readFile(path.join(root, "word-pages.config.json"), "utf8"))
49
+ const configTemplate = await readFile(path.join(root, "quartz.config.ts"), "utf8")
50
+ const renderedConfig = configTemplate
51
+ .replaceAll("__WORD_PAGES_TITLE__", JSON.stringify(config.site.title))
52
+ .replaceAll("__WORD_PAGES_BASE_URL__", JSON.stringify(config.site.baseUrl))
53
+
54
+ await writeFile(path.join(quartzDir, "quartz.config.ts"), renderedConfig)
55
+ await run("npm", ["exec", "quartz", "--", "plugin", "install", "--from-config"], {
56
+ cwd: quartzDir,
57
+ env: {
58
+ ...process.env,
59
+ npm_config_cache: npmCache
60
+ }
61
+ })
62
+
63
+ console.log(`Quartz is ready at ${quartzDir}.`)
@@ -0,0 +1,80 @@
1
+ import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
2
+ import path from "node:path"
3
+ import { parseFrontmatter, stringifyFrontmatter } from "./frontmatter.mjs"
4
+
5
+ const root = process.cwd()
6
+ const config = JSON.parse(await readFile(path.join(root, "word-pages.config.json"), "utf8"))
7
+ const sourceRoot = path.join(root, config.content.source)
8
+ const stagedRoot = path.join(root, config.content.staged)
9
+
10
+ const allowedTypes = new Set(["page", "post", "note"])
11
+
12
+ function slugify(value) {
13
+ return String(value ?? "")
14
+ .trim()
15
+ .toLowerCase()
16
+ .replace(/['"]/g, "")
17
+ .replace(/[^a-z0-9]+/g, "-")
18
+ .replace(/^-+|-+$/g, "")
19
+ }
20
+
21
+ async function collectMarkdown(dir) {
22
+ const entries = await readdir(dir, { withFileTypes: true })
23
+ const files = []
24
+ for (const entry of entries) {
25
+ const fullPath = path.join(dir, entry.name)
26
+ if (entry.isDirectory()) {
27
+ if (entry.name === ".obsidian") continue
28
+ files.push(...await collectMarkdown(fullPath))
29
+ } else if (entry.name.endsWith(".md")) {
30
+ files.push(fullPath)
31
+ }
32
+ }
33
+ return files
34
+ }
35
+
36
+ function routeFor(filePath, data) {
37
+ const type = String(data.type ?? "").toLowerCase()
38
+ if (!allowedTypes.has(type)) {
39
+ throw new Error(`${filePath} has unsupported type "${data.type}". Use page, post, or note.`)
40
+ }
41
+
42
+ const fallbackSlug = slugify(data.title ?? path.basename(filePath, ".md"))
43
+ const slug = slugify(data.slug ?? fallbackSlug)
44
+ if (!slug) throw new Error(`${filePath} needs a title or slug.`)
45
+
46
+ if (type === "post") return path.join("posts", `${slug}.md`)
47
+ if (type === "note") return path.join("notes", `${slug}.md`)
48
+ if (slug === "index" || slug === "home") return "index.md"
49
+ return `${slug}.md`
50
+ }
51
+
52
+ await rm(stagedRoot, { recursive: true, force: true })
53
+ await mkdir(stagedRoot, { recursive: true })
54
+
55
+ const files = await collectMarkdown(sourceRoot)
56
+ let published = 0
57
+
58
+ for (const file of files) {
59
+ const raw = await readFile(file, "utf8")
60
+ const parsed = parseFrontmatter(raw)
61
+ if (parsed.data.publish !== true) continue
62
+
63
+ const relativeOutput = routeFor(file, parsed.data)
64
+ const outputPath = path.join(stagedRoot, relativeOutput)
65
+ await mkdir(path.dirname(outputPath), { recursive: true })
66
+
67
+ const data = {
68
+ ...parsed.data,
69
+ title: parsed.data.title ?? path.basename(file, ".md")
70
+ }
71
+
72
+ await writeFile(outputPath, stringifyFrontmatter(parsed.content.trimStart(), data))
73
+ published += 1
74
+ }
75
+
76
+ if (published === 0) {
77
+ throw new Error("No publishable Markdown files found. Add publish: true to at least one content file.")
78
+ }
79
+
80
+ console.log(`Prepared ${published} publishable Markdown files for Quartz.`)
@@ -0,0 +1,20 @@
1
+ import { spawn } from "node:child_process"
2
+ import path from "node:path"
3
+
4
+ const root = process.cwd()
5
+ const quartzDir = path.join(root, ".word-pages", "quartz")
6
+ const npmCache = path.join(root, ".word-pages", "npm-cache")
7
+ const args = process.argv.slice(2)
8
+
9
+ const child = spawn("npm", ["exec", "quartz", "--", ...args], {
10
+ cwd: quartzDir,
11
+ stdio: "inherit",
12
+ env: {
13
+ ...process.env,
14
+ npm_config_cache: npmCache
15
+ }
16
+ })
17
+
18
+ child.on("exit", (code) => {
19
+ process.exit(code ?? 1)
20
+ })
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Word Pages Wizard</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
@@ -0,0 +1,144 @@
1
+ import React, { useEffect, useMemo, useState } from "react"
2
+ import { createRoot } from "react-dom/client"
3
+ import "./styles.css"
4
+
5
+ type WordPagesConfig = {
6
+ site: {
7
+ title: string
8
+ description: string
9
+ author: string
10
+ githubUsername: string
11
+ repositoryName: string
12
+ visibility: "ask" | "public" | "private"
13
+ baseUrl: string
14
+ }
15
+ theme: {
16
+ preset: string
17
+ }
18
+ content: {
19
+ source: string
20
+ staged: string
21
+ }
22
+ }
23
+
24
+ function deriveBaseUrl(username: string, repositoryName: string) {
25
+ if (!username || !repositoryName) return ""
26
+ if (repositoryName.toLowerCase() === `${username.toLowerCase()}.github.io`) return `${username}.github.io`
27
+ return `${username}.github.io/${repositoryName}`
28
+ }
29
+
30
+ function displayPagesUrl(baseUrl: string) {
31
+ return baseUrl ? `https://${baseUrl}/` : "Configure GitHub details to preview the Pages URL."
32
+ }
33
+
34
+ function App() {
35
+ const [config, setConfig] = useState<WordPagesConfig | null>(null)
36
+ const [status, setStatus] = useState("Loading")
37
+
38
+ useEffect(() => {
39
+ fetch("/api/config")
40
+ .then((response) => response.json())
41
+ .then((data) => {
42
+ setConfig(data)
43
+ setStatus("Ready")
44
+ })
45
+ .catch(() => setStatus("Could not load configuration"))
46
+ }, [])
47
+
48
+ const pageUrl = useMemo(() => {
49
+ if (!config?.site.githubUsername || !config.site.repositoryName) return "Configure GitHub details to preview the Pages URL."
50
+ return displayPagesUrl(deriveBaseUrl(config.site.githubUsername, config.site.repositoryName))
51
+ }, [config])
52
+
53
+ if (!config) {
54
+ return <main className="shell"><p>{status}</p></main>
55
+ }
56
+
57
+ function updateSite<K extends keyof WordPagesConfig["site"]>(key: K, value: WordPagesConfig["site"][K]) {
58
+ setConfig((current) => current && {
59
+ ...current,
60
+ site: {
61
+ ...current.site,
62
+ [key]: value,
63
+ baseUrl: key === "githubUsername" || key === "repositoryName"
64
+ ? deriveBaseUrl(
65
+ key === "githubUsername" ? String(value) : current.site.githubUsername,
66
+ key === "repositoryName" ? String(value) : current.site.repositoryName
67
+ )
68
+ : current.site.baseUrl
69
+ }
70
+ })
71
+ }
72
+
73
+ 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")
81
+ }
82
+
83
+ return (
84
+ <main className="shell">
85
+ <section className="hero">
86
+ <div>
87
+ <p className="eyebrow">Word Pages setup</p>
88
+ <h1>Configure your Obsidian-powered site.</h1>
89
+ <p className="lede">This wizard writes local configuration only. It does not ask for GitHub credentials and does not upload your vault.</p>
90
+ </div>
91
+ <button onClick={save}>Save</button>
92
+ </section>
93
+
94
+ <section className="grid">
95
+ <label>
96
+ Site title
97
+ <input value={config.site.title} onChange={(event) => updateSite("title", event.target.value)} />
98
+ </label>
99
+ <label>
100
+ Author or organization
101
+ <input value={config.site.author} onChange={(event) => updateSite("author", event.target.value)} />
102
+ </label>
103
+ <label className="wide">
104
+ Description
105
+ <textarea value={config.site.description} onChange={(event) => updateSite("description", event.target.value)} />
106
+ </label>
107
+ <label>
108
+ GitHub username
109
+ <input value={config.site.githubUsername} onChange={(event) => updateSite("githubUsername", event.target.value)} />
110
+ </label>
111
+ <label>
112
+ Repository name
113
+ <input value={config.site.repositoryName} onChange={(event) => updateSite("repositoryName", event.target.value)} />
114
+ </label>
115
+ <label>
116
+ Repository visibility
117
+ <select value={config.site.visibility} onChange={(event) => updateSite("visibility", event.target.value as WordPagesConfig["site"]["visibility"])}>
118
+ <option value="ask">Decide before publishing</option>
119
+ <option value="public">Public</option>
120
+ <option value="private">Private</option>
121
+ </select>
122
+ </label>
123
+ <label>
124
+ GitHub Pages base URL
125
+ <input value={config.site.baseUrl} onChange={(event) => updateSite("baseUrl", event.target.value)} />
126
+ </label>
127
+ </section>
128
+
129
+ <section className="notice">
130
+ <h2>Publishing boundary</h2>
131
+ <p><strong>Rendered site:</strong> only Markdown with <code>publish: true</code> is staged for Quartz.</p>
132
+ <p><strong>Repository source:</strong> if this repo is public, committed Markdown may be visible on GitHub even when it is not rendered.</p>
133
+ <p><strong>Expected Pages URL:</strong> {pageUrl}</p>
134
+ </section>
135
+
136
+ <footer>
137
+ <span>{status}</span>
138
+ <span>Next: open <code>content/</code> in Obsidian, then run <code>npm run preview</code>.</span>
139
+ </footer>
140
+ </main>
141
+ )
142
+ }
143
+
144
+ createRoot(document.getElementById("root")!).render(<App />)
@@ -0,0 +1,132 @@
1
+ :root {
2
+ color: #1d1b16;
3
+ background: #f9f6ee;
4
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
5
+ }
6
+
7
+ * {
8
+ box-sizing: border-box;
9
+ }
10
+
11
+ body {
12
+ margin: 0;
13
+ }
14
+
15
+ .shell {
16
+ width: min(1040px, calc(100vw - 32px));
17
+ margin: 0 auto;
18
+ padding: 40px 0;
19
+ }
20
+
21
+ .hero {
22
+ display: flex;
23
+ align-items: end;
24
+ justify-content: space-between;
25
+ gap: 24px;
26
+ padding-bottom: 28px;
27
+ border-bottom: 1px solid #ded8ca;
28
+ }
29
+
30
+ .eyebrow {
31
+ margin: 0 0 10px;
32
+ color: #28786f;
33
+ font-size: 0.82rem;
34
+ font-weight: 700;
35
+ text-transform: uppercase;
36
+ }
37
+
38
+ h1 {
39
+ max-width: 720px;
40
+ margin: 0;
41
+ font-size: clamp(2rem, 4vw, 3.7rem);
42
+ line-height: 1.02;
43
+ }
44
+
45
+ .lede {
46
+ max-width: 650px;
47
+ color: #5f594f;
48
+ font-size: 1.05rem;
49
+ }
50
+
51
+ button {
52
+ min-height: 44px;
53
+ border: 0;
54
+ border-radius: 6px;
55
+ padding: 0 20px;
56
+ color: white;
57
+ background: #28786f;
58
+ font-weight: 700;
59
+ cursor: pointer;
60
+ }
61
+
62
+ .grid {
63
+ display: grid;
64
+ grid-template-columns: repeat(2, minmax(0, 1fr));
65
+ gap: 18px;
66
+ margin: 28px 0;
67
+ }
68
+
69
+ label {
70
+ display: grid;
71
+ gap: 8px;
72
+ color: #413d35;
73
+ font-weight: 700;
74
+ }
75
+
76
+ .wide {
77
+ grid-column: 1 / -1;
78
+ }
79
+
80
+ input,
81
+ select,
82
+ textarea {
83
+ width: 100%;
84
+ border: 1px solid #cfc7b7;
85
+ border-radius: 6px;
86
+ padding: 12px;
87
+ color: #1d1b16;
88
+ background: #fffdf8;
89
+ font: inherit;
90
+ }
91
+
92
+ textarea {
93
+ min-height: 104px;
94
+ resize: vertical;
95
+ }
96
+
97
+ .notice {
98
+ border: 1px solid #ded8ca;
99
+ border-radius: 8px;
100
+ padding: 20px;
101
+ background: #fffdf8;
102
+ }
103
+
104
+ .notice h2 {
105
+ margin-top: 0;
106
+ }
107
+
108
+ code {
109
+ border-radius: 4px;
110
+ padding: 2px 5px;
111
+ background: #ece6d8;
112
+ }
113
+
114
+ footer {
115
+ display: flex;
116
+ justify-content: space-between;
117
+ gap: 20px;
118
+ margin-top: 24px;
119
+ color: #6f675c;
120
+ }
121
+
122
+ @media (max-width: 720px) {
123
+ .hero,
124
+ footer {
125
+ align-items: stretch;
126
+ flex-direction: column;
127
+ }
128
+
129
+ .grid {
130
+ grid-template-columns: 1fr;
131
+ }
132
+ }
@@ -0,0 +1,43 @@
1
+ import { defineConfig } from "vite"
2
+ import react from "@vitejs/plugin-react"
3
+ import { readFile, writeFile } from "node:fs/promises"
4
+ import path from "node:path"
5
+
6
+ const configPath = path.resolve(process.cwd(), "word-pages.config.json")
7
+
8
+ export default defineConfig({
9
+ root: "wizard",
10
+ plugins: [
11
+ react(),
12
+ {
13
+ name: "word-pages-config-api",
14
+ configureServer(server) {
15
+ 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
+ res.setHeader("Content-Type", "application/json")
32
+ res.end(JSON.stringify({ ok: true }))
33
+ })
34
+ return
35
+ }
36
+
37
+ res.statusCode = 405
38
+ res.end("Method not allowed")
39
+ })
40
+ }
41
+ }
42
+ ]
43
+ })
@@ -0,0 +1,18 @@
1
+ {
2
+ "site": {
3
+ "title": "Word Pages",
4
+ "description": "A public knowledge base, blog, and portfolio managed from Obsidian.",
5
+ "author": "Your Name",
6
+ "githubUsername": "",
7
+ "repositoryName": "__WORD_PAGES_PROJECT_NAME__",
8
+ "visibility": "ask",
9
+ "baseUrl": "/"
10
+ },
11
+ "theme": {
12
+ "preset": "clean-garden"
13
+ },
14
+ "content": {
15
+ "source": "content",
16
+ "staged": ".word-pages/quartz-content"
17
+ }
18
+ }