@nan0web/release 3.4.0 → 3.5.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/README.md CHANGED
@@ -6,36 +6,29 @@ Project management as code, Git-native, GPG-signed, and test-driven.
6
6
 
7
7
  Unlike traditional project tools that require constant syncing or manual updates,
8
8
  `@nan0web/release` enforces project structure where:
9
-
10
9
  - Tasks are represented as tests in `.test.js` files.
11
10
  - Status is tracked via test outcomes.
12
11
  - Team roles and approvals are stored in structured files.
13
12
  - All changes are signed, versioned, and composable.
14
13
 
15
-
16
14
  This ensures:
17
-
18
15
  - Clear, automatable project tracking.
19
16
  - Immutable release notes and retro reflections.
20
17
  - Zero ambiguity – tasks don't exist unless tested.
21
18
 
22
-
23
19
  ## Installation
24
20
 
25
21
  How to install with pnpm?
26
-
27
22
  ```bash
28
23
  pnpm add @nan0web/release
29
24
  ```
30
25
 
31
26
  How to install with npm?
32
-
33
27
  ```bash
34
28
  npm install @nan0web/release
35
29
  ```
36
30
 
37
31
  How to install with yarn?
38
-
39
32
  ```bash
40
33
  yarn add @nan0web/release
41
34
  ```
@@ -45,7 +38,6 @@ yarn add @nan0web/release
45
38
  Start by initializing a new release:
46
39
 
47
40
  How to initialize a new release version?
48
-
49
41
  ```bash
50
42
  nan0release init v1.0.0
51
43
  ```
@@ -53,7 +45,6 @@ nan0release init v1.0.0
53
45
  Show release details:
54
46
 
55
47
  How to show release information?
56
-
57
48
  ```bash
58
49
  release show [--full]
59
50
  ```
@@ -66,7 +57,6 @@ console.info(release.version) // v1.0.0
66
57
  List all releases in the project:
67
58
 
68
59
  How to list all releases?
69
-
70
60
  ```bash
71
61
  nan0release list [--json]
72
62
  ```
@@ -74,7 +64,6 @@ nan0release list [--json]
74
64
  Add a chat message to the current release:
75
65
 
76
66
  How to write a release chat message?
77
-
78
67
  ```bash
79
68
  nan0release chat write --user alice "Issue with the build pipeline"
80
69
  ```
@@ -82,7 +71,6 @@ nan0release chat write --user alice "Issue with the build pipeline"
82
71
  Host a static server for viewing releases:
83
72
 
84
73
  How to host release UI?
85
-
86
74
  ```bash
87
75
  nan0release host [--webui] [--port 3000]
88
76
  ```
@@ -90,7 +78,6 @@ nan0release host [--webui] [--port 3000]
90
78
  Serve all release files for local inspection:
91
79
 
92
80
  How to serve release static assets?
93
-
94
81
  ```bash
95
82
  nan0release serve [--port 8080]
96
83
  ```
@@ -98,7 +85,6 @@ nan0release serve [--port 8080]
98
85
  Validate release tasks and integrity:
99
86
 
100
87
  How to validate release tasks?
101
-
102
88
  ```bash
103
89
  nan0release validate [--ignore-fail]
104
90
  ```
@@ -106,7 +92,6 @@ nan0release validate [--ignore-fail]
106
92
  Seal the release with retro reflection:
107
93
 
108
94
  How to seal a release?
109
-
110
95
  ```bash
111
96
  nan0release seal [--message "All core APIs are stable and tested"]
112
97
  ```
@@ -116,16 +101,13 @@ nan0release seal [--message "All core APIs are stable and tested"]
116
101
  ### 1. Release as Object
117
102
 
118
103
  A `Release` is a structured class containing:
119
-
120
104
  - `version`: release identifier (vX.Y.Z).
121
105
  - `createdAt`, `startAt`, `planAt`, `completeAt`: datetime milestones.
122
106
  - `document`: parsed markdown document with structure-aware parsing.
123
107
 
124
-
125
108
  Each property is typed and validated automatically via JSDoc and runtime parsing.
126
109
 
127
110
  How to instantiate a Release?
