@redpanda-data/docs-extensions-and-macros 5.56.1 → 5.57.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/bin/doc-tools.js CHANGED
@@ -2132,6 +2132,11 @@ automation
2132
2132
  * bullets — it is a CANDIDATE for the curation step that rewrites voice, adds
2133
2133
  * `Area::` labels, normalizes units, and phrases CVEs.
2134
2134
  *
2135
+ * In phase 1, a backticked name that matches a property in the release
2136
+ * series' redpanda-properties-<tag>.json becomes a `prop:<name>[link=true]` link. The
2137
+ * file is found in modules/reference/attachments/ unless --properties-file
2138
+ * names one. With no file, names stay monospace and a warning is logged.
2139
+ *
2135
2140
  * @why
2136
2141
  * Release notes are the one reference surface whose source of truth is
2137
2142
  * engineer-authored prose rather than code, so the deterministic part
@@ -2164,6 +2169,7 @@ automation
2164
2169
  .option('--section-file <file>', 'Phase 2: a curated section (from the sm-release-notes skill) to insert (relative to repo root, must stay inside the repository)')
2165
2170
  .option('--date <date>', 'Authored release date as YYYY-MM-DD (required with --body; confirmed on the PR)')
2166
2171
  .option('--page <file>', 'Target release-notes page (relative to repo root, must stay inside the repository)', 'modules/reference/pages/releases/redpanda.adoc')
2172
+ .option('--properties-file <file>', 'Phase 1: property JSON whose names become prop: links (relative to repo root, must stay inside the repository). Default: the newest redpanda-properties-<tag>.json in modules/reference/attachments/ for the tag\'s release series')
2167
2173
  .option('--section-only', 'Phase 1 only: print the candidate section and do not read or modify the page')
2168
2174
  .option('--dry-run', 'Print output to stdout instead of writing the page')
2169
2175
  .action(async (options) => {
@@ -2172,6 +2178,11 @@ automation
2172
2178
  buildReleaseSection,
2173
2179
  isGaTag,
2174
2180
  } = require('../tools/redpanda-release-notes/generate-release-notes.js')
2181
+ const {
2182
+ ATTACHMENTS_DIR,
2183
+ findPropertiesFile,
2184
+ loadPropertyNames,
2185
+ } = require('../tools/redpanda-release-notes/property-names.js')
2175
2186
  try {
2176
2187
  const repoRoot = findRepoRoot()
2177
2188
 
@@ -2192,6 +2203,19 @@ automation
2192
2203
  return abs
2193
2204
  }
2194
2205
 
2206
+ // Phase 1 links known property names. An explicit --properties-file must
2207
+ // exist; a missing default only means names stay monospace.
2208
+ const readPropertyNames = () => {
2209
+ const file = options.propertiesFile
2210
+ ? readInside(options.propertiesFile, '--properties-file')
2211
+ : findPropertiesFile(path.join(repoRoot, ATTACHMENTS_DIR), options.tag)
2212
+ if (!file) {
2213
+ console.warn(`[release-notes] WARN: no property file for ${options.tag} in ${ATTACHMENTS_DIR}; property names stay monospace.`)
2214
+ return undefined
2215
+ }
2216
+ return loadPropertyNames(file)
2217
+ }
2218
+
2195
2219
  // Phase-1 candidate preview: emit the candidate for the curation step. No
2196
2220
  // page is read or written — this is the LLM step's input, nothing else.
2197
2221
  if (options.sectionOnly) {
@@ -2203,7 +2227,9 @@ automation
2203
2227
  return
2204
2228
  }
2205
2229
  const body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
2206
- const section = buildReleaseSection({ body, version: options.tag, date: options.date })
2230
+ const section = buildReleaseSection({
2231
+ body, version: options.tag, date: options.date, propertyNames: readPropertyNames(),
2232
+ })
2207
2233
  process.stdout.write(section)
2208
2234
  return
2209
2235
  }
@@ -2213,6 +2239,7 @@ automation
2213
2239
  const genArgs = { tag: options.tag, date: options.date, pageContent }
