Skip to content

Commit a3bc317

Browse files
feat: update to support new grammar
1 parent da85edf commit a3bc317

12 files changed

Lines changed: 196 additions & 86 deletions

src/index.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,29 @@ const plugin: Plugin = {
1818
const sections = splitPywireSections(text)
1919
const formattedHtml = await formatHtmlWithPrettier(sections.html, options)
2020

21-
if (!sections.separator) {
22-
return formattedHtml
21+
const parts: string[] = []
22+
23+
// Directives
24+
if (sections.directives.trim().length > 0) {
25+
parts.push(sections.directives.trimEnd())
2326
}
2427

25-
const parts: string[] = []
26-
if (sections.header.trim().length > 0) {
27-
parts.push(sections.header.trimEnd())
28+
// Python fence
29+
if (sections.separator && sections.python.trim().length > 0) {
30+
if (parts.length > 0) {
31+
parts.push('')
32+
}
33+
parts.push('---')
34+
parts.push(sections.python.trimEnd())
35+
parts.push('---')
2836
}
29-
parts.push(sections.separator.trimEnd())
37+
38+
// HTML template
3039
if (formattedHtml.trim().length > 0) {
31-
parts.push(formattedHtml.trimEnd())
40+
if (parts.length > 0) {
41+
parts.push('')
42+
}
43+
parts.push(formattedHtml.trim())
3244
}
3345

3446
return parts.join('\n')

src/parser.test.ts

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,34 @@ import { describe, it, expect } from 'vitest'
22
import { parsePywire } from './parser.js'
33

44
describe('parsePywire', () => {
5-
it('splits python header and html', () => {
6-
const input = `name = "World"\n\n---html---\n<div>{name}</div>\n`
5+
it('splits python header and html with --- fences', () => {
6+
const input = `---\nname = "World"\n---\n<div>{name}</div>\n`
77
const result = parsePywire(input)
88

99
expect(result.headerNodes).toHaveLength(1)
10-
expect(result.headerNodes[0]).toEqual({ type: 'Python', text: 'name = "World"\n' })
11-
expect(result.separator).toBe('---html---')
10+
expect(result.headerNodes[0]).toEqual({ type: 'Python', text: 'name = "World"' })
11+
expect(result.separator).toBe('---')
1212
expect(result.html).toBe('<div>{name}</div>\n')
1313
})
1414

15-
it('groups directives separately from python', () => {
16-
const input = `!path '/'\n\nx = 1\n---html---\n<p>{x}</p>\n`
15+
it('groups directives separately from fenced python', () => {
16+
const input = `!path '/'\n\n---\nx = 1\n---\n<p>{x}</p>\n`
1717
const result = parsePywire(input)
1818

1919
expect(result.headerNodes).toHaveLength(2)
2020
expect(result.headerNodes[0]).toEqual({ type: 'Directive', text: "!path '/'" })
21-
expect(result.headerNodes[1]).toEqual({ type: 'Python', text: '\nx = 1' })
21+
expect(result.headerNodes[1]).toEqual({ type: 'Python', text: 'x = 1' })
2222
})
2323

2424
it('captures multiline directive blocks', () => {
25-
const input = `!path {\n "main": "/"\n}\n\nvalue = 2\n---html---\n<div></div>\n`
25+
const input = `!path {\n "main": "/"\n}\n\n---\nvalue = 2\n---\n<div></div>\n`
2626
const result = parsePywire(input)
2727

2828
expect(result.headerNodes).toHaveLength(2)
2929
expect(result.headerNodes[0].type).toBe('Directive')
3030
expect(result.headerNodes[0].text).toContain('!path {')
3131
expect(result.headerNodes[0].text).toContain('"main": "/"')
32-
expect(result.headerNodes[1]).toEqual({ type: 'Python', text: '\nvalue = 2' })
32+
expect(result.headerNodes[1]).toEqual({ type: 'Python', text: 'value = 2' })
3333
})
3434

3535
it('treats files without separator as html', () => {
@@ -40,4 +40,13 @@ describe('parsePywire', () => {
4040
expect(result.headerNodes).toHaveLength(0)
4141
expect(result.html).toBe(input)
4242
})
43+
44+
it('supports legacy ---html--- separator', () => {
45+
const input = `x = 1\n---html---\n<div>{x}</div>\n`
46+
const result = parsePywire(input)
47+
48+
expect(result.headerNodes).toHaveLength(1)
49+
expect(result.headerNodes[0]).toEqual({ type: 'Python', text: 'x = 1' })
50+
expect(result.separator).toBe('---html---')
51+
})
4352
})

src/parser.ts

Lines changed: 116 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
const separatorRegex = /^\s*(-{3,})\s*html\s*\1\s*$/i
1+
const fenceSeparatorRegex = /^---\s*$/
2+
const legacySeparatorRegex = /^\s*(-{3,})\s*html\s*\1\s*$/i
23
const directiveStartRegex = /^\s*!/
34

45
export type HeaderNode = { type: 'Directive'; text: string } | { type: 'Python'; text: string }
@@ -13,34 +14,125 @@ export type ParsedDocument = {
1314
}
1415

1516
export type SplitSections = {
16-
header: string
17+
directives: string
1718
separator: string | null
19+
python: string
1820
html: string
1921
}
2022

23+
/**
24+
* Split a .wire file into its sections:
25+
* 1. Directives (optional) — lines starting with !
26+
* 2. Fenced Python (optional) — between --- separators
27+
* 3. Template HTML
28+
*
29+
* Also supports legacy ---html--- single separator format.
30+
*/
2131
export function splitPywireSections(text: string): SplitSections {
2232
const lines = text.split(/\r?\n/)
23-
const separatorIndex = lines.findIndex((line) => separatorRegex.test(line))
2433

25-
if (separatorIndex < 0) {
34+
// First, check for legacy ---html--- format (single separator)
35+
const legacyIdx = lines.findIndex((line) => legacySeparatorRegex.test(line))
36+
if (legacyIdx >= 0) {
2637
return {
27-
header: '',
38+
directives: '',
39+
separator: lines[legacyIdx],
40+
python: lines.slice(0, legacyIdx).join('\n'),
41+
html: lines.slice(legacyIdx + 1).join('\n'),
42+
}
43+
}
44+
45+
// Find all --- fence lines
46+
const fenceIndices: number[] = []
47+
for (let i = 0; i < lines.length; i++) {
48+
if (fenceSeparatorRegex.test(lines[i])) {
49+
fenceIndices.push(i)
50+
}
51+
}
52+
53+
// No fences — everything is either directives + html or just html
54+
if (fenceIndices.length < 2) {
55+
const firstNonDirectiveLine = lines.findIndex((line) => {
56+
const trimmed = line.trim()
57+
return trimmed.length > 0 && !directiveStartRegex.test(trimmed)
58+
})
59+
60+
// Check if the first non-empty, non-directive line looks like html or python
61+
// If there are no directives at all, everything is html
62+
const hasDirectives = lines.some((line) => directiveStartRegex.test(line.trim()))
63+
64+
if (!hasDirectives) {
65+
return {
66+
directives: '',
67+
separator: null,
68+
python: '',
69+
html: text,
70+
}
71+
}
72+
73+
// Has directives but no fence — split at first non-directive, non-empty line
74+
// that isn't part of a block directive
75+
let directiveEnd = 0
76+
let braceDepth = 0
77+
for (let i = 0; i < lines.length; i++) {
78+
const trimmed = lines[i].trim()
79+
braceDepth += countBraces(lines[i])
80+
81+
if (braceDepth > 0) {
82+
directiveEnd = i + 1
83+
continue
84+
}
85+
86+
if (directiveStartRegex.test(trimmed)) {
87+
directiveEnd = i + 1
88+
continue
89+
}
90+
91+
if (trimmed.length === 0 && directiveEnd === i) {
92+
directiveEnd = i + 1
93+
continue
94+
}
95+
96+
if (directiveEnd > 0 && trimmed.length > 0 && !directiveStartRegex.test(trimmed)) {
97+
break
98+
}
99+
}
100+
101+
return {
102+
directives: lines.slice(0, directiveEnd).join('\n'),
28103
separator: null,
29-
html: lines.join('\n'),
104+
python: '',
105+
html: lines.slice(directiveEnd).join('\n'),
30106
}
31107
}
32108

109+
// Two or more fences — content between first two fences is Python
110+
const openFence = fenceIndices[0]
111+
const closeFence = fenceIndices[1]
112+
33113
return {
34-
header: lines.slice(0, separatorIndex).join('\n'),
35-
separator: lines[separatorIndex],
36-
html: lines.slice(separatorIndex + 1).join('\n'),
114+
directives: lines.slice(0, openFence).join('\n'),
115+
separator: lines[openFence],
116+
python: lines.slice(openFence + 1, closeFence).join('\n'),
117+
html: lines.slice(closeFence + 1).join('\n'),
37118
}
38119
}
39120

40121
export function parsePywire(text: string): ParsedDocument {
41122
const sections = splitPywireSections(text)
42-
const headerLines = sections.header.length > 0 ? sections.header.split('\n') : []
43-
const headerNodes = parseHeader(headerLines)
123+
124+
const headerNodes: HeaderNode[] = []
125+
126+
// Parse directives
127+
if (sections.directives.trim().length > 0) {
128+
const directiveNodes = parseDirectives(sections.directives.split('\n'))
129+
headerNodes.push(...directiveNodes)
130+
}
131+
132+
// Parse python
133+
if (sections.python.trim().length > 0) {
134+
headerNodes.push({ type: 'Python', text: sections.python })
135+
}
44136

45137
return {
46138
type: 'Document',
@@ -52,45 +144,39 @@ export function parsePywire(text: string): ParsedDocument {
52144
}
53145
}
54146

55-
function parseHeader(lines: string[]): HeaderNode[] {
147+
function parseDirectives(lines: string[]): HeaderNode[] {
56148
const nodes: HeaderNode[] = []
57-
let currentPythonLines: string[] = []
58-
59-
const flushPython = () => {
60-
if (currentPythonLines.length === 0) {
61-
return
62-
}
63-
nodes.push({ type: 'Python', text: currentPythonLines.join('\n') })
64-
currentPythonLines = []
65-
}
66-
67149
let index = 0
150+
68151
while (index < lines.length) {
69152
const line = lines[index]
70-
if (!directiveStartRegex.test(line)) {
71-
currentPythonLines.push(line)
72-
index += 1
153+
const trimmed = line.trim()
154+
155+
if (trimmed.length === 0) {
156+
index++
73157
continue
74158
}
75159

76-
flushPython()
160+
if (!directiveStartRegex.test(trimmed)) {
161+
// Non-directive, non-empty line in directives section — skip
162+
index++
163+
continue
164+
}
77165

78166
const directiveLines: string[] = [line]
79167
let braceDepth = countBraces(line)
80-
index += 1
168+
index++
81169

82170
while (braceDepth > 0 && index < lines.length) {
83171
const nextLine = lines[index]
84172
directiveLines.push(nextLine)
85173
braceDepth += countBraces(nextLine)
86-
index += 1
174+
index++
87175
}
88176

89177
nodes.push({ type: 'Directive', text: directiveLines.join('\n') })
90178
}
91179

92-
flushPython()
93-
94180
return nodes
95181
}
96182

src/printer.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,19 @@ vi.mock('./utils/ruff-config.js', () => ({
1212
}))
1313

1414
describe('printPywire', () => {
15-
it('formats python header and html', () => {
16-
const input = `x=1\n\n---html---\n<div><span>{x}</span></div>\n`
15+
it('formats python header and html with --- fences', () => {
16+
const input = `---\nx=1\n---\n<div><span>{x}</span></div>\n`
1717
const doc = parsePywire(input)
1818
const output = printPywire({ getValue: () => doc }, { printWidth: 80 })
1919

20+
expect(output).toContain('---')
2021
expect(output).toContain('FORMATTED(x=1)')
2122
expect(output).toContain('<div>')
2223
expect(output).toContain('<span>{x}</span>')
2324
})
2425

2526
it('preserves interpolations while formatting html', () => {
26-
const input = `---html---\n<div>{f"Hi {name}"}</div>\n`
27+
const input = `<div>{f"Hi {name}"}</div>\n`
2728
const doc = parsePywire(input)
2829
const output = printPywire({ getValue: () => doc }, { printWidth: 80 })
2930

0 commit comments

Comments
 (0)