@postxl/generator 1.1.1 → 1.3.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/README.md CHANGED
@@ -79,3 +79,281 @@ This file contains the hash values for each generated file. With this hash value
79
79
  the hash in the lock file
80
80
  - If the file was changed by the latest generator run: in this case the hash of the newly generated
81
81
  file will be different from the hash in the lock file
82
+
83
+ ## File Sync Algorithm
84
+
85
+ The generator uses a **3-way sync algorithm** to intelligently handle file changes. The three sources are:
86
+
87
+ 1. **Virtual File System (VFS)** - The newly generated content
88
+ 2. **Lock File** - Hash values from the previous generation run (`postxl-lock.json`)
89
+ 3. **Disk** - The actual files on the filesystem
90
+
91
+ ### State Matrix
92
+
93
+ | Generated | Lock File | Disk | Action | Description |
94
+ | --------- | --------- | -------- | ------------------ | -------------------------------------------------------- |
95
+ | ✓ Changed | Same | Modified | **Merge Conflict** | File was ejected AND generator template changed |
96
+ | ✓ Changed | Same | Same | Write | Template changed, file not ejected → auto-update |
97
+ | ✓ Same | Same | Modified | No Action | File ejected, but template unchanged → keep your changes |
98
+ | ✓ Same | Same | Same | No Action | Nothing changed |
99
+ | ✓ New | - | Exists | **Merge Conflict** | New generated file conflicts with existing file |
100
+ | ✓ New | - | - | Write | Brand new file |
101
+ | - Removed | Exists | Modified | Delete | Generator no longer produces this file |
102
+
103
+ ### Ejected Files
104
+
105
+ A file is considered **"ejected"** when you manually modify it. Once ejected:
106
+
107
+ - The generator will not overwrite your changes automatically
108
+ - If the generator template changes, you'll get a merge conflict to resolve
109
+ - The file remains tracked in `postxl-lock.json` so the generator knows it exists
110
+
111
+ ## Merge Conflicts
112
+
113
+ When both you and the generator have made changes to the same file, the generator creates Git-style merge conflict markers:
114
+
115
+ ```typescript
116
+ // Unchanged code stays clean
117
+ import { Injectable } from '@nestjs/common'
118
+
119
+ @Injectable()
120
+ export class UserService {
121
+ <<<<<<< Manual
122
+ // Your manual changes appear here
123
+ findAll() {
124
+ return this.customLogic()
125
+ }
126
+ =======
127
+ // Generated version appears here
128
+ findAll() {
129
+ return this.repository.findAll()
130
+ }
131
+ >>>>>>> Generated
132
+ }
133
+ ```
134
+
135
+ ### Resolving Merge Conflicts
136
+
137
+ 1. Open the file in your editor
138
+ 2. Decide which version to keep (or combine both)
139
+ 3. Remove the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)
140
+ 4. Run the generator again to verify
141
+
142
+ > **Note**: The generator will refuse to run if there are unresolved merge conflicts in your project (unless `--force` flag is set). Resolve all conflicts before regenerating.
143
+
144
+ ### Force Regeneration
145
+
146
+ If you want to discard your changes and reset to the generated version:
147
+
148
+ ```bash
149
+ # Force regenerate a specific file
150
+ pnpm run generate -f -p 'backend/libs/types/**/*.ts'
151
+
152
+ # Force regenerate everything (careful!)
153
+ pnpm run generate -f
154
+ ```
155
+
156
+ ## Custom Block Preservation
157
+
158
+ When you extend generated files with custom code, you can mark your additions with special comment markers. This prevents unnecessary merge conflicts when the generator updates other parts of the file.
159
+
160
+ ### Basic Usage
161
+
162
+ ```typescript
163
+ import { Injectable } from '@nestjs/common'
164
+
165
+ // @custom-start:imports
166
+ import { CustomLogger } from './logger'
167
+ import { MetricsService } from './metrics'
168
+ // @custom-end:imports
169
+
170
+ @Injectable()
171
+ export class UserService {
172
+ constructor(
173
+ private readonly repository: UserRepository,
174
+ // @custom-start:dependencies
175
+ private readonly logger: CustomLogger,
176
+ private readonly metrics: MetricsService,
177
+ // @custom-end:dependencies
178
+ ) {}
179
+
180
+ // Generated methods...
181
+ findAll() {
182
+ return this.repository.findAll()
183
+ }
184
+
185
+ // @custom-start:customMethods
186
+ async findAllWithMetrics() {
187
+ this.metrics.increment('user.findAll')
188
+ return this.findAll()
189
+ }
190
+
191
+ async customBusinessLogic() {
192
+ this.logger.log('Custom logic executed')
193
+ return 'custom result'
194
+ }
195
+ // @custom-end:customMethods
196
+ }
197
+ ```
198
+
199
+ ### How It Works
200
+
201
+ When the generator runs and detects custom block markers in an ejected file:
202
+
203
+ 1. **Extract**: Custom blocks are identified and extracted from your modified file
204
+ 2. **Compare**: The remaining code (minus custom blocks) is compared to the new generated output
205
+ 3. **Reinsert**: Custom blocks are automatically inserted back into the generated output at the same relative position
206
+ 4. **Conflict only if needed**: Only actual changes outside your custom blocks will show merge conflict markers
207
+
208
+ ### Marker Syntax
209
+
210
+ ```typescript
211
+ // Line comment style (recommended)
212
+ // @custom-start:blockName
213
+ // ... your custom code ...
214
+ // @custom-end:blockName
215
+
216
+ // Unnamed blocks (works, but names help with clarity)
217
+ // @custom-start
218
+ // ... your custom code ...
219
+ // @custom-end
220
+
221
+ // Block comment style (for languages that prefer it)
222
+ /* @custom-start:blockName */
223
+ /* ... your custom code ... */
224
+ /* @custom-end:blockName */
225
+ ```
226
+
227
+ ### Block Names
228
+
229
+ Names are optional but recommended when you have multiple custom blocks:
230
+
231
+ - Must be alphanumeric with hyphens/underscores: `[a-zA-Z0-9_-]+`
232
+ - Help identify blocks in warnings
233
+ - Opening and closing names should match
234
+
235
+ ### Anchor-Based Positioning
236
+
237
+ Custom blocks are repositioned based on **anchor context** - the significant code lines immediately before and after your block. For best results:
238
+
239
+ - Place custom blocks after stable, identifiable lines (method signatures, class declarations, import statements)
240
+ - Avoid placing blocks in areas that frequently change
241
+ - The more unique the surrounding context, the more reliable the repositioning
242
+
243
+ ### When Blocks Cannot Be Placed
244
+
245
+ If the generator cannot find a suitable position for a custom block (e.g., the surrounding code changed significantly), it will:
246
+
247
+ 1. Append the block at the end of the file
248
+ 2. Add a warning comment so you know to move it manually
249
+
250
+ ```typescript
251
+ // ... rest of file ...
252
+
253
+ // ⚠️ WARNING: The following custom blocks could not be automatically placed.
254
+ // Please manually move them to the appropriate location.
255
+
256
+ // --- Unplaced custom block: orphanedFeature ---
257
+ // @custom-start:orphanedFeature
258
+ // This code needs to be moved manually
259
+ // @custom-end:orphanedFeature
260
+ ```
261
+
262
+ ### Best Practices
263
+
264
+ 1. **Use descriptive names**: `// @custom-start:authMiddleware` is better than `// @custom-start`
265
+ 2. **Keep blocks focused**: One feature per block makes them easier to manage
266
+ 3. **Place strategically**: Put blocks after stable anchor points
267
+ 4. **Don't nest blocks**: Nested custom blocks are not supported
268
+ 5. **Match names**: Ensure `@custom-start:foo` has a matching `@custom-end:foo`
269
+
270
+ ### Example: Adding Custom Routes
271
+
272
+ ```typescript
273
+ // Generated router file
274
+ import { Router } from 'express'
275
+ import { getUsers, getUserById, createUser } from './handlers'
276
+
277
+ const router = Router()
278
+
279
+ // Generated routes
280
+ router.get('/users', getUsers)
281
+ router.get('/users/:id', getUserById)
282
+ router.post('/users', createUser)
283
+
284
+ // @custom-start:customRoutes
285
+ // Custom export endpoint
286
+ router.get('/users/export', async (req, res) => {
287
+ const users = await exportUsersToCSV()
288
+ res.attachment('users.csv').send(users)
289
+ })
290
+
291
+ // Custom bulk operations
292
+ router.post('/users/bulk', bulkCreateUsers)
293
+ router.delete('/users/bulk', bulkDeleteUsers)
294
+ // @custom-end:customRoutes
295
+
296
+ export default router
297
+ ```
298
+
299
+ When the generator adds new routes, your custom routes will be preserved without conflict markers (assuming the anchor context—the generated routes above—remains recognizable).
300
+
301
+ ## CLI Options
302
+
303
+ ```bash
304
+ # Standard generation
305
+ pnpm run generate
306
+
307
+ # Force regenerate all files (overwrites ejected files)
308
+ pnpm run generate -f
309
+
310
+ # Force regenerate specific files (glob pattern)
311
+ pnpm run generate -f -p 'backend/libs/types/**/*.ts'
312
+
313
+ # Show ejected files after generation
314
+ pnpm run generate -e
315
+
316
+ # Show diff between ejected and generated versions
317
+ pnpm run generate -d
318
+
319
+ # Watch mode - regenerate on schema changes
320
+ pnpm run generate:watch
321
+
322
+ # Skip linting and formatting
323
+ pnpm run generate -t
324
+ ```
325
+
326
+ ## Troubleshooting
327
+
328
+ ### "Unresolved merge conflicts detected"
329
+
330
+ The generator found files with conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). Resolve these manually before running the generator again.
331
+
332
+ ### Custom blocks appearing at end of file
333
+
334
+ The generator couldn't find the anchor context for your block. This happens when:
335
+
336
+ - The code before/after your block changed significantly
337
+ - The block was placed in a frequently-changing area
338
+
339
+ Solution: Move the block back to its correct position and ensure it has stable anchor lines nearby.
340
+
341
+ ### Unexpected merge conflicts in custom block areas
342
+
343
+ If you're seeing conflicts around custom blocks, check:
344
+
345
+ - Block markers are properly formatted (`@custom-start`/`@custom-end`)
346
+ - Names match between start and end markers
347
+ - No nested custom blocks
348
+
349
+ ### Lock file out of sync
350
+
351
+ If `postxl-lock.json` gets out of sync with your files:
352
+
353
+ ```bash
354
+ # Regenerate everything (preserves ejected files unless they conflict)
355
+ pnpm run generate
356
+
357
+ # Or force regenerate to reset lock file
358
+ pnpm run generate -f
359
+ ```
@@ -31,18 +31,18 @@
31
31
  * - `ImportPaths`: FilePath | PackageName