128
-
129
111
  ```js
130
112
  import { Release } from '@nan0web/release'
131
113
  const release = new Release({
@@ -138,14 +120,11 @@ console.info(release.createdAt instanceof Date) // ← true
138
120
  ### 2. Release Document Parsing
139
121
 
140
122
  A `ReleaseDocument` extends `@nan0web/markdown` with:
141
-
142
123
  - Structured parsing of markdown release notes
143
124
  - Extraction of sections and tasks
144
125
  - Team and role parsing from markdown frontmatter or code
145
126
 
146
-
147
127
  How to parse release notes markdown into structured data?
148
-
149
128
  ```js
150
129
  import { ReleaseDocument } from '@nan0web/release'
151
130
  const md = `# v1.0.0 - 2025-08-20
@@ -162,14 +141,11 @@ console.info(doc.document.children instanceof Array) // ← true
162
141
  ### 3. Person & Team Structures
163
142
 
164
143
  A `Person` instance includes:
165
-
166
144
  - `name`: HumanName
167
145
  - `gender`: HumanGender
168
146
  - `contacts`: array of HumanContact
169
147
 
170
-
171
148
  How to create a Person with typed properties?
172
-
173
149
  ```js
174
150
  import { Person } from '@nan0web/release'
175
151
  const person = new Person({
@@ -190,7 +166,6 @@ Tasks are registered as `taskId` → `testFilePath` in `ProjectManagement`.
190
166
  Their status is derived by running `node:test` suites.
191
167
 
192
168
  How to register and validate tasks as tests?
193
-
194
169
  ```js
195
170
  import { ProjectManagement } from '@nan0web/release'
196
171
  const pm = new ProjectManagement()
@@ -210,14 +185,11 @@ console.info(results.pending.includes('task-2')) // ← true
210
185
 
211
186
  The `ReleaseManager` coordinates releases and validates readiness.
212
187
  It ensures:
213
-
214
188
  - All registered tests pass
215
189
  - Version is correctly incremented
216
190
  - Git tag is applied if all validations succeed
217
191
 
218
-
219
192
  How to execute a release after validation?
220
-
221
193
  ```js
222
194
  import { ReleaseManager, ProjectManagement } from '@nan0web/release'
223
195
  const pm = new ProjectManagement()
@@ -231,14 +203,11 @@ console.info(result.published) // ← true
231
203
  ### Changelog Integration
232
204
 
233
205
  Using `ChangelogTaskManager`, you can:
234
-
235
206
  - Extract task definitions from changelog entries
236
207
  - Generate corresponding test files
237
208
  - Automate task progression in CI
238
209
 
239
-
240
210
  How to parse changelog and extract tasks?
241
-
242
211
  ```js
243
212
  import { ChangelogTaskManager } from '@nan0web/release'
244
213
  const ctm = new ChangelogTaskManager()
@@ -260,7 +229,6 @@ Uses JSDoc and .d.ts files for autocompletion
260
229
  Try the package in the terminal:
261
230
 
262
231
  How to run playground demo?
263
-
264
232
  ```bash
265
233
  git clone https://github.com/nan0web/release.git
266
234
  cd release
@@ -273,7 +241,6 @@ pnpm run playground
273
241
  To verify the project is ready, run:
274
242
 
275
243
  How to check project status before publishing?
276
-
277
244
  ```bash
278
245
  npm test
279
246
  npm run test:coverage
@@ -287,5 +254,3 @@ How to contribute? - [check here](./CONTRIBUTING.md)
287
254
  ## License
288
255
 
289
256
  How to license ISC? - [check here](./LICENSE)
290
-
291
-
@@ -28,10 +28,10 @@ if (process.cwd() !== root && existsSync(resolve(root, 'releases.txt'))) {
28
28
  process.chdir(root)
29
29
  }
30
30
 
31
- const db = new DBFS()
31
+ const db = new DBFS({ root: '.' })
32
32
  await db.connect()
33
33
 
34
- bootstrapApp(ReleaseApp, { db }).catch((err) => {
34
+ bootstrapApp(ReleaseApp, { db, root: '.' }).catch((err) => {
35
35
  console.error(err)
36
36
  process.exit(1)
37
37
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nan0web/release",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Project Management as Code, Git-Native, GPG-Signed, Test-Driven",
5
5
  "main": "src/index.js",
6
6
  "types": "types/index.d.ts",
@@ -37,16 +37,17 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@nan0web/changelog": "3.4.0",
40
- "@nan0web/db": "3.4.0",
40
+ "@nan0web/csv0": "3.6.0",
41
+ "@nan0web/co": "3.5.0",
42
+ "@nan0web/db-fs": "3.6.0",
43
+ "@nan0web/db": "3.5.0",
44
+ "@nan0web/inspect": "3.7.0",
41
45
  "@nan0web/log": "3.4.0",
42
46
  "@nan0web/logos": "3.4.0",
43
- "@nan0web/co": "3.4.0",
44
- "@nan0web/inspect": "3.4.0",
47
+ "@nan0web/markdown": "3.4.0",
48
+ "@nan0web/ui": "3.5.0",
45
49
  "@nan0web/types": "3.4.0",
46
- "@nan0web/ui": "3.4.0",
47
- "@nan0web/ui-cli": "3.4.0",
48
- "@nan0web/db-fs": "3.4.0",
49
- "@nan0web/markdown": "3.4.0"
50
+ "@nan0web/ui-cli": "3.5.0"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/node": "^25.6.0",
package/src/domain/App.js CHANGED
@@ -12,6 +12,7 @@ import PreviewCommand from './PreviewCommand.js'
12
12
  import ViewCommand from './ViewCommand.js'
13
13
  import ListCommand from './ListCommand.js'
14
14
  import ServeCommand from './ServeCommand.js'
15
+ import ValidateCommand from './ValidateCommand.js'
15
16
 
16
17
  export class ReleaseApp extends ModelAsApp {
17
18
  static alias = 'release'
@@ -25,6 +26,7 @@ export class ReleaseApp extends ModelAsApp {
25
26
  options: [
26
27
  InitCommand,
27
28
  CheckCommand,
29
+ ValidateCommand,
28
30
  CloseCommand,
29
31
  DepsCommand,
30
32
  PublishCommand,
@@ -0,0 +1,418 @@
1
+ import Markdown, {
2
+ MDHeading1,
3
+ MDHeading2,
4
+ MDHeading3,
5
+ MDTaskList,
6
+ MDTable,
7
+ MDTableRow,
8
+ MDList,
9
+ MDParagraph,
10
+ } from '@nan0web/markdown'
11
+ import {
12
+ ReleaseModel,
13
+ ReleaseMemberModel,
14
+ ReleaseTaskModel,
15
+ ReleaseParityModel,
16
+ ReleaseIssueModel,
17
+ } from './models/index.js'
18
+
19
+ /**
20
+ * Clean text from emoji, symbols, markdown formatting and trim.
21
+ * @param {string} text
22
+ * @returns {string}
23
+ */
24
+ export function cleanSectionTitle(text) {
25
+ return String(text || '')
26
+ .replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}]/gu, '')
27
+ .replace(/^[#\s*_-]+|[#\s*_-]+$/g, '')
28
+ .trim()
29
+ .toLowerCase()
30
+ }
31
+
32
+ /**
33
+ * Match section title against known synonyms in ReleaseModel.SECTIONS.
34
+ * @param {string} headingText
35
+ * @param {typeof ReleaseModel} ModelClass
36
+ * @returns {string | null} Recognized section key or null
37
+ */
38
+ export function identifySection(headingText, ModelClass = ReleaseModel) {
39
+ const cleaned = cleanSectionTitle(headingText)
40
+ if (!cleaned) return null
41
+
42
+ for (const [sectionKey, synonyms] of Object.entries(ModelClass.SECTIONS)) {
43
+ for (const synonym of synonyms) {
44
+ const synCleaned = synonym.trim().toLowerCase()
45
+ if (cleaned === synCleaned || cleaned.startsWith(synCleaned) || cleaned.endsWith(synCleaned)) {
46
+ return sectionKey
47
+ }
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /**
54
+ * Resolve external tracker URL for a task or issue ID using template URL.
55
+ * Example: trackerUrl = "https://github.com/org/repo/issues/{id}", rawId = "[#142]" -> "https://github.com/org/repo/issues/142"
56
+ * @param {string} id
57
+ * @param {string} trackerTemplate
58
+ * @returns {string}
59
+ */
60
+ export function resolveTrackerUrl(id, trackerTemplate) {
61
+ if (!trackerTemplate || !id) return ''
62
+ const cleanId = String(id).replace(/^\[#?|\]$/g, '').trim()
63
+ return trackerTemplate.replace(/\{id\}/g, cleanId)
64
+ }
65
+
66
+ /**
67
+ * PMaCParser - Parses PMaC Release/Task specification documents into ReleaseModel instances.
68
+ */
69
+ export class PMaCParser {
70
+ /**
71
+ * @param {typeof ReleaseModel} [modelClass]
72
+ */
73
+ constructor(modelClass = ReleaseModel) {
74
+ this.ModelClass = modelClass
75
+ }
76
+
77
+ /**
78
+ * Parse markdown content into a ReleaseModel instance.
79
+ * @param {string} markdownContent
80
+ * @returns {ReleaseModel}
81
+ */
82
+ parse(markdownContent) {
83
+ const md = new Markdown(markdownContent)
84
+ const frontmatter = md.vars || {}
85
+ const elements = md.document?.children || []
86
+
87
+ // 1. Identify H1 for version and title
88
+ let version = ''
89
+ let title = ''
90
+
91
+ const h1 = elements.find((el) => el instanceof MDHeading1)
92
+ if (h1) {
93
+ const rawTitle = h1.content?.trim() || ''
94
+ // Matches formats like: "# v0.1.0 — Core Release Engine" or "# v0.1.0 - Core Release" or "# v1.0.0"
95
+ const h1Match = rawTitle.match(/^(v\d+\.\d+\.\d+(?:-[\w.]+)?)\s*(?:[—–-]\s*(.+))?$/i)
96
+ if (h1Match) {
97
+ version = h1Match[1]
98
+ title = (h1Match[2] || '').trim()
99
+ } else {
100
+ // Fallback: check if starts with version
101
+ const vMatch = rawTitle.match(/^(v\d+\.\d+\.\d+(?:-[\w.]+)?)/i)
102
+ if (vMatch) {
103
+ version = vMatch[1]
104
+ title = rawTitle.slice(vMatch[0].length).replace(/^[—–-\s]+/, '').trim()
105
+ } else {
106
+ title = rawTitle
107
+ }
108
+ }
109
+ }
110
+
111
+ const trackerTemplate = frontmatter.tracker || ''
112
+
113
+ // Build release data
114
+ const releaseData = {
115
+ version: frontmatter.version || version,
116
+ title: frontmatter.title || title,
117
+ status: frontmatter.status || 'todo',
118
+ type: frontmatter.type || 'minor',
119
+ epic: frontmatter.epic,
120
+ sprint: frontmatter.sprint,
121
+ tracker: trackerTemplate,
122
+ summary: '',
123
+ team: [],
124
+ tasks: [],
125
+ criteria: [],
126
+ parity: [],
127
+ issues: [],
128
+ files: [],
129
+ meta: { ...frontmatter },
130
+ }
131
+
132
+ // 2. Iterate through sections delimited by MDHeading2
133
+ let currentSection = null
134
+ let currentTaskGroup = ''
135
+
136
+ for (const el of elements) {
137
+ if (el instanceof MDHeading2) {
138
+ currentSection = identifySection(el.content, this.ModelClass)
139
+ currentTaskGroup = ''
140
+ continue
141
+ }
142
+
143
+ if (el instanceof MDHeading3) {
144
+ currentTaskGroup = el.content?.trim() || ''
145
+ continue
146
+ }
147
+
148
+ if (!currentSection) continue
149
+
150
+ if (currentSection === 'summary') {
151
+ if (el instanceof MDParagraph) {
152
+ const text = el.content?.trim()
153
+ if (text) {
154
+ releaseData.summary = releaseData.summary
155
+ ? `${releaseData.summary}\n\n${text}`
156
+ : text
157
+ }
158
+ }
159
+ } else if (currentSection === 'team') {
160
+ this._parseTeamSection(el, releaseData.team)
161
+ } else if (currentSection === 'tasks') {
162
+ this._parseTasksSection(el, releaseData.tasks, currentTaskGroup, trackerTemplate)
163
+ } else if (currentSection === 'criteria') {
164
+ this._parseTasksSection(el, releaseData.criteria, currentTaskGroup, trackerTemplate)
165
+ } else if (currentSection === 'parity') {
166
+ this._parseParitySection(el, releaseData.parity)
167
+ } else if (currentSection === 'issues') {
168
+ this._parseIssuesSection(el, releaseData.issues)
169
+ } else if (currentSection === 'artifacts') {
170
+ this._parseArtifactsSection(el, releaseData.files)
171
+ }
172
+ }
173
+
174
+ return new this.ModelClass(releaseData)
175
+ }
176
+
177
+ /**
178
+ * @private
179
+ */
180
+ _parseTeamSection(el, teamList) {
181
+ const items = []
182
+ if (el instanceof MDList) {
183
+ for (const child of el.children) {
184
+ items.push(child.content?.trim() || '')
185
+ }
186
+ } else if (el.content) {
187
+ const lines = el.content.split('\n')
188
+ for (const line of lines) {
189
+ const trimmed = line.trim()
190
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
191
+ items.push(trimmed.slice(2).trim())
192
+ }
193
+ }
194
+ }
195
+
196
+ for (const item of items) {
197
+ // Matches: "**Lead / Architect:** Олександр Снігірьов (@snigirev)"
198
+ // or "**Посада:** Ім'я (@handle)"
199
+ const match = item.match(/^\*\*([^*]+)\*\*\s*(.*)$/)
200
+ if (match) {
201
+ const role = match[1].replace(/:$/, '').trim()
202
+ const rest = match[2].trim()
203
+ const handleMatch = rest.match(/\(@([^)]+)\)/)
204
+ const handle = handleMatch ? handleMatch[1].trim() : ''
205
+ const name = rest.replace(/\(@[^)]+\)/, '').trim()
206
+ teamList.push(new ReleaseMemberModel({ role, name, handle }))
207
+ } else {
208
+ teamList.push(new ReleaseMemberModel({ role: '', name: item, handle: '' }))
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * @private
215
+ */
216
+ _parseTasksSection(el, tasksList, taskGroup, trackerTemplate) {
217
+ const items = []
218
+ if (el instanceof MDTaskList || el instanceof MDList) {
219
+ for (const child of el.children) {
220
+ items.push(child.content?.trim() || '')
221
+ }
222
+ } else if (el.content && el.content.includes('- [')) {
223
+ const lines = el.content.split('\n')
224
+ for (const line of lines) {
225
+ const trimmed = line.trim()
226
+ if (trimmed.startsWith('- [')) {
227
+ items.push(trimmed.slice(2).trim())
228
+ }
229
+ }
230
+ }
231
+
232
+ for (const rawItem of items) {
233
+ // e.g. "[x] [#101-1] Винести базову модель ReleaseModel у пакет @nan0web/release"
234
+ // or "- [x] [#101-1] ..." or "[ ] [#101-3] ..."
235
+ let line = rawItem
236
+ if (line.startsWith('- ')) line = line.slice(2).trim()
237
+
238
+ const boxMatch = line.match(/^\[([ xX])\]\s*(.*)$/)
239
+ let isDone = false
240
+ let text = line
241
+
242
+ if (boxMatch) {
243
+ isDone = boxMatch[1].toLowerCase() === 'x'
244
+ text = boxMatch[2].trim()
245
+ }
246
+
247
+ // Extract task ID like [#101-1] or [101-1] or [CORE-101]
248
+ let id = ''
249
+ const idMatch = text.match(/^\[#?([A-Za-z0-9_-]+)\]\s*(.*)$/)
250
+ if (idMatch) {
251
+ id = idMatch[1]
252
+ text = idMatch[2].trim()
253
+ } else {
254
+ // Fallback: derive ID from group or random
255
+ id = `task-${tasksList.length + 1}`
256
+ }
257
+
258
+ // Check for notes or assignee or hours in parens/brackets
259
+ let notes = ''
260
+ if (taskGroup) {
261
+ notes = taskGroup
262
+ }
263
+
264
+ tasksList.push(
265
+ new ReleaseTaskModel({
266
+ id,
267
+ title: text,
268
+ status: isDone ? 'done' : 'todo',
269
+ notes,
270
+ })
271
+ )
272
+ }
273
+ }
274
+
275
+ /**
276
+ * @private
277
+ */
278
+ _parseParitySection(el, parityList) {
279
+ let rows = []
280
+ if (el instanceof MDTable) {
281
+ rows = el.children || []
282
+ } else if (el instanceof MDTableRow) {
283
+ rows = [el]
284
+ }
285
+ if (rows.length === 0) return
286
+
287
+ for (const row of rows) {
288
+ const rowContent = row.content?.trim() || ''
289
+ // Skip separator rows like | :--- | :---: |
290
+ if (/^\|[\s|:-]+\|$/.test(rowContent)) continue
291
+
292
+ const cells = rowContent
293
+ .split('|')
294
+ .map((c) => c.trim())
295
+ .filter((c, idx, arr) => idx > 0 && idx < arr.length - 1)
296
+
297
+ // Skip header row
298
+ if (cells.length >= 3) {
299
+ if (cells[0].toLowerCase() === 'колекція' || cells[0].toLowerCase() === 'collection') {
300
+ continue
301
+ }
302
+ const collection = cells[0].replace(/`/g, '')
303
+ const expected = parseInt(cells[1], 10) || 0
304
+ const actual = parseInt(cells[2], 10) || 0
305
+ const statusText = cells[3] || ''
306
+ const comment = cells[4] || ''
307
+ const synced = statusText.includes('OK') || statusText.includes('✅') || expected === actual
308
+
309
+ parityList.push(
310
+ new ReleaseParityModel({
311
+ collection,
312
+ expected,
313
+ actual,
314
+ synced,
315
+ comment,
316
+ })
317
+ )
318
+ }
319
+ }
320
+ }
321
+
322
+ /**
323
+ * @private
324
+ */
325
+ _parseIssuesSection(el, issuesList) {
326
+ const items = []
327
+ if (el instanceof MDList) {
328
+ for (const child of el.children) {
329
+ items.push(child.content?.trim() || '')
330
+ }
331
+ } else if (el.content) {
332
+ const lines = el.content.split('\n')
333
+ for (const line of lines) {
334
+ const trimmed = line.trim()
335
+ if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) {
336
+ items.push(trimmed.slice(2).trim())
337
+ }
338
+ }
339
+ }
340
+
341
+ for (const item of items) {
342
+ // Check if this is a sub-detail line (e.g. "Джерело: `@nan0web/payload-release/admin.jsx:L85`" or "Source: ...")
343
+ const sourceMatch = item.match(/^(?:Джерело|Source|File):\s*`?([^`]+)`?/i)
344
+ if (sourceMatch && issuesList.length > 0) {
345
+ const lastIssue = issuesList[issuesList.length - 1]
346
+ lastIssue.source = sourceMatch[1].trim()
347
+ lastIssue.file = sourceMatch[1].split(':')[0].trim()
348
+ continue
349
+ }
350
+
351
+ // Example: "**[WARN-001]** (Performance) Файли розміром > 2MB не повинні відкриватися"
352
+ const codeMatch = item.match(/^\*\*\[?([A-Za-z0-9_-]+)\]?\*\*\s*(.*)$/)
353
+ let code = ''
354
+ let rest = item
355
+ if (codeMatch) {
356
+ code = codeMatch[1]
357
+ rest = codeMatch[2].trim()
358
+ }
359
+
360
+ let level = 'warning'
361
+ const upperCode = code.toUpperCase()
362
+ if (upperCode.includes('ERR') || upperCode.includes('BLOCK')) level = 'error'
363
+ else if (upperCode.includes('INFO')) level = 'info'
364
+
365
+ issuesList.push(
366
+ new ReleaseIssueModel({
367
+ code,
368
+ message: rest,
369
+ level,
370
+ })
371
+ )
372
+ }
373
+ }
374
+
375
+ /**
376
+ * @private
377
+ */
378
+ _parseArtifactsSection(el, filesList) {
379
+ let rows = []
380
+ if (el instanceof MDTable) {
381
+ rows = el.children || []
382
+ } else if (el instanceof MDTableRow) {
383
+ rows = [el]
384
+ }
385
+ if (rows.length === 0) return
386
+
387
+ for (const row of rows) {
388
+ const rowContent = row.content?.trim() || ''
389
+ if (/^\|[\s|:-]+\|$/.test(rowContent)) continue
390
+
391
+ const cells = rowContent
392
+ .split('|')
393
+ .map((c) => c.trim())
394
+ .filter((c, idx, arr) => idx > 0 && idx < arr.length - 1)
395
+
396
+ if (cells.length >= 1) {
397
+ if (cells[0].toLowerCase() === 'файл' || cells[0].toLowerCase() === 'file') {
398
+ continue
399
+ }
400
+ const file = cells[0].replace(/`/g, '')
401
+ const size = cells[1] || ''
402
+ const hash = cells[2] ? cells[2].replace(/`/g, '') : ''
403
+ const type = cells[3] || ''
404
+ filesList.push({ file, size, hash, type })
405
+ }
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Static shortcut to parse specification
411
+ * @param {string} markdownContent
412
+ * @returns {ReleaseModel}
413
+ */
414
+ static parse(markdownContent) {
415
+ return new PMaCParser().parse(markdownContent)
416
+ }
417
+ }
418
+