-
Notifications
You must be signed in to change notification settings - Fork 171
Expand file tree
/
Copy pathrenderBuiltinFunctionsTable.ts
More file actions
184 lines (172 loc) · 10.3 KB
/
Copy pathrenderBuiltinFunctionsTable.ts
File metadata and controls
184 lines (172 loc) · 10.3 KB
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
/**
* @license
* Copyright (c) 2025 Handsoncode. All rights reserved.
*/
import {slugify as vuepressSlugify} from '@vuepress/shared-utils'
import {CUSTOM_FUNCTION_CATEGORY, DocumentedFunctionCategory, FUNCTION_CATEGORIES, FunctionDetails, FunctionListEntry} from '../src/interpreter/functionMetadata/FunctionDescription'
import {formatFunctionSyntax} from './formatFunctionSyntax'
/** A pair of markers delimiting one autogenerated region of the guide page. */
interface RegionMarkers {
/** Opening marker; the generated region begins on the next line. */
start: string,
/** Closing marker; anything the template puts after it is preserved verbatim. */
end: string,
}
/** Markers around the page's table of contents: one bullet per rendered `### <Category>` section. */
const CATEGORY_LIST_MARKERS: RegionMarkers = {
start: '<!-- AUTOGENERATED:CATEGORIES:START -->',
end: '<!-- AUTOGENERATED:CATEGORIES:END -->',
}
/**
* Markers around the `### <Category>` sections. The template currently ends on the closing marker, so there is no tail
* region: a description needing a footnote must inline the note (as EDATE and EOMONTH do with their OpenDocument
* caveat) rather than emit a `[^ref]` that has nowhere to be defined.
*/
const FUNCTIONS_TABLE_MARKERS: RegionMarkers = {
start: '<!-- AUTOGENERATED:FUNCTIONS:START -->',
end: '<!-- AUTOGENERATED:FUNCTIONS:END -->',
}
/**
* The very slugifier VuePress feeds to `markdown-it-anchor`, so a table-of-contents link can never disagree with the
* id of the heading it points at: `@vuepress/markdown` passes this function as the anchor plugin's `slugify` unless
* `markdown.slugify` is overridden, and `docs/.vuepress/config.js` does not override it. Hand-rolling a
* lowercase-and-hyphenate transform instead would drift for the first category label containing punctuation, an
* ampersand or a non-ASCII letter, and drift silently — the page would still build, only its links would lead nowhere.
*
* The cast supplies the types the package fails to: `@vuepress/shared-utils` 1.9 ships its declarations under `types/`
* while its `package.json` points `types` at a `lib/index.d.ts` it does not publish, so the import resolves to `any`.
* Only this dev script depends on VuePress, never the shipped bundle (`tsconfig.json` `include` is `["src"]`).
*/
const slugifyHeading = vuepressSlugify as (heading: string) => string
/** The generated regions of the built-in functions guide page, both produced by one pass over the categories. */
export interface BuiltinFunctionsMarkdown {
/** The page's table of contents: a bullet linking to each rendered section, in page order. */
categoryList: string,
/** The `### <Category>` sections, each holding that category's function table. */
functionSections: string,
}
/**
* Escapes the table-breaking pipe and the backslash that would otherwise consume the pipe's own
* escape; all other markdown (links, code, `<br>`, footnotes) is verbatim. Both are replaced in a
* single pass, so an input backslash can never end up escaping a backslash we just inserted
* (escaping `|` alone leaves `a\|b` rendering as a cell break — CodeQL js/incomplete-sanitization).
*
* Line breaks need no handling: a catalogue string spells them as a literal `<br>` (see
* `FunctionDoc.shortDescription`), so none of the strings that reach here — the localized name, the shortDescription,
* or the syntax built from the parameter names — can contain a raw newline. A newline-to-`<br>` pass would be dead
* code; keep the `<br>` convention in the catalogue instead.
*
* @param {string} text - the cell text to escape
*/
function escapeCell(text: string): string {
return text.replace(/[\\|]/g, '\\$&')
}
/**
* Renders the generated regions of the built-in functions guide page as markdown: one `### Category` section per
* category that has entries (in the canonical `FUNCTION_CATEGORIES` order), each a 3-column table
* (Function ID | Description | Syntax), plus the table of contents that links to those sections. Both regions come
* out of the same pass, so a bullet exists if and only if the section it points at was rendered: a category the
* catalogue leaves empty gets neither, and the contents list can never link to an anchor no heading emits. Rows are
* sorted by localized name with `localeCompare`, with the canonical name as a stable tiebreaker for entries that
* share a localized name (mirrors `HyperFormula.buildAvailableFunctions`); the order therefore follows the collation
* rules of the host that generates the page. Reads only the passed entries + details provider, so it is independent
* of how the catalogue is stored (forward-compatible with a per-function file split).
*
* Only the documented categories get a section, so every entry passed in must declare one: the page is generated from
* the built-in catalogue, where `category` is a [[DocumentedFunctionCategory]] by type. A `'Custom'` entry is rejected
* rather than skipped — silently dropping it would leave the page short of a row while the printed function total,
* which is computed independently in `docs/.vuepress/config.js`, still claimed it.
*
* @param {FunctionListEntry[]} entries - the function set to document (e.g. an engine's `getAvailableFunctions`)
* @param {(canonicalName: string) => FunctionDetails | undefined} detailsFor - resolves a function's details
* @returns {BuiltinFunctionsMarkdown} the markdown of both regions (no surrounding markers, LF, trailing newline)
* @throws {Error} when a listed entry has no resolvable details
* @throws {Error} when a listed entry reports a category with no section on the page (i.e. `'Custom'`)
* @throws {Error} when two rendered categories slugify to the same anchor, which would make their links ambiguous
*/
export function renderBuiltinFunctionsMarkdown(
entries: FunctionListEntry[],
detailsFor: (canonicalName: string) => FunctionDetails | undefined,
): BuiltinFunctionsMarkdown {
const byCategory = new Map<DocumentedFunctionCategory, FunctionListEntry[]>()
for (const entry of entries) {
if (entry.category === CUSTOM_FUNCTION_CATEGORY) {
throw new Error(`Function "${entry.canonicalName}" reports category "${entry.category}", which has no section on the generated page; the page is generated from the built-in catalogue only.`)
}
const bucket = byCategory.get(entry.category) ?? []
bucket.push(entry)
byCategory.set(entry.category, bucket)
}
const bullets: string[] = []
const sections: string[] = []
const categoryBySlug = new Map<string, DocumentedFunctionCategory>()
for (const category of FUNCTION_CATEGORIES) {
const bucket = byCategory.get(category)
if (bucket === undefined || bucket.length === 0) {
continue
}
const slug = slugifyHeading(category)
const categorySharingSlug = categoryBySlug.get(slug)
if (categorySharingSlug !== undefined) {
throw new Error(`Categories "${categorySharingSlug}" and "${category}" both slugify to the anchor "#${slug}", so the table of contents cannot link to them separately.`)
}
categoryBySlug.set(slug, category)
bullets.push(`- [${category}](#${slug})`)
bucket.sort((a, b) => a.localizedName.localeCompare(b.localizedName) || a.canonicalName.localeCompare(b.canonicalName))
const rows = bucket.map(entry => {
const details = detailsFor(entry.canonicalName)
if (details === undefined) {
throw new Error(`No details for listed function "${entry.canonicalName}"`)
}
const syntax = formatFunctionSyntax(details.localizedName, details.parameters, details.repeatLastArgs)
const anchor = `<a id="${entry.canonicalName}"></a>`
// `shortDescription` is optional only because a custom function has none, and those are rejected above, so
// the fallback is unreachable for the catalogue this page is generated from.
return `| ${anchor}${escapeCell(entry.localizedName)} | ${escapeCell(entry.shortDescription ?? '')} | ${escapeCell(syntax)} |`
})
sections.push(`### ${category}\n\n| Function ID | Description | Syntax |\n|:---|:---|:---|\n${rows.join('\n')}`)
}
return {
categoryList: bullets.join('\n') + '\n',
functionSections: sections.join('\n\n') + '\n',
}
}
/**
* Replaces the content between one pair of autogenerated markers, preserving everything outside them (intro prose,
* footnote definitions). Fails loud on a malformed marker state rather than clobbering the file.
*
* @param {string} fileContent - the full current file content
* @param {RegionMarkers} markers - the marker pair delimiting the region to replace
* @param {string} generatedSection - the markdown to place between the markers
* @returns {string} the updated file content
* @throws {Error} when either marker is missing, duplicated, or out of order
*/
function spliceGeneratedRegion(fileContent: string, markers: RegionMarkers, generatedSection: string): string {
const startCount = fileContent.split(markers.start).length - 1
const endCount = fileContent.split(markers.end).length - 1
if (startCount !== 1 || endCount !== 1) {
throw new Error(`Expected exactly one ${markers.start} and one ${markers.end}, found ${startCount}/${endCount}`)
}
const startIdx = fileContent.indexOf(markers.start)
const endIdx = fileContent.indexOf(markers.end)
if (endIdx < startIdx) {
throw new Error(`${markers.end} appears before ${markers.start}`)
}
const before = fileContent.slice(0, startIdx + markers.start.length)
const after = fileContent.slice(endIdx)
return `${before}\n${generatedSection}\n${after}`
}
/**
* Splices both generated regions into the template: the table of contents and the function sections. Both go through
* the same marker-driven helper, so a malformed marker state in either region fails the build rather than writing a
* half-updated page.
*
* @param {string} template - the full template file content
* @param {BuiltinFunctionsMarkdown} markdown - the generated regions (from renderBuiltinFunctionsMarkdown)
* @returns {string} the generated page content
* @throws {Error} when any of the four markers is missing, duplicated, or out of order
*/
export function spliceBuiltinFunctionsMarkdown(template: string, markdown: BuiltinFunctionsMarkdown): string {
const withCategoryList = spliceGeneratedRegion(template, CATEGORY_LIST_MARKERS, markdown.categoryList)
return spliceGeneratedRegion(withCategoryList, FUNCTIONS_TABLE_MARKERS, markdown.functionSections)
}