Skip to content

Commit 99b83b0

Browse files
committed
Add custom YAML type for !reference tags and process references in toYaml
1 parent dd6b3e7 commit 99b83b0

File tree

3 files changed

+135
-6
lines changed

3 files changed

+135
-6
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@noxify/gitlab-ci-builder": patch
3+
---
4+
5+
Fixed YAML serialization of `!reference` tags to output inline format without quotes, enabling proper GitLab CI reference resolution.

src/export.ts

Lines changed: 112 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,71 @@ import yaml from "js-yaml"
33

44
import type { GitLabCi } from "./"
55

6+
// Wrapper class to mark arrays that should be rendered in flow style
7+
class FlowArray<T = unknown> extends Array<T> {
8+
constructor(...items: T[]) {
9+
super()
10+
this.push(...items)
11+
}
12+
}
13+
14+
// Custom YAML type for !reference tags
15+
const referenceTag = new yaml.Type("!reference", {
16+
kind: "sequence",
17+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
18+
construct: (data) => data,
19+
predicate: (obj) => {
20+
return obj instanceof FlowArray
21+
},
22+
represent: (obj: unknown) => {
23+
return obj
24+
},
25+
instanceOf: FlowArray,
26+
})
27+
28+
const CUSTOM_SCHEMA = yaml.DEFAULT_SCHEMA.extend({ explicit: [referenceTag] })
29+
30+
/**
31+
* Process a value to convert !reference strings to proper arrays
32+
*/
33+
function processReferences(value: unknown): unknown {
34+
if (typeof value === "string" && value.startsWith("!reference [")) {
35+
// Parse "!reference [.template, script]" into FlowArray format
36+
const match = /^!reference\s*\[([^\]]+)\]$/.exec(value)
37+
if (match?.[1]) {
38+
const parts = match[1].split(",").map((s) => s.trim())
39+
if (parts.length === 2) {
40+
return new FlowArray(...parts)
41+
}
42+
}
43+
}
44+
45+
if (Array.isArray(value)) {
46+
return value.map(processReferences)
47+
}
48+
49+
if (value && typeof value === "object") {
50+
const result: Record<string, unknown> = {}
51+
for (const [key, val] of Object.entries(value)) {
52+
result[key] = processReferences(val)
53+
}
54+
return result
55+
}
56+
57+
return value
58+
}
59+
660
/**
761
* Convert a plain `GitLabCi` object to a YAML string.
862
*
963
* @param config - The YAML-serializable `GitLabCi` object produced by `getPlainObject()`.
1064
* @returns YAML string representation of the pipeline.
1165
*/
1266
export function toYaml(config: GitLabCi) {
13-
const { jobs, ...rest } = config
67+
// Process references before serialization
68+
const processed = processReferences(config) as GitLabCi
69+
70+
const { jobs, ...rest } = processed
1471

1572
// Define preferred order for top-level keys
1673
const keyOrder = [
@@ -54,15 +111,64 @@ export function toYaml(config: GitLabCi) {
54111
}
55112
}
56113

57-
const yamlString = yaml.dump(ordered, { noRefs: true, sortKeys: false, lineWidth: -1 })
114+
const yamlString = yaml.dump(ordered, {
115+
noRefs: true,
116+
sortKeys: false,
117+
lineWidth: -1,
118+
schema: CUSTOM_SCHEMA,
119+
})
58120

59-
// Add blank lines between top-level sections for better readability
121+
// Post-process to convert multiline !reference to inline format
60122
const lines = yamlString.split("\n")
61123
const resultLines: string[] = []
124+
let i = 0
125+
126+
while (i < lines.length) {
127+
const line = lines[i]
128+
129+
// Check if this line contains a multiline !reference tag
130+
if (line && line.trim() === "- !reference") {
131+
// Next two lines should contain the array elements
132+
const nextLine1 = lines[i + 1]
133+
const nextLine2 = lines[i + 2]
134+
135+
if (
136+
nextLine1 &&
137+
nextLine2 &&
138+
nextLine1.trim().startsWith("- ") &&
139+
nextLine2.trim().startsWith("- ")
140+
) {
141+
const elem1 = nextLine1.trim().slice(2)
142+
const elem2 = nextLine2.trim().slice(2)
143+
144+
// Get the indentation from the original "- !reference" line
145+
const match = /^(\s*)/.exec(line)
146+
const indent = match?.[1] ?? ""
147+
148+
// Create inline format
149+
resultLines.push(`${indent}- !reference [${elem1}, ${elem2}]`)
150+
151+
// Skip the next two lines
152+
i += 3
153+
continue
154+
}
155+
}
156+
157+
if (line !== undefined) {
158+
resultLines.push(line)
159+
}
160+
i++
161+
}
162+
163+
const processedYaml = resultLines.join("\n")
164+
165+
// Add blank lines between top-level sections for better readability
166+
const finalLines = processedYaml.split("\n")
167+
const outputLines: string[] = []
62168
let lastTopLevelKey: string | null = null
63169
let previousLineWasValue = false
64170

65-
for (const line of lines) {
171+
for (const line of finalLines) {
66172
const trimmed = line.trim()
67173

68174
// Check if this is a top-level key (no indentation and ends with :)
@@ -83,10 +189,10 @@ export function toYaml(config: GitLabCi) {
83189
previousLineWasValue = true
84190
}
85191

86-
resultLines.push(line)
192+
outputLines.push(line)
87193
}
88194

89-
return resultLines.join("\n")
195+
return outputLines.join("\n")
90196
}
91197

92198
/**

tests/export.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,5 +281,23 @@ describe("export", () => {
281281
expect(yaml).toContain("optional: true")
282282
expect(yaml).toContain("- job: unit_tests")
283283
})
284+
285+
it("should handle !reference tags without quotes", () => {
286+
const config = new Config()
287+
config.template(".pnpm_install_template", {
288+
script: ["pnpm install"],
289+
})
290+
config.job("test", {
291+
script: ["!reference [.pnpm_install_template, script]", "pnpm run test"],
292+
})
293+
294+
const yaml = toYaml(config.getPlainObject())
295+
296+
expect(yaml).toContain("test:")
297+
expect(yaml).toContain("script:")
298+
expect(yaml).toContain("- !reference [.pnpm_install_template, script]")
299+
expect(yaml).not.toContain('"!reference')
300+
expect(yaml).toContain("- pnpm run test")
301+
})
284302
})
285303
})

0 commit comments

Comments
 (0)