2214
2240
  if (hasBody) {
2215
2241
  genArgs.body = fs.readFileSync(readInside(options.body, '--body'), 'utf8')
2242
+ genArgs.propertyNames = readPropertyNames()
2216
2243
  } else {
2217
2244
  genArgs.section = fs.readFileSync(readInside(options.sectionFile, '--section-file'), 'utf8')
2218
2245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redpanda-data/docs-extensions-and-macros",
3
- "version": "5.56.1",
3
+ "version": "5.57.0",
4
4
  "description": "Antora extensions and macros developed for Redpanda documentation.",
5
5
  "keywords": [
6
6
  "antora",
@@ -132,48 +132,90 @@ function insertReleaseSection(pageContent, sectionText) {
132
132
  }
133
133
 
134
134
  /**
135
- * Reorders the entries within each `=== category` of a curated section so that
136
- * entries sharing an `Area::` label sit together. Areas are ordered
137
- * ALPHABETICALLY (case-insensitive), so a given area sits in the same place on
138
- * every release instead of moving with the source's merge order; entries keep
139
- * their source order within an area, so the sort is stable. The label is
140
- * repeated per entry (not merged into one heading), so the page format is
141
- * unchanged — only the order of entries within a category changes. Running it on
142
- * an already-clustered section is a no-op, so it is safe to apply on every
143
- * release and on a backfill.
135
+ * Groups the entries within each `=== category` of a curated section by their
136
+ * `Area::` label. Areas are ordered ALPHABETICALLY (case-insensitive), so a given
137
+ * area sits in the same place on every release instead of moving with the
138
+ * source's merge order; entries keep their source order within an area, so the
139
+ * sort is stable. Each area is written ONCE per category, as the definition-list
140
+ * term, with its entries as a bulleted list under it:
144
141
  *
145
- * Only the clean single-line `Area:: description` definition-list form is
146
- * reordered. A category whose entries are not all of that form — a phase-1
147
- * candidate's plain `*` bullets, or a multi-line entry — is left in its original
148
- * order, so this is a safe no-op on anything it does not recognize. A curated
149
- * category (one with at least one `Area::` entry) that bails logs a warning, since
150
- * it then ships unclustered. Area keys are trimmed for grouping, so `Kafka API`
151
- * and `Kafka API ` are one area. Headings and any prose outside a category are
152
- * passed through untouched.
142
+ * Cloud Topics::
143
+ * * First entry.
144
+ * * Second entry.
145
+ *
146
+ * An area with one entry still gets a one-item list. Input entries may be in
147
+ * either form: single-line `Area:: description` (what curation writes) or an
148
+ * already-grouped `Area::` paragraph with single-line `* ` items. A category can
149
+ * mix the two. Running it on its own output is a no-op, so it is safe to apply on
150
+ * every release, on a backfill, and on a section grouped by hand.
151
+ *
152
+ * A category with any other kind of entry (a phase-1 candidate's plain `*`
153
+ * bullets, or a multi-line entry) is not grouped, so this is a safe no-op on
154
+ * anything it does not recognize. Its entries keep their order and text. The
155
+ * one change is that single-line entries on adjacent lines come out with a
156
+ * blank line between them, which AsciiDoc renders the same way. Line endings
157
+ * in the output are LF. A curated category (one with at least
158
+ * one `Area::` entry) that is left as it is logs a warning, because it then
159
+ * ships ungrouped. Area keys are trimmed, so `Kafka API` and `Kafka API ` are one
160
+ * area, written as the trimmed label. Headings and any prose outside a category
161
+ * are passed through untouched.
153
162
  *
154
163
  * @param {string} sectionText - A rendered release section.
155
- * @return {string} The section with each category's entries clustered by area,
164
+ * @return {string} The section with each category's entries grouped by area,
156
165
  * ending in a single newline.
157
166
  */
158
167
  function clusterSectionByArea(sectionText) {
159
- // The area label is the text up to the first `:: ` (double-colon + space),
160
- // which is the AsciiDoc definition-list term separator. A lazy match stops at
161
- // that first separator, so a `::` later in the description is left alone. A
162
- // leading list marker (`*`, `-`, `.`), at column zero or indented, is rejected
163
- // so a plain bullet that happens to carry a `::` — a phase-1 candidate line —
164
- // is not mistaken for an area entry; its category then bails and is left
165
- // untouched.
166
- const ENTRY_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s/;
168
+ // A single-line entry: the area label is the text up to the first `:: `
169
+ // (double-colon + space), the AsciiDoc definition-list term separator. A lazy
170
+ // match stops at that first separator, so a `::` later in the description is
171
+ // left alone. A leading list marker (`*`, `-`, `.`), at column zero or
172
+ // indented, is rejected so a plain bullet that happens to carry a `::` (a
173
+ // phase-1 candidate line) is not mistaken for an area entry.
174
+ const ENTRY_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s+(\S.*)$/;
175
+ // A grouped area: a term line with nothing after the `::`.
176
+ const TERM_RE = /^(?!\s*[*.\-]+\s)(.+?)::\s*$/;
177
+ // An item of a grouped area: one `* ` bullet on one line.
178
+ const ITEM_RE = /^\* (\S.*)$/;
179
+
180
+ // Parses one paragraph into [area, items], or null if it is not a recognized
181
+ // entry. A grouped paragraph must be a term line followed only by items.
182
+ const parseEntry = (para) => {
183
+ if (para.length === 1) {
184
+ const m = para[0].match(ENTRY_RE);
185
+ if (m) return [m[1].trim(), [m[2]]];
186
+ }
187
+ const t = para[0].match(TERM_RE);
188
+ if (t && para.length > 1) {
189
+ const items = [];
190
+ for (const line of para.slice(1)) {
191
+ const i = line.match(ITEM_RE);
192
+ if (!i) return null;
193
+ items.push(i[1]);
194
+ }
195
+ return [t[1].trim(), items];
196
+ }
197
+ return null;
198
+ };
199
+ const looksCurated = (para) => ENTRY_RE.test(para[0]) || TERM_RE.test(para[0]);
167
200
 
168
201
  // Tokenize into paragraphs: maximal runs of non-blank lines. In this format
169
- // every heading and every entry is one such paragraph.
170
- const lines = String(sectionText).replace(/\n+$/, '').split('\n');
202
+ // every heading, every single-line entry, and every grouped area is one such
203
+ // paragraph. Single-line entries on adjacent lines, with no blank line
204
+ // between them, are valid AsciiDoc too, so each one starts its own paragraph.
205
+ // A grouped area's term line does not match ENTRY_RE, so its bullets stay in
206
+ // one paragraph with it. CRLF and CR line endings become LF first: `.` in
207
+ // the patterns above does not match `\r`, so a CRLF line would match nothing.
208
+ const lines = String(sectionText).replace(/\r\n?/g, '\n').replace(/\n+$/, '').split('\n');
171
209
  const paragraphs = [];
172
210
  let current = [];
173
211
  for (const line of lines) {
174
212
  if (/^\s*$/.test(line)) {
175
213
  if (current.length) { paragraphs.push(current); current = []; }
176
214
  } else {
215
+ if (current.length === 1 && ENTRY_RE.test(current[0]) && ENTRY_RE.test(line)) {
216
+ paragraphs.push(current);
217
+ current = [];
218
+ }
177
219
  current.push(line);
178
220
  }
179
221
  }
@@ -181,38 +223,35 @@ function clusterSectionByArea(sectionText) {
181
223
 
182
224
  const out = [];
183
225
  let categoryEntries = null; // entry paragraphs of the open `=== category`
184
- let categoryBail = false; // the open category has an unrecognized entry
185
226
  let categoryTitle = ''; // the open category's heading text, for warnings
186
227
 
187
228
  const flushCategory = () => {
188
229
  if (!categoryEntries) return;
189
- if (categoryBail) {
190
- // A phase-1 bullet category is expected to bail. A curated one (it has at
191
- // least one `Area::` entry) bailing means some entry spans several lines,
192
- // so the whole category ships unclustered — say so rather than fail quietly.
193
- if (categoryEntries.some((entry) => ENTRY_RE.test(entry[0]))) {
194
- console.warn(`${LOG_TAG} WARN: "${categoryTitle}" not grouped by area: an entry is not a single-line \`Area:: description\` (for example a \`+\` continuation or a code block).`);
230
+ const parsed = categoryEntries.map(parseEntry);
231
+ if (parsed.some((e) => e === null)) {
232
+ // A phase-1 bullet category is expected to be left as it is. A curated one
233
+ // (it has at least one `Area::` entry) left as it is means some entry is
234
+ // neither form, so the whole category ships ungrouped. Say so rather than
235
+ // fail quietly.
236
+ if (categoryEntries.some(looksCurated)) {
237
+ console.warn(`${LOG_TAG} WARN: "${categoryTitle}" not grouped by area: an entry is neither a single-line \`Area:: description\` nor an \`Area::\` term with single-line \`* \` items (for example a \`+\` continuation or a code block).`);
195
238
  }
196
239
  for (const entry of categoryEntries) out.push(entry);
197
240
  } else {
198
241
  const order = [];
199
242
  const groups = new Map();
200
- for (const entry of categoryEntries) {
201
- // Trimmed, so a stray space before `::` does not split one area in two.
202
- // Only the grouping key is trimmed; the entry text is emitted verbatim.
203
- const key = entry[0].match(ENTRY_RE)[1].trim();
243
+ for (const [key, items] of parsed) {
204
244
  if (!groups.has(key)) { groups.set(key, []); order.push(key); }
205
- groups.get(key).push(entry);
245
+ groups.get(key).push(...items);
206
246
  }
207
247
  // Areas alphabetical (case-insensitive), so an area lands in the same
208
248
  // place on every release; entries keep their source order within an area.
209
249
  order.sort((a, b) => a.localeCompare(b, 'en', { sensitivity: 'base' }));
210
250
  for (const key of order) {
211
- for (const entry of groups.get(key)) out.push(entry);
251
+ out.push([`${key}::`, ...groups.get(key).map((item) => `* ${item}`)]);
212
252
  }
213
253
  }
214
254
  categoryEntries = null;
215
- categoryBail = false;
216
255
  };
217
256
 
218
257
  for (const para of paragraphs) {
@@ -222,7 +261,6 @@ function clusterSectionByArea(sectionText) {
222
261
  flushCategory();
223
262
  out.push(para);
224
263
  categoryEntries = [];
225
- categoryBail = false;
226
264
  categoryTitle = first.replace(/^===\s+/, '').trim();
227
265
  } else if (/^==\s/.test(first)) {
228
266
  // The release heading (or any other top-level heading) closes the open
@@ -232,7 +270,6 @@ function clusterSectionByArea(sectionText) {
232
270
  categoryEntries = null;
233
271
  } else if (categoryEntries) {
234
272
  categoryEntries.push(para);
235
- if (para.length !== 1 || !ENTRY_RE.test(para[0])) categoryBail = true;
236
273
  } else {
237
274
  // Prose outside any category (for example an intro paragraph): untouched.
238
275
  out.push(para);
@@ -250,10 +287,11 @@ function clusterSectionByArea(sectionText) {
250
287
  * @param {string} options.body - The raw rpchangelog release body markdown.
251
288
  * @param {string} options.version - The release version (`v26.2.3` or `26.2.3`).
252
289
  * @param {string} options.date - The release date as `YYYY-MM-DD`.
290
+ * @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros.
253
291
  * @return {string} The rendered AsciiDoc section.
254
292
  */
255
- function buildReleaseSection({ body, version, date }) {
256
- const { sections } = parseReleaseBody(body);
293
+ function buildReleaseSection({ body, version, date, propertyNames }) {
294
+ const { sections } = parseReleaseBody(body, { propertyNames });
257
295
  return renderReleaseSection({ version, date, sections });
258
296
  }
259
297
 
@@ -306,9 +344,10 @@ function assertSectionMatchesTag(section, version) {
306
344
  * @param {string} options.tag - The release tag (`v26.2.3`).
307
345
  * @param {string} [options.date] - The authored release date as `YYYY-MM-DD` (phase 1 only).
308
346
  * @param {string} options.pageContent - The current target page content.
347
+ * @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros (phase 1 only).
309
348
  * @return {{status: 'ok'|'skipped', reason?: string, section?: string, content?: string}}
310
349
  */
311
- function generateReleaseNotes({ body, section, tag, date, pageContent }) {
350
+ function generateReleaseNotes({ body, section, tag, date, pageContent, propertyNames }) {
312
351
  if (body == null && section == null) {
313
352
  throw new Error('Provide either body (phase 1) or section (phase 2).');
314
353
  }
@@ -337,7 +376,7 @@ function generateReleaseNotes({ body, section, tag, date, pageContent }) {
337
376
  // have to, and so a hand-written section is normalized the same way.
338
377
  finalSection = clusterSectionByArea(section);
339
378
  } else {
340
- finalSection = buildReleaseSection({ body, version, date });
379
+ finalSection = buildReleaseSection({ body, version, date, propertyNames });
341
380
  }
342
381
  const content = insertReleaseSection(pageContent, finalSection);
343
382
  return { status: 'ok', section: finalSection, content };
@@ -8,7 +8,8 @@
8
8
  //
9
9
  // This module does ONLY the deterministic, no-judgment work: split into
10
10
  // sections, join wrapped bullet lines, strip source noise, unescape backticks,
11
- // de-duplicate backport repeats, and normalize category order/case. It does NOT
11
+ // link known configuration properties, de-duplicate backport repeats, and
12
+ // normalize category order/case. It does NOT
12
13
  // rewrite voice, add `Area::` labels, normalize units, phrase CVEs, or drop
13
14
  // bodyless entries — those need editorial judgment and belong to the curation
14
15
  // step that runs on the generated page.
@@ -88,18 +89,43 @@ function stripLeadingIssueRef(text) {
88
89
  * the editorial curation step.
89
90
  *
90
91
  * @param {string} raw - The joined raw bullet text.
92
+ * @param {Object} [options]
93
+ * @param {Set<string>} [options.propertyNames] - Known property names to link (see linkProperties).
91
94
  * @return {string} The cleaned entry text.
92
95
  */
93
- function cleanEntry(raw) {
96
+ function cleanEntry(raw, { propertyNames } = {}) {
94
97
  let text = raw.replace(/\s+/g, ' ').trim();
95
98
  text = stripLeadingIssueRef(text);
96
99
  text = stripAttribution(text);
97
100
  text = text.replace(/\\`/g, '`');
101
+ text = linkProperties(text, propertyNames);
98
102
  text = normalizeUnits(text);
99
103
  text = escapeAsciiDocBraces(text);
100
104
  return text.trim();
101
105
  }
102
106
 
107
+ /**
108
+ * Rewrites a backticked configuration property name as a `prop:` macro with
109
+ * `link=true`, so the page links it to the property reference. Without
110
+ * `link=true`, the macro shows only a hover tooltip. Engineers cannot write `prop:` in
111
+ * the source, because nothing resolves it outside the docs build, so this is
112
+ * where the link is added.
113
+ *
114
+ * Only an exact match for a known name is rewritten: `` `retention.ms` `` is,
115
+ * `` `retention.ms=1000` `` and `` `health_manager` `` are not. A name with no
116
+ * `_` or `.` (such as `admin` or `rack`) is never rewritten, because in prose
117
+ * those words usually mean something other than the property.
118
+ *
119
+ * @param {string} text - The entry text, with backticks already unescaped.
120
+ * @param {Set<string>} [propertyNames] - Known property names. Without it, the text is returned unchanged.
121
+ * @return {string} The text with known property names as `prop:<name>[link=true]`.
122
+ */
123
+ function linkProperties(text, propertyNames) {
124
+ if (!propertyNames || propertyNames.size === 0) return text;
125
+ return text.replace(/`([\w.-]+)`/g, (match, name) =>
126
+ /[_.]/.test(name) && propertyNames.has(name) ? `prop:${name}[link=true]` : match);
127
+ }
128
+
103
129
  /**
104
130
  * Rewrites the source's underscore data-size notation to the readable unit the
105
131
  * standard requires (`4_MiB` -> `4 MiB`). Only a digit followed by `_` and a
@@ -131,11 +157,13 @@ function escapeAsciiDocBraces(text) {
131
157
  * grouped by canonical category in page order.
132
158
  *
133
159
  * @param {string} body - The raw GitHub Release body markdown.
160
+ * @param {Object} [options]
161
+ * @param {Set<string>} [options.propertyNames] - Known property names to link as `prop:` macros.
134
162
  * @return {{sections: Array<{kind: string, title: string, entries: string[]}>}}
135
163
  * Sections in page order (Features, Bug fixes, Improvements); a category with
136
164
  * no entries is omitted.
137
165
  */
138
- function parseReleaseBody(body) {
166
+ function parseReleaseBody(body, { propertyNames } = {}) {
139
167
  if (typeof body !== 'string' || body.trim() === '') {
140
168
  throw new Error('Release body is empty.');
141
169
  }
@@ -155,7 +183,7 @@ function parseReleaseBody(body) {
155
183
 
156
184
  const flush = () => {
157
185
  if (current !== null && currentKind && buckets[currentKind]) {
158
- const entry = cleanEntry(current);
186
+ const entry = cleanEntry(current, { propertyNames });
159
187
  if (entry) buckets[currentKind].push(entry);
160
188
  }
161
189
  current = null;
@@ -231,6 +259,7 @@ module.exports = {
231
259
  stripAttribution,
232
260
  stripLeadingIssueRef,
233
261
  cleanEntry,
262
+ linkProperties,
234
263
  normalizeUnits,
235
264
  escapeAsciiDocBraces,
236
265
  PAGE_ORDER,
@@ -0,0 +1,65 @@
1
+ // Load the configuration property names that the release-notes generator links
2
+ // as `prop:` macros. The names come from the same redpanda-properties-<tag>.json
3
+ // attachment that the prop macro validates against, so a name linked here is a
4
+ // name the docs build can resolve.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ // The attachment directory, relative to the docs repository root.
10
+ const ATTACHMENTS_DIR = 'modules/reference/attachments';
11
+ const PROPERTIES_JSON_RX = /^redpanda-properties-v(\d+)\.(\d+)\.(\d+)\.json$/;
12
+
13
+ /**
14
+ * Returns the property names in a parsed property JSON file.
15
+ *
16
+ * @param {Object} json - Parsed redpanda-properties-<tag>.json content.
17
+ * @return {Set<string>} The property names.
18
+ * @throws {Error} If the file has no `properties` object.
19
+ */
20
+ function propertyNamesFromJson(json) {
21
+ if (!json || typeof json.properties !== 'object' || json.properties === null) {
22
+ throw new Error('Property file has no "properties" object.');
23
+ }
24
+ return new Set(Object.keys(json.properties));
25
+ }
26
+
27
+ /**
28
+ * Finds the newest GA property file for a tag's release series. A 26.2 tag
29
+ * uses 26.2 data only, the same rule the prop macro applies to a page, so a
30
+ * property from another release series is never linked.
31
+ *
32
+ * @param {string} dir - The directory to search.
33
+ * @param {string} tag - The release tag (`v26.2.5` or `26.2.5`).
34
+ * @return {string|null} The path to the newest matching file, or null if there is none.
35
+ */
36
+ function findPropertiesFile(dir, tag) {
37
+ const m = String(tag).match(/^v?(\d+)\.(\d+)\./);
38
+ if (!m || !fs.existsSync(dir)) return null;
39
+ const [, major, minor] = m;
40
+ let best = null;
41
+ for (const file of fs.readdirSync(dir)) {
42
+ const f = file.match(PROPERTIES_JSON_RX);
43
+ if (!f || f[1] !== major || f[2] !== minor) continue;
44
+ const patch = Number(f[3]);
45
+ if (!best || patch > best.patch) best = { patch, file };
46
+ }
47
+ return best ? path.join(dir, best.file) : null;
48
+ }
49
+
50
+ /**
51
+ * Reads the property names from a property JSON file.
52
+ *
53
+ * @param {string} file - Path to a redpanda-properties-<tag>.json file.
54
+ * @return {Set<string>} The property names.
55
+ */
56
+ function loadPropertyNames(file) {
57
+ return propertyNamesFromJson(JSON.parse(fs.readFileSync(file, 'utf8')));
58
+ }
59
+
60
+ module.exports = {
61
+ ATTACHMENTS_DIR,
62
+ propertyNamesFromJson,
63
+ findPropertiesFile,
64
+ loadPropertyNames,
65
+ };