@org-press/lint 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,344 @@
1
+ // src/plugin.ts
2
+ import { CreateCommand } from "org-press";
3
+
4
+ // src/utils.ts
5
+ import * as fs from "fs";
6
+ import * as path from "path";
7
+ import { parse } from "uniorg-parse/lib/parser.js";
8
+ function findOrgFiles(dir) {
9
+ const files = [];
10
+ if (!fs.existsSync(dir)) {
11
+ return files;
12
+ }
13
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
14
+ for (const entry of entries) {
15
+ const fullPath = path.join(dir, entry.name);
16
+ if (entry.isDirectory()) {
17
+ if (entry.name !== "node_modules" && !entry.name.startsWith(".")) {
18
+ files.push(...findOrgFiles(fullPath));
19
+ }
20
+ } else if (entry.name.endsWith(".org")) {
21
+ files.push(fullPath);
22
+ }
23
+ }
24
+ return files;
25
+ }
26
+ function extractBlocksFromFile(orgFilePath, projectRoot, options) {
27
+ const absolutePath = path.isAbsolute(orgFilePath) ? orgFilePath : path.join(projectRoot, orgFilePath);
28
+ const relativePath = path.relative(projectRoot, absolutePath);
29
+ const content = fs.readFileSync(absolutePath, "utf-8");
30
+ const lines = content.split("\n");
31
+ const ast = parse(content);
32
+ const blocks = [];
33
+ let blockIndex = 0;
34
+ const blockPositions = [];
35
+ for (let i = 0; i < lines.length; i++) {
36
+ const line = lines[i];
37
+ const beginMatch = line.match(/^\s*#\+begin_src\s+(\w+)(.*)$/i);
38
+ if (beginMatch) {
39
+ const language = beginMatch[1].toLowerCase();
40
+ let name;
41
+ for (let j = i - 1; j >= 0; j--) {
42
+ const prevLine = lines[j].trim();
43
+ const nameMatch = prevLine.match(/^#\+name:\s*(.+)$/i);
44
+ if (nameMatch) {
45
+ name = nameMatch[1].trim();
46
+ break;
47
+ }
48
+ if (prevLine && !prevLine.startsWith("#")) break;
49
+ }
50
+ let endLine = i;
51
+ for (let k = i + 1; k < lines.length; k++) {
52
+ if (lines[k].match(/^\s*#\+end_src\s*$/i)) {
53
+ endLine = k;
54
+ break;
55
+ }
56
+ }
57
+ blockPositions.push({
58
+ startLine: i + 1,
59
+ // 1-based
60
+ endLine: endLine + 1,
61
+ // 1-based
62
+ name,
63
+ language
64
+ });
65
+ }
66
+ }
67
+ function walk(node) {
68
+ if (!node || typeof node !== "object") return;
69
+ const nodeObj = node;
70
+ if (nodeObj.type === "src-block") {
71
+ const position = blockPositions[blockIndex];
72
+ if (!position) {
73
+ blockIndex++;
74
+ return;
75
+ }
76
+ const language = nodeObj.language?.toLowerCase() || "";
77
+ if (options?.languages && options.languages.length > 0 && !options.languages.includes(language)) {
78
+ blockIndex++;
79
+ return;
80
+ }
81
+ blocks.push({
82
+ orgFilePath: relativePath,
83
+ blockIndex,
84
+ blockName: position.name,
85
+ code: nodeObj.value || "",
86
+ language,
87
+ startLine: position.startLine,
88
+ endLine: position.endLine
89
+ });
90
+ blockIndex++;
91
+ }
92
+ if (nodeObj.children && Array.isArray(nodeObj.children)) {
93
+ for (const child of nodeObj.children) {
94
+ walk(child);
95
+ }
96
+ }
97
+ }
98
+ walk(ast);
99
+ return blocks;
100
+ }
101
+ async function collectCodeBlocks(contentDir, projectRoot = process.cwd(), options) {
102
+ const absoluteContentDir = path.isAbsolute(contentDir) ? contentDir : path.join(projectRoot, contentDir);
103
+ let orgFiles = findOrgFiles(absoluteContentDir);
104
+ if (options?.files && options.files.length > 0) {
105
+ const patterns = options.files.map(
106
+ (f) => path.isAbsolute(f) ? f : path.join(projectRoot, f)
107
+ );
108
+ orgFiles = orgFiles.filter(
109
+ (file) => patterns.some((pattern) => {
110
+ if (pattern.endsWith(".org")) {
111
+ return file === pattern || file.endsWith(pattern);
112
+ }
113
+ return file.includes(pattern);
114
+ })
115
+ );
116
+ }
117
+ const allBlocks = [];
118
+ for (const orgFile of orgFiles) {
119
+ try {
120
+ const blocks = extractBlocksFromFile(orgFile, projectRoot, options);
121
+ allBlocks.push(...blocks);
122
+ } catch (error) {
123
+ console.warn(
124
+ `[lint] Warning: Failed to parse ${orgFile}: ${error instanceof Error ? error.message : String(error)}`
125
+ );
126
+ }
127
+ }
128
+ return allBlocks;
129
+ }
130
+ function writeBlockContentBatch(updates, projectRoot = process.cwd()) {
131
+ const byFile = /* @__PURE__ */ new Map();
132
+ for (const update of updates) {
133
+ const filePath = update.block.orgFilePath;
134
+ if (!byFile.has(filePath)) {
135
+ byFile.set(filePath, []);
136
+ }
137
+ byFile.get(filePath).push(update);
138
+ }
139
+ for (const [filePath, fileUpdates] of byFile) {
140
+ fileUpdates.sort((a, b) => b.block.blockIndex - a.block.blockIndex);
141
+ const absolutePath = path.isAbsolute(filePath) ? filePath : path.join(projectRoot, filePath);
142
+ let fileContent = fs.readFileSync(absolutePath, "utf-8");
143
+ let lines = fileContent.split("\n");
144
+ for (const { block, newContent } of fileUpdates) {
145
+ const beginLineIndex = block.startLine - 1;
146
+ const endLineIndex = block.endLine - 1;
147
+ if (beginLineIndex < 0 || endLineIndex >= lines.length || beginLineIndex >= endLineIndex) {
148
+ console.warn(
149
+ `[lint] Warning: Invalid block position in ${filePath}: lines ${block.startLine}-${block.endLine}`
150
+ );
151
+ continue;
152
+ }
153
+ const beginLine = lines[beginLineIndex];
154
+ const endLine = lines[endLineIndex];
155
+ if (!beginLine.match(/^\s*#\+begin_src\s+\w+/i)) {
156
+ console.warn(
157
+ `[lint] Warning: Expected #+begin_src at line ${block.startLine} in ${filePath}`
158
+ );
159
+ continue;
160
+ }
161
+ if (!endLine.match(/^\s*#\+end_src\s*$/i)) {
162
+ console.warn(
163
+ `[lint] Warning: Expected #+end_src at line ${block.endLine} in ${filePath}`
164
+ );
165
+ continue;
166
+ }
167
+ const trimmedContent = newContent.replace(/\n$/, "");
168
+ const beforeBlock = lines.slice(0, beginLineIndex + 1);
169
+ const afterBlock = lines.slice(endLineIndex);
170
+ lines = [...beforeBlock, trimmedContent, ...afterBlock];
171
+ }
172
+ fs.writeFileSync(absolutePath, lines.join("\n"), "utf-8");
173
+ }
174
+ }
175
+ function findEslintConfig(projectRoot) {
176
+ const configFiles = [
177
+ "eslint.config.js",
178
+ "eslint.config.mjs",
179
+ "eslint.config.cjs",
180
+ // Legacy configs
181
+ ".eslintrc.js",
182
+ ".eslintrc.cjs",
183
+ ".eslintrc.json",
184
+ ".eslintrc"
185
+ ];
186
+ for (const configFile of configFiles) {
187
+ const configPath = path.join(projectRoot, configFile);
188
+ if (fs.existsSync(configPath)) {
189
+ return configPath;
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+
195
+ // src/types.ts
196
+ var LANGUAGE_EXTENSIONS = {
197
+ typescript: "ts",
198
+ ts: "ts",
199
+ tsx: "tsx",
200
+ javascript: "js",
201
+ js: "js",
202
+ jsx: "jsx",
203
+ json: "json",
204
+ css: "css",
205
+ scss: "scss",
206
+ less: "less",
207
+ html: "html",
208
+ yaml: "yaml",
209
+ yml: "yml",
210
+ markdown: "md",
211
+ md: "md",
212
+ graphql: "graphql",
213
+ gql: "graphql"
214
+ };
215
+ var LINT_LANGUAGES = [
216
+ "typescript",
217
+ "ts",
218
+ "tsx",
219
+ "javascript",
220
+ "js",
221
+ "jsx"
222
+ ];
223
+
224
+ // src/command.ts
225
+ function parseLintArgs(args) {
226
+ const options = {
227
+ fix: false,
228
+ languages: [],
229
+ files: []
230
+ };
231
+ for (let i = 0; i < args.length; i++) {
232
+ const arg = args[i];
233
+ if (arg === "--fix" || arg === "-f") {
234
+ options.fix = true;
235
+ } else if (arg === "--languages" || arg === "-l") {
236
+ const next = args[++i];
237
+ if (next) {
238
+ options.languages = next.split(",").map((l) => l.trim().toLowerCase());
239
+ }
240
+ } else if (!arg.startsWith("-")) {
241
+ options.files.push(arg);
242
+ }
243
+ }
244
+ return options;
245
+ }
246
+ function getExtension(language) {
247
+ return LANGUAGE_EXTENSIONS[language.toLowerCase()] || "js";
248
+ }
249
+ async function runLint(args, ctx) {
250
+ const options = parseLintArgs(args);
251
+ let ESLint;
252
+ try {
253
+ const eslintModule = await import("eslint");
254
+ ESLint = eslintModule.ESLint;
255
+ } catch {
256
+ console.error("[lint] ESLint is not installed. Please install eslint:");
257
+ console.error(" npm install -D eslint");
258
+ return 1;
259
+ }
260
+ const configPath = findEslintConfig(ctx.projectRoot);
261
+ if (!configPath) {
262
+ console.warn("[lint] No ESLint configuration found. Using default settings.");
263
+ }
264
+ const languages = options.languages?.length ? options.languages : LINT_LANGUAGES;
265
+ const blocks = await collectCodeBlocks(ctx.contentDir, ctx.projectRoot, {
266
+ files: options.files?.length ? options.files : void 0,
267
+ languages
268
+ });
269
+ if (blocks.length === 0) {
270
+ console.log("[lint] No code blocks found to lint");
271
+ return 0;
272
+ }
273
+ const eslint = new ESLint({
274
+ cwd: ctx.projectRoot,
275
+ fix: options.fix
276
+ });
277
+ let errorCount = 0;
278
+ let warningCount = 0;
279
+ const updates = [];
280
+ for (const block of blocks) {
281
+ const ext = getExtension(block.language);
282
+ const virtualPath = block.blockName ? `${block.orgFilePath}#${block.blockName}.${ext}` : `${block.orgFilePath}#block-${block.blockIndex}.${ext}`;
283
+ try {
284
+ const results = await eslint.lintText(block.code, {
285
+ filePath: virtualPath
286
+ });
287
+ for (const result of results) {
288
+ for (const msg of result.messages) {
289
+ const orgLine = block.startLine + msg.line;
290
+ const severity = msg.severity === 2 ? "error" : "warning";
291
+ const ruleId = msg.ruleId ? ` (${msg.ruleId})` : "";
292
+ console.log(
293
+ `${block.orgFilePath}:${orgLine}:${msg.column} ${severity}: ${msg.message}${ruleId}`
294
+ );
295
+ if (msg.severity === 2) {
296
+ errorCount++;
297
+ } else {
298
+ warningCount++;
299
+ }
300
+ }
301
+ if (options.fix && result.output && result.output !== block.code) {
302
+ updates.push({ block, newContent: result.output.replace(/\n$/, "") });
303
+ }
304
+ }
305
+ } catch (error) {
306
+ const location = block.blockName ? `${block.orgFilePath}:${block.startLine} (${block.blockName})` : `${block.orgFilePath}:${block.startLine}`;
307
+ console.warn(
308
+ `[lint] Warning: Failed to lint ${location}: ${error instanceof Error ? error.message : String(error)}`
309
+ );
310
+ }
311
+ }
312
+ if (updates.length > 0) {
313
+ writeBlockContentBatch(updates, ctx.projectRoot);
314
+ console.log(`[lint] Fixed ${updates.length} block(s)`);
315
+ }
316
+ if (errorCount > 0 || warningCount > 0) {
317
+ console.log(
318
+ `[lint] Found ${errorCount} error(s) and ${warningCount} warning(s) in ${blocks.length} block(s)`
319
+ );
320
+ } else {
321
+ console.log(`[lint] No issues found in ${blocks.length} block(s)`);
322
+ }
323
+ return errorCount > 0 ? 1 : 0;
324
+ }
325
+
326
+ // src/plugin.ts
327
+ var lintPlugin = CreateCommand("lint", {
328
+ description: "Lint code blocks in org files using ESLint",
329
+ args: [
330
+ { name: "fix", type: "boolean", description: "Auto-fix problems" },
331
+ { name: "languages", type: "string", description: "Comma-separated list of languages" }
332
+ ],
333
+ execute: (args, ctx) => runLint(args._, ctx)
334
+ });
335
+ export {
336
+ LANGUAGE_EXTENSIONS,
337
+ LINT_LANGUAGES,
338
+ collectCodeBlocks,
339
+ findEslintConfig,
340
+ lintPlugin,
341
+ parseLintArgs,
342
+ runLint,
343
+ writeBlockContentBatch
344
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@org-press/lint",
3
+ "version": "0.9.12",
4
+ "description": "Lint code blocks in org files using ESLint",
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
+ "optionalDependencies": {
24
+ "eslint": "^8.0.0 || ^9.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
+ "eslint": "^9.0.0",
32
+ "@types/node": "^20.0.0",
33
+ "org-press": "0.9.13"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "homepage": "https://orgp.dev",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "https://github.com/org-press/org-press.git"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/org-press/org-press/issues"
45
+ },
46
+ "scripts": {
47
+ "build": "tsup",
48
+ "dev": "tsup --watch",
49
+ "clean": "rm -rf dist"
50
+ }
51
+ }
package/src/command.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * Lint Command Implementation
3
+ *
4
+ * Lints code blocks in org files using ESLint.
5
+ */
6
+
7
+ import type { CommandContext } from "org-press";
8
+ import { collectCodeBlocks, writeBlockContentBatch, findEslintConfig } from "./utils.js";
9
+ import { LINT_LANGUAGES, LANGUAGE_EXTENSIONS, type LintOptions } from "./types.js";
10
+
11
+ /**
12
+ * Parse command line arguments for lint command
13
+ */
14
+ export function parseLintArgs(args: string[]): LintOptions {
15
+ const options: LintOptions = {
16
+ fix: false,
17
+ languages: [],
18
+ files: [],
19
+ };
20
+
21
+ for (let i = 0; i < args.length; i++) {
22
+ const arg = args[i];
23
+
24
+ if (arg === "--fix" || arg === "-f") {
25
+ options.fix = true;
26
+ } else if (arg === "--languages" || arg === "-l") {
27
+ const next = args[++i];
28
+ if (next) {
29
+ options.languages = next.split(",").map((l) => l.trim().toLowerCase());
30
+ }
31
+ } else if (!arg.startsWith("-")) {
32
+ // Positional argument - file pattern
33
+ options.files!.push(arg);
34
+ }
35
+ }
36
+
37
+ return options;
38
+ }
39
+
40
+ /**
41
+ * Get file extension for a language
42
+ */
43
+ function getExtension(language: string): string {
44
+ return LANGUAGE_EXTENSIONS[language.toLowerCase()] || "js";
45
+ }
46
+
47
+ /**
48
+ * Run the lint command
49
+ *
50
+ * @param args - Command line arguments
51
+ * @param ctx - CLI context
52
+ * @returns Exit code (0 for success, 1 if errors found)
53
+ */
54
+ export async function runLint(
55
+ args: string[],
56
+ ctx: CommandContext
57
+ ): Promise<number> {
58
+ const options = parseLintArgs(args);
59
+
60
+ // Check if ESLint is available
61
+ let ESLint: typeof import("eslint").ESLint;
62
+ try {
63
+ const eslintModule = await import("eslint");
64
+ ESLint = eslintModule.ESLint;
65
+ } catch {
66
+ console.error("[lint] ESLint is not installed. Please install eslint:");
67
+ console.error(" npm install -D eslint");
68
+ return 1;
69
+ }
70
+
71
+ // Check for ESLint config
72
+ const configPath = findEslintConfig(ctx.projectRoot);
73
+ if (!configPath) {
74
+ console.warn("[lint] No ESLint configuration found. Using default settings.");
75
+ }
76
+
77
+ // Determine which languages to lint
78
+ const languages =
79
+ options.languages?.length ? options.languages : LINT_LANGUAGES;
80
+
81
+ // Collect blocks
82
+ const blocks = await collectCodeBlocks(ctx.contentDir, ctx.projectRoot, {
83
+ files: options.files?.length ? options.files : undefined,
84
+ languages,
85
+ });
86
+
87
+ if (blocks.length === 0) {
88
+ console.log("[lint] No code blocks found to lint");
89
+ return 0;
90
+ }
91
+
92
+ // Create ESLint instance
93
+ const eslint = new ESLint({
94
+ cwd: ctx.projectRoot,
95
+ fix: options.fix,
96
+ });
97
+
98
+ let errorCount = 0;
99
+ let warningCount = 0;
100
+ const updates: Array<{ block: (typeof blocks)[0]; newContent: string }> = [];
101
+
102
+ for (const block of blocks) {
103
+ const ext = getExtension(block.language);
104
+ // Use virtual filename for ESLint to apply correct rules
105
+ const virtualPath = block.blockName
106
+ ? `${block.orgFilePath}#${block.blockName}.${ext}`
107
+ : `${block.orgFilePath}#block-${block.blockIndex}.${ext}`;
108
+
109
+ try {
110
+ const results = await eslint.lintText(block.code, {
111
+ filePath: virtualPath,
112
+ });
113
+
114
+ for (const result of results) {
115
+ // Print messages
116
+ for (const msg of result.messages) {
117
+ // Map line number back to org file
118
+ const orgLine = block.startLine + msg.line;
119
+ const severity = msg.severity === 2 ? "error" : "warning";
120
+ const ruleId = msg.ruleId ? ` (${msg.ruleId})` : "";
121
+
122
+ console.log(
123
+ `${block.orgFilePath}:${orgLine}:${msg.column} ${severity}: ${msg.message}${ruleId}`
124
+ );
125
+
126
+ if (msg.severity === 2) {
127
+ errorCount++;
128
+ } else {
129
+ warningCount++;
130
+ }
131
+ }
132
+
133
+ // Collect fixes if --fix and there's output
134
+ if (options.fix && result.output && result.output !== block.code) {
135
+ updates.push({ block, newContent: result.output.replace(/\n$/, "") });
136
+ }
137
+ }
138
+ } catch (error) {
139
+ const location = block.blockName
140
+ ? `${block.orgFilePath}:${block.startLine} (${block.blockName})`
141
+ : `${block.orgFilePath}:${block.startLine}`;
142
+ console.warn(
143
+ `[lint] Warning: Failed to lint ${location}: ${
144
+ error instanceof Error ? error.message : String(error)
145
+ }`
146
+ );
147
+ }
148
+ }
149
+
150
+ // Write fixes
151
+ if (updates.length > 0) {
152
+ writeBlockContentBatch(updates, ctx.projectRoot);
153
+ console.log(`[lint] Fixed ${updates.length} block(s)`);
154
+ }
155
+
156
+ // Summary
157
+ if (errorCount > 0 || warningCount > 0) {
158
+ console.log(
159
+ `[lint] Found ${errorCount} error(s) and ${warningCount} warning(s) in ${blocks.length} block(s)`
160
+ );
161
+ } else {
162
+ console.log(`[lint] No issues found in ${blocks.length} block(s)`);
163
+ }
164
+
165
+ return errorCount > 0 ? 1 : 0;
166
+ }
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @org-press/lint
3
+ *
4
+ * Lint code blocks in org files using ESLint.
5
+ *
6
+ * This package provides the `orgp lint` CLI command plugin.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // .org-press/config.ts
11
+ * import { lintPlugin } from '@org-press/lint';
12
+ *
13
+ * export default {
14
+ * contentDir: 'content',
15
+ * plugins: [lintPlugin],
16
+ * };
17
+ * ```
18
+ */
19
+
20
+ // Plugin export
21
+ export { lintPlugin } from "./plugin.js";
22
+
23
+ // Command export (for programmatic use)
24
+ export { runLint, parseLintArgs } from "./command.js";
25
+
26
+ // Type exports
27
+ export type { CollectedBlock, CollectOptions, LintOptions } from "./types.js";
28
+
29
+ // Constant exports
30
+ export { LANGUAGE_EXTENSIONS, LINT_LANGUAGES } from "./types.js";
31
+
32
+ // Utility exports (for advanced use cases)
33
+ export { collectCodeBlocks, writeBlockContentBatch, findEslintConfig } from "./utils.js";
package/src/plugin.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Lint Plugin
3
+ *
4
+ * Provides the `orgp lint` command for linting code blocks with ESLint.
5
+ */
6
+
7
+ import { CreateCommand } from "org-press";
8
+ import type { ParsedArgs, CommandContext } from "org-press";
9
+ import { runLint } from "./command.js";
10
+
11
+ /**
12
+ * Lint plugin
13
+ *
14
+ * Registers the `lint` CLI command for linting JavaScript/TypeScript
15
+ * code blocks in org files using ESLint.
16
+ *
17
+ * Usage:
18
+ * orgp lint # Lint all JS/TS blocks
19
+ * orgp lint --fix # Auto-fix problems
20
+ * orgp lint --languages ts,tsx # Lint only TypeScript blocks
21
+ * orgp lint content/utils.org # Lint specific file
22
+ */
23
+ export const lintPlugin = CreateCommand("lint", {
24
+ description: "Lint code blocks in org files using ESLint",
25
+ args: [
26
+ { name: "fix", type: "boolean", description: "Auto-fix problems" },
27
+ { name: "languages", type: "string", description: "Comma-separated list of languages" },
28
+ ],
29
+ execute: (args: ParsedArgs, ctx: CommandContext) => runLint(args._, ctx),
30
+ });
package/src/types.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Types for @org-press/lint
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 lint command
37
+ */
38
+ export interface LintOptions {
39
+ /** Auto-fix problems */
40
+ fix?: boolean;
41
+ /** Filter by languages */
42
+ languages?: string[];
43
+ /** Filter by file patterns */
44
+ files?: string[];
45
+ }
46
+
47
+ /**
48
+ * Language to file extension mapping
49
+ */
50
+ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
51
+ typescript: "ts",
52
+ ts: "ts",
53
+ tsx: "tsx",
54
+ javascript: "js",
55
+ js: "js",
56
+ jsx: "jsx",
57
+ json: "json",
58
+ css: "css",
59
+ scss: "scss",
60
+ less: "less",
61
+ html: "html",
62
+ yaml: "yaml",
63
+ yml: "yml",
64
+ markdown: "md",
65
+ md: "md",
66
+ graphql: "graphql",
67
+ gql: "graphql",
68
+ };
69
+
70
+ /**
71
+ * Languages that can be linted with ESLint
72
+ */
73
+ export const LINT_LANGUAGES = [
74
+ "typescript",
75
+ "ts",
76
+ "tsx",
77
+ "javascript",
78
+ "js",
79
+ "jsx",
80
+ ];
package/src/utils.ts ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Utilities for @org-press/lint
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 { parse } from "uniorg-parse/lib/parser.js";
10
+ import type { OrgData } from "uniorg";
11
+ import type { CollectedBlock, CollectOptions } from "./types.js";
12
+
13
+ /**
14
+ * Find all org files recursively in a directory
15
+ */
16
+ function findOrgFiles(dir: string): string[] {
17
+ const files: string[] = [];
18
+
19
+ if (!fs.existsSync(dir)) {
20
+ return files;
21
+ }
22
+
23
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
24
+
25
+ for (const entry of entries) {
26
+ const fullPath = path.join(dir, entry.name);
27
+
28
+ if (entry.isDirectory()) {
29
+ // Skip node_modules and hidden directories
30
+ if (entry.name !== "node_modules" && !entry.name.startsWith(".")) {
31
+ files.push(...findOrgFiles(fullPath));
32
+ }
33
+ } else if (entry.name.endsWith(".org")) {
34
+ files.push(fullPath);
35
+ }
36
+ }
37
+
38
+ return files;
39
+ }
40
+
41
+ /**
42
+ * Extract code blocks from a single org file
43
+ */
44
+ function extractBlocksFromFile(
45
+ orgFilePath: string,
46
+ projectRoot: string,
47
+ options?: CollectOptions
48
+ ): CollectedBlock[] {
49
+ const absolutePath = path.isAbsolute(orgFilePath)
50
+ ? orgFilePath
51
+ : path.join(projectRoot, orgFilePath);
52
+ const relativePath = path.relative(projectRoot, absolutePath);
53
+ const content = fs.readFileSync(absolutePath, "utf-8");
54
+ const lines = content.split("\n");
55
+ const ast = parse(content) as OrgData;
56
+
57
+ const blocks: CollectedBlock[] = [];
58
+ let blockIndex = 0;
59
+
60
+ // Find block positions and names by scanning raw content
61
+ const blockPositions: Array<{
62
+ startLine: number;
63
+ endLine: number;
64
+ name?: string;
65
+ language: string;
66
+ }> = [];
67
+
68
+ for (let i = 0; i < lines.length; i++) {
69
+ const line = lines[i];
70
+ const beginMatch = line.match(/^\s*#\+begin_src\s+(\w+)(.*)$/i);
71
+
72
+ if (beginMatch) {
73
+ const language = beginMatch[1].toLowerCase();
74
+
75
+ // Look backwards for #+NAME: directive
76
+ let name: string | undefined;
77
+ for (let j = i - 1; j >= 0; j--) {
78
+ const prevLine = lines[j].trim();
79
+ const nameMatch = prevLine.match(/^#\+name:\s*(.+)$/i);
80
+ if (nameMatch) {
81
+ name = nameMatch[1].trim();
82
+ break;
83
+ }
84
+ // Stop looking if we hit a non-comment, non-empty line
85
+ if (prevLine && !prevLine.startsWith("#")) break;
86
+ }
87
+
88
+ // Find matching #+end_src
89
+ let endLine = i;
90
+ for (let k = i + 1; k < lines.length; k++) {
91
+ if (lines[k].match(/^\s*#\+end_src\s*$/i)) {
92
+ endLine = k;
93
+ break;
94
+ }
95
+ }
96
+
97
+ blockPositions.push({
98
+ startLine: i + 1, // 1-based
99
+ endLine: endLine + 1, // 1-based
100
+ name,
101
+ language,
102
+ });
103
+ }
104
+ }
105
+
106
+ // Walk AST and match blocks with their positions
107
+ function walk(node: unknown): void {
108
+ if (!node || typeof node !== "object") return;
109
+
110
+ const nodeObj = node as Record<string, unknown>;
111
+
112
+ if (nodeObj.type === "src-block") {
113
+ const position = blockPositions[blockIndex];
114
+ if (!position) {
115
+ blockIndex++;
116
+ return;
117
+ }
118
+
119
+ const language = (nodeObj.language as string)?.toLowerCase() || "";
120
+
121
+ // Filter by language if specified
122
+ if (
123
+ options?.languages &&
124
+ options.languages.length > 0 &&
125
+ !options.languages.includes(language)
126
+ ) {
127
+ blockIndex++;
128
+ return;
129
+ }
130
+
131
+ blocks.push({
132
+ orgFilePath: relativePath,
133
+ blockIndex,
134
+ blockName: position.name,
135
+ code: (nodeObj.value as string) || "",
136
+ language,
137
+ startLine: position.startLine,
138
+ endLine: position.endLine,
139
+ });
140
+
141
+ blockIndex++;
142
+ }
143
+
144
+ // Recurse into children
145
+ if (nodeObj.children && Array.isArray(nodeObj.children)) {
146
+ for (const child of nodeObj.children) {
147
+ walk(child);
148
+ }
149
+ }
150
+ }
151
+
152
+ walk(ast);
153
+ return blocks;
154
+ }
155
+
156
+ /**
157
+ * Collect all code blocks from a content directory
158
+ *
159
+ * @param contentDir - Content directory to scan
160
+ * @param projectRoot - Project root directory
161
+ * @param options - Collection options
162
+ * @returns Array of collected code blocks
163
+ */
164
+ export async function collectCodeBlocks(
165
+ contentDir: string,
166
+ projectRoot: string = process.cwd(),
167
+ options?: CollectOptions
168
+ ): Promise<CollectedBlock[]> {
169
+ const absoluteContentDir = path.isAbsolute(contentDir)
170
+ ? contentDir
171
+ : path.join(projectRoot, contentDir);
172
+
173
+ // Find all org files
174
+ let orgFiles = findOrgFiles(absoluteContentDir);
175
+
176
+ // Filter by file patterns if specified
177
+ if (options?.files && options.files.length > 0) {
178
+ const patterns = options.files.map((f) =>
179
+ path.isAbsolute(f) ? f : path.join(projectRoot, f)
180
+ );
181
+
182
+ orgFiles = orgFiles.filter((file) =>
183
+ patterns.some((pattern) => {
184
+ // Support both exact match and contains match
185
+ if (pattern.endsWith(".org")) {
186
+ return file === pattern || file.endsWith(pattern);
187
+ }
188
+ return file.includes(pattern);
189
+ })
190
+ );
191
+ }
192
+
193
+ const allBlocks: CollectedBlock[] = [];
194
+
195
+ // Process each file
196
+ for (const orgFile of orgFiles) {
197
+ try {
198
+ const blocks = extractBlocksFromFile(orgFile, projectRoot, options);
199
+ allBlocks.push(...blocks);
200
+ } catch (error) {
201
+ console.warn(
202
+ `[lint] Warning: Failed to parse ${orgFile}: ${
203
+ error instanceof Error ? error.message : String(error)
204
+ }`
205
+ );
206
+ }
207
+ }
208
+
209
+ return allBlocks;
210
+ }
211
+
212
+ /**
213
+ * Batch write multiple block updates to minimize file I/O
214
+ *
215
+ * Updates are grouped by file and applied in reverse order
216
+ * (to preserve line numbers for earlier blocks in the same file)
217
+ *
218
+ * @param updates - Array of {block, newContent} pairs
219
+ * @param projectRoot - Project root directory
220
+ */
221
+ export function writeBlockContentBatch(
222
+ updates: Array<{ block: CollectedBlock; newContent: string }>,
223
+ projectRoot: string = process.cwd()
224
+ ): void {
225
+ // Group updates by file
226
+ const byFile = new Map<string, Array<{ block: CollectedBlock; newContent: string }>>();
227
+
228
+ for (const update of updates) {
229
+ const filePath = update.block.orgFilePath;
230
+ if (!byFile.has(filePath)) {
231
+ byFile.set(filePath, []);
232
+ }
233
+ byFile.get(filePath)!.push(update);
234
+ }
235
+
236
+ // Process each file
237
+ for (const [filePath, fileUpdates] of byFile) {
238
+ // Sort by blockIndex in reverse order so we can apply changes
239
+ // from the end of the file backwards (preserving line numbers)
240
+ fileUpdates.sort((a, b) => b.block.blockIndex - a.block.blockIndex);
241
+
242
+ const absolutePath = path.isAbsolute(filePath)
243
+ ? filePath
244
+ : path.join(projectRoot, filePath);
245
+
246
+ let fileContent = fs.readFileSync(absolutePath, "utf-8");
247
+ let lines = fileContent.split("\n");
248
+
249
+ for (const { block, newContent } of fileUpdates) {
250
+ const beginLineIndex = block.startLine - 1;
251
+ const endLineIndex = block.endLine - 1;
252
+
253
+ // Validate
254
+ if (
255
+ beginLineIndex < 0 ||
256
+ endLineIndex >= lines.length ||
257
+ beginLineIndex >= endLineIndex
258
+ ) {
259
+ console.warn(
260
+ `[lint] Warning: Invalid block position in ${filePath}: lines ${block.startLine}-${block.endLine}`
261
+ );
262
+ continue;
263
+ }
264
+
265
+ const beginLine = lines[beginLineIndex];
266
+ const endLine = lines[endLineIndex];
267
+
268
+ if (!beginLine.match(/^\s*#\+begin_src\s+\w+/i)) {
269
+ console.warn(
270
+ `[lint] Warning: Expected #+begin_src at line ${block.startLine} in ${filePath}`
271
+ );
272
+ continue;
273
+ }
274
+
275
+ if (!endLine.match(/^\s*#\+end_src\s*$/i)) {
276
+ console.warn(
277
+ `[lint] Warning: Expected #+end_src at line ${block.endLine} in ${filePath}`
278
+ );
279
+ continue;
280
+ }
281
+
282
+ // Replace content
283
+ const trimmedContent = newContent.replace(/\n$/, "");
284
+ const beforeBlock = lines.slice(0, beginLineIndex + 1);
285
+ const afterBlock = lines.slice(endLineIndex);
286
+ lines = [...beforeBlock, trimmedContent, ...afterBlock];
287
+ }
288
+
289
+ fs.writeFileSync(absolutePath, lines.join("\n"), "utf-8");
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Check if ESLint flat config exists
295
+ *
296
+ * @param projectRoot - Project root directory
297
+ * @returns Path to ESLint config if found, null otherwise
298
+ */
299
+ export function findEslintConfig(projectRoot: string): string | null {
300
+ const configFiles = [
301
+ "eslint.config.js",
302
+ "eslint.config.mjs",
303
+ "eslint.config.cjs",
304
+ // Legacy configs
305
+ ".eslintrc.js",
306
+ ".eslintrc.cjs",
307
+ ".eslintrc.json",
308
+ ".eslintrc",
309
+ ];
310
+
311
+ for (const configFile of configFiles) {
312
+ const configPath = path.join(projectRoot, configFile);
313
+ if (fs.existsSync(configPath)) {
314
+ return configPath;
315
+ }
316
+ }
317
+
318
+ return null;
319
+ }