@org-press/fmt 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ GNU GENERAL PUBLIC LICENSE
2
+ Version 2, June 1991
3
+
4
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.
5
+ 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
6
+
7
+ Everyone is permitted to copy and distribute verbatim copies
8
+ of this license document, but changing it is not allowed.
9
+
10
+ For the full license text, see: https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt
11
+
12
+ ---
13
+
14
+ Org-Press - Static site generator for org-mode files
15
+ Copyright (C) 2024-2026
16
+
17
+ This program is free software; you can redistribute it and/or modify
18
+ it under the terms of the GNU General Public License as published by
19
+ the Free Software Foundation; either version 2 of the License, or
20
+ (at your option) any later version.
21
+
22
+ This program is distributed in the hope that it will be useful,
23
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
24
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
25
+ GNU General Public License for more details.
26
+
27
+ You should have received a copy of the GNU General Public License
28
+ along with this program; if not, write to the Free Software
29
+ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
package/dist/index.js ADDED
@@ -0,0 +1,361 @@
1
+ // src/plugin.ts
2
+ import { CreateCommand } from "org-press";
3
+
4
+ // src/command.ts
5
+ import * as prettier from "prettier";
6
+
7
+ // src/utils.ts
8
+ import * as fs from "fs";
9
+ import * as path from "path";
10
+ import { pathToFileURL } from "url";
11
+ import { parse } from "uniorg-parse/lib/parser.js";
12
+ function findOrgFiles(dir) {
13
+ const files = [];
14
+ if (!fs.existsSync(dir)) {
15
+ return files;
16
+ }
17
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
18
+ for (const entry of entries) {
19
+ const fullPath = path.join(dir, entry.name);
20
+ if (entry.isDirectory()) {
21
+ if (entry.name !== "node_modules" && !entry.name.startsWith(".")) {
22
+ files.push(...findOrgFiles(fullPath));
23
+ }
24
+ } else if (entry.name.endsWith(".org")) {
25
+ files.push(fullPath);
26
+ }
27
+ }
28
+ return files;
29
+ }
30
+ function extractBlocksFromFile(orgFilePath, projectRoot, options) {
31
+ const absolutePath = path.isAbsolute(orgFilePath) ? orgFilePath : path.join(projectRoot, orgFilePath);
32
+ const relativePath = path.relative(projectRoot, absolutePath);
33
+ const content = fs.readFileSync(absolutePath, "utf-8");
34
+ const lines = content.split("\n");
35
+ const ast = parse(content);
36
+ const blocks = [];
37
+ let blockIndex = 0;
38
+ const blockPositions = [];
39
+ for (let i = 0; i < lines.length; i++) {
40
+ const line = lines[i];
41
+ const beginMatch = line.match(/^\s*#\+begin_src\s+(\w+)(.*)$/i);
42
+ if (beginMatch) {
43
+ const language = beginMatch[1].toLowerCase();
44
+ let name;
45
+ for (let j = i - 1; j >= 0; j--) {
46
+ const prevLine = lines[j].trim();
47
+ const nameMatch = prevLine.match(/^#\+name:\s*(.+)$/i);
48
+ if (nameMatch) {
49
+ name = nameMatch[1].trim();
50
+ break;
51
+ }
52
+ if (prevLine && !prevLine.startsWith("#")) break;
53
+ }
54
+ let endLine = i;
55
+ for (let k = i + 1; k < lines.length; k++) {
56
+ if (lines[k].match(/^\s*#\+end_src\s*$/i)) {
57
+ endLine = k;
58
+ break;
59
+ }
60
+ }
61
+ blockPositions.push({
62
+ startLine: i + 1,
63
+ // 1-based
64
+ endLine: endLine + 1,
65
+ // 1-based
66
+ name,
67
+ language
68
+ });
69
+ }
70
+ }
71
+ function walk(node) {
72
+ if (!node || typeof node !== "object") return;
73
+ const nodeObj = node;
74
+ if (nodeObj.type === "src-block") {
75
+ const position = blockPositions[blockIndex];
76
+ if (!position) {
77
+ blockIndex++;
78
+ return;
79
+ }
80
+ const language = nodeObj.language?.toLowerCase() || "";
81
+ if (options?.languages && options.languages.length > 0 && !options.languages.includes(language)) {
82
+ blockIndex++;
83
+ return;
84
+ }
85
+ blocks.push({
86
+ orgFilePath: relativePath,
87
+ blockIndex,
88
+ blockName: position.name,
89
+ code: nodeObj.value || "",
90
+ language,
91
+ startLine: position.startLine,
92
+ endLine: position.endLine
93
+ });
94
+ blockIndex++;
95
+ }
96
+ if (nodeObj.children && Array.isArray(nodeObj.children)) {
97
+ for (const child of nodeObj.children) {
98
+ walk(child);
99
+ }
100
+ }
101
+ }
102
+ walk(ast);
103
+ return blocks;
104
+ }
105
+ async function collectCodeBlocks(contentDir, projectRoot = process.cwd(), options) {
106
+ const absoluteContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir);
107
+ let orgFiles = findOrgFiles(absoluteContentDir);
108
+ if (options?.files && options.files.length > 0) {
109
+ const patterns = options.files.map(
110
+ (f) => path.isAbsolute(f) ? f : path.join(projectRoot, f)
111
+ );
112
+ orgFiles = orgFiles.filter(
113
+ (file) => patterns.some((pattern) => {
114
+ if (pattern.endsWith(".org")) {
115
+ return file === pattern || file.endsWith(pattern);
116
+ }
117
+ return file.includes(pattern);
118
+ })
119
+ );
120
+ }
121
+ const allBlocks = [];
122
+ for (const orgFile of orgFiles) {
123
+ try {
124
+ const blocks = extractBlocksFromFile(orgFile, projectRoot, options);
125
+ allBlocks.push(...blocks);
126
+ } catch (error) {
127
+ console.warn(
128
+ `[fmt] Warning: Failed to parse ${orgFile}: ${error instanceof Error ? error.message : String(error)}`
129
+ );
130
+ }
131
+ }
132
+ return allBlocks;
133
+ }
134
+ function writeBlockContentBatch(updates, projectRoot = process.cwd()) {
135
+ const byFile = /* @__PURE__ */ new Map();
136
+ for (const update of updates) {
137
+ const filePath = update.block.orgFilePath;
138
+ if (!byFile.has(filePath)) {
139
+ byFile.set(filePath, []);
140
+ }
141
+ byFile.get(filePath).push(update);
142
+ }
143
+ for (const [filePath, fileUpdates] of byFile) {
144
+ fileUpdates.sort((a, b) => b.block.blockIndex - a.block.blockIndex);
145
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
146
+ let fileContent = fs.readFileSync(absolutePath, "utf-8");
147
+ let lines = fileContent.split("\n");
148
+ for (const { block, newContent } of fileUpdates) {
149
+ const beginLineIndex = block.startLine - 1;
150
+ const endLineIndex = block.endLine - 1;
151
+ if (beginLineIndex < 0 || endLineIndex >= lines.length || beginLineIndex >= endLineIndex) {
152
+ console.warn(
153
+ `[fmt] Warning: Invalid block position in ${filePath}: lines ${block.startLine}-${block.endLine}`
154
+ );
155
+ continue;
156
+ }
157
+ const beginLine = lines[beginLineIndex];
158
+ const endLine = lines[endLineIndex];
159
+ if (!beginLine.match(/^\s*#\+begin_src\s+\w+/i)) {
160
+ console.warn(
161
+ `[fmt] Warning: Expected #+begin_src at line ${block.startLine} in ${filePath}`
162
+ );
163
+ continue;
164
+ }
165
+ if (!endLine.match(/^\s*#\+end_src\s*$/i)) {
166
+ console.warn(
167
+ `[fmt] Warning: Expected #+end_src at line ${block.endLine} in ${filePath}`
168
+ );
169
+ continue;
170
+ }
171
+ const trimmedContent = newContent.replace(/\n$/, "");
172
+ const beforeBlock = lines.slice(0, beginLineIndex + 1);
173
+ const afterBlock = lines.slice(endLineIndex);
174
+ lines = [...beforeBlock, trimmedContent, ...afterBlock];
175
+ }
176
+ fs.writeFileSync(absolutePath, lines.join("\n"), "utf-8");
177
+ }
178
+ }
179
+ async function loadPrettierConfig(projectRoot) {
180
+ const configFiles = [
181
+ ".prettierrc",
182
+ ".prettierrc.json",
183
+ ".prettierrc.js",
184
+ ".prettierrc.cjs",
185
+ ".prettierrc.mjs",
186
+ "prettier.config.js",
187
+ "prettier.config.cjs",
188
+ "prettier.config.mjs"
189
+ ];
190
+ for (const configFile of configFiles) {
191
+ const configPath = path.join(projectRoot, configFile);
192
+ if (!fs.existsSync(configPath)) {
193
+ continue;
194
+ }
195
+ try {
196
+ if (configFile.endsWith(".json") || configFile === ".prettierrc") {
197
+ const content = fs.readFileSync(configPath, "utf-8");
198
+ return JSON.parse(content);
199
+ }
200
+ if (configFile.endsWith(".js") || configFile.endsWith(".cjs") || configFile.endsWith(".mjs")) {
201
+ const configUrl = pathToFileURL(configPath).href;
202
+ const module = await import(configUrl);
203
+ return module.default || module;
204
+ }
205
+ } catch (error) {
206
+ console.warn(
207
+ `[fmt] Warning: Failed to load ${configFile}: ${error instanceof Error ? error.message : String(error)}`
208
+ );
209
+ }
210
+ }
211
+ return {};
212
+ }
213
+
214
+ // src/types.ts
215
+ var PRETTIER_PARSERS = {
216
+ typescript: "typescript",
217
+ ts: "typescript",
218
+ tsx: "typescript",
219
+ javascript: "babel",
220
+ js: "babel",
221
+ jsx: "babel",
222
+ json: "json",
223
+ css: "css",
224
+ scss: "scss",
225
+ less: "less",
226
+ html: "html",
227
+ yaml: "yaml",
228
+ yml: "yaml",
229
+ markdown: "markdown",
230
+ md: "markdown",
231
+ graphql: "graphql",
232
+ gql: "graphql"
233
+ };
234
+
235
+ // src/command.ts
236
+ function parseFmtArgs(args) {
237
+ const options = {
238
+ check: false,
239
+ write: true,
240
+ languages: [],
241
+ files: []
242
+ };
243
+ for (let i = 0; i < args.length; i++) {
244
+ const arg = args[i];
245
+ if (arg === "--check" || arg === "-c") {
246
+ options.check = true;
247
+ options.write = false;
248
+ } else if (arg === "--write" || arg === "-w") {
249
+ options.write = true;
250
+ } else if (arg === "--languages" || arg === "-l") {
251
+ const next = args[++i];
252
+ if (next) {
253
+ options.languages = next.split(",").map((l) => l.trim().toLowerCase());
254
+ }
255
+ } else if (!arg.startsWith("-")) {
256
+ options.files.push(arg);
257
+ }
258
+ }
259
+ return options;
260
+ }
261
+ function getParserForLanguage(language) {
262
+ return PRETTIER_PARSERS[language.toLowerCase()] || null;
263
+ }
264
+ async function runFmt(args, ctx) {
265
+ const options = parseFmtArgs(args);
266
+ const prettierConfig = await loadPrettierConfig(ctx.projectRoot);
267
+ const collectOptions = {
268
+ files: options.files?.length ? options.files : void 0,
269
+ languages: options.languages?.length ? options.languages : void 0
270
+ };
271
+ const blocks = await collectCodeBlocks(
272
+ ctx.contentDir,
273
+ ctx.projectRoot,
274
+ collectOptions
275
+ );
276
+ if (blocks.length === 0) {
277
+ console.log("[fmt] No code blocks found to format");
278
+ return 0;
279
+ }
280
+ let changedCount = 0;
281
+ let skippedCount = 0;
282
+ const updates = [];
283
+ for (const block of blocks) {
284
+ const parser = getParserForLanguage(block.language);
285
+ if (!parser) {
286
+ skippedCount++;
287
+ continue;
288
+ }
289
+ try {
290
+ const formatted2 = await prettier.format(block.code, {
291
+ parser,
292
+ ...prettierConfig
293
+ });
294
+ const normalizedFormatted = formatted2.replace(/\n$/, "");
295
+ const normalizedOriginal = block.code.replace(/\n$/, "");
296
+ if (normalizedFormatted !== normalizedOriginal) {
297
+ changedCount++;
298
+ if (options.check) {
299
+ const location = block.blockName ? `${block.orgFilePath}:${block.startLine} (${block.blockName})` : `${block.orgFilePath}:${block.startLine}`;
300
+ console.log(`[fmt] Would format: ${location}`);
301
+ } else {
302
+ updates.push({ block, newContent: normalizedFormatted });
303
+ }
304
+ }
305
+ } catch (error) {
306
+ const location = block.blockName ? `${block.orgFilePath}:${block.startLine} (${block.blockName})` : `${block.orgFilePath}:${block.startLine}`;
307
+ console.warn(
308
+ `[fmt] Warning: Failed to format ${location}: ${error instanceof Error ? error.message : String(error)}`
309
+ );
310
+ }
311
+ }
312
+ if (!options.check && updates.length > 0) {
313
+ writeBlockContentBatch(updates, ctx.projectRoot);
314
+ }
315
+ const total = blocks.length;
316
+ const formatted = options.check ? 0 : changedCount;
317
+ const unchanged = total - changedCount - skippedCount;
318
+ if (options.check) {
319
+ if (changedCount > 0) {
320
+ console.log(
321
+ `[fmt] ${changedCount} block(s) need formatting (${unchanged} unchanged, ${skippedCount} skipped)`
322
+ );
323
+ return 1;
324
+ } else {
325
+ console.log(
326
+ `[fmt] All ${unchanged} block(s) are formatted (${skippedCount} skipped)`
327
+ );
328
+ return 0;
329
+ }
330
+ } else {
331
+ if (formatted > 0) {
332
+ console.log(
333
+ `[fmt] Formatted ${formatted} block(s) (${unchanged} unchanged, ${skippedCount} skipped)`
334
+ );
335
+ } else {
336
+ console.log(
337
+ `[fmt] All ${unchanged} block(s) are formatted (${skippedCount} skipped)`
338
+ );
339
+ }
340
+ return 0;
341
+ }
342
+ }
343
+
344
+ // src/plugin.ts
345
+ var fmtPlugin = CreateCommand("fmt", {
346
+ description: "Format code blocks in org files using Prettier",
347
+ args: [
348
+ { name: "check", type: "boolean", description: "Check if files are formatted" },
349
+ { name: "languages", type: "string", description: "Comma-separated list of languages" }
350
+ ],
351
+ execute: (args, ctx) => runFmt(args._, ctx)
352
+ });
353
+ export {
354
+ PRETTIER_PARSERS,
355
+ collectCodeBlocks,
356
+ fmtPlugin,
357
+ loadPrettierConfig,
358
+ parseFmtArgs,
359
+ runFmt,
360
+ writeBlockContentBatch
361
+ };
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@org-press/fmt",
3
+ "version": "0.9.12",
4
+ "description": "Format code blocks in org files using Prettier",
5
+ "license": "GPL-2.0",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "files": [
16
+ "dist",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "peerDependencies": {
21
+ "org-press": ">=0.9.0"
22
+ },
23
+ "dependencies": {
24
+ "prettier": "^3.0.0"
25
+ },
26
+ "devDependencies": {
27
+ "tsup": "^8.0.0",
28
+ "typescript": "~5.7.3",
29
+ "uniorg": "^1.3.0",
30
+ "uniorg-parse": "^3.2.0",
31
+ "@types/node": "^20.0.0",
32
+ "org-press": "0.9.13"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "homepage": "https://orgp.dev",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/org-press/org-press.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/org-press/org-press/issues"
44
+ },
45
+ "scripts": {
46
+ "build": "tsup",
47
+ "dev": "tsup --watch",
48
+ "clean": "rm -rf dist"
49
+ }
50
+ }
package/src/command.ts ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Format Command Implementation
3
+ *
4
+ * Formats code blocks in org files using Prettier.
5
+ */
6
+
7
+ import * as prettier from "prettier";
8
+ import type { CommandContext } from "org-press";
9
+ import { collectCodeBlocks, writeBlockContentBatch, loadPrettierConfig } from "./utils.js";
10
+ import { PRETTIER_PARSERS, type FmtOptions } from "./types.js";
11
+
12
+ /**
13
+ * Parse command line arguments for fmt command
14
+ */
15
+ export function parseFmtArgs(args: string[]): FmtOptions {
16
+ const options: FmtOptions = {
17
+ check: false,
18
+ write: true,
19
+ languages: [],
20
+ files: [],
21
+ };
22
+
23
+ for (let i = 0; i < args.length; i++) {
24
+ const arg = args[i];
25
+
26
+ if (arg === "--check" || arg === "-c") {
27
+ options.check = true;
28
+ options.write = false;
29
+ } else if (arg === "--write" || arg === "-w") {
30
+ options.write = true;
31
+ } else if (arg === "--languages" || arg === "-l") {
32
+ const next = args[++i];
33
+ if (next) {
34
+ options.languages = next.split(",").map((l) => l.trim().toLowerCase());
35
+ }
36
+ } else if (!arg.startsWith("-")) {
37
+ // Positional argument - file pattern
38
+ options.files!.push(arg);
39
+ }
40
+ }
41
+
42
+ return options;
43
+ }
44
+
45
+ /**
46
+ * Get Prettier parser for a language
47
+ */
48
+ function getParserForLanguage(language: string): string | null {
49
+ return PRETTIER_PARSERS[language.toLowerCase()] || null;
50
+ }
51
+
52
+ /**
53
+ * Run the format command
54
+ *
55
+ * @param args - Command line arguments
56
+ * @param ctx - CLI context
57
+ * @returns Exit code (0 for success, 1 if check found changes)
58
+ */
59
+ export async function runFmt(
60
+ args: string[],
61
+ ctx: CommandContext
62
+ ): Promise<number> {
63
+ const options = parseFmtArgs(args);
64
+ const prettierConfig = await loadPrettierConfig(ctx.projectRoot);
65
+
66
+ // Collect blocks, filtering by language if formatting specific languages
67
+ const collectOptions = {
68
+ files: options.files?.length ? options.files : undefined,
69
+ languages: options.languages?.length ? options.languages : undefined,
70
+ };
71
+
72
+ const blocks = await collectCodeBlocks(
73
+ ctx.contentDir,
74
+ ctx.projectRoot,
75
+ collectOptions
76
+ );
77
+
78
+ if (blocks.length === 0) {
79
+ console.log("[fmt] No code blocks found to format");
80
+ return 0;
81
+ }
82
+
83
+ let changedCount = 0;
84
+ let skippedCount = 0;
85
+ const updates: Array<{ block: (typeof blocks)[0]; newContent: string }> = [];
86
+
87
+ for (const block of blocks) {
88
+ const parser = getParserForLanguage(block.language);
89
+
90
+ if (!parser) {
91
+ skippedCount++;
92
+ continue;
93
+ }
94
+
95
+ try {
96
+ const formatted = await prettier.format(block.code, {
97
+ parser,
98
+ ...prettierConfig,
99
+ });
100
+
101
+ // Prettier adds trailing newline, but we store without it
102
+ const normalizedFormatted = formatted.replace(/\n$/, "");
103
+ const normalizedOriginal = block.code.replace(/\n$/, "");
104
+
105
+ if (normalizedFormatted !== normalizedOriginal) {
106
+ changedCount++;
107
+
108
+ if (options.check) {
109
+ const location = block.blockName
110
+ ? `${block.orgFilePath}:${block.startLine} (${block.blockName})`
111
+ : `${block.orgFilePath}:${block.startLine}`;
112
+ console.log(`[fmt] Would format: ${location}`);
113
+ } else {
114
+ updates.push({ block, newContent: normalizedFormatted });
115
+ }
116
+ }
117
+ } catch (error) {
118
+ const location = block.blockName
119
+ ? `${block.orgFilePath}:${block.startLine} (${block.blockName})`
120
+ : `${block.orgFilePath}:${block.startLine}`;
121
+ console.warn(
122
+ `[fmt] Warning: Failed to format ${location}: ${
123
+ error instanceof Error ? error.message : String(error)
124
+ }`
125
+ );
126
+ }
127
+ }
128
+
129
+ // Write changes if not in check mode
130
+ if (!options.check && updates.length > 0) {
131
+ writeBlockContentBatch(updates, ctx.projectRoot);
132
+ }
133
+
134
+ // Summary
135
+ const total = blocks.length;
136
+ const formatted = options.check ? 0 : changedCount;
137
+ const unchanged = total - changedCount - skippedCount;
138
+
139
+ if (options.check) {
140
+ if (changedCount > 0) {
141
+ console.log(
142
+ `[fmt] ${changedCount} block(s) need formatting (${unchanged} unchanged, ${skippedCount} skipped)`
143
+ );
144
+ return 1;
145
+ } else {
146
+ console.log(
147
+ `[fmt] All ${unchanged} block(s) are formatted (${skippedCount} skipped)`
148
+ );
149
+ return 0;
150
+ }
151
+ } else {
152
+ if (formatted > 0) {
153
+ console.log(
154
+ `[fmt] Formatted ${formatted} block(s) (${unchanged} unchanged, ${skippedCount} skipped)`
155
+ );
156
+ } else {
157
+ console.log(
158
+ `[fmt] All ${unchanged} block(s) are formatted (${skippedCount} skipped)`
159
+ );
160
+ }
161
+ return 0;
162
+ }
163
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @org-press/fmt
3
+ *
4
+ * Format code blocks in org files using Prettier.
5
+ *
6
+ * This package provides the `orgp fmt` CLI command plugin.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // .org-press/config.ts
11
+ * import { fmtPlugin } from '@org-press/fmt';
12
+ *
13
+ * export default {
14
+ * contentDir: 'content',
15
+ * plugins: [fmtPlugin],
16
+ * };
17
+ * ```
18
+ */
19
+
20
+ // Plugin export
21
+ export { fmtPlugin } from "./plugin.js";
22
+
23
+ // Command export (for programmatic use)
24
+ export { runFmt, parseFmtArgs } from "./command.js";
25
+
26
+ // Type exports
27
+ export type { CollectedBlock, CollectOptions, FmtOptions } from "./types.js";
28
+
29
+ // Constant exports
30
+ export { PRETTIER_PARSERS } from "./types.js";
31
+
32
+ // Utility exports (for advanced use cases)
33
+ export { collectCodeBlocks, writeBlockContentBatch, loadPrettierConfig } from "./utils.js";
package/src/plugin.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Format Plugin
3
+ *
4
+ * Provides the `orgp fmt` command for formatting code blocks with Prettier.
5
+ */
6
+
7
+ import { CreateCommand } from "org-press";
8
+ import type { ParsedArgs, CommandContext } from "org-press";
9
+ import { runFmt } from "./command.js";
10
+
11
+ /**
12
+ * Format plugin
13
+ *
14
+ * Registers the `fmt` CLI command for formatting code blocks
15
+ * in org files using Prettier.
16
+ *
17
+ * Usage:
18
+ * orgp fmt # Format all blocks
19
+ * orgp fmt --check # Check if blocks are formatted
20
+ * orgp fmt --languages ts,tsx # Format only TypeScript blocks
21
+ * orgp fmt content/api.org # Format specific file
22
+ */
23
+ export const fmtPlugin = CreateCommand("fmt", {
24
+ description: "Format code blocks in org files using Prettier",
25
+ args: [
26
+ { name: "check", type: "boolean", description: "Check if files are formatted" },
27
+ { name: "languages", type: "string", description: "Comma-separated list of languages" },
28
+ ],
29
+ execute: (args: ParsedArgs, ctx: CommandContext) => runFmt(args._, ctx),
30
+ });
package/src/types.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Types for @org-press/fmt
3
+ */
4
+
5
+ /**
6
+ * A collected code block from an org file
7
+ */
8
+ export interface CollectedBlock {
9
+ /** Relative path to the org file */
10
+ orgFilePath: string;
11
+ /** 0-based index of the block in the file */
12
+ blockIndex: number;
13
+ /** Block name from #+NAME: directive */
14
+ blockName?: string;
15
+ /** The source code content */
16
+ code: string;
17
+ /** Block language (e.g., "typescript", "javascript") */
18
+ language: string;
19
+ /** 1-based line number where the block starts (#+begin_src line) */
20
+ startLine: number;
21
+ /** 1-based line number where the block ends (#+end_src line) */
22
+ endLine: number;
23
+ }
24
+
25
+ /**
26
+ * Options for collecting code blocks
27
+ */
28
+ export interface CollectOptions {
29
+ /** Filter by file path patterns */
30
+ files?: string[];
31
+ /** Filter by languages */
32
+ languages?: string[];
33
+ }
34
+
35
+ /**
36
+ * Options for the format command
37
+ */
38
+ export interface FmtOptions {
39
+ /** Check only, don't write changes */
40
+ check?: boolean;
41
+ /** Write changes (default: true when not checking) */
42
+ write?: boolean;
43
+ /** Filter by languages */
44
+ languages?: string[];
45
+ /** Filter by file patterns */
46
+ files?: string[];
47
+ }
48
+
49
+ /**
50
+ * Language to Prettier parser mapping
51
+ */
52
+ export const PRETTIER_PARSERS: Record<string, string> = {
53
+ typescript: "typescript",
54
+ ts: "typescript",
55
+ tsx: "typescript",
56
+ javascript: "babel",
57
+ js: "babel",
58
+ jsx: "babel",
59
+ json: "json",
60
+ css: "css",
61
+ scss: "scss",
62
+ less: "less",
63
+ html: "html",
64
+ yaml: "yaml",
65
+ yml: "yaml",
66
+ markdown: "markdown",
67
+ md: "markdown",
68
+ graphql: "graphql",
69
+ gql: "graphql",
70
+ };
package/src/utils.ts ADDED
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Utilities for @org-press/fmt
3
+ *
4
+ * Block collection, writing, and config loading functions.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+ import { pathToFileURL } from "node:url";
10
+ import { parse } from "uniorg-parse/lib/parser.js";
11
+ import type { OrgData } from "uniorg";
12
+ import type { Options as PrettierOptions } from "prettier";
13
+ import type { CollectedBlock, CollectOptions } from "./types.js";
14
+
15
+ /**
16
+ * Find all org files recursively in a directory
17
+ */
18
+ function findOrgFiles(dir: string): string[] {
19
+ const files: string[] = [];
20
+
21
+ if (!fs.existsSync(dir)) {
22
+ return files;
23
+ }
24
+
25
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
26
+
27
+ for (const entry of entries) {
28
+ const fullPath = path.join(dir, entry.name);
29
+
30
+ if (entry.isDirectory()) {
31
+ // Skip node_modules and hidden directories
32
+ if (entry.name !== "node_modules" && !entry.name.startsWith(".")) {
33
+ files.push(...findOrgFiles(fullPath));
34
+ }
35
+ } else if (entry.name.endsWith(".org")) {
36
+ files.push(fullPath);
37
+ }
38
+ }
39
+
40
+ return files;
41
+ }
42
+
43
+ /**
44
+ * Extract code blocks from a single org file
45
+ */
46
+ function extractBlocksFromFile(
47
+ orgFilePath: string,
48
+ projectRoot: string,
49
+ options?: CollectOptions
50
+ ): CollectedBlock[] {
51
+ const absolutePath = path.isAbsolute(orgFilePath)
52
+ ? orgFilePath
53
+ : path.join(projectRoot, orgFilePath);
54
+ const relativePath = path.relative(projectRoot, absolutePath);
55
+ const content = fs.readFileSync(absolutePath, "utf-8");
56
+ const lines = content.split("\n");
57
+ const ast = parse(content) as OrgData;
58
+
59
+ const blocks: CollectedBlock[] = [];
60
+ let blockIndex = 0;
61
+
62
+ // Find block positions and names by scanning raw content
63
+ const blockPositions: Array<{
64
+ startLine: number;
65
+ endLine: number;
66
+ name?: string;
67
+ language: string;
68
+ }> = [];
69
+
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const line = lines[i];
72
+ const beginMatch = line.match(/^\s*#\+begin_src\s+(\w+)(.*)$/i);
73
+
74
+ if (beginMatch) {
75
+ const language = beginMatch[1].toLowerCase();
76
+
77
+ // Look backwards for #+NAME: directive
78
+ let name: string | undefined;
79
+ for (let j = i - 1; j >= 0; j--) {
80
+ const prevLine = lines[j].trim();
81
+ const nameMatch = prevLine.match(/^#\+name:\s*(.+)$/i);
82
+ if (nameMatch) {
83
+ name = nameMatch[1].trim();
84
+ break;
85
+ }
86
+ // Stop looking if we hit a non-comment, non-empty line
87
+ if (prevLine && !prevLine.startsWith("#")) break;
88
+ }
89
+
90
+ // Find matching #+end_src
91
+ let endLine = i;
92
+ for (let k = i + 1; k < lines.length; k++) {
93
+ if (lines[k].match(/^\s*#\+end_src\s*$/i)) {
94
+ endLine = k;
95
+ break;
96
+ }
97
+ }
98
+
99
+ blockPositions.push({
100
+ startLine: i + 1, // 1-based
101
+ endLine: endLine + 1, // 1-based
102
+ name,
103
+ language,
104
+ });
105
+ }
106
+ }
107
+
108
+ // Walk AST and match blocks with their positions
109
+ function walk(node: unknown): void {
110
+ if (!node || typeof node !== "object") return;
111
+
112
+ const nodeObj = node as Record<string, unknown>;
113
+
114
+ if (nodeObj.type === "src-block") {
115
+ const position = blockPositions[blockIndex];
116
+ if (!position) {
117
+ blockIndex++;
118
+ return;
119
+ }
120
+
121
+ const language = (nodeObj.language as string)?.toLowerCase() || "";
122
+
123
+ // Filter by language if specified
124
+ if (
125
+ options?.languages &&
126
+ options.languages.length > 0 &&
127
+ !options.languages.includes(language)
128
+ ) {
129
+ blockIndex++;
130
+ return;
131
+ }
132
+
133
+ blocks.push({
134
+ orgFilePath: relativePath,
135
+ blockIndex,
136
+ blockName: position.name,
137
+ code: (nodeObj.value as string) || "",
138
+ language,
139
+ startLine: position.startLine,
140
+ endLine: position.endLine,
141
+ });
142
+
143
+ blockIndex++;
144
+ }
145
+
146
+ // Recurse into children
147
+ if (nodeObj.children && Array.isArray(nodeObj.children)) {
148
+ for (const child of nodeObj.children) {
149
+ walk(child);
150
+ }
151
+ }
152
+ }
153
+
154
+ walk(ast);
155
+ return blocks;
156
+ }
157
+
158
+ /**
159
+ * Collect all code blocks from a content directory
160
+ *
161
+ * @param contentDir - Content directory to scan
162
+ * @param projectRoot - Project root directory
163
+ * @param options - Collection options
164
+ * @returns Array of collected code blocks
165
+ */
166
+ export async function collectCodeBlocks(
167
+ contentDir: string,
168
+ projectRoot: string = process.cwd(),
169
+ options?: CollectOptions
170
+ ): Promise<CollectedBlock[]> {
171
+ const absoluteContentDir = path.isAbsolute(contentDir)
172
+ ? contentDir
173
+ : path.join(projectRoot, contentDir);
174
+
175
+ // Find all org files
176
+ let orgFiles = findOrgFiles(absoluteContentDir);
177
+
178
+ // Filter by file patterns if specified
179
+ if (options?.files && options.files.length > 0) {
180
+ const patterns = options.files.map((f) =>
181
+ path.isAbsolute(f) ? f : path.join(projectRoot, f)
182
+ );
183
+
184
+ orgFiles = orgFiles.filter((file) =>
185
+ patterns.some((pattern) => {
186
+ // Support both exact match and contains match
187
+ if (pattern.endsWith(".org")) {
188
+ return file === pattern || file.endsWith(pattern);
189
+ }
190
+ return file.includes(pattern);
191
+ })
192
+ );
193
+ }
194
+
195
+ const allBlocks: CollectedBlock[] = [];
196
+
197
+ // Process each file
198
+ for (const orgFile of orgFiles) {
199
+ try {
200
+ const blocks = extractBlocksFromFile(orgFile, projectRoot, options);
201
+ allBlocks.push(...blocks);
202
+ } catch (error) {
203
+ console.warn(
204
+ `[fmt] Warning: Failed to parse ${orgFile}: ${
205
+ error instanceof Error ? error.message : String(error)
206
+ }`
207
+ );
208
+ }
209
+ }
210
+
211
+ return allBlocks;
212
+ }
213
+
214
+ /**
215
+ * Batch write multiple block updates to minimize file I/O
216
+ *
217
+ * Updates are grouped by file and applied in reverse order
218
+ * (to preserve line numbers for earlier blocks in the same file)
219
+ *
220
+ * @param updates - Array of {block, newContent} pairs
221
+ * @param projectRoot - Project root directory
222
+ */
223
+ export function writeBlockContentBatch(
224
+ updates: Array<{ block: CollectedBlock; newContent: string }>,
225
+ projectRoot: string = process.cwd()
226
+ ): void {
227
+ // Group updates by file
228
+ const byFile = new Map<string, Array<{ block: CollectedBlock; newContent: string }>>();
229
+
230
+ for (const update of updates) {
231
+ const filePath = update.block.orgFilePath;
232
+ if (!byFile.has(filePath)) {
233
+ byFile.set(filePath, []);
234
+ }
235
+ byFile.get(filePath)!.push(update);
236
+ }
237
+
238
+ // Process each file
239
+ for (const [filePath, fileUpdates] of byFile) {
240
+ // Sort by blockIndex in reverse order so we can apply changes
241
+ // from the end of the file backwards (preserving line numbers)
242
+ fileUpdates.sort((a, b) => b.block.blockIndex - a.block.blockIndex);
243
+
244
+ const absolutePath = path.isAbsolute(filePath)
245
+ ? filePath
246
+ : path.join(projectRoot, filePath);
247
+
248
+ let fileContent = fs.readFileSync(absolutePath, "utf-8");
249
+ let lines = fileContent.split("\n");
250
+
251
+ for (const { block, newContent } of fileUpdates) {
252
+ const beginLineIndex = block.startLine - 1;
253
+ const endLineIndex = block.endLine - 1;
254
+
255
+ // Validate
256
+ if (
257
+ beginLineIndex < 0 ||
258
+ endLineIndex >= lines.length ||
259
+ beginLineIndex >= endLineIndex
260
+ ) {
261
+ console.warn(
262
+ `[fmt] Warning: Invalid block position in ${filePath}: lines ${block.startLine}-${block.endLine}`
263
+ );
264
+ continue;
265
+ }
266
+
267
+ const beginLine = lines[beginLineIndex];
268
+ const endLine = lines[endLineIndex];
269
+
270
+ if (!beginLine.match(/^\s*#\+begin_src\s+\w+/i)) {
271
+ console.warn(
272
+ `[fmt] Warning: Expected #+begin_src at line ${block.startLine} in ${filePath}`
273
+ );
274
+ continue;
275
+ }
276
+
277
+ if (!endLine.match(/^\s*#\+end_src\s*$/i)) {
278
+ console.warn(
279
+ `[fmt] Warning: Expected #+end_src at line ${block.endLine} in ${filePath}`
280
+ );
281
+ continue;
282
+ }
283
+
284
+ // Replace content
285
+ const trimmedContent = newContent.replace(/\n$/, "");
286
+ const beforeBlock = lines.slice(0, beginLineIndex + 1);
287
+ const afterBlock = lines.slice(endLineIndex);
288
+ lines = [...beforeBlock, trimmedContent, ...afterBlock];
289
+ }
290
+
291
+ fs.writeFileSync(absolutePath, lines.join("\n"), "utf-8");
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Load Prettier configuration from the project root
297
+ *
298
+ * Searches for:
299
+ * - .prettierrc
300
+ * - .prettierrc.json
301
+ * - .prettierrc.js
302
+ * - .prettierrc.cjs
303
+ * - .prettierrc.mjs
304
+ * - prettier.config.js
305
+ * - prettier.config.cjs
306
+ * - prettier.config.mjs
307
+ *
308
+ * @param projectRoot - Project root directory
309
+ * @returns Prettier options or empty object if not found
310
+ */
311
+ export async function loadPrettierConfig(
312
+ projectRoot: string
313
+ ): Promise<PrettierOptions> {
314
+ const configFiles = [
315
+ ".prettierrc",
316
+ ".prettierrc.json",
317
+ ".prettierrc.js",
318
+ ".prettierrc.cjs",
319
+ ".prettierrc.mjs",
320
+ "prettier.config.js",
321
+ "prettier.config.cjs",
322
+ "prettier.config.mjs",
323
+ ];
324
+
325
+ for (const configFile of configFiles) {
326
+ const configPath = path.join(projectRoot, configFile);
327
+
328
+ if (!fs.existsSync(configPath)) {
329
+ continue;
330
+ }
331
+
332
+ try {
333
+ if (configFile.endsWith(".json") || configFile === ".prettierrc") {
334
+ const content = fs.readFileSync(configPath, "utf-8");
335
+ return JSON.parse(content) as PrettierOptions;
336
+ }
337
+
338
+ if (
339
+ configFile.endsWith(".js") ||
340
+ configFile.endsWith(".cjs") ||
341
+ configFile.endsWith(".mjs")
342
+ ) {
343
+ const configUrl = pathToFileURL(configPath).href;
344
+ const module = await import(configUrl);
345
+ return (module.default || module) as PrettierOptions;
346
+ }
347
+ } catch (error) {
348
+ console.warn(
349
+ `[fmt] Warning: Failed to load ${configFile}: ${
350
+ error instanceof Error ? error.message : String(error)
351
+ }`
352
+ );
353
+ }
354
+ }
355
+
356
+ // Return default options if no config found
357
+ return {};
358
+ }