@ahmedalbarghouti/farq 0.0.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/dist/config.d.ts +18 -0
  4. package/dist/config.js +74 -0
  5. package/dist/extract-json.d.ts +5 -0
  6. package/dist/extract-json.js +60 -0
  7. package/dist/git.d.ts +36 -0
  8. package/dist/git.js +277 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +204 -0
  11. package/dist/open-pr.d.ts +26 -0
  12. package/dist/open-pr.js +199 -0
  13. package/dist/providers/claude.d.ts +3 -0
  14. package/dist/providers/claude.js +88 -0
  15. package/dist/providers/fake.d.ts +8 -0
  16. package/dist/providers/fake.js +43 -0
  17. package/dist/providers/index.d.ts +22 -0
  18. package/dist/providers/index.js +40 -0
  19. package/dist/providers/opencode.d.ts +3 -0
  20. package/dist/providers/opencode.js +33 -0
  21. package/dist/render/json.d.ts +2 -0
  22. package/dist/render/json.js +3 -0
  23. package/dist/render/pr.d.ts +7 -0
  24. package/dist/render/pr.js +48 -0
  25. package/dist/render/slack.d.ts +2 -0
  26. package/dist/render/slack.js +25 -0
  27. package/dist/schema.d.ts +74 -0
  28. package/dist/schema.js +39 -0
  29. package/dist/summarize.d.ts +17 -0
  30. package/dist/summarize.js +72 -0
  31. package/dist/template.d.ts +11 -0
  32. package/dist/template.js +87 -0
  33. package/dist/title.d.ts +12 -0
  34. package/dist/title.js +37 -0
  35. package/dist/visual/chrome.d.ts +12 -0
  36. package/dist/visual/chrome.js +62 -0
  37. package/dist/visual/compose.d.ts +16 -0
  38. package/dist/visual/compose.js +58 -0
  39. package/dist/visual/diagram.d.ts +17 -0
  40. package/dist/visual/diagram.js +36 -0
  41. package/dist/visual/gate.d.ts +11 -0
  42. package/dist/visual/gate.js +28 -0
  43. package/dist/visual/mockup.d.ts +26 -0
  44. package/dist/visual/mockup.js +46 -0
  45. package/dist/visual/pipeline.d.ts +23 -0
  46. package/dist/visual/pipeline.js +126 -0
  47. package/package.json +53 -0