32
32
  */
33
33
  import z from 'zod';
34
- declare const zGeneratorInterfaceId: z.ZodBranded<z.ZodString, "PXL.GeneratorInterfaceId">;
34
+ declare const zGeneratorInterfaceId: z.core.$ZodBranded<z.ZodString, "PXL.GeneratorInterfaceId", "out">;
35
35
  /**
36
36
  * A generator interface id that is used identify what interface a generator implements.
37
37
  */
38
38
  export type GeneratorInterfaceId = z.infer<typeof zGeneratorInterfaceId>;
39
- export declare const toGeneratorInterfaceId: (input: string) => string & z.BRAND<"PXL.GeneratorInterfaceId">;
40
- declare const zTypeName: z.ZodBranded<z.ZodBranded<z.ZodString, "PXL.TypeName">, "PXL.Importable">;
39
+ export declare const toGeneratorInterfaceId: (input: string) => string & z.core.$brand<"PXL.GeneratorInterfaceId">;
40
+ declare const zTypeName: z.core.$ZodBranded<z.core.$ZodBranded<z.ZodString, "PXL.TypeName", "out">, "PXL.Importable", "out">;
41
41
  /**
42
42
  * A type name that is used to refer to a type in the generated code.
43
43
  */
44
44
  export type TypeName = z.infer<typeof zTypeName>;
45
- export declare const toTypeName: (input: string) => string & z.BRAND<"PXL.TypeName"> & z.BRAND<"PXL.Importable">;
45
+ export declare const toTypeName: (input: string) => string & z.core.$brand<"PXL.TypeName"> & z.core.$brand<"PXL.Importable">;
46
46
  /**
47
47
  * A TypeName that is annotated with the kind of the type.
48
48
  * Note: This is used when distinguishing between kinds is required at runtime.
@@ -59,80 +59,80 @@ export declare const toAnnotatedTypeName: (name: TypeName) => AnnotatedTypeName;
59
59
  * Type guard to check if a given type is an AnnotatedTypeName.
60
60
  */
61
61
  export declare const isAnnotatedTypeName: (t: string | AnnotatedTypeName) => t is AnnotatedTypeName;
62
- declare const zFunctionName: z.ZodBranded<z.ZodBranded<z.ZodString, "PXL.FunctionName">, "PXL.Importable">;
62
+ declare const zFunctionName: z.core.$ZodBranded<z.core.$ZodBranded<z.ZodString, "PXL.FunctionName", "out">, "PXL.Importable", "out">;
63
63
  /**
64
64
  * A function name that is used to refer to a function in the generated code.
65
65
  */
66
66
  export type FunctionName = z.infer<typeof zFunctionName>;
67
- export declare const toFunctionName: (input: string) => string & z.BRAND<"PXL.FunctionName"> & z.BRAND<"PXL.Importable">;
68
- declare const zVariableName: z.ZodBranded<z.ZodBranded<z.ZodString, "PXL.VariableName">, "PXL.Importable">;
67
+ export declare const toFunctionName: (input: string) => string & z.core.$brand<"PXL.FunctionName"> & z.core.$brand<"PXL.Importable">;
68
+ declare const zVariableName: z.core.$ZodBranded<z.core.$ZodBranded<z.ZodString, "PXL.VariableName", "out">, "PXL.Importable", "out">;
69
69
  /**
70
70
  * A variable name that is used to refer to a variable in the generated code.
71
71
  */
72
72
  export type VariableName = z.infer<typeof zVariableName>;
73
- export declare const toVariableName: (input: string) => string & z.BRAND<"PXL.VariableName"> & z.BRAND<"PXL.Importable">;
74
- declare const zPostXlPackageName: z.ZodBranded<z.ZodEffects<z.ZodString, string, string>, "PXL.PackageName">;
73
+ export declare const toVariableName: (input: string) => string & z.core.$brand<"PXL.VariableName"> & z.core.$brand<"PXL.Importable">;
74
+ declare const zPostXlPackageName: z.core.$ZodBranded<z.ZodString, "PXL.PackageName", "out">;
75
75
  /**
76
76
  * A package name that is used to refer to a package in the generated code.
77
77
  */
78
- export declare const toPostXlPackageName: (input: string) => string & z.BRAND<"PXL.PackageName">;
79
- export declare const toPackageName: (input: string) => string & z.BRAND<"PXL.PackageName">;
78
+ export declare const toPostXlPackageName: (input: string) => string & z.core.$brand<"PXL.PackageName">;
79
+ export declare const toPackageName: (input: string) => string & z.core.$brand<"PXL.PackageName">;
80
80
  /**
81
81
  * A package name that is used to refer to a package name that is provided via
82
82
  * in package.json or is a NodeJS module.
83
83
  * E.g. "fs", "random", "@nestjs/common".
84
84
  */
