next-content-overlay 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.
- package/LICENSE +22 -0
- package/README.md +110 -0
- package/dist/index.cjs +555 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +532 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ReadyAF
|
|
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.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# next-content-overlay
|
|
2
|
+
|
|
3
|
+
**Edit your Next.js text content from the CLI — no database, no re-prompting your AI.**
|
|
4
|
+
|
|
5
|
+
If you're a vibe coder or AI-first builder, you know the pain: your app's text lives scattered across dozens of components, and every copy change means either digging into database tables or writing another natural language prompt and hoping the AI touches the right file. Multiply that by 50+ pages of real content and it becomes unmanageable.
|
|
6
|
+
|
|
7
|
+
`next-content-overlay` gives you a Squarespace-style content layer for Next.js App Router projects — except it's file-backed, CLI-driven, and works with your existing codebase. No CMS platform, no vendor lock-in, no config hell.
|
|
8
|
+
|
|
9
|
+
### The flow
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
init → scan → edit → publish
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
1. **Scan** your JSX and automatically detect every text string
|
|
16
|
+
2. **Edit** any string by its generated key — one command, instant draft
|
|
17
|
+
3. **Publish** when you're ready — drafts stay separate until you say go
|
|
18
|
+
|
|
19
|
+
Your content lives in a simple JSON file. Your source code stays untouched. Version control handles the rest.
|
|
20
|
+
|
|
21
|
+
### Why this exists
|
|
22
|
+
|
|
23
|
+
When your app has real, comprehensive text content — frameworks, guides, course material, marketing copy — you need a way to iterate on words without re-entering your code editor or AI chat every time. This tool lets you treat content as a first-class, editable layer on top of your Next.js app.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i -D next-content-overlay
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
Run from your Next.js repo root:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx content-overlay init
|
|
37
|
+
npx content-overlay scan
|
|
38
|
+
npx content-overlay edit app.page.your-key "Your updated text"
|
|
39
|
+
npx content-overlay publish
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Use a key from `.overlay-content/content-map.json` for the `edit` command.
|
|
43
|
+
|
|
44
|
+
## Commands
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
content-overlay init [--force] # Create config + content files
|
|
48
|
+
content-overlay scan # Scan JSX for text strings
|
|
49
|
+
content-overlay edit <key> <val> # Update a draft value
|
|
50
|
+
content-overlay publish # Promote drafts to published content
|
|
51
|
+
content-overlay --help
|
|
52
|
+
content-overlay --version
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Generated files
|
|
56
|
+
|
|
57
|
+
| File | Purpose |
|
|
58
|
+
|------|---------|
|
|
59
|
+
| `content-overlay.config.json` | Scan dirs, extensions, file paths |
|
|
60
|
+
| `content/site.json` | Published content (your app reads this) |
|
|
61
|
+
| `.overlay-content/content-map.json` | Auto-generated key → source mapping |
|
|
62
|
+
| `.overlay-content/draft.json` | Working drafts before publish |
|
|
63
|
+
| `.overlay-content/last-publish.json` | Publish metadata |
|
|
64
|
+
|
|
65
|
+
## 5-minute demo
|
|
66
|
+
|
|
67
|
+
A runnable Next.js demo app is included:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
cd examples/next-demo
|
|
71
|
+
npm install
|
|
72
|
+
npm run overlay:flow
|
|
73
|
+
npm run dev
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Then open `http://localhost:3000` to see content loaded from the overlay.
|
|
77
|
+
|
|
78
|
+
## How it works with AI coding
|
|
79
|
+
|
|
80
|
+
If you're building with Cursor, Claude Code, Copilot, or any AI assistant:
|
|
81
|
+
|
|
82
|
+
1. Let your AI scaffold components with placeholder text as usual
|
|
83
|
+
2. Run `content-overlay scan` to extract all text into a structured map
|
|
84
|
+
3. Edit content independently — no need to prompt your AI for every word change
|
|
85
|
+
4. Your AI keeps building features; you keep refining copy in parallel
|
|
86
|
+
|
|
87
|
+
This separates the "build" loop from the "write" loop, so neither blocks the other.
|
|
88
|
+
|
|
89
|
+
## Local development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
npm install
|
|
93
|
+
npm run check
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`npm run check` runs typecheck, tests, build, and license check.
|
|
97
|
+
|
|
98
|
+
## Show your setup
|
|
99
|
+
|
|
100
|
+
If you're using `next-content-overlay` in a project, we'd love to see it in action. Record a short screen capture of your workflow and share it in [Show & Tell Discussions](../../discussions/categories/show-and-tell).
|
|
101
|
+
|
|
102
|
+
Standout demos may be featured in this README.
|
|
103
|
+
|
|
104
|
+
## Contributing
|
|
105
|
+
|
|
106
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT — see [LICENSE](LICENSE).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/commands/edit.ts
|
|
27
|
+
var import_node_path3 = __toESM(require("path"), 1);
|
|
28
|
+
|
|
29
|
+
// src/lib/config.ts
|
|
30
|
+
var import_node_path2 = __toESM(require("path"), 1);
|
|
31
|
+
|
|
32
|
+
// src/lib/errors.ts
|
|
33
|
+
var CliError = class extends Error {
|
|
34
|
+
code;
|
|
35
|
+
hint;
|
|
36
|
+
constructor(message, options) {
|
|
37
|
+
super(message);
|
|
38
|
+
this.name = "CliError";
|
|
39
|
+
this.code = options?.code ?? "CLI_ERROR";
|
|
40
|
+
this.hint = options?.hint;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
function toErrorMessage(error) {
|
|
44
|
+
if (error instanceof CliError) {
|
|
45
|
+
return { message: error.message, hint: error.hint };
|
|
46
|
+
}
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
return { message: error.message };
|
|
49
|
+
}
|
|
50
|
+
return { message: String(error) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// src/lib/fs.ts
|
|
54
|
+
var import_node_fs = require("fs");
|
|
55
|
+
var import_node_path = __toESM(require("path"), 1);
|
|
56
|
+
async function ensureDir(dirPath) {
|
|
57
|
+
await import_node_fs.promises.mkdir(dirPath, { recursive: true });
|
|
58
|
+
}
|
|
59
|
+
async function fileExists(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
await import_node_fs.promises.access(filePath);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
async function readJsonFile(filePath) {
|
|
68
|
+
if (!await fileExists(filePath)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
let raw;
|
|
72
|
+
try {
|
|
73
|
+
raw = await import_node_fs.promises.readFile(filePath, "utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
throw new CliError(`Failed to read file: ${filePath}`);
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
return JSON.parse(raw);
|
|
79
|
+
} catch {
|
|
80
|
+
throw new CliError(`Invalid JSON in file: ${filePath}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
async function writeJsonFile(filePath, value) {
|
|
84
|
+
const dir = import_node_path.default.dirname(filePath);
|
|
85
|
+
try {
|
|
86
|
+
await ensureDir(dir);
|
|
87
|
+
await import_node_fs.promises.writeFile(filePath, `${JSON.stringify(value, null, 2)}
|
|
88
|
+
`, "utf8");
|
|
89
|
+
} catch {
|
|
90
|
+
throw new CliError(`Failed to write file: ${filePath}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function collectFiles(rootDir, extensions) {
|
|
94
|
+
const files = [];
|
|
95
|
+
async function walk(currentDir) {
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = await import_node_fs.promises.readdir(currentDir, { withFileTypes: true });
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
for (const entry of entries) {
|
|
103
|
+
const fullPath = import_node_path.default.join(currentDir, entry.name);
|
|
104
|
+
if (entry.isDirectory()) {
|
|
105
|
+
if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
await walk(fullPath);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!entry.isFile()) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const ext = import_node_path.default.extname(entry.name).toLowerCase();
|
|
115
|
+
if (extensions.has(ext)) {
|
|
116
|
+
files.push(fullPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
await walk(rootDir);
|
|
121
|
+
return files;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/lib/validation.ts
|
|
125
|
+
function isPlainObject(input) {
|
|
126
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
127
|
+
}
|
|
128
|
+
function assertStringArray(input, fieldName) {
|
|
129
|
+
if (!Array.isArray(input) || input.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
130
|
+
throw new CliError(`Invalid ${fieldName} in content-overlay config.`, {
|
|
131
|
+
hint: `Expected "${fieldName}" to be a non-empty string array.`
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return input;
|
|
135
|
+
}
|
|
136
|
+
function validateOverlayConfig(input) {
|
|
137
|
+
if (!isPlainObject(input)) {
|
|
138
|
+
throw new CliError("Invalid content-overlay config file.", {
|
|
139
|
+
hint: "Run: content-overlay init --force"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
const version = input.version;
|
|
143
|
+
if (version !== 1) {
|
|
144
|
+
throw new CliError(`Unsupported config version: ${String(version)}.`, {
|
|
145
|
+
hint: "Run: content-overlay init --force"
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const scanDirs = assertStringArray(input.scanDirs, "scanDirs");
|
|
149
|
+
const extensions = assertStringArray(input.extensions, "extensions");
|
|
150
|
+
const contentFile = input.contentFile;
|
|
151
|
+
const mapFile = input.mapFile;
|
|
152
|
+
const draftFile = input.draftFile;
|
|
153
|
+
if (typeof contentFile !== "string" || contentFile.trim().length === 0) {
|
|
154
|
+
throw new CliError("Invalid contentFile in content-overlay config.");
|
|
155
|
+
}
|
|
156
|
+
if (typeof mapFile !== "string" || mapFile.trim().length === 0) {
|
|
157
|
+
throw new CliError("Invalid mapFile in content-overlay config.");
|
|
158
|
+
}
|
|
159
|
+
if (typeof draftFile !== "string" || draftFile.trim().length === 0) {
|
|
160
|
+
throw new CliError("Invalid draftFile in content-overlay config.");
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
version: 1,
|
|
164
|
+
scanDirs,
|
|
165
|
+
extensions,
|
|
166
|
+
contentFile,
|
|
167
|
+
mapFile,
|
|
168
|
+
draftFile
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function ensureStringRecord(input, label) {
|
|
172
|
+
if (!isPlainObject(input)) {
|
|
173
|
+
throw new CliError(`Invalid ${label}; expected a JSON object.`, {
|
|
174
|
+
hint: "Run: content-overlay init"
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
const result = {};
|
|
178
|
+
for (const [key, value] of Object.entries(input)) {
|
|
179
|
+
if (typeof value !== "string") {
|
|
180
|
+
throw new CliError(`Invalid ${label}; key "${key}" must be a string.`);
|
|
181
|
+
}
|
|
182
|
+
result[key] = value;
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
function ensureContentMap(input) {
|
|
187
|
+
if (!Array.isArray(input)) {
|
|
188
|
+
throw new CliError("Invalid content map; expected an array.", {
|
|
189
|
+
hint: "Run: content-overlay scan"
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const result = [];
|
|
193
|
+
for (const value of input) {
|
|
194
|
+
if (!isPlainObject(value)) {
|
|
195
|
+
throw new CliError("Invalid content map entry; expected object values.");
|
|
196
|
+
}
|
|
197
|
+
const { key, sourceFile, line, column, defaultValue } = value;
|
|
198
|
+
if (typeof key !== "string" || typeof sourceFile !== "string" || typeof line !== "number" || typeof column !== "number" || typeof defaultValue !== "string") {
|
|
199
|
+
throw new CliError("Invalid content map entry shape.", {
|
|
200
|
+
hint: "Run: content-overlay scan"
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
result.push({ key, sourceFile, line, column, defaultValue });
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/lib/config.ts
|
|
209
|
+
var CONFIG_FILE = "content-overlay.config.json";
|
|
210
|
+
var DEFAULT_CONFIG = {
|
|
211
|
+
version: 1,
|
|
212
|
+
scanDirs: ["app", "components"],
|
|
213
|
+
extensions: [".tsx", ".ts", ".jsx", ".js", ".mdx"],
|
|
214
|
+
contentFile: "content/site.json",
|
|
215
|
+
mapFile: ".overlay-content/content-map.json",
|
|
216
|
+
draftFile: ".overlay-content/draft.json"
|
|
217
|
+
};
|
|
218
|
+
async function loadConfig(cwd) {
|
|
219
|
+
const configPath = import_node_path2.default.join(cwd, CONFIG_FILE);
|
|
220
|
+
const config = await readJsonFile(configPath);
|
|
221
|
+
if (config === null) {
|
|
222
|
+
throw new CliError(`Missing ${CONFIG_FILE}.`, {
|
|
223
|
+
hint: "Run: content-overlay init"
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
return validateOverlayConfig(config);
|
|
227
|
+
}
|
|
228
|
+
async function writeDefaultConfig(cwd, force = false) {
|
|
229
|
+
const configPath = import_node_path2.default.join(cwd, CONFIG_FILE);
|
|
230
|
+
const exists = await fileExists(configPath);
|
|
231
|
+
if (exists && !force) {
|
|
232
|
+
return { created: false, path: configPath };
|
|
233
|
+
}
|
|
234
|
+
await writeJsonFile(configPath, DEFAULT_CONFIG);
|
|
235
|
+
return { created: true, path: configPath };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/commands/edit.ts
|
|
239
|
+
async function runEdit(options) {
|
|
240
|
+
if (!/^[a-z0-9._-]+$/i.test(options.key)) {
|
|
241
|
+
throw new CliError(`Invalid key "${options.key}".`, {
|
|
242
|
+
hint: "Keys should use letters, numbers, dots, hyphens, and underscores."
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
if (options.value.trim().length === 0) {
|
|
246
|
+
throw new CliError("Edit value cannot be empty.");
|
|
247
|
+
}
|
|
248
|
+
const config = await loadConfig(options.cwd);
|
|
249
|
+
const draftPath = import_node_path3.default.join(options.cwd, config.draftFile);
|
|
250
|
+
const contentPath = import_node_path3.default.join(options.cwd, config.contentFile);
|
|
251
|
+
const mapPath = import_node_path3.default.join(options.cwd, config.mapFile);
|
|
252
|
+
const rawContent = await readJsonFile(contentPath);
|
|
253
|
+
const content = rawContent === null ? {} : ensureStringRecord(rawContent, config.contentFile);
|
|
254
|
+
const rawDraft = await readJsonFile(draftPath);
|
|
255
|
+
const draft = rawDraft === null ? { ...content } : ensureStringRecord(rawDraft, config.draftFile);
|
|
256
|
+
const rawMap = await readJsonFile(mapPath);
|
|
257
|
+
const map = rawMap === null ? [] : ensureContentMap(rawMap);
|
|
258
|
+
const knownKeys = /* @__PURE__ */ new Set([...Object.keys(content), ...map.map((entry) => entry.key)]);
|
|
259
|
+
if (!knownKeys.has(options.key)) {
|
|
260
|
+
throw new CliError(`Unknown key "${options.key}".`, {
|
|
261
|
+
hint: "Run: content-overlay scan, then use one of the generated keys."
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const oldValue = draft[options.key];
|
|
265
|
+
if (oldValue === options.value) {
|
|
266
|
+
console.log(`No change for key: ${options.key}`);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
draft[options.key] = options.value;
|
|
270
|
+
await writeJsonFile(draftPath, draft);
|
|
271
|
+
if (oldValue === void 0) {
|
|
272
|
+
console.log(`Created draft key: ${options.key}`);
|
|
273
|
+
} else {
|
|
274
|
+
console.log(`Updated draft key: ${options.key}`);
|
|
275
|
+
console.log(`Old: ${oldValue}`);
|
|
276
|
+
}
|
|
277
|
+
console.log(`New: ${options.value}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// src/commands/init.ts
|
|
281
|
+
var import_node_path4 = __toESM(require("path"), 1);
|
|
282
|
+
var import_node_fs2 = require("fs");
|
|
283
|
+
async function runInit(options) {
|
|
284
|
+
const result = await writeDefaultConfig(options.cwd, options.force);
|
|
285
|
+
const activeConfig = result.created ? DEFAULT_CONFIG : await loadConfig(options.cwd);
|
|
286
|
+
if (result.created) {
|
|
287
|
+
console.log(`Created ${import_node_path4.default.relative(options.cwd, result.path)}`);
|
|
288
|
+
} else {
|
|
289
|
+
console.log("Config already exists. Use --force to overwrite.");
|
|
290
|
+
}
|
|
291
|
+
const contentFile = import_node_path4.default.join(options.cwd, activeConfig.contentFile);
|
|
292
|
+
const rawContent = await readJsonFile(contentFile);
|
|
293
|
+
const existingContent = rawContent === null ? null : ensureStringRecord(rawContent, activeConfig.contentFile);
|
|
294
|
+
if (!existingContent) {
|
|
295
|
+
await writeJsonFile(contentFile, {});
|
|
296
|
+
console.log(`Created ${activeConfig.contentFile}`);
|
|
297
|
+
}
|
|
298
|
+
const draftFile = import_node_path4.default.join(options.cwd, activeConfig.draftFile);
|
|
299
|
+
const rawDraft = await readJsonFile(draftFile);
|
|
300
|
+
const existingDraft = rawDraft === null ? null : ensureStringRecord(rawDraft, activeConfig.draftFile);
|
|
301
|
+
if (!existingDraft) {
|
|
302
|
+
await writeJsonFile(draftFile, existingContent ?? {});
|
|
303
|
+
console.log(`Created ${activeConfig.draftFile}`);
|
|
304
|
+
}
|
|
305
|
+
const mapFile = import_node_path4.default.join(options.cwd, activeConfig.mapFile);
|
|
306
|
+
const rawMap = await readJsonFile(mapFile);
|
|
307
|
+
if (rawMap === null) {
|
|
308
|
+
await writeJsonFile(mapFile, []);
|
|
309
|
+
console.log(`Created ${activeConfig.mapFile}`);
|
|
310
|
+
} else {
|
|
311
|
+
ensureContentMap(rawMap);
|
|
312
|
+
}
|
|
313
|
+
const packageJsonPath = import_node_path4.default.join(options.cwd, "package.json");
|
|
314
|
+
try {
|
|
315
|
+
const raw = await import_node_fs2.promises.readFile(packageJsonPath, "utf8");
|
|
316
|
+
const pkg = JSON.parse(raw);
|
|
317
|
+
const hasNext = Boolean(pkg.dependencies?.next || pkg.devDependencies?.next);
|
|
318
|
+
if (!hasNext) {
|
|
319
|
+
console.log("Warning: next dependency not found in package.json. This tool is built for Next.js App Router.");
|
|
320
|
+
}
|
|
321
|
+
} catch (error) {
|
|
322
|
+
if (error instanceof SyntaxError) {
|
|
323
|
+
throw new CliError("Invalid package.json; unable to parse JSON.");
|
|
324
|
+
}
|
|
325
|
+
console.log("Warning: package.json not found. Run this in your Next.js project root.");
|
|
326
|
+
}
|
|
327
|
+
console.log("Init complete.");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// src/commands/publish.ts
|
|
331
|
+
var import_node_path5 = __toESM(require("path"), 1);
|
|
332
|
+
async function runPublish(options) {
|
|
333
|
+
const config = await loadConfig(options.cwd);
|
|
334
|
+
const contentPath = import_node_path5.default.join(options.cwd, config.contentFile);
|
|
335
|
+
const draftPath = import_node_path5.default.join(options.cwd, config.draftFile);
|
|
336
|
+
const publishMetaPath = import_node_path5.default.join(options.cwd, ".overlay-content/last-publish.json");
|
|
337
|
+
const rawContent = await readJsonFile(contentPath);
|
|
338
|
+
const content = rawContent === null ? {} : ensureStringRecord(rawContent, config.contentFile);
|
|
339
|
+
const rawDraft = await readJsonFile(draftPath);
|
|
340
|
+
const draft = rawDraft === null ? { ...content } : ensureStringRecord(rawDraft, config.draftFile);
|
|
341
|
+
const nextContent = { ...content, ...draft };
|
|
342
|
+
const candidateKeys = /* @__PURE__ */ new Set([...Object.keys(content), ...Object.keys(nextContent)]);
|
|
343
|
+
const changedKeys = [...candidateKeys].filter((key) => nextContent[key] !== content[key]);
|
|
344
|
+
if (changedKeys.length === 0) {
|
|
345
|
+
console.log("No draft changes to publish.");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
await writeJsonFile(contentPath, nextContent);
|
|
349
|
+
await writeJsonFile(draftPath, nextContent);
|
|
350
|
+
await writeJsonFile(publishMetaPath, {
|
|
351
|
+
publishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
352
|
+
changedKeys
|
|
353
|
+
});
|
|
354
|
+
console.log(`Published ${changedKeys.length} change(s) to ${config.contentFile}.`);
|
|
355
|
+
for (const key of changedKeys) {
|
|
356
|
+
console.log(`- ${key}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/commands/scan.ts
|
|
361
|
+
var import_node_path7 = __toESM(require("path"), 1);
|
|
362
|
+
var import_node_fs3 = require("fs");
|
|
363
|
+
|
|
364
|
+
// src/lib/scan.ts
|
|
365
|
+
var import_node_path6 = __toESM(require("path"), 1);
|
|
366
|
+
var JSX_TEXT_REGEX = />([^<>{}\n]+)</g;
|
|
367
|
+
function toSlug(input) {
|
|
368
|
+
return input.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 40);
|
|
369
|
+
}
|
|
370
|
+
function indexToLineCol(input, index) {
|
|
371
|
+
const before = input.slice(0, index);
|
|
372
|
+
const lines = before.split("\n");
|
|
373
|
+
const line = lines.length;
|
|
374
|
+
const column = lines[lines.length - 1].length + 1;
|
|
375
|
+
return { line, column };
|
|
376
|
+
}
|
|
377
|
+
function isUsefulText(text) {
|
|
378
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
379
|
+
if (normalized.length < 3) return false;
|
|
380
|
+
if (!/[a-zA-Z]/.test(normalized)) return false;
|
|
381
|
+
if (normalized.startsWith("{") || normalized.endsWith("}")) return false;
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
function extractTextEntries(filePath, fileContents, cwd) {
|
|
385
|
+
const rel = import_node_path6.default.relative(cwd, filePath).replace(/\\/g, "/");
|
|
386
|
+
const relNoExt = rel.replace(/\.[^.]+$/, "");
|
|
387
|
+
const fileToken = relNoExt.replace(/\//g, ".");
|
|
388
|
+
const seen = /* @__PURE__ */ new Map();
|
|
389
|
+
const results = [];
|
|
390
|
+
for (const match of fileContents.matchAll(JSX_TEXT_REGEX)) {
|
|
391
|
+
const raw = match[1] ?? "";
|
|
392
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
393
|
+
if (!isUsefulText(text)) continue;
|
|
394
|
+
const slug = toSlug(text);
|
|
395
|
+
if (!slug) continue;
|
|
396
|
+
const count = (seen.get(slug) ?? 0) + 1;
|
|
397
|
+
seen.set(slug, count);
|
|
398
|
+
const key = count === 1 ? `${fileToken}.${slug}` : `${fileToken}.${slug}-${count}`;
|
|
399
|
+
const position = indexToLineCol(fileContents, match.index ?? 0);
|
|
400
|
+
results.push({
|
|
401
|
+
key,
|
|
402
|
+
sourceFile: rel,
|
|
403
|
+
line: position.line,
|
|
404
|
+
column: position.column,
|
|
405
|
+
defaultValue: text
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return results;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// src/commands/scan.ts
|
|
412
|
+
async function runScan(options) {
|
|
413
|
+
const config = await loadConfig(options.cwd);
|
|
414
|
+
const extensions = new Set(config.extensions.map((ext) => ext.toLowerCase()));
|
|
415
|
+
const allFiles = [];
|
|
416
|
+
const existingDirs = [];
|
|
417
|
+
for (const dir of config.scanDirs) {
|
|
418
|
+
const dirPath = import_node_path7.default.join(options.cwd, dir);
|
|
419
|
+
try {
|
|
420
|
+
const stat = await import_node_fs3.promises.stat(dirPath);
|
|
421
|
+
if (!stat.isDirectory()) {
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
existingDirs.push(dir);
|
|
425
|
+
} catch {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
const files = await collectFiles(dirPath, extensions);
|
|
429
|
+
allFiles.push(...files);
|
|
430
|
+
}
|
|
431
|
+
if (existingDirs.length === 0) {
|
|
432
|
+
throw new CliError("No scan directories found.", {
|
|
433
|
+
hint: `Expected one of: ${config.scanDirs.join(", ")}`
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const uniqueFiles = [...new Set(allFiles)].sort((a, b) => a.localeCompare(b));
|
|
437
|
+
const map = [];
|
|
438
|
+
for (const filePath of uniqueFiles) {
|
|
439
|
+
const source = await import_node_fs3.promises.readFile(filePath, "utf8");
|
|
440
|
+
const entries = extractTextEntries(filePath, source, options.cwd);
|
|
441
|
+
map.push(...entries);
|
|
442
|
+
}
|
|
443
|
+
const contentPath = import_node_path7.default.join(options.cwd, config.contentFile);
|
|
444
|
+
const rawContent = await readJsonFile(contentPath);
|
|
445
|
+
const existingContent = rawContent === null ? {} : ensureStringRecord(rawContent, config.contentFile);
|
|
446
|
+
const nextContent = { ...existingContent };
|
|
447
|
+
let newKeys = 0;
|
|
448
|
+
for (const entry of map) {
|
|
449
|
+
if (!(entry.key in nextContent)) {
|
|
450
|
+
nextContent[entry.key] = entry.defaultValue;
|
|
451
|
+
newKeys += 1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
await writeJsonFile(import_node_path7.default.join(options.cwd, config.mapFile), map);
|
|
455
|
+
await writeJsonFile(contentPath, nextContent);
|
|
456
|
+
const draftPath = import_node_path7.default.join(options.cwd, config.draftFile);
|
|
457
|
+
const rawDraft = await readJsonFile(draftPath);
|
|
458
|
+
const existingDraft = rawDraft === null ? null : ensureStringRecord(rawDraft, config.draftFile);
|
|
459
|
+
const nextDraft = { ...nextContent, ...existingDraft ?? {} };
|
|
460
|
+
await writeJsonFile(draftPath, nextDraft);
|
|
461
|
+
console.log(`Scanned ${uniqueFiles.length} files.`);
|
|
462
|
+
console.log(`Found ${map.length} editable text nodes.`);
|
|
463
|
+
console.log(`Added ${newKeys} new keys to ${config.contentFile}.`);
|
|
464
|
+
if (map.length === 0) {
|
|
465
|
+
console.log("Warning: No text nodes found. Make sure files contain plain JSX text between tags.");
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// src/index.ts
|
|
470
|
+
var VERSION = "0.1.0";
|
|
471
|
+
function printHelp() {
|
|
472
|
+
console.log("content-overlay <command> [options]");
|
|
473
|
+
console.log("");
|
|
474
|
+
console.log("Core flow: init -> scan -> edit -> publish");
|
|
475
|
+
console.log("");
|
|
476
|
+
console.log("Commands");
|
|
477
|
+
console.log(" init [--force] Initialize config and content files");
|
|
478
|
+
console.log(" scan Scan source files and build content map");
|
|
479
|
+
console.log(' edit <key> "<value>" Update one draft content value');
|
|
480
|
+
console.log(" publish Publish draft values to content/site.json");
|
|
481
|
+
console.log("");
|
|
482
|
+
console.log("Flags");
|
|
483
|
+
console.log(" -h, --help Show help");
|
|
484
|
+
console.log(" -v, --version Show CLI version");
|
|
485
|
+
}
|
|
486
|
+
async function main() {
|
|
487
|
+
const [, , command, ...args] = process.argv;
|
|
488
|
+
const cwd = process.cwd();
|
|
489
|
+
try {
|
|
490
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
491
|
+
printHelp();
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
495
|
+
console.log(VERSION);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (command === "init") {
|
|
499
|
+
const allowed = /* @__PURE__ */ new Set(["--force"]);
|
|
500
|
+
const unknown = args.filter((arg) => arg.startsWith("-") && !allowed.has(arg));
|
|
501
|
+
if (unknown.length > 0) {
|
|
502
|
+
throw new CliError(`Unknown option(s): ${unknown.join(", ")}`, {
|
|
503
|
+
hint: "Usage: content-overlay init [--force]"
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
const force = args.includes("--force");
|
|
507
|
+
await runInit({ cwd, force });
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (command === "scan") {
|
|
511
|
+
if (args.length > 0) {
|
|
512
|
+
throw new CliError("scan does not accept arguments.", {
|
|
513
|
+
hint: "Usage: content-overlay scan"
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
await runScan({ cwd });
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (command === "edit") {
|
|
520
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
521
|
+
console.log('Usage: content-overlay edit <key> "<value>"');
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const key = args[0];
|
|
525
|
+
const value = args.slice(1).join(" ").trim();
|
|
526
|
+
if (!key || !value) {
|
|
527
|
+
throw new CliError("Missing required edit arguments.", {
|
|
528
|
+
hint: 'Usage: content-overlay edit <key> "<value>"'
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
await runEdit({ cwd, key, value });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (command === "publish") {
|
|
535
|
+
if (args.length > 0) {
|
|
536
|
+
throw new CliError("publish does not accept arguments.", {
|
|
537
|
+
hint: "Usage: content-overlay publish"
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
await runPublish({ cwd });
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
throw new CliError(`Unknown command: ${command}`, {
|
|
544
|
+
hint: "Run: content-overlay --help"
|
|
545
|
+
});
|
|
546
|
+
} catch (error) {
|
|
547
|
+
const { message, hint } = toErrorMessage(error);
|
|
548
|
+
console.error(`Error: ${message}`);
|
|
549
|
+
if (hint) {
|
|
550
|
+
console.error(`Hint: ${hint}`);
|
|
551
|
+
}
|
|
552
|
+
process.exitCode = 1;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
main();
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/commands/edit.ts
|
|
4
|
+
import path3 from "path";
|
|
5
|
+
|
|
6
|
+
// src/lib/config.ts
|
|
7
|
+
import path2 from "path";
|
|
8
|
+
|
|
9
|
+
// src/lib/errors.ts
|
|
10
|
+
var CliError = class extends Error {
|
|
11
|
+
code;
|
|
12
|
+
hint;
|
|
13
|
+
constructor(message, options) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = "CliError";
|
|
16
|
+
this.code = options?.code ?? "CLI_ERROR";
|
|
17
|
+
this.hint = options?.hint;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
function toErrorMessage(error) {
|
|
21
|
+
if (error instanceof CliError) {
|
|
22
|
+
return { message: error.message, hint: error.hint };
|
|
23
|
+
}
|
|
24
|
+
if (error instanceof Error) {
|
|
25
|
+
return { message: error.message };
|
|
26
|
+
}
|
|
27
|
+
return { message: String(error) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/lib/fs.ts
|
|
31
|
+
import { promises as fs } from "fs";
|
|
32
|
+
import path from "path";
|
|
33
|
+
async function ensureDir(dirPath) {
|
|
34
|
+
await fs.mkdir(dirPath, { recursive: true });
|
|
35
|
+
}
|
|
36
|
+
async function fileExists(filePath) {
|
|
37
|
+
try {
|
|
38
|
+
await fs.access(filePath);
|
|
39
|
+
return true;
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
async function readJsonFile(filePath) {
|
|
45
|
+
if (!await fileExists(filePath)) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
51
|
+
} catch {
|
|
52
|
+
throw new CliError(`Failed to read file: ${filePath}`);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(raw);
|
|
56
|
+
} catch {
|
|
57
|
+
throw new CliError(`Invalid JSON in file: ${filePath}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function writeJsonFile(filePath, value) {
|
|
61
|
+
const dir = path.dirname(filePath);
|
|
62
|
+
try {
|
|
63
|
+
await ensureDir(dir);
|
|
64
|
+
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}
|
|
65
|
+
`, "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
throw new CliError(`Failed to write file: ${filePath}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async function collectFiles(rootDir, extensions) {
|
|
71
|
+
const files = [];
|
|
72
|
+
async function walk(currentDir) {
|
|
73
|
+
let entries;
|
|
74
|
+
try {
|
|
75
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
76
|
+
} catch {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
for (const entry of entries) {
|
|
80
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
81
|
+
if (entry.isDirectory()) {
|
|
82
|
+
if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
await walk(fullPath);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!entry.isFile()) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
92
|
+
if (extensions.has(ext)) {
|
|
93
|
+
files.push(fullPath);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
await walk(rootDir);
|
|
98
|
+
return files;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/lib/validation.ts
|
|
102
|
+
function isPlainObject(input) {
|
|
103
|
+
return typeof input === "object" && input !== null && !Array.isArray(input);
|
|
104
|
+
}
|
|
105
|
+
function assertStringArray(input, fieldName) {
|
|
106
|
+
if (!Array.isArray(input) || input.some((value) => typeof value !== "string" || value.trim().length === 0)) {
|
|
107
|
+
throw new CliError(`Invalid ${fieldName} in content-overlay config.`, {
|
|
108
|
+
hint: `Expected "${fieldName}" to be a non-empty string array.`
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return input;
|
|
112
|
+
}
|
|
113
|
+
function validateOverlayConfig(input) {
|
|
114
|
+
if (!isPlainObject(input)) {
|
|
115
|
+
throw new CliError("Invalid content-overlay config file.", {
|
|
116
|
+
hint: "Run: content-overlay init --force"
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
const version = input.version;
|
|
120
|
+
if (version !== 1) {
|
|
121
|
+
throw new CliError(`Unsupported config version: ${String(version)}.`, {
|
|
122
|
+
hint: "Run: content-overlay init --force"
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
const scanDirs = assertStringArray(input.scanDirs, "scanDirs");
|
|
126
|
+
const extensions = assertStringArray(input.extensions, "extensions");
|
|
127
|
+
const contentFile = input.contentFile;
|
|
128
|
+
const mapFile = input.mapFile;
|
|
129
|
+
const draftFile = input.draftFile;
|
|
130
|
+
if (typeof contentFile !== "string" || contentFile.trim().length === 0) {
|
|
131
|
+
throw new CliError("Invalid contentFile in content-overlay config.");
|
|
132
|
+
}
|
|
133
|
+
if (typeof mapFile !== "string" || mapFile.trim().length === 0) {
|
|
134
|
+
throw new CliError("Invalid mapFile in content-overlay config.");
|
|
135
|
+
}
|
|
136
|
+
if (typeof draftFile !== "string" || draftFile.trim().length === 0) {
|
|
137
|
+
throw new CliError("Invalid draftFile in content-overlay config.");
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
version: 1,
|
|
141
|
+
scanDirs,
|
|
142
|
+
extensions,
|
|
143
|
+
contentFile,
|
|
144
|
+
mapFile,
|
|
145
|
+
draftFile
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function ensureStringRecord(input, label) {
|
|
149
|
+
if (!isPlainObject(input)) {
|
|
150
|
+
throw new CliError(`Invalid ${label}; expected a JSON object.`, {
|
|
151
|
+
hint: "Run: content-overlay init"
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const result = {};
|
|
155
|
+
for (const [key, value] of Object.entries(input)) {
|
|
156
|
+
if (typeof value !== "string") {
|
|
157
|
+
throw new CliError(`Invalid ${label}; key "${key}" must be a string.`);
|
|
158
|
+
}
|
|
159
|
+
result[key] = value;
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
function ensureContentMap(input) {
|
|
164
|
+
if (!Array.isArray(input)) {
|
|
165
|
+
throw new CliError("Invalid content map; expected an array.", {
|
|
166
|
+
hint: "Run: content-overlay scan"
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
const result = [];
|
|
170
|
+
for (const value of input) {
|
|
171
|
+
if (!isPlainObject(value)) {
|
|
172
|
+
throw new CliError("Invalid content map entry; expected object values.");
|
|
173
|
+
}
|
|
174
|
+
const { key, sourceFile, line, column, defaultValue } = value;
|
|
175
|
+
if (typeof key !== "string" || typeof sourceFile !== "string" || typeof line !== "number" || typeof column !== "number" || typeof defaultValue !== "string") {
|
|
176
|
+
throw new CliError("Invalid content map entry shape.", {
|
|
177
|
+
hint: "Run: content-overlay scan"
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
result.push({ key, sourceFile, line, column, defaultValue });
|
|
181
|
+
}
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/lib/config.ts
|
|
186
|
+
var CONFIG_FILE = "content-overlay.config.json";
|
|
187
|
+
var DEFAULT_CONFIG = {
|
|
188
|
+
version: 1,
|
|
189
|
+
scanDirs: ["app", "components"],
|
|
190
|
+
extensions: [".tsx", ".ts", ".jsx", ".js", ".mdx"],
|
|
191
|
+
contentFile: "content/site.json",
|
|
192
|
+
mapFile: ".overlay-content/content-map.json",
|
|
193
|
+
draftFile: ".overlay-content/draft.json"
|
|
194
|
+
};
|
|
195
|
+
async function loadConfig(cwd) {
|
|
196
|
+
const configPath = path2.join(cwd, CONFIG_FILE);
|
|
197
|
+
const config = await readJsonFile(configPath);
|
|
198
|
+
if (config === null) {
|
|
199
|
+
throw new CliError(`Missing ${CONFIG_FILE}.`, {
|
|
200
|
+
hint: "Run: content-overlay init"
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
return validateOverlayConfig(config);
|
|
204
|
+
}
|
|
205
|
+
async function writeDefaultConfig(cwd, force = false) {
|
|
206
|
+
const configPath = path2.join(cwd, CONFIG_FILE);
|
|
207
|
+
const exists = await fileExists(configPath);
|
|
208
|
+
if (exists && !force) {
|
|
209
|
+
return { created: false, path: configPath };
|
|
210
|
+
}
|
|
211
|
+
await writeJsonFile(configPath, DEFAULT_CONFIG);
|
|
212
|
+
return { created: true, path: configPath };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/commands/edit.ts
|
|
216
|
+
async function runEdit(options) {
|
|
217
|
+
if (!/^[a-z0-9._-]+$/i.test(options.key)) {
|
|
218
|
+
throw new CliError(`Invalid key "${options.key}".`, {
|
|
219
|
+
hint: "Keys should use letters, numbers, dots, hyphens, and underscores."
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
if (options.value.trim().length === 0) {
|
|
223
|
+
throw new CliError("Edit value cannot be empty.");
|
|
224
|
+
}
|
|
225
|
+
const config = await loadConfig(options.cwd);
|
|
226
|
+
const draftPath = path3.join(options.cwd, config.draftFile);
|
|
227
|
+
const contentPath = path3.join(options.cwd, config.contentFile);
|
|
228
|
+
const mapPath = path3.join(options.cwd, config.mapFile);
|
|
229
|
+
const rawContent = await readJsonFile(contentPath);
|
|
230
|
+
const content = rawContent === null ? {} : ensureStringRecord(rawContent, config.contentFile);
|
|
231
|
+
const rawDraft = await readJsonFile(draftPath);
|
|
232
|
+
const draft = rawDraft === null ? { ...content } : ensureStringRecord(rawDraft, config.draftFile);
|
|
233
|
+
const rawMap = await readJsonFile(mapPath);
|
|
234
|
+
const map = rawMap === null ? [] : ensureContentMap(rawMap);
|
|
235
|
+
const knownKeys = /* @__PURE__ */ new Set([...Object.keys(content), ...map.map((entry) => entry.key)]);
|
|
236
|
+
if (!knownKeys.has(options.key)) {
|
|
237
|
+
throw new CliError(`Unknown key "${options.key}".`, {
|
|
238
|
+
hint: "Run: content-overlay scan, then use one of the generated keys."
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const oldValue = draft[options.key];
|
|
242
|
+
if (oldValue === options.value) {
|
|
243
|
+
console.log(`No change for key: ${options.key}`);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
draft[options.key] = options.value;
|
|
247
|
+
await writeJsonFile(draftPath, draft);
|
|
248
|
+
if (oldValue === void 0) {
|
|
249
|
+
console.log(`Created draft key: ${options.key}`);
|
|
250
|
+
} else {
|
|
251
|
+
console.log(`Updated draft key: ${options.key}`);
|
|
252
|
+
console.log(`Old: ${oldValue}`);
|
|
253
|
+
}
|
|
254
|
+
console.log(`New: ${options.value}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/commands/init.ts
|
|
258
|
+
import path4 from "path";
|
|
259
|
+
import { promises as fs2 } from "fs";
|
|
260
|
+
async function runInit(options) {
|
|
261
|
+
const result = await writeDefaultConfig(options.cwd, options.force);
|
|
262
|
+
const activeConfig = result.created ? DEFAULT_CONFIG : await loadConfig(options.cwd);
|
|
263
|
+
if (result.created) {
|
|
264
|
+
console.log(`Created ${path4.relative(options.cwd, result.path)}`);
|
|
265
|
+
} else {
|
|
266
|
+
console.log("Config already exists. Use --force to overwrite.");
|
|
267
|
+
}
|
|
268
|
+
const contentFile = path4.join(options.cwd, activeConfig.contentFile);
|
|
269
|
+
const rawContent = await readJsonFile(contentFile);
|
|
270
|
+
const existingContent = rawContent === null ? null : ensureStringRecord(rawContent, activeConfig.contentFile);
|
|
271
|
+
if (!existingContent) {
|
|
272
|
+
await writeJsonFile(contentFile, {});
|
|
273
|
+
console.log(`Created ${activeConfig.contentFile}`);
|
|
274
|
+
}
|
|
275
|
+
const draftFile = path4.join(options.cwd, activeConfig.draftFile);
|
|
276
|
+
const rawDraft = await readJsonFile(draftFile);
|
|
277
|
+
const existingDraft = rawDraft === null ? null : ensureStringRecord(rawDraft, activeConfig.draftFile);
|
|
278
|
+
if (!existingDraft) {
|
|
279
|
+
await writeJsonFile(draftFile, existingContent ?? {});
|
|
280
|
+
console.log(`Created ${activeConfig.draftFile}`);
|
|
281
|
+
}
|
|
282
|
+
const mapFile = path4.join(options.cwd, activeConfig.mapFile);
|
|
283
|
+
const rawMap = await readJsonFile(mapFile);
|
|
284
|
+
if (rawMap === null) {
|
|
285
|
+
await writeJsonFile(mapFile, []);
|
|
286
|
+
console.log(`Created ${activeConfig.mapFile}`);
|
|
287
|
+
} else {
|
|
288
|
+
ensureContentMap(rawMap);
|
|
289
|
+
}
|
|
290
|
+
const packageJsonPath = path4.join(options.cwd, "package.json");
|
|
291
|
+
try {
|
|
292
|
+
const raw = await fs2.readFile(packageJsonPath, "utf8");
|
|
293
|
+
const pkg = JSON.parse(raw);
|
|
294
|
+
const hasNext = Boolean(pkg.dependencies?.next || pkg.devDependencies?.next);
|
|
295
|
+
if (!hasNext) {
|
|
296
|
+
console.log("Warning: next dependency not found in package.json. This tool is built for Next.js App Router.");
|
|
297
|
+
}
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof SyntaxError) {
|
|
300
|
+
throw new CliError("Invalid package.json; unable to parse JSON.");
|
|
301
|
+
}
|
|
302
|
+
console.log("Warning: package.json not found. Run this in your Next.js project root.");
|
|
303
|
+
}
|
|
304
|
+
console.log("Init complete.");
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/commands/publish.ts
|
|
308
|
+
import path5 from "path";
|
|
309
|
+
async function runPublish(options) {
|
|
310
|
+
const config = await loadConfig(options.cwd);
|
|
311
|
+
const contentPath = path5.join(options.cwd, config.contentFile);
|
|
312
|
+
const draftPath = path5.join(options.cwd, config.draftFile);
|
|
313
|
+
const publishMetaPath = path5.join(options.cwd, ".overlay-content/last-publish.json");
|
|
314
|
+
const rawContent = await readJsonFile(contentPath);
|
|
315
|
+
const content = rawContent === null ? {} : ensureStringRecord(rawContent, config.contentFile);
|
|
316
|
+
const rawDraft = await readJsonFile(draftPath);
|
|
317
|
+
const draft = rawDraft === null ? { ...content } : ensureStringRecord(rawDraft, config.draftFile);
|
|
318
|
+
const nextContent = { ...content, ...draft };
|
|
319
|
+
const candidateKeys = /* @__PURE__ */ new Set([...Object.keys(content), ...Object.keys(nextContent)]);
|
|
320
|
+
const changedKeys = [...candidateKeys].filter((key) => nextContent[key] !== content[key]);
|
|
321
|
+
if (changedKeys.length === 0) {
|
|
322
|
+
console.log("No draft changes to publish.");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
await writeJsonFile(contentPath, nextContent);
|
|
326
|
+
await writeJsonFile(draftPath, nextContent);
|
|
327
|
+
await writeJsonFile(publishMetaPath, {
|
|
328
|
+
publishedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
329
|
+
changedKeys
|
|
330
|
+
});
|
|
331
|
+
console.log(`Published ${changedKeys.length} change(s) to ${config.contentFile}.`);
|
|
332
|
+
for (const key of changedKeys) {
|
|
333
|
+
console.log(`- ${key}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/commands/scan.ts
|
|
338
|
+
import path7 from "path";
|
|
339
|
+
import { promises as fs3 } from "fs";
|
|
340
|
+
|
|
341
|
+
// src/lib/scan.ts
|
|
342
|
+
import path6 from "path";
|
|
343
|
+
var JSX_TEXT_REGEX = />([^<>{}\n]+)</g;
|
|
344
|
+
function toSlug(input) {
|
|
345
|
+
return input.toLowerCase().replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-").slice(0, 40);
|
|
346
|
+
}
|
|
347
|
+
function indexToLineCol(input, index) {
|
|
348
|
+
const before = input.slice(0, index);
|
|
349
|
+
const lines = before.split("\n");
|
|
350
|
+
const line = lines.length;
|
|
351
|
+
const column = lines[lines.length - 1].length + 1;
|
|
352
|
+
return { line, column };
|
|
353
|
+
}
|
|
354
|
+
function isUsefulText(text) {
|
|
355
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
356
|
+
if (normalized.length < 3) return false;
|
|
357
|
+
if (!/[a-zA-Z]/.test(normalized)) return false;
|
|
358
|
+
if (normalized.startsWith("{") || normalized.endsWith("}")) return false;
|
|
359
|
+
return true;
|
|
360
|
+
}
|
|
361
|
+
function extractTextEntries(filePath, fileContents, cwd) {
|
|
362
|
+
const rel = path6.relative(cwd, filePath).replace(/\\/g, "/");
|
|
363
|
+
const relNoExt = rel.replace(/\.[^.]+$/, "");
|
|
364
|
+
const fileToken = relNoExt.replace(/\//g, ".");
|
|
365
|
+
const seen = /* @__PURE__ */ new Map();
|
|
366
|
+
const results = [];
|
|
367
|
+
for (const match of fileContents.matchAll(JSX_TEXT_REGEX)) {
|
|
368
|
+
const raw = match[1] ?? "";
|
|
369
|
+
const text = raw.replace(/\s+/g, " ").trim();
|
|
370
|
+
if (!isUsefulText(text)) continue;
|
|
371
|
+
const slug = toSlug(text);
|
|
372
|
+
if (!slug) continue;
|
|
373
|
+
const count = (seen.get(slug) ?? 0) + 1;
|
|
374
|
+
seen.set(slug, count);
|
|
375
|
+
const key = count === 1 ? `${fileToken}.${slug}` : `${fileToken}.${slug}-${count}`;
|
|
376
|
+
const position = indexToLineCol(fileContents, match.index ?? 0);
|
|
377
|
+
results.push({
|
|
378
|
+
key,
|
|
379
|
+
sourceFile: rel,
|
|
380
|
+
line: position.line,
|
|
381
|
+
column: position.column,
|
|
382
|
+
defaultValue: text
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
return results;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// src/commands/scan.ts
|
|
389
|
+
async function runScan(options) {
|
|
390
|
+
const config = await loadConfig(options.cwd);
|
|
391
|
+
const extensions = new Set(config.extensions.map((ext) => ext.toLowerCase()));
|
|
392
|
+
const allFiles = [];
|
|
393
|
+
const existingDirs = [];
|
|
394
|
+
for (const dir of config.scanDirs) {
|
|
395
|
+
const dirPath = path7.join(options.cwd, dir);
|
|
396
|
+
try {
|
|
397
|
+
const stat = await fs3.stat(dirPath);
|
|
398
|
+
if (!stat.isDirectory()) {
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
existingDirs.push(dir);
|
|
402
|
+
} catch {
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
const files = await collectFiles(dirPath, extensions);
|
|
406
|
+
allFiles.push(...files);
|
|
407
|
+
}
|
|
408
|
+
if (existingDirs.length === 0) {
|
|
409
|
+
throw new CliError("No scan directories found.", {
|
|
410
|
+
hint: `Expected one of: ${config.scanDirs.join(", ")}`
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
const uniqueFiles = [...new Set(allFiles)].sort((a, b) => a.localeCompare(b));
|
|
414
|
+
const map = [];
|
|
415
|
+
for (const filePath of uniqueFiles) {
|
|
416
|
+
const source = await fs3.readFile(filePath, "utf8");
|
|
417
|
+
const entries = extractTextEntries(filePath, source, options.cwd);
|
|
418
|
+
map.push(...entries);
|
|
419
|
+
}
|
|
420
|
+
const contentPath = path7.join(options.cwd, config.contentFile);
|
|
421
|
+
const rawContent = await readJsonFile(contentPath);
|
|
422
|
+
const existingContent = rawContent === null ? {} : ensureStringRecord(rawContent, config.contentFile);
|
|
423
|
+
const nextContent = { ...existingContent };
|
|
424
|
+
let newKeys = 0;
|
|
425
|
+
for (const entry of map) {
|
|
426
|
+
if (!(entry.key in nextContent)) {
|
|
427
|
+
nextContent[entry.key] = entry.defaultValue;
|
|
428
|
+
newKeys += 1;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
await writeJsonFile(path7.join(options.cwd, config.mapFile), map);
|
|
432
|
+
await writeJsonFile(contentPath, nextContent);
|
|
433
|
+
const draftPath = path7.join(options.cwd, config.draftFile);
|
|
434
|
+
const rawDraft = await readJsonFile(draftPath);
|
|
435
|
+
const existingDraft = rawDraft === null ? null : ensureStringRecord(rawDraft, config.draftFile);
|
|
436
|
+
const nextDraft = { ...nextContent, ...existingDraft ?? {} };
|
|
437
|
+
await writeJsonFile(draftPath, nextDraft);
|
|
438
|
+
console.log(`Scanned ${uniqueFiles.length} files.`);
|
|
439
|
+
console.log(`Found ${map.length} editable text nodes.`);
|
|
440
|
+
console.log(`Added ${newKeys} new keys to ${config.contentFile}.`);
|
|
441
|
+
if (map.length === 0) {
|
|
442
|
+
console.log("Warning: No text nodes found. Make sure files contain plain JSX text between tags.");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// src/index.ts
|
|
447
|
+
var VERSION = "0.1.0";
|
|
448
|
+
function printHelp() {
|
|
449
|
+
console.log("content-overlay <command> [options]");
|
|
450
|
+
console.log("");
|
|
451
|
+
console.log("Core flow: init -> scan -> edit -> publish");
|
|
452
|
+
console.log("");
|
|
453
|
+
console.log("Commands");
|
|
454
|
+
console.log(" init [--force] Initialize config and content files");
|
|
455
|
+
console.log(" scan Scan source files and build content map");
|
|
456
|
+
console.log(' edit <key> "<value>" Update one draft content value');
|
|
457
|
+
console.log(" publish Publish draft values to content/site.json");
|
|
458
|
+
console.log("");
|
|
459
|
+
console.log("Flags");
|
|
460
|
+
console.log(" -h, --help Show help");
|
|
461
|
+
console.log(" -v, --version Show CLI version");
|
|
462
|
+
}
|
|
463
|
+
async function main() {
|
|
464
|
+
const [, , command, ...args] = process.argv;
|
|
465
|
+
const cwd = process.cwd();
|
|
466
|
+
try {
|
|
467
|
+
if (!command || command === "help" || command === "--help" || command === "-h") {
|
|
468
|
+
printHelp();
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (command === "--version" || command === "-v" || command === "version") {
|
|
472
|
+
console.log(VERSION);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (command === "init") {
|
|
476
|
+
const allowed = /* @__PURE__ */ new Set(["--force"]);
|
|
477
|
+
const unknown = args.filter((arg) => arg.startsWith("-") && !allowed.has(arg));
|
|
478
|
+
if (unknown.length > 0) {
|
|
479
|
+
throw new CliError(`Unknown option(s): ${unknown.join(", ")}`, {
|
|
480
|
+
hint: "Usage: content-overlay init [--force]"
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const force = args.includes("--force");
|
|
484
|
+
await runInit({ cwd, force });
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (command === "scan") {
|
|
488
|
+
if (args.length > 0) {
|
|
489
|
+
throw new CliError("scan does not accept arguments.", {
|
|
490
|
+
hint: "Usage: content-overlay scan"
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
await runScan({ cwd });
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
if (command === "edit") {
|
|
497
|
+
if (args[0] === "--help" || args[0] === "-h") {
|
|
498
|
+
console.log('Usage: content-overlay edit <key> "<value>"');
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const key = args[0];
|
|
502
|
+
const value = args.slice(1).join(" ").trim();
|
|
503
|
+
if (!key || !value) {
|
|
504
|
+
throw new CliError("Missing required edit arguments.", {
|
|
505
|
+
hint: 'Usage: content-overlay edit <key> "<value>"'
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
await runEdit({ cwd, key, value });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
if (command === "publish") {
|
|
512
|
+
if (args.length > 0) {
|
|
513
|
+
throw new CliError("publish does not accept arguments.", {
|
|
514
|
+
hint: "Usage: content-overlay publish"
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
await runPublish({ cwd });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
throw new CliError(`Unknown command: ${command}`, {
|
|
521
|
+
hint: "Run: content-overlay --help"
|
|
522
|
+
});
|
|
523
|
+
} catch (error) {
|
|
524
|
+
const { message, hint } = toErrorMessage(error);
|
|
525
|
+
console.error(`Error: ${message}`);
|
|
526
|
+
if (hint) {
|
|
527
|
+
console.error(`Hint: ${hint}`);
|
|
528
|
+
}
|
|
529
|
+
process.exitCode = 1;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "next-content-overlay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Simple overlay content workflow for Next.js App Router",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"content-overlay": "./dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
17
|
+
"dev": "tsx src/index.ts",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"test": "tsx --test tests/**/*.test.ts",
|
|
20
|
+
"license:check": "node scripts/license-check.mjs",
|
|
21
|
+
"check": "npm run typecheck && npm run test && npm run build && npm run license:check",
|
|
22
|
+
"prepack": "npm run check"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^22.13.10",
|
|
26
|
+
"tsup": "^8.4.0",
|
|
27
|
+
"tsx": "^4.19.3",
|
|
28
|
+
"typescript": "^5.8.2"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=20.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|