@@ -0,0 +1,28 @@
1
+ const UI_EXT = /\.(tsx|jsx|vue|svelte|html|htm|css|scss|sass|less|css\.ts|module\.css)$/i;
2
+ const UI_HUNK = /[+-].*(class(Name)?=|style=|styled\.|createGlobalStyle|<([A-Z][A-Za-z0-9]*|[a-z]+)|@media|--[a-z-]+\s*:)/;
3
+ /**
4
+ * Prefer a visual whenever possible:
5
+ * - UI markup/style → mockup
6
+ * - everything else with real file changes → diagram (flowchart / concept before-after)
7
+ * - none only when there is nothing to depict
8
+ *
9
+ * The model can still return feasible:false so we never invent a bad visual.
10
+ */
11
+ export function decideGate(files) {
12
+ if (files.length === 0)
13
+ return "none";
14
+ let uiHits = 0;
15
+ for (const file of files) {
16
+ const uiFile = UI_EXT.test(file.path);
17
+ const patch = file.patch || "";
18
+ if (uiFile && patch.length > 0) {
19
+ if (UI_HUNK.test(patch) ||
20
+ /\.(tsx|jsx|vue|svelte|html)$/i.test(file.path)) {
21
+ uiHits++;
22
+ }
23
+ }
24
+ }
25
+ if (uiHits > 0)
26
+ return "mockup";
27
+ return "diagram";
28
+ }
@@ -0,0 +1,26 @@
1
+ import type { ChangeSummary } from "../schema.js";
2
+ import type { Provider } from "../providers/index.js";
3
+ export type MockupResult = {
4
+ feasible: true;
5
+ beforePath: string;
6
+ afterPath: string;
7
+ viewport?: {
8
+ width: number;
9
+ height: number;
10
+ };
11
+ } | {
12
+ feasible: false;
13
+ reason: string;
14
+ };
15
+ export declare function generateMockup(options: {
16
+ provider: Provider;
17
+ summary: ChangeSummary;
18
+ files: Array<{
19
+ path: string;
20
+ before: string;
21
+ after: string;
22
+ }>;
23
+ outDir: string;
24
+ model?: string;
25
+ log?: (msg: string) => void;
26
+ }): Promise<MockupResult>;
@@ -0,0 +1,46 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { extractJson } from "../extract-json.js";
4
+ export async function generateMockup(options) {
5
+ const prompt = `You create faithful before/after HTML mockups from a git diff.
6
+
7
+ Rules:
8
+ - The diff contains exact before/after markup/styles. Emit two fully self-contained HTML documents (inline CSS, no external requests, system fonts) that render this exact markup with these exact styles.
9
+ - Stub only the minimum: container width, placeholder text where dynamic props appear (realistic neutral placeholders).
10
+ - Do not invent UI that is not present in the code.
11
+ - Never show raw code listings.
12
+ - If you cannot render faithfully, return {"feasible": false, "reason": "..."}.
13
+
14
+ Return JSON only:
15
+ {"feasible": true, "before_html": "...", "after_html": "...", "viewport": {"width":1280,"height":900}}
16
+ or {"feasible": false, "reason": "..."}
17
+
18
+ Change summary context:
19
+ ${JSON.stringify(options.summary, null, 2)}
20
+
21
+ File contents:
22
+ ${options.files
23
+ .map((f) => `### ${f.path}\n--- BEFORE ---\n${f.before}\n--- AFTER ---\n${f.after}`)
24
+ .join("\n\n")}
25
+ `;
26
+ const raw = await options.provider.complete(prompt, { model: options.model });
27
+ const json = extractJson(raw);
28
+ if (!json.feasible) {
29
+ options.log?.(json.reason ?? "mockup not feasible");
30
+ return { feasible: false, reason: json.reason ?? "not feasible" };
31
+ }
32
+ if (!json.before_html || !json.after_html) {
33
+ return { feasible: false, reason: "missing before_html/after_html" };
34
+ }
35
+ mkdirSync(options.outDir, { recursive: true });
36
+ const beforePath = join(options.outDir, "before.html");
37
+ const afterPath = join(options.outDir, "after.html");
38
+ writeFileSync(beforePath, json.before_html, "utf8");
39
+ writeFileSync(afterPath, json.after_html, "utf8");
40
+ return {
41
+ feasible: true,
42
+ beforePath,
43
+ afterPath,
44
+ viewport: json.viewport,
45
+ };
46
+ }
@@ -0,0 +1,23 @@
1
+ import type { ChangeSummary } from "../schema.js";
2
+ import type { Provider } from "../providers/index.js";
3
+ import type { GatherDiffResult } from "../git.js";
4
+ export type VisualPipelineResult = {
5
+ imagePath: string | null;
6
+ images: string[];
7
+ softDegraded: boolean;
8
+ warning?: string;
9
+ };
10
+ export type VisualPipelineOptions = {
11
+ cwd?: string;
12
+ outDir?: string;
13
+ summary: ChangeSummary;
14
+ diff: GatherDiffResult;
15
+ provider: Provider;
16
+ modelCheap?: string;
17
+ noImages?: boolean;
18
+ before?: string;
19
+ after?: string;
20
+ verbose?: boolean;
21
+ log?: (msg: string) => void;
22
+ };
23
+ export declare function runVisualPipeline(options: VisualPipelineOptions): Promise<VisualPipelineResult>;
@@ -0,0 +1,126 @@
1
+ import { copyFileSync, mkdirSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { gatherVisualFileContents } from "../git.js";
5
+ import { decideGate } from "./gate.js";
6
+ import { generateMockup } from "./mockup.js";
7
+ import { generateDiagram } from "./diagram.js";
8
+ import { composeBeforeAfter, ChromeError } from "./compose.js";
9
+ import { screenshotHtml, resolveChrome } from "./chrome.js";
10
+ export async function runVisualPipeline(options) {
11
+ const cwd = options.cwd ?? process.cwd();
12
+ const outDir = resolve(cwd, options.outDir ?? ".farq");
13
+ const log = options.log ?? (() => undefined);
14
+ const vlog = (msg) => {
15
+ if (options.verbose)
16
+ log(msg);
17
+ };
18
+ if (options.noImages) {
19
+ return { imagePath: null, images: [], softDegraded: false };
20
+ }
21
+ // Manual screenshots skip generation
22
+ if (options.before && options.after) {
23
+ try {
24
+ mkdirSync(outDir, { recursive: true });
25
+ const beforeCopy = join(outDir, "before.png");
26
+ const afterCopy = join(outDir, "after.png");
27
+ copyFileSync(options.before, beforeCopy);
28
+ copyFileSync(options.after, afterCopy);
29
+ const composed = await composeBeforeAfter({
30
+ cwd,
31
+ outDir,
32
+ beforePath: beforeCopy,
33
+ afterPath: afterCopy,
34
+ badge: "before / after",
35
+ });
36
+ return { imagePath: composed, images: [composed], softDegraded: false };
37
+ }
38
+ catch (err) {
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ throw new ChromeError(msg);
41
+ }
42
+ }
43
+ const gate = decideGate(options.diff.files);
44
+ vlog(`visual gate: ${gate}`);
45
+ if (gate === "none") {
46
+ return { imagePath: null, images: [], softDegraded: false };
47
+ }
48
+ try {
49
+ resolveChrome();
50
+ }
51
+ catch (err) {
52
+ const msg = err instanceof Error ? err.message : String(err);
53
+ return {
54
+ imagePath: null,
55
+ images: [],
56
+ softDegraded: true,
57
+ warning: msg,
58
+ };
59
+ }
60
+ try {
61
+ if (gate === "mockup") {
62
+ const files = await gatherVisualFileContents(cwd, options.diff.files, options.diff.baseRef);
63
+ const mockup = await generateMockup({
64
+ provider: options.provider,
65
+ summary: options.summary,
66
+ files,
67
+ outDir,
68
+ model: options.modelCheap,
69
+ log: vlog,
70
+ });
71
+ if (mockup.feasible) {
72
+ const beforePng = join(outDir, "before.png");
73
+ const afterPng = join(outDir, "after.png");
74
+ await screenshotHtml({
75
+ url: pathToFileURL(mockup.beforePath).href,
76
+ outPath: beforePng,
77
+ width: mockup.viewport?.width,
78
+ height: mockup.viewport?.height,
79
+ });
80
+ await screenshotHtml({
81
+ url: pathToFileURL(mockup.afterPath).href,
82
+ outPath: afterPng,
83
+ width: mockup.viewport?.width,
84
+ height: mockup.viewport?.height,
85
+ });
86
+ const composed = await composeBeforeAfter({
87
+ cwd,
88
+ outDir,
89
+ beforePath: beforePng,
90
+ afterPath: afterPng,
91
+ badge: "generated preview",
92
+ });
93
+ return { imagePath: composed, images: [composed], softDegraded: false };
94
+ }
95
+ vlog(`mockup infeasible: ${mockup.reason}; trying diagram`);
96
+ }
97
+ // diagram path (gate=diagram or mockup downgrade)
98
+ const diagram = await generateDiagram({
99
+ provider: options.provider,
100
+ summary: options.summary,
101
+ diffText: options.diff.diffText,
102
+ outDir,
103
+ model: options.modelCheap,
104
+ log: vlog,
105
+ });
106
+ if (!diagram.feasible) {
107
+ vlog(`diagram infeasible: ${diagram.reason}`);
108
+ return { imagePath: null, images: [], softDegraded: false };
109
+ }
110
+ const outPng = join(outDir, "before-after.png");
111
+ await screenshotHtml({
112
+ url: pathToFileURL(diagram.htmlPath).href,
113
+ outPath: outPng,
114
+ });
115
+ return { imagePath: outPng, images: [outPng], softDegraded: false };
116
+ }
117
+ catch (err) {
118
+ const msg = err instanceof Error ? err.message : String(err);
119
+ return {
120
+ imagePath: null,
121
+ images: [],
122
+ softDegraded: true,
123
+ warning: msg,
124
+ };
125
+ }
126
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@ahmedalbarghouti/farq",
3
+ "version": "0.0.1",
4
+ "description": "Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20"
8
+ },
9
+ "bin": {
10
+ "farq": "dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "README.md",
15
+ "LICENSE"
16
+ ],
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "prepublishOnly": "npm run build",
22
+ "pretest": "npm run build"
23
+ },
24
+ "keywords": [
25
+ "git",
26
+ "pr",
27
+ "cli",
28
+ "diff",
29
+ "changelog"
30
+ ],
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/AhmedAlbarghouti/farq.git"
35
+ },
36
+ "dependencies": {
37
+ "commander": "^13.0.0",
38
+ "execa": "^9.0.0",
39
+ "zod": "^3.24.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/node": "^22.0.0",
43
+ "typescript": "^5.7.0",
44
+ "vitest": "^3.0.0"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/AhmedAlbarghouti/farq/issues"
48
+ },
49
+ "homepage": "https://github.com/AhmedAlbarghouti/farq#readme",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ }
53
+ }