85
85
  export type PackageName = z.infer<typeof zPostXlPackageName>;
86
- declare const zClassName: z.ZodBranded<z.ZodBranded<z.ZodString, "PXL.ClassName">, "PXL.Importable">;
86
+ declare const zClassName: z.core.$ZodBranded<z.core.$ZodBranded<z.ZodString, "PXL.ClassName", "out">, "PXL.Importable", "out">;
87
87
  /**
88
88
  * A class name that is used to refer to a class in the generated code.
89
89
  */
90
90
  export type ClassName = z.infer<typeof zClassName>;
91
- export declare const toClassName: (input: string) => string & z.BRAND<"PXL.ClassName"> & z.BRAND<"PXL.Importable">;
92
- declare const zConstantName: z.ZodBranded<z.ZodBranded<z.ZodString, "PXL.ConstantName">, "PXL.Importable">;
91
+ export declare const toClassName: (input: string) => string & z.core.$brand<"PXL.ClassName"> & z.core.$brand<"PXL.Importable">;
92
+ declare const zConstantName: z.core.$ZodBranded<z.core.$ZodBranded<z.ZodString, "PXL.ConstantName", "out">, "PXL.Importable", "out">;
93
93
  /**
94
94
  * A constant name that is used to refer to a constant in the generated code.
95
95
  */
96
96
  export type ConstantName = z.infer<typeof zConstantName>;
97
- export declare const toConstantName: (input: string) => string & z.BRAND<"PXL.ConstantName"> & z.BRAND<"PXL.Importable">;
98
- declare const zConstantValue: z.ZodBranded<z.ZodUnion<[z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>, "PXL.ConstantValue">;
97
+ export declare const toConstantName: (input: string) => string & z.core.$brand<"PXL.ConstantName"> & z.core.$brand<"PXL.Importable">;
98
+ declare const zConstantValue: z.core.$ZodBranded<z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodNull]>, "PXL.ConstantValue", "out">;
99
99
  /**
100
100
  * A constant value that is used in the generated code.
101
101
  */
102
102
  export type ConstantValue = z.infer<typeof zConstantValue>;
103
- export declare const toConstantValue: (input: string | number | boolean | null) => (string | number | boolean | null) & z.BRAND<"PXL.ConstantValue">;
104
- declare const zDiscriminantValue: z.ZodBranded<z.ZodString, "PXL.DiscriminantValue">;
103
+ export declare const toConstantValue: (input: string | number | boolean | null) => (string & z.core.$brand<"PXL.ConstantValue">) | (number & z.core.$brand<"PXL.ConstantValue">) | (false & z.core.$brand<"PXL.ConstantValue">) | (true & z.core.$brand<"PXL.ConstantValue">);
104
+ declare const zDiscriminantValue: z.core.$ZodBranded<z.ZodString, "PXL.DiscriminantValue", "out">;
105
105
  /**
106
106
  * A discriminant value, used to discriminate union types.
107
107
  */
108
108
  export type DiscriminantValue = z.infer<typeof zDiscriminantValue>;
109
- export declare const toDiscriminantValue: (input: string) => string & z.BRAND<"PXL.DiscriminantValue">;
110
- declare const zFileName: z.ZodBranded<z.ZodString, "PXL.FileName">;
109
+ export declare const toDiscriminantValue: (input: string) => string & z.core.$brand<"PXL.DiscriminantValue">;
110
+ declare const zFileName: z.core.$ZodBranded<z.ZodString, "PXL.FileName", "out">;
111
111
  /**
112
112
  * A file name that is used to refer to a file in the generated code.
113
113
  */
114
114
  export type FileName = z.infer<typeof zFileName>;
115
- export declare const toFileName: (input: string) => string & z.BRAND<"PXL.FileName">;
116
- declare const zFolderName: z.ZodBranded<z.ZodString, "PXL.FolderName">;
115
+ export declare const toFileName: (input: string) => string & z.core.$brand<"PXL.FileName">;
116
+ declare const zFolderName: z.core.$ZodBranded<z.ZodString, "PXL.FolderName", "out">;
117
117
  /**
118
118
  * A folder name that is used to refer to a folder in the generated code.
119
119
  */
120
120
  export type FolderName = z.infer<typeof zFolderName>;
121
- export declare const toFolderName: (input: string) => string & z.BRAND<"PXL.FolderName">;
122
- declare const zFilePath: z.ZodBranded<z.ZodString, "PXL.FilePath">;
121
+ export declare const toFolderName: (input: string) => string & z.core.$brand<"PXL.FolderName">;
122
+ declare const zFilePath: z.core.$ZodBranded<z.ZodString, "PXL.FilePath", "out">;
123
123
  /**
124
124
  * A file path that is used to refer to a file in the generated code.
125
125
  */
126
126
  export type FilePath = z.infer<typeof zFilePath>;
127
- export declare const toFilePath: (input: string) => string & z.BRAND<"PXL.FilePath">;
128
- declare const zBackendModuleName: z.ZodBranded<z.ZodString, "PXL.BackendModuleName">;
127
+ export declare const toFilePath: (input: string) => string & z.core.$brand<"PXL.FilePath">;
128
+ declare const zBackendModuleName: z.core.$ZodBranded<z.ZodString, "PXL.BackendModuleName", "out">;
129
129
  /**
130
130
  * A backend module name that is used to refer to a module in the backend.
131
131
  * E.g. `@actions`.
132
132
  */
133
133
  export type BackendModuleName = z.infer<typeof zBackendModuleName>;
134
- export declare const toBackendModuleName: (input: string) => string & z.BRAND<"PXL.BackendModuleName">;
135
- declare const zBackendModuleLocation: z.ZodBranded<z.ZodString, "PXL.BackendModuleLocation">;
134
+ export declare const toBackendModuleName: (input: string) => string & z.core.$brand<"PXL.BackendModuleName">;
135
+ declare const zBackendModuleLocation: z.core.$ZodBranded<z.ZodString, "PXL.BackendModuleLocation", "out">;
136
136
  /**
137
137
  * A module location is a reference to a file in a locale backend module.
138
138
  * E.g. `@actions/actions.types`.
@@ -143,7 +143,7 @@ declare const zBackendModuleLocation: z.ZodBranded<z.ZodString, "PXL.BackendModu
143
143
  * Therefore, we need to reference the actual file together with the package name.
144
144
  */
145
145
  export type BackendModuleLocation = z.infer<typeof zBackendModuleLocation>;
146
- export declare const toBackendModuleLocation: (input: `@${string}`) => string & z.BRAND<"PXL.BackendModuleLocation">;
146
+ export declare const toBackendModuleLocation: (input: `@${string}`) => string & z.core.$brand<"PXL.BackendModuleLocation">;
147
147
  export type ImportableTypes = TypeName | FunctionName | ClassName | ConstantName | AnnotatedTypeName;
148
148
  export type ImportPaths = FilePath | PackageName | BackendModuleLocation;
149
149
  export {};
@@ -64,7 +64,9 @@ const toVariableName = (input) => zVariableName.parse(input);
64
64
  exports.toVariableName = toVariableName;
65
65
  const zPostXlPackageName = zod_1.default
66
66
  .string()
67
- .refine((name) => name.startsWith('@postxl/'), (name) => ({ message: `Package name must start with "@postxl/", got "${name}"!` }))
67
+ .refine((name) => name.startsWith('@postxl/'), {
68
+ error: (issue) => `Package name must start with "@postxl/", got "${issue.input}"!`,
69
+ })
68
70
  .brand('PXL.PackageName');
