-
Notifications
You must be signed in to change notification settings - Fork 4
/
Copy pathbuild.fsx
210 lines (191 loc) · 9.03 KB
/
build.fsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// This script creates the full spec doc with freshly numbered section headers, adjusted reference links and ToC.
// Note that the reference links do work on github and in VS Code, but not with all other markdown dialects. For
// releases (which will probably be html or pdf), a conversion tool must be used that preserves the links, or else
// this build script must be updated to add proper name tags to the headings.
// Configuration of file locations and some document elements
let sourceDir = "spec"
let outDir = "artifacts"
let catalogPath = $"{sourceDir}/Catalog.json"
let fullDocName = "spec"
let outChapterDir = $"{outDir}/chapters"
let versionPlaceholder () = [$"_This version was created from sources on {System.DateTime.Now}_"; ""]
let tocHeader = [""; "# Table of Contents"]
open System
open System.Text.RegularExpressions
open System.Text.Json
open System.IO
type Chapter = {name: string; lines: string list}
type Sources = {frontMatter: Chapter; clauses: Chapter list}
type Catalog = {FrontMatter: string; MainBody: string list; Annexes: string list}
type FilenameHandling = KeepFilename | DiscardFilename
type BuildState = {
chapterName: string
lineNumber: int
sectionNumber: int list
inCodeBlock: bool
toc: Map<int list, string>
errors: string list
}
type BuildError =
| IoFailure of string
| DocumentErrors of string list
let initialState = {
chapterName = ""
lineNumber = 0
sectionNumber = []
inCodeBlock = false
toc = Map.empty
errors = []
}
let readSources () =
try
use catalogStream = File.OpenRead catalogPath
let catalog = JsonSerializer.Deserialize<Catalog> catalogStream
let getChapter name = {name = name; lines = File.ReadAllLines($"{sourceDir}/{name}.md") |> Array.toList}
let clauses = catalog.MainBody |> List.map getChapter
let frontMatter = getChapter catalog.FrontMatter
let totalChapters = clauses.Length + 1
let totalLines = List.sumBy (_.lines >> List.length) clauses + frontMatter.lines.Length
printfn $"read {totalChapters} files with a total of {totalLines} lines"
Ok {frontMatter = frontMatter; clauses = clauses}
with e ->
Error(IoFailure e.Message)
let writeArtifacts (fullDoc, chapters) =
try
if not <| Directory.Exists outDir then
Directory.CreateDirectory outDir |> ignore
let fullDocPath = $"{outDir}/{fullDocName}.md"
File.WriteAllLines(fullDocPath, fullDoc.lines)
printfn $"created {fullDocPath}"
if not <| Directory.Exists outChapterDir then
Directory.CreateDirectory outChapterDir |> ignore
for chapter in chapters do
let chapterPath = $"{outChapterDir}/{chapter.name}.md"
File.WriteAllLines(chapterPath, chapter.lines)
printfn $"created {List.length chapters} chapters in {outChapterDir}"
Ok()
with e ->
Error(IoFailure e.Message)
let sectionText sectionNumber =
$"""{List.head sectionNumber}.{List.tail sectionNumber |> List.map string |> String.concat "."}"""
let newSection level prevSection =
let rec newSectionR prevSectionR =
match prevSectionR with
| [] -> [1]
| h :: t when prevSectionR.Length = level -> (h + 1) :: t
| _ :: t when prevSectionR.Length > level -> newSectionR t
| _ when prevSectionR.Length = level - 1 -> 1 :: prevSectionR
| _ -> []
newSectionR (List.rev prevSection) |> List.rev
let kebabCase (s: string) =
let convertChar c =
if Char.IsAsciiLetterLower c || c = '-' || Char.IsAsciiDigit c then Some c
elif Char.IsAsciiLetterUpper c then Some(Char.ToLower c)
elif c = ' ' then Some '-'
else None
s |> Seq.choose convertChar |> Seq.toArray |> String
let mkError state msg = $"{state.chapterName}.md({state.lineNumber}): {msg}"
let checkCodeBlock state line =
let m = Regex.Match(line, " *```(.*)")
if not m.Success then
state
else if state.inCodeBlock then
{state with inCodeBlock = false}
else
let infoString = m.Groups[1].Value
let validInfoStrings = ["fsgrammar"; "fsharp"; "csharp"; "fsother"]
if not <| List.contains infoString validInfoStrings then
let validFences = validInfoStrings |> List.map ((+) "```") |> String.concat ", "
let msg = $"starting code block fences must be one of {validFences}"
{state with inCodeBlock = true; errors = (mkError state msg) :: state.errors}
else
{state with inCodeBlock = true}
let renumberIfHeaderLine state line =
let state = {state with lineNumber = state.lineNumber + 1}
let state = checkCodeBlock state line
let m = Regex.Match(line, "^(#+) +(.*)")
if state.inCodeBlock || not m.Success then
line, state
else
let headerPrefix = m.Groups[1].Value
let level = headerPrefix.Length
let heading = m.Groups[2].Value
let m = Regex.Match(heading, "^\d")
if m.Success then
let msg = "Headers must not start with digits"
line, {state with errors = (mkError state msg) :: state.errors}
else
let sectionNumber = newSection level state.sectionNumber
if sectionNumber.IsEmpty then
let msg = $"The header level jumps from {state.sectionNumber.Length} to {level}"
line, {state with errors = (mkError state msg) :: state.errors}
else
let headerLine = $"{headerPrefix} {sectionText sectionNumber} {heading}"
headerLine, {state with sectionNumber = sectionNumber; toc = state.toc.Add(sectionNumber, heading)}
let renumberClause state clause =
let state = {state with chapterName = clause.name; lineNumber = 0}
let outLines, state = (state, clause.lines) ||> List.mapFold renumberIfHeaderLine
{clause with lines = outLines}, state
let tocLines toc =
let tocLine (number, heading) =
let sText = sectionText number
let anchor = $"#{kebabCase sText}-{kebabCase heading}"
String.replicate (number.Length - 1) " " + $"- [{sText} {heading}]({anchor})"
toc |> Map.toList |> List.map tocLine
let adjustLinks fileNameHandling state line =
let state = {state with lineNumber = state.lineNumber + 1}
let rec adjustLinks' state lineFragment =
let m = Regex.Match(lineFragment, "(.*)\[§(\d+\.[\.\d]*)\]\(([^#)]+)#([^)]+)\)(.*)")
if m.Success then
let pre, sText, filename, anchor, post =
m.Groups[1].Value, m.Groups[2].Value, m.Groups[3].Value, m.Groups[4].Value, m.Groups[5].Value
match Map.tryPick (fun n heading -> if sectionText n = sText then Some heading else None) state.toc with
| Some _ ->
let post', state' = adjustLinks' state post // recursive check for multiple links in a line
let adjustedLine =
match fileNameHandling with
| KeepFilename -> $"{pre}[§{sText}]({filename}#{kebabCase sText}-{anchor}){post'}"
| DiscardFilename -> $"{pre}[§{sText}](#{kebabCase sText}-{anchor}){post'}"
adjustedLine, state'
| None ->
let msg = $"unknown link target {filename}#{anchor} ({sText})"
lineFragment, {state with errors = mkError state msg :: state.errors}
else
lineFragment, state
adjustLinks' state line
let processSources chapters =
// Add section numbers to the headers, collect the ToC information, and check for correct code fence info strings
let (processedChapters, state) = (initialState, chapters.clauses) ||> List.mapFold renumberClause
// Create the ToC and build the complete spec
let allLines =
List.concat [
chapters.frontMatter.lines
versionPlaceholder ()
tocHeader
tocLines state.toc
List.collect _.lines processedChapters
]
// Adjust the reference links to point to the correct header of the new spec
let (allLines, _) =
({state with chapterName = fullDocName; lineNumber = 0}, allLines)
||> List.mapFold (adjustLinks DiscardFilename)
let fullDoc = {name = fullDocName; lines = allLines}
let adjustChapterLinks chapter =
let adjustedLines, _ =
({state with chapterName = chapter.name; lineNumber = 0}, chapter.lines)
||> List.mapFold (adjustLinks KeepFilename)
{name = chapter.name; lines = adjustedLines}
let frontMatterLines = chapters.frontMatter.lines @ versionPlaceholder()
let adjustedChapters = processedChapters |> List.map adjustChapterLinks
let outputChapters = {name = "index"; lines = frontMatterLines} :: adjustedChapters
if not state.errors.IsEmpty then Error(DocumentErrors(List.rev state.errors)) else Ok(fullDoc, outputChapters)
let build () =
match readSources () |> Result.bind processSources |> Result.bind writeArtifacts with
| Ok() -> 0
| Error(IoFailure msg) ->
printfn $"IO error: %s{msg}"
1
| Error(DocumentErrors errors) ->
errors |> List.iter (printfn "Error: %s")
2
build ()