69
71
  /**
70
72
  * A package name that is used to refer to a package in the generated code.
@@ -1,5 +1,5 @@
1
1
  import z from 'zod';
2
- export declare const zChecksum: z.ZodBranded<z.ZodString, "PXL.Checksum">;
2
+ export declare const zChecksum: z.core.$ZodBranded<z.ZodString, "PXL.Checksum", "out">;
3
3
  /**
4
4
  * Branded Id type that should be used to identify checksum strings.
5
5
  */
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Custom Block Preservation for Merge Conflicts
3
+ *
4
+ * This module provides functionality to preserve custom code blocks during
5
+ * merge conflict generation. Developers can mark sections of code with special
6
+ * comment markers, and these sections will be automatically preserved when
7
+ * the generator runs.
8
+ *
9
+ * ## Usage
10
+ *
11
+ * Mark custom code blocks in your ejected files:
12
+ *
13
+ * ```typescript
14
+ * // @custom-start
15
+ * // your custom code here
16
+ * // @custom-end
17
+ *
18
+ * // Or with a name for clarity:
19
+ * // @custom-start:myFeature
20
+ * // your custom code here
21
+ * // @custom-end:myFeature
22
+ * ```
23
+ *
24
+ * When the generator runs and would normally create merge conflicts,
25
+ * it will:
26
+ * 1. Extract custom blocks from the modified file
27
+ * 2. Find appropriate anchor points in the generated content
28
+ * 3. Re-insert custom blocks at the correct positions
29
+ * 4. Only create merge conflict markers for actual conflicts
30
+ */
31
+ /**
32
+ * Marker patterns for custom blocks
33
+ *
34
+ * Supported formats:
35
+ * - Line comments: // @custom-start or // @custom-start:name
36
+ * - Block comments: Must be on a single line with both delimiters
37
+ *
38
+ * Note: Multi-line block comments are NOT supported. The opening and closing
39
+ * delimiters must be on the same line as the marker. This simplifies parsing
40
+ * and avoids ambiguity. See tests for examples.
41
+ */
42
+ export declare const customBlockMarkers: {
43
+ readonly startPattern: RegExp;
44
+ readonly endPattern: RegExp;
45
+ };
46
+ /**
47
+ * Represents a custom block extracted from source code
48
+ */
49
+ export type CustomBlock = {
50
+ /** Optional name of the block (from @custom-start:name) */
51
+ name: string | undefined;
52
+ /** The lines of content within the block (including markers) */
53
+ lines: string[];
54
+ /** The line index where the block starts (0-based) */
55
+ startLineIndex: number;
56
+ /** The line index where the block ends (0-based, inclusive) */
57
+ endLineIndex: number;
58
+ /** Anchor context: non-empty lines before the block for positioning */
59
+ anchorBefore: string[];
60
+ /** Anchor context: non-empty lines after the block for positioning */
61
+ anchorAfter: string[];
62
+ };
63
+ /**
64
+ * Result of extracting custom blocks from source code
65
+ */
66
+ export type ExtractResult = {
67
+ /** Successfully extracted custom blocks */
68
+ blocks: CustomBlock[];
69
+ /** Lines that are not part of any custom block */
70
+ nonCustomLines: string[];
71
+ /** Indices of non-custom lines in the original source */
72
+ nonCustomLineIndices: number[];
73
+ /** Any errors encountered during extraction */
74
+ errors: CustomBlockError[];
75
+ };
76
+ export type CustomBlockError = {
77
+ type: 'unclosed_block' | 'unexpected_end' | 'mismatched_name';
78
+ message: string;
79
+ lineIndex: number;
80
+ };
81
+ /**
82
+ * Extracts custom blocks from source code content.
83
+ *
84
+ * @param content - The source code content as a string
85
+ * @returns ExtractResult containing blocks, non-custom lines, and any errors
86
+ */
87
+ export declare function extractCustomBlocks(content: string): ExtractResult;
88
+ /**
89
+ * Finds the best position to insert a custom block in the target content
90
+ * based on anchor context matching.
91
+ *
92
+ * @param block - The custom block to insert
93
+ * @param targetLines - The target content lines
94
+ * @returns The line index where the block should be inserted, or null if no good position found
95
+ */
96
+ export declare function findInsertionPosition(block: CustomBlock, targetLines: string[]): number | null;
97
+ /**
98
+ * Inserts custom blocks into the target content at their appropriate positions.
99
+ *
100
+ * @param blocks - The custom blocks to insert
101
+ * @param targetContent - The target content to insert blocks into
102
+ * @returns Object containing the modified content and any blocks that couldn't be placed
103
+ */
104
+ export declare function insertCustomBlocks(blocks: CustomBlock[], targetContent: string): {
105
+ content: string;
106
+ unplacedBlocks: CustomBlock[];
107
+ };
108
+ /**
109
+ * Checks if a string contains any custom block markers
110
+ */
111
+ export declare function hasCustomBlockMarkers(content: string): boolean;
112
+ /**
113
+ * Reconstructs content from non-custom lines, used when comparing
114
+ * the "real" content differences (excluding custom blocks)
115
+ */
116
+ export declare function reconstructNonCustomContent(extractResult: ExtractResult): string;
@@ -0,0 +1,454 @@
1
+ "use strict";
2
+ /**
3
+ * Custom Block Preservation for Merge Conflicts
4
+ *
5
+ * This module provides functionality to preserve custom code blocks during
6
+ * merge conflict generation. Developers can mark sections of code with special
7
+ * comment markers, and these sections will be automatically preserved when
8
+ * the generator runs.
9
+ *
10
+ * ## Usage
11
+ *
12
+ * Mark custom code blocks in your ejected files:
13
+ *
14
+ * ```typescript
15
+ * // @custom-start
16
+ * // your custom code here
17
+ * // @custom-end
18
+ *
19
+ * // Or with a name for clarity:
20
+ * // @custom-start:myFeature
21
+ * // your custom code here
22
+ * // @custom-end:myFeature
23
+ * ```
24
+ *
25
+ * When the generator runs and would normally create merge conflicts,
26
+ * it will:
27
+ * 1. Extract custom blocks from the modified file
28
+ * 2. Find appropriate anchor points in the generated content
29
+ * 3. Re-insert custom blocks at the correct positions
30
+ * 4. Only create merge conflict markers for actual conflicts
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.customBlockMarkers = void 0;
34
+ exports.extractCustomBlocks = extractCustomBlocks;
35
+ exports.findInsertionPosition = findInsertionPosition;
36
+ exports.insertCustomBlocks = insertCustomBlocks;
37
+ exports.hasCustomBlockMarkers = hasCustomBlockMarkers;
38
+ exports.reconstructNonCustomContent = reconstructNonCustomContent;
39
+ /**
40
+ * Marker patterns for custom blocks
41
+ *
42
+ * Supported formats:
43
+ * - Line comments: // @custom-start or // @custom-start:name
44
+ * - Block comments: Must be on a single line with both delimiters
45
+ *
46
+ * Note: Multi-line block comments are NOT supported. The opening and closing
47
+ * delimiters must be on the same line as the marker. This simplifies parsing
48
+ * and avoids ambiguity. See tests for examples.
49
+ */
50
+ exports.customBlockMarkers = {
51
+ // Matches: // @custom-start or // @custom-start:name
52
+ // Also supports: /* @custom-start */ or /* @custom-start:name */
53
+ startPattern: /^\s*(?:\/\/|\/\*)\s*@custom-start(?::([a-zA-Z0-9_-]+))?\s*(?:\*\/)?\s*$/,
54
+ // Matches: // @custom-end or // @custom-end:name
55
+ // Also supports: /* @custom-end */ or /* @custom-end:name */
56
+ endPattern: /^\s*(?:\/\/|\/\*)\s*@custom-end(?::([a-zA-Z0-9_-]+))?\s*(?:\*\/)?\s*$/,
57
+ };
58
+ /**
59
+ * Number of non-empty lines to capture before/after a custom block for anchoring
60
+ */
61
+ const ANCHOR_CONTEXT_LINES = 3;
62
+ /**
63
+ * Extracts the block name from a regex match result
64
+ */
65
+ function extractBlockName(match) {
66
+ return match[1] ?? undefined;
67
+ }
68
+ /**
69
+ * Processes a @custom-start marker
70
+ */
71
+ function processStartMarker(line, lineIndex, startMatch, currentBlock, errors) {
72
+ if (!currentBlock) {
73
+ // Starting a new custom block
74
+ return {
75
+ name: extractBlockName(startMatch),
76
+ lines: [line],
77
+ startLineIndex: lineIndex,
78
+ };
79
+ }
80
+ // Nested @custom-start - this is an error
81
+ errors.push({
82
+ type: 'unclosed_block',
83
+ message: `Found @custom-start while already inside a custom block${currentBlock.name ? ` (${currentBlock.name})` : ''}. Nested custom blocks are not supported.`,
84
+ lineIndex,
85
+ });
86
+ currentBlock.lines.push(line);
87
+ return currentBlock;
88
+ }
89
+ /**
90
+ * Processes a @custom-end marker
91
+ * Always returns null since processing an end marker closes the current block
92
+ */
93
+ function processEndMarker(line, lineIndex, endMatch, currentBlock, lines, blocks, errors) {
94
+ if (!currentBlock) {
95
+ // @custom-end without a matching start
96
+ errors.push({
97
+ type: 'unexpected_end',
98
+ message: `Found @custom-end without a matching @custom-start`,
99
+ lineIndex,
100
+ });
101
+ return null;
102
+ }
103
+ const endName = extractBlockName(endMatch);
104
+ // Check for name mismatch
105
+ if (currentBlock.name !== endName) {
106
+ errors.push({
107
+ type: 'mismatched_name',
108
+ message: `Custom block name mismatch: started with "${currentBlock.name ?? '(unnamed)'}" but ended with "${endName ?? '(unnamed)'}"`,
109
+ lineIndex,
110
+ });
111
+ }
112
+ currentBlock.lines.push(line);
113
+ // Capture anchor context
114
+ const anchorBefore = captureAnchorContext(lines, currentBlock.startLineIndex, 'before');
115
+ const anchorAfter = captureAnchorContext(lines, lineIndex, 'after');
116
+ blocks.push({
117
+ name: currentBlock.name,
118
+ lines: currentBlock.lines,
119
+ startLineIndex: currentBlock.startLineIndex,
120
+ endLineIndex: lineIndex,
121
+ anchorBefore,
122
+ anchorAfter,
123
+ });
124
+ return null;
125
+ }
126
+ /**
127
+ * Adds unclosed block's lines to non-custom lines
128
+ */
129
+ function processUnclosedBlock(currentBlock, nonCustomLines, nonCustomLineIndices, errors) {
130
+ errors.push({
131
+ type: 'unclosed_block',
132
+ message: `Custom block${currentBlock.name ? ` (${currentBlock.name})` : ''} was never closed`,
133
+ lineIndex: currentBlock.startLineIndex,
134
+ });
135
+ // Add the unclosed block's lines to non-custom lines
136
+ for (let i = 0; i < currentBlock.lines.length; i++) {
137
+ const blockLine = currentBlock.lines[i];
138
+ if (blockLine === undefined) {
139
+ continue;
140
+ }
141
+ nonCustomLines.push(blockLine);
142
+ nonCustomLineIndices.push(currentBlock.startLineIndex + i);
143
+ }
144
+ }
145
+ /**
146
+ * Extracts custom blocks from source code content.
147
+ *
148
+ * @param content - The source code content as a string
149
+ * @returns ExtractResult containing blocks, non-custom lines, and any errors
150
+ */
151
+ function extractCustomBlocks(content) {
152
+ const lines = content.split('\n');
153
+ const blocks = [];
154
+ const errors = [];
155
+ const nonCustomLines = [];
156
+ const nonCustomLineIndices = [];
157
+ let currentBlock = null;
158
+ for (let i = 0; i < lines.length; i++) {
159
+ const line = lines[i];
160
+ if (line === undefined) {
161
+ continue;
162
+ }
163
+ const startMatch = exports.customBlockMarkers.startPattern.exec(line);
164
+ const endMatch = exports.customBlockMarkers.endPattern.exec(line);
165
+ if (startMatch) {
166
+ currentBlock = processStartMarker(line, i, startMatch, currentBlock, errors);
167
+ }
168
+ else if (endMatch) {
169
+ const wasInBlock = currentBlock !== null;
170
+ currentBlock = processEndMarker(line, i, endMatch, currentBlock, lines, blocks, errors);
171
+ // If we weren't in a block, this line goes to non-custom lines
172
+ if (!wasInBlock) {
173
+ nonCustomLines.push(line);
174
+ nonCustomLineIndices.push(i);
175
+ }
176
+ }
177
+ else if (currentBlock) {
178
+ // Inside a custom block, add to current block's lines
179
+ currentBlock.lines.push(line);
180
+ }
181
+ else {
182
+ // Not inside a custom block
183
+ nonCustomLines.push(line);
184
+ nonCustomLineIndices.push(i);
185
+ }
186
+ }
187
+ // Check for unclosed block at end of file
188
+ if (currentBlock) {
189
+ processUnclosedBlock(currentBlock, nonCustomLines, nonCustomLineIndices, errors);
190
+ }
191
+ return { blocks, nonCustomLines, nonCustomLineIndices, errors };
192
+ }
193
+ /**
194
+ * Captures anchor context lines (non-empty, significant lines) before or after a position
195
+ */
196
+ function captureAnchorContext(lines, position, direction) {
197
+ const anchors = [];
198
+ const step = direction === 'before' ? -1 : 1;
199
+ const start = direction === 'before' ? position - 1 : position + 1;
200
+ for (let i = start; direction === 'before' ? i >= 0 : i < lines.length; i += step) {
201
+ const line = lines[i];
202
+ if (line === undefined) {
203
+ continue;
204
+ }
205
+ // Skip empty lines and custom block markers
206
+ if (isSignificantLine(line)) {
207
+ anchors.push(normalizeAnchorLine(line));
208
+ if (anchors.length >= ANCHOR_CONTEXT_LINES) {
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ // Reverse "before" anchors so they're in original order (closest to block first)
214
+ if (direction === 'before') {
215
+ anchors.reverse();
216
+ }
217
+ return anchors;
218
+ }
219
+ /**
220
+ * Checks if a line is significant for anchoring purposes
221
+ * (not empty, not a comment-only line, not a custom block marker)
222
+ */
223
+ function isSignificantLine(line) {
224
+ const trimmed = line.trim();
225
+ if (trimmed === '') {
226
+ return false;
227
+ }
228
+ if (exports.customBlockMarkers.startPattern.test(line)) {
229
+ return false;
230
+ }
231
+ if (exports.customBlockMarkers.endPattern.test(line)) {
232
+ return false;
233
+ }
234
+ // Consider all other lines significant (including comments that might be documentation)
235
+ return true;
236
+ }
237
+ /**
238
+ * Normalizes a line for anchor comparison (trims whitespace)
239
+ */
240
+ function normalizeAnchorLine(line) {
241
+ return line.trim();
242
+ }
243
+ /**
244
+ * Finds the best position to insert a custom block in the target content
245
+ * based on anchor context matching.
246
+ *
247
+ * @param block - The custom block to insert
248
+ * @param targetLines - The target content lines
249
+ * @returns The line index where the block should be inserted, or null if no good position found
250
+ */
251
+ function findInsertionPosition(block, targetLines) {
252
+ // Strategy 1: Try to find a match using "before" anchors
253
+ // We look for the anchor sequence and insert after the last matched anchor
254
+ const beforePosition = findAnchorSequence(block.anchorBefore, targetLines, 'before');
255
+ if (beforePosition !== null) {
256
+ return beforePosition + 1; // Insert after the anchor
257
+ }
258
+ // Strategy 2: Try to find a match using "after" anchors
259
+ // We look for the anchor sequence and insert before the first matched anchor
260
+ const afterPosition = findAnchorSequence(block.anchorAfter, targetLines, 'after');
261
+ if (afterPosition !== null) {
262
+ return afterPosition; // Insert before the anchor
263
+ }
264
+ // Strategy 3: Try to find any single anchor match
265
+ const singleAnchor = findSingleAnchorMatch(block, targetLines);
266
+ if (singleAnchor !== null) {
267
+ return singleAnchor;
268
+ }
269
+ return null;
270
+ }
271
+ /**
272
+ * Finds a sequence of anchor lines in the target content
273
+ */
274
+ function findAnchorSequence(anchors, targetLines, mode) {
275
+ if (anchors.length === 0) {
276
+ return null;
277
+ }
278
+ // For "before" mode, we want to find the sequence and return the position of the last anchor
279
+ // For "after" mode, we want to find the sequence and return the position of the first anchor
280
+ // Try to match progressively fewer anchors (from all to just one)
281
+ for (let matchCount = anchors.length; matchCount >= 1; matchCount--) {
282
+ const anchorsToMatch = mode === 'before'
283
+ ? anchors.slice(-matchCount) // Take last N anchors for "before"
284
+ : anchors.slice(0, matchCount); // Take first N anchors for "after"
285
+ const position = findSequenceInTarget(anchorsToMatch, targetLines, mode);
286
+ if (position !== null) {
287
+ return position;
288
+ }
289
+ }
290
+ return null;
291
+ }
292
+ /**
293
+ * Finds the next significant line index starting from a given position
294
+ */
295
+ function findNextSignificantLineIndex(targetLines, startIndex) {
296
+ for (let i = startIndex; i < targetLines.length; i++) {
297
+ const line = targetLines[i];
298
+ if (line !== undefined && isSignificantLine(line)) {
299
+ return i;
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+ /**
305
+ * Checks if a sequence matches at a given position in target lines
306
+ */
307
+ function matchSequenceAtPosition(sequence, targetLines, startIndex) {
308
+ let lastMatchIndex = startIndex;
309
+ for (let j = 1; j < sequence.length; j++) {
310
+ const expectedAnchor = sequence[j];
311
+ if (expectedAnchor === undefined) {
312
+ return { matches: false, lastMatchIndex };
313
+ }
314
+ // Find the next significant line in target
315
+ const nextIndex = findNextSignificantLineIndex(targetLines, lastMatchIndex + 1);
316
+ if (nextIndex === null) {
317
+ return { matches: false, lastMatchIndex };
318
+ }
319
+ const nextTargetLine = targetLines[nextIndex];
320
+ if (nextTargetLine === undefined || normalizeAnchorLine(nextTargetLine) !== expectedAnchor) {
321
+ return { matches: false, lastMatchIndex };
322
+ }
323
+ lastMatchIndex = nextIndex;
324
+ }
325
+ return { matches: true, lastMatchIndex };
326
+ }
327
+ /**
328
+ * Finds a specific sequence of lines in the target
329
+ */
330
+ function findSequenceInTarget(sequence, targetLines, mode) {
331
+ if (sequence.length === 0) {
332
+ return null;
333
+ }
334
+ const firstAnchor = sequence[0];
335
+ if (firstAnchor === undefined) {
336
+ return null;
337
+ }
338
+ for (let i = 0; i < targetLines.length; i++) {
339
+ const targetLine = targetLines[i];
340
+ if (targetLine === undefined) {
341
+ continue;
342
+ }
343
+ if (normalizeAnchorLine(targetLine) === firstAnchor) {
344
+ const { matches, lastMatchIndex } = matchSequenceAtPosition(sequence, targetLines, i);
345
+ if (matches) {
346
+ return mode === 'before' ? lastMatchIndex : i;
347
+ }
348
+ }
349
+ }
350
+ return null;
351
+ }
352
+ /**
353
+ * Finds a single anchor in target lines
354
+ */
355
+ function findAnchorInTarget(anchor, targetLines) {
356
+ for (let i = 0; i < targetLines.length; i++) {
357
+ const targetLine = targetLines[i];
358
+ if (targetLine === undefined) {
359
+ continue;
360
+ }
361
+ if (normalizeAnchorLine(targetLine) === anchor) {
362
+ return i;
363
+ }
364
+ }
365
+ return null;
366
+ }
367
+ /**
368
+ * Finds a single anchor match as a fallback
369
+ */
370
+ function findSingleAnchorMatch(block, targetLines) {
371
+ // Try "before" anchors first (prefer closest anchor)
372
+ for (let i = block.anchorBefore.length - 1; i >= 0; i--) {
373
+ const anchor = block.anchorBefore[i];
374
+ if (anchor === undefined) {
375
+ continue;
376
+ }
377
+ const position = findAnchorInTarget(anchor, targetLines);
378
+ if (position !== null) {
379
+ return position + 1; // Insert after this anchor
380
+ }
381
+ }
382
+ // Try "after" anchors
383
+ for (const anchor of block.anchorAfter) {
384
+ const position = findAnchorInTarget(anchor, targetLines);
385
+ if (position !== null) {
386
+ return position; // Insert before this anchor
387
+ }
388
+ }
389
+ return null;
390
+ }
391
+ /**
392
+ * Inserts custom blocks into the target content at their appropriate positions.
393
+ *
394
+ * @param blocks - The custom blocks to insert
395
+ * @param targetContent - The target content to insert blocks into
396
+ * @returns Object containing the modified content and any blocks that couldn't be placed
397
+ */
398
+ function insertCustomBlocks(blocks, targetContent) {
399
+ if (blocks.length === 0) {
400
+ return { content: targetContent, unplacedBlocks: [] };
401
+ }
402
+ const targetLines = targetContent.split('\n');
403
+ const unplacedBlocks = [];
404
+ // Sort blocks by their insertion position (reverse order to maintain indices)
405
+ const blocksWithPositions = blocks.map((block) => ({
406
+ block,
407
+ position: findInsertionPosition(block, targetLines),
408
+ }));
409
+ // Separate placed and unplaced blocks
410
+ const placedBlocks = blocksWithPositions
411
+ .filter((b) => b.position !== null)
412
+ .sort((a, b) => b.position - a.position); // Sort descending to insert from end
413
+ for (const { block, position } of blocksWithPositions) {
414
+ if (position === null) {
415
+ unplacedBlocks.push(block);
416
+ }
417
+ }
418
+ // Insert blocks from end to beginning to maintain correct positions
419
+ for (const { block, position } of placedBlocks) {
420
+ // Add an empty line before the block if the previous line is not empty
421
+ const prevLine = position > 0 ? targetLines[position - 1] : undefined;
422
+ const needsEmptyLineBefore = prevLine !== undefined && prevLine.trim() !== '';
423
+ // Add an empty line after the block if the next line is not empty
424
+ const nextLine = position < targetLines.length ? targetLines[position] : undefined;
425
+ const needsEmptyLineAfter = nextLine !== undefined && nextLine.trim() !== '';
426
+ const insertLines = [];
427
+ if (needsEmptyLineBefore) {
428
+ insertLines.push('');
429
+ }
430
+ insertLines.push(...block.lines);
431
+ if (needsEmptyLineAfter) {
432
+ insertLines.push('');
433
+ }
434
+ targetLines.splice(position, 0, ...insertLines);
435
+ }
436
+ return {
437
+ content: targetLines.join('\n'),
438
+ unplacedBlocks,
439
+ };
440
+ }
441
+ /**
442
+ * Checks if a string contains any custom block markers
443
+ */
444
+ function hasCustomBlockMarkers(content) {
445
+ const lines = content.split('\n');
446
+ return lines.some((line) => exports.customBlockMarkers.startPattern.test(line) || exports.customBlockMarkers.endPattern.test(line));
447
+ }
448
+ /**
449
+ * Reconstructs content from non-custom lines, used when comparing
450
+ * the "real" content differences (excluding custom blocks)
451
+ */
452
+ function reconstructNonCustomContent(extractResult) {
453
+ return extractResult.nonCustomLines.join('\n');
454
+ }
@@ -1,3 +1,4 @@
1
+ export * from './custom-blocks';
1
2
  export * from './jsdoc';
2
3
  export * from './lint';
3
4
  export * from './path';
@@ -14,6 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ __exportStar(require("./custom-blocks"), exports);
17
18
  __exportStar(require("./jsdoc"), exports);
18
19
  __exportStar(require("./lint"), exports);
19
20
  __exportStar(require("./path"), exports);
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { VirtualFileSystem } from './vfs.class';
3
- declare const zLockFile: z.ZodEffects<z.ZodRecord<z.ZodBranded<z.ZodString, "PXL.PosixPath">, z.ZodBranded<z.ZodString, "PXL.Checksum">>, Map<string & z.BRAND<"PXL.PosixPath">, string & z.BRAND<"PXL.Checksum">>, Record<string, string>>;
3
+ declare const zLockFile: z.ZodPipe<z.ZodRecord<z.core.$ZodBranded<z.ZodString, "PXL.PosixPath", "out">, z.core.$ZodBranded<z.ZodString, "PXL.Checksum", "out">>, z.ZodTransform<Map<string & z.core.$brand<"PXL.PosixPath">, string & z.core.$brand<"PXL.Checksum">>, Record<string & z.core.$brand<"PXL.PosixPath">, string & z.core.$brand<"PXL.Checksum">>>>;
4
4
  type LockFile = z.infer<typeof zLockFile>;
5
5
  export declare function writeLockFile(lockFilePath: string, vfs: VirtualFileSystem): Promise<void>;
6
6
  export declare function readLockFile(lockFilePath: string): Promise<LockFile | undefined>;
@@ -17,11 +17,23 @@ export declare function hasMergeConflictMarkers(content: FileContent): boolean;
17
17
  /**
18
18
  * Generates a merged output with conflict markers between two file contents.
19
19
  *
20
+ * This function supports custom block preservation: if the source content contains
21
+ * custom blocks marked with `// @custom-start` and `// @custom-end` comments,
22
+ * those blocks will be automatically inserted into the generated content at their
23
+ * appropriate positions (based on anchor context), avoiding unnecessary merge conflicts
24
+ * for custom code that was intentionally added.
25
+ *
20
26
  * @param contentSource - The content of the first file as a string.
21
27
  * @param contentIncoming - The content of the second file as a string.
22
28
  * @param labelSource - Optional label for the first file in conflict markers.
23
29
  * @param labelIncoming - Optional label for the second file in conflict markers.
24
30
  * @returns The merged content with conflict markers as a string.
31
+ *
32
+ * @example
33
+ * // Mark custom code in ejected files:
34
+ * // @custom-start:myFeature
35
+ * // your custom code here
36
+ * // @custom-end:myFeature
25
37
  */
26
38
  export declare function generateMergeConflict({ contentSource, contentIncoming, labelSource, labelIncoming, }: {
27
39
  contentSource: FileContent;
@@ -39,6 +39,7 @@ exports.generateMergeConflict = generateMergeConflict;
39
39
  exports.generateDiffSummary = generateDiffSummary;
40
40
  const Diff = __importStar(require("diff"));
41
41
  const utils_1 = require("@postxl/utils");
42
+ const custom_blocks_1 = require("./custom-blocks");
42
43
  /**
43
44
  * Standard merge conflict markers used by git and our generator
44
45
  */
@@ -84,6 +85,28 @@ function normalizeWhitespace(content) {
84
85
  }
85
86
  return lines.join('\n');
86
87
  }
88
+ /**
89
+ * Normalizes whitespace more aggressively for semantic comparison.
90
+ * Used when comparing content with custom blocks removed to check if there are real differences.
91
+ * - All whitespace normalization from normalizeWhitespace
92
+ * - Removes ALL empty lines (only compares non-empty content)
93
+ * - Trims each line
94
+ *
95
+ * This aggressive approach is appropriate because when users add custom blocks,
96
+ * they often add empty lines before/after for formatting. These empty lines
97
+ * should not be considered "real differences" when the custom block is removed.
98
+ */
99
+ function normalizeForSemanticComparison(content) {
100
+ const normalized = content
101
+ .replaceAll('\r\n', '\n')
102
+ .replaceAll('\n\r', '\n')
103
+ .replaceAll('\r', '\n')
104
+ .replaceAll('\uFEFF', '');
105
+ const lines = normalized.split('\n').map((line) => line.trimEnd());
106
+ // Keep only non-empty lines for comparison
107
+ const nonEmptyLines = lines.filter((line) => line.trim() !== '');
108
+ return nonEmptyLines.join('\n');
109
+ }
87
110
  /**
88
111
  * Checks if two arrays of lines differ only in whitespace/spacing
89
112
  */
@@ -95,11 +118,23 @@ function isWhitespaceOnlyDifference(sourceLines, incomingLines) {
95
118
  /**
96
119
  * Generates a merged output with conflict markers between two file contents.
97
120
  *
121
+ * This function supports custom block preservation: if the source content contains
122
+ * custom blocks marked with `// @custom-start` and `// @custom-end` comments,
123
+ * those blocks will be automatically inserted into the generated content at their
124
+ * appropriate positions (based on anchor context), avoiding unnecessary merge conflicts
125
+ * for custom code that was intentionally added.
126
+ *
98
127
  * @param contentSource - The content of the first file as a string.
99
128
  * @param contentIncoming - The content of the second file as a string.
100
129
  * @param labelSource - Optional label for the first file in conflict markers.
101
130
  * @param labelIncoming - Optional label for the second file in conflict markers.
102
131
  * @returns The merged content with conflict markers as a string.
132
+ *
133
+ * @example
134
+ * // Mark custom code in ejected files:
135
+ * // @custom-start:myFeature
136
+ * // your custom code here
137
+ * // @custom-end:myFeature
103
138
  */
104
139
  function generateMergeConflict({ contentSource, contentIncoming, labelSource = 'Manual', labelIncoming = 'Generated', }) {
105
140
  // In case the content is binary, we just return the incoming content
@@ -113,6 +148,92 @@ function generateMergeConflict({ contentSource, contentIncoming, labelSource = '
113
148
  if (sourceNormalized === incomingNormalized) {
114
149
  return contentIncoming;
115
150
  }
151
+ // Handle custom blocks: extract from source, insert into incoming
152
+ if ((0, custom_blocks_1.hasCustomBlockMarkers)(contentSource)) {
153
+ return generateMergeConflictWithCustomBlocks({
154
+ contentSource,
155
+ contentIncoming,
156
+ labelSource,
157
+ labelIncoming,
158
+ });
159
+ }
160
+ // Standard merge conflict generation (no custom blocks)
161
+ return generateStandardMergeConflict({
162
+ contentSource,
163
+ contentIncoming,
164
+ labelSource,
165
+ labelIncoming,
166
+ });
167
+ }
168
+ /**
169
+ * Generates merge conflict output while preserving custom blocks.
170
+ *
171
+ * The algorithm:
172
+ * 1. Extract custom blocks from the source (manual) content
173
+ * 2. Compare source (minus custom blocks) with original incoming to check for real conflicts
174
+ * 3. If no real conflicts, insert custom blocks into incoming and return without conflict markers
175
+ * 4. If real conflicts exist, insert custom blocks into incoming and generate merge conflict
176
+ * between source (minus custom blocks) and modified incoming
177
+ */
178
+ function generateMergeConflictWithCustomBlocks({ contentSource, contentIncoming, labelSource, labelIncoming, }) {
179
+ // Extract custom blocks from source
180
+ const extractResult = (0, custom_blocks_1.extractCustomBlocks)(contentSource);
181
+ if (extractResult.blocks.length === 0) {
182
+ // No valid custom blocks found, fall back to standard merge conflict
183
+ return generateStandardMergeConflict({
184
+ contentSource,
185
+ contentIncoming,
186
+ labelSource,
187
+ labelIncoming,
188
+ });
189
+ }
190
+ // Reconstruct source content without custom blocks
191
+ const sourceWithoutCustomBlocks = (0, custom_blocks_1.reconstructNonCustomContent)(extractResult);
192
+ // Compare source (minus custom blocks) with original incoming to check for real conflicts
193
+ // If they match, the only difference was the custom blocks, so no conflict needed
194
+ // Use semantic comparison which is more lenient about empty line differences
195
+ const sourceNormalized = normalizeForSemanticComparison(sourceWithoutCustomBlocks);
196
+ const incomingNormalized = normalizeForSemanticComparison(contentIncoming);
197
+ // Insert custom blocks into incoming content
198
+ const { content: incomingWithCustomBlocks, unplacedBlocks } = (0, custom_blocks_1.insertCustomBlocks)(extractResult.blocks, contentIncoming);
199
+ if (sourceNormalized === incomingNormalized) {
200
+ // No conflicts outside of custom blocks - just return incoming with custom blocks
201
+ return handleUnplacedBlocks(incomingWithCustomBlocks, unplacedBlocks);
202
+ }
203
+ // There are real conflicts outside of custom blocks
204
+ // Generate merge conflict between source (minus custom blocks) and incoming-with-custom-blocks
205
+ // This way, the custom blocks appear on the "Generated" side of any conflicts
206
+ const mergeResult = generateStandardMergeConflict({
207
+ contentSource: sourceWithoutCustomBlocks,
208
+ contentIncoming: incomingWithCustomBlocks,
209
+ labelSource,
210
+ labelIncoming,
211
+ });
212
+ return handleUnplacedBlocks(mergeResult, unplacedBlocks);
213
+ }
214
+ /**
215
+ * Appends unplaced custom blocks at the end of the content with a warning comment
216
+ */
217
+ function handleUnplacedBlocks(content, unplacedBlocks) {
218
+ if (unplacedBlocks.length === 0) {
219
+ return content;
220
+ }
221
+ let result = content.trimEnd();
222
+ result += '\n\n';
223
+ result += '// ⚠️ WARNING: The following custom blocks could not be automatically placed.\n';
224
+ result += '// Please manually move them to the appropriate location.\n';
225
+ for (const block of unplacedBlocks) {
226
+ result += '\n';
227
+ result += `// --- Unplaced custom block${block.name ? `: ${block.name}` : ''} ---\n`;
228
+ result += block.lines.join('\n');
229
+ result += '\n';
230
+ }
231
+ return result;
232
+ }
233
+ /**
234
+ * Standard merge conflict generation without custom block handling
235
+ */
236
+ function generateStandardMergeConflict({ contentSource, contentIncoming, labelSource, labelIncoming, }) {
116
237
  const blocks = new Blocks({ source: contentSource, incoming: contentIncoming });
117
238
  let result = '';
118
239
  for (const block of blocks.blocks) {
@@ -131,9 +252,13 @@ function generateMergeConflict({ contentSource, contentIncoming, labelSource = '
131
252
  }
132
253
  // Real content difference - create conflict markers
133
254
  result += `${exports.mergeConflictMarkers.start} ${labelSource}\n`;
134
- result += block.source.join('\n') + '\n';
255
+ if (block.source.length > 0) {
256
+ result += block.source.join('\n') + '\n';
257
+ }
135
258
  result += `${exports.mergeConflictMarkers.separator}\n`;
136
- result += block.incoming.join('\n') + '\n';
259
+ if (block.incoming.length > 0) {
260
+ result += block.incoming.join('\n') + '\n';
261
+ }
137
262
  result += `${exports.mergeConflictMarkers.end} ${labelIncoming}\n`;
138
263
  }
139
264
  return result;
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod';
2
- export declare const zPosixPath: z.ZodBranded<z.ZodString, "PXL.PosixPath">;
2
+ export declare const zPosixPath: z.core.$ZodBranded<z.ZodString, "PXL.PosixPath", "out">;
3
3
  /**
4
4
  * A path string that has been normalized to use the unix path separator.
5
5
  */
6
6
  export type PosixPath = z.infer<typeof zPosixPath>;
7
- export declare const POSIX_ROOT: string & z.BRAND<"PXL.PosixPath">;
7
+ export declare const POSIX_ROOT: string & z.core.$brand<"PXL.PosixPath">;
8
8
  /**
9
9
  * Normalizes a unix or Windows path to use the unix path separator. Additionally,
10
10
  * it normalizes the path so that every path is considered absolute.
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
- export declare const zTypescript: z.ZodBranded<z.ZodString, "PXL.Typescript">;
2
+ export declare const zTypescript: z.core.$ZodBranded<z.ZodString, "PXL.Typescript", "out">;
3
3
  export type Typescript = z.infer<typeof zTypescript>;
4
- export declare function ts(input: string): string & z.BRAND<"PXL.Typescript">;
4
+ export declare function ts(input: string): string & z.core.$brand<"PXL.Typescript">;
5
5
  export declare function trimLines(input: string): string;
6
6
  /**
7
7
  * Removes leading and trailing newlines from the input string.
@@ -32,10 +32,10 @@ function logSyncResult(results, options = {}) {
32
32
  actionGroups['Ejected'].push(path);
33
33
  }
34
34
  }
35
+ logActionResult('NoAction', actionGroups['NoAction'], utils_1.cyan, 'unchanged', logger);
36
+ logActionResult('Write', actionGroups['Write'], utils_1.green, 'written:', logger);
35
37
  logActionResult('Delete', actionGroups['Delete'], utils_1.red, 'deleted', logger);
36
38
  logActionResult('MergeConflict', actionGroups['MergeConflict'], utils_1.yellow, 'with merge conflicts', logger);
37
- logActionResult('Write', actionGroups['Write'], utils_1.green, 'written:', logger);
38
- logActionResult('NoAction', actionGroups['NoAction'], utils_1.cyan, 'unchanged', logger);
39
39
  if (options.showEjectedStats) {
40
40
  if (actionGroups['Ejected'].length === 0) {
41
41
  logger.log('✅ No files ejected');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@postxl/generator",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Core package that orchestrates the code generation of a PXL project",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -46,8 +46,8 @@
46
46
  "jszip": "3.10.1",
47
47
  "minimatch": "^10.1.1",
48
48
  "p-limit": "3.1.0",
49
- "@postxl/schema": "^1.1.1",
50
- "@postxl/utils": "^1.1.0"
49
+ "@postxl/schema": "^1.2.0",
50
+ "@postxl/utils": "^1.3.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/diff": "8.0.0"