trackfw 7.2.0 → 7.4.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.
@@ -4,6 +4,17 @@ const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
6
  const { readAgentConventions } = require('../config/index.js')
7
+ const { homedir } = require('../homedir');
8
+
9
+ // PACKAGE_VERSION — the version of the trackfw binary generating the CI
10
+ // workflow templates. Read once from package.json (never a literal), so
11
+ // the pin written into the workflow always tracks the generator that
12
+ // wrote it (ADR-2026-08-28: gate de CI nasce pinado na versão que o
13
+ // gerou). Reused by buildGitHubActionsWorkflowContent /
14
+ // buildGitLabCIWorkflowContent AND by scaffold doctor's comparison
15
+ // (which calls the same functions), so drift between write and compare
16
+ // paths is structurally impossible.
17
+ const { version: PACKAGE_VERSION } = require('../../package.json')
7
18
 
8
19
  const GOV_DIRS = [
9
20
  'docs/adr',
@@ -31,6 +42,7 @@ async function scaffold(cfg, cwd) {
31
42
  }
32
43
 
33
44
  generateVaultIndex(root)
45
+ generateGitAttributes(root)
34
46
  writeTrackfwConfig(cfg, root)
35
47
  generateValidateScript(cfg, root)
36
48
  generateAttentionScripts(cfg, root)
@@ -45,6 +57,60 @@ async function scaffold(cfg, cwd) {
45
57
  injectHooksDetected(root)
46
58
  }
47
59
 
60
+ // gitAttributesRuleTarget — primeiro campo da regra mantida por este gerador.
61
+ // Casa o BASENAME (padrão sem barra) de propósito: `roadmap_dir` e `req_dir` são
62
+ // configuráveis por projeto (trackfw.yaml) e ambos carregam um `.trackfw-log`,
63
+ // então caminho fixo nasceria quebrado em quem configurou diretório diferente.
64
+ const GITATTRIBUTES_RULE_TARGET = '.trackfw-log'
65
+
66
+ // GITATTRIBUTES_BLOCK — byte-idêntico ao de internal/generators/scaffold.go e
67
+ // pypi/trackfw/generators/init_gen.py (regra dura de paridade).
68
+ const GITATTRIBUTES_BLOCK = `# trackfw: .trackfw-log is append-only — every write lands on the last line, so
69
+ # two parallel branches conflict on every merge. merge=union keeps the lines
70
+ # from both sides (chronological order is not guaranteed). The pattern has no
71
+ # slash, so it matches the file in any directory — roadmap_dir and req_dir both
72
+ # carry one, and both are configurable per project.
73
+ .trackfw-log merge=union
74
+ `
75
+
76
+ /**
77
+ * hasGitAttributesRule — verdadeiro quando ALGUMA linha não-comentário tem
78
+ * `.trackfw-log` como primeiro campo. Predicado sobre o CAMPO, não sobre a
79
+ * string literal da linha inteira: espaçamento diferente ou outro atributo já
80
+ * são "a regra existe", e reescrever por cima sobrescreveria decisão do projeto.
81
+ * @param {string} content
82
+ */
83
+ function hasGitAttributesRule(content) {
84
+ for (const line of content.split('\n')) {
85
+ const trimmed = line.trim()
86
+ if (trimmed === '' || trimmed.startsWith('#')) continue
87
+ if (trimmed.split(/\s+/)[0] === GITATTRIBUTES_RULE_TARGET) return true
88
+ }
89
+ return false
90
+ }
91
+
92
+ /**
93
+ * generateGitAttributes — garante `merge=union` para o `.trackfw-log` no
94
+ * `.gitattributes` da raiz. Três ramos, todos idempotentes: ausente → cria;
95
+ * existe sem a regra → APPEND (nunca sobrescreve); existe com a regra → no-op.
96
+ * @param {string} root
97
+ */
98
+ function generateGitAttributes(root) {
99
+ const filePath = path.join(root || process.cwd(), '.gitattributes')
100
+ if (!fs.existsSync(filePath)) {
101
+ fs.writeFileSync(filePath, GITATTRIBUTES_BLOCK, 'utf8')
102
+ console.log(' ✓ .gitattributes')
103
+ return
104
+ }
105
+ const existing = fs.readFileSync(filePath, 'utf8')
106
+ if (hasGitAttributesRule(existing)) return
107
+ // Arquivo preexistente sem newline final: emendar direto grudaria a primeira
108
+ // linha do bloco na última linha do projeto, corrompendo a config em silêncio.
109
+ const prefix = existing !== '' && !existing.endsWith('\n') ? existing + '\n' : existing
110
+ fs.writeFileSync(filePath, prefix + GITATTRIBUTES_BLOCK, 'utf8')
111
+ console.log(' ✓ .gitattributes')
112
+ }
113
+
48
114
  /**
49
115
  * generateVaultIndex — cria vault/notes/index.md se ainda não existir.
50
116
  * @param {string} root
@@ -123,6 +189,10 @@ function generateValidateScript(cfg, cwd) {
123
189
  const script = buildValidateScript(cfg)
124
190
  const scriptPath = path.join(root, 'scripts', 'trackfw-validate.sh')
125
191
  fs.writeFileSync(scriptPath, script, { encoding: 'utf8', mode: 0o755 })
192
+ // AC9 (REQ-2026-08-28): writeFileSync's {mode} option applies only on O_CREAT;
193
+ // for an existing file the mode is not changed. fs.chmodSync restores 0o755
194
+ // unconditionally, matching Python's os.chmod behavior (which was already correct).
195
+ fs.chmodSync(scriptPath, 0o755)
126
196
  console.log(` ✓ ${path.join('scripts', 'trackfw-validate.sh')}`)
127
197
  }
128
198
 
@@ -201,17 +271,34 @@ function generateCIWorkflow(cfg) {
201
271
  }
202
272
  }
203
273
 
204
- function generateGitHubActionsWorkflow() {
205
- fs.mkdirSync('.github/workflows', { recursive: true })
206
-
207
- const content = `name: trackfw-gate
274
+ // buildGitHubActionsWorkflowContent returns the byte-identical content the GitHub Actions
275
+ // workflow generator would write. Exported for scaffold doctor (ADR-2026-08-27): the
276
+ // comparison uses the same function the write path uses, so drift is structurally impossible.
277
+ // The cfg parameter is accepted for API symmetry with Go's buildGitHubActionsWorkflowContent
278
+ // (which also ignores cfg for now — the template is cfg-independent but, since
279
+ // ADR-2026-08-28, NOT version-independent: it pins TRACKFW_VERSION to the version of the
280
+ // trackfw binary that generated it, read from package.json — never a literal).
281
+ //
282
+ // Job id is `governance-install-script` (ML-1A, ROADMAP-2026-09-01): was `governance`,
283
+ // colliding with the discover-installed sibling workflow's job id (trackfw-validate.yml,
284
+ // buildDiscoverGitHubActionsWorkflowContent in commands/discover.js) — both previously
285
+ // `governance`, producing two identically-named check-runs on any PR of a project with
286
+ // both workflows installed, which makes required_status_checks ambiguous (GitHub
287
+ // matches by name). Named after the install mechanism (install.sh here, `go install`
288
+ // there) rather than the workflow file, since that's what a required_status_checks
289
+ // reader needs to tell the two apart without opening the YAML.
290
+ function buildGitHubActionsWorkflowContent(_cfg) {
291
+ return `name: trackfw-gate
208
292
  on:
209
293
  pull_request:
210
294
  branches: [main]
211
295
 
212
296
  jobs:
213
- governance:
297
+ governance-install-script:
214
298
  runs-on: ubuntu-latest
299
+ timeout-minutes: 10
300
+ env:
301
+ TRACKFW_VERSION: "${PACKAGE_VERSION}"
215
302
  steps:
216
303
  - uses: actions/checkout@v4
217
304
 
@@ -222,17 +309,18 @@ jobs:
222
309
  - name: Governance gate
223
310
  run: trackfw validate
224
311
  `
225
-
226
- const filePath = '.github/workflows/trackfw-gate.yml'
227
- fs.writeFileSync(filePath, content, 'utf8')
228
- console.log(` ✓ ${filePath}`)
229
312
  }
230
313
 
231
- function generateGitLabCIWorkflow() {
232
- const content = `# trackfw governance gate
314
+ // buildGitLabCIWorkflowContent mirrors buildGitHubActionsWorkflowContent for GitLab CI.
315
+ // Same version-pin rationale as above — see buildGitHubActionsWorkflowContent.
316
+ function buildGitLabCIWorkflowContent(_cfg) {
317
+ return `# trackfw governance gate
233
318
  trackfw-gate:
234
319
  stage: test
235
320
  image: alpine:latest
321
+ timeout: 10 minutes
322
+ variables:
323
+ TRACKFW_VERSION: "${PACKAGE_VERSION}"
236
324
  before_script:
237
325
  - apk add --no-cache curl
238
326
  - curl -sSfL https://github.com/kgsaran/trackfw/releases/latest/download/install.sh | sh
@@ -241,8 +329,17 @@ trackfw-gate:
241
329
  only:
242
330
  - merge_requests
243
331
  `
332
+ }
333
+
334
+ function generateGitHubActionsWorkflow() {
335
+ fs.mkdirSync('.github/workflows', { recursive: true })
336
+ const filePath = '.github/workflows/trackfw-gate.yml'
337
+ fs.writeFileSync(filePath, buildGitHubActionsWorkflowContent(null), 'utf8')
338
+ console.log(` ✓ ${filePath}`)
339
+ }
244
340
 
245
- fs.writeFileSync('.gitlab-ci-trackfw.yml', content, 'utf8')
341
+ function generateGitLabCIWorkflow() {
342
+ fs.writeFileSync('.gitlab-ci-trackfw.yml', buildGitLabCIWorkflowContent(null), 'utf8')
246
343
  console.log(' ✓ .gitlab-ci-trackfw.yml')
247
344
  }
248
345
 
@@ -320,9 +417,12 @@ function generateCommitMsgHook(cfg) {
320
417
  // ---------------------------------------------------------------------------
321
418
 
322
419
  function generatePomXml(cfg) {
323
- const slug = cfg.projectName
324
- ? cfg.projectName.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
325
- : 'my-app'
420
+ // Usa o toSlug compartilhado em vez de uma variante inline: a versao anterior nao
421
+ // fazia NFKD e PERDIA a letra acentuada — "Cafe App" (com acento) virava "caf-app"
422
+ // aqui e "cafe-app" no Go, que usa o toSlug dele. Ver a secao
423
+ // "Artifact slug contract" em docs/cli-parity.md.
424
+ const { toSlug } = require('./adr')
425
+ const slug = cfg.projectName ? toSlug(cfg.projectName) : 'my-app'
326
426
  const name = cfg.projectName || 'My App'
327
427
  const content = `<?xml version="1.0" encoding="UTF-8"?>
328
428
  <project xmlns="http://maven.apache.org/POM/4.0.0"
@@ -452,7 +552,7 @@ Delete the file when resolved. Visible as a live banner in \`trackfw serve\`.
452
552
  - **Docker + .env from day 1:** containerize early; all config via env vars
453
553
  - **2-layer validation:** frontend (UX) + backend (security) — never only one
454
554
  - **API-first:** define OpenAPI contract before coding frontend/backend integration
455
- - **Security wave:** include a red-team review wave in every feature roadmap
555
+ - **Threat model waves:** every feature roadmap opens with a Wave 0 threat model (before implementation) and closes with a red-team review wave (before release)
456
556
  - **Test coverage:** TDD for critical logic; min 60% (prototype) / 80% (production)
457
557
  - Use \`/trackfw:architect\` to define stack before the first REQ
458
558
  ` + conventionsSection + `
@@ -776,6 +876,13 @@ function backendCommands(cfg) {
776
876
  // (missing roadmap.md, implement.md, barrier.md) undetected.
777
877
  // ---------------------------------------------------------------------------
778
878
 
879
+ // CLAUDE_COMMANDS "trackfw não está instalado" blurbs (AC13) are deliberately
880
+ // OUT of the version pin introduced by ADR-2026-08-28. This text is shown when
881
+ // the `trackfw` command fails because it is not installed — recommending the
882
+ // exact old version that generated the project would be worse UX than
883
+ // pointing at `releases/latest`, since the reason to (re)install is usually
884
+ // being outdated. Do not pin these on a future pass without a product
885
+ // decision; see ROADMAP-2026-08-28 Wave 0 §3 (AC13 row).
779
886
  const CLAUDE_COMMANDS = {
780
887
  'adr.md': `Execute o seguinte comando bash: \`trackfw adr new "$ARGUMENTS"\`
781
888
 
@@ -855,7 +962,7 @@ trackfw não está instalado. Instale com:
855
962
  3. **Gerar o roadmap**
856
963
  Com base no conteúdo da REQ, gere um roadmap seguindo **estritamente** este formato:
857
964
 
858
- \`\`\`markdown
965
+ \`\`\`\`markdown
859
966
  ---
860
967
  status: backlog
861
968
  date: <YYYY-MM-DD>
@@ -870,6 +977,28 @@ trackfw não está instalado. Instale com:
870
977
  ## Diagnóstico / Contexto
871
978
  <resumo do problema, motivação e escopo extraídos da REQ>
872
979
 
980
+ ## Wave 0 — Threat Model
981
+ > Dependencies: none. Blocks all implementation.
982
+
983
+ ### ML-0A — Threat model for this roadmap
984
+ **Status:** pending
985
+ **Files affected:**
986
+ **Actions:**
987
+ 1. Enumeration completeness — is the list of surfaces in this roadmap complete? Name what is missing, or show the list is closed. Do not limit the search to the files already named by the REQ — before declaring the list closed, search the repository for other places that emit the same artifact or the same pattern (for example, grep for the literal the final artifact contains).
988
+ 2. Threat model — who empties this Wave 0 without breaking any written rule, and how?
989
+ 3. Falsification targets in both directions — for each surface, what breaks when the behavior regresses, and what breaks when it regresses the opposite way?
990
+ 4. Declared residual — what this design accepts not covering.
991
+ **Acceptance criteria:**
992
+ - [ ] The four sections above answered with evidence, not a one-line assertion
993
+ - [ ] No implementation line written for this ML
994
+
995
+ **Gates da wave:**
996
+ \`\`\`bash
997
+ # Wave 0 gate — replace this placeholder with a project-specific check before
998
+ # marking ML-0A done. Do not remove the gate; replace its command (AC13).
999
+ exit 1 # placeholder gate fails closed until ML-0A replaces it — see docs/cli-parity.md
1000
+ \`\`\`
1001
+
873
1002
  ## Wave 1 — <nome descritivo> (<N> MLs em paralelo)
874
1003
  > Dependências: Independente
875
1004
 
@@ -890,7 +1019,7 @@ trackfw não está instalado. Instale com:
890
1019
  ## Wave 2 — <nome> (depende de Wave 1)
891
1020
  > Dependências: Wave 1 completa
892
1021
  ...
893
- \`\`\`
1022
+ \`\`\`\`
894
1023
 
895
1024
  **Princípios obrigatórios:**
896
1025
  - MLs dentro da mesma Wave são **independentes** (arquivos distintos, sem conflito)
@@ -1032,9 +1161,19 @@ Próximo passo: abrir PR com gh pr create
1032
1161
 
1033
1162
  Execute primeiro:
1034
1163
  \`\`\`bash
1035
- trackfw barrier <roadmap> --wave <n> --json
1164
+ trackfw barrier <roadmap> --wave <n> --trust-local-gates --json
1036
1165
  \`\`\`
1037
1166
 
1167
+ \`--trust-local-gates\` é obrigatório aqui: roadmaps WIP (modificados localmente, ainda não commitados em
1168
+ \`origin/main\`) são marcados como não confiáveis pela CLI direta por padrão, como proteção contra a
1169
+ execução de gates de roadmaps chegados por PR de terceiro. O slash command aplica esse flag porque
1170
+ ele representa o fluxo legítimo do arquiteto operando no próprio repositório — não porque os gates
1171
+ são inspecionados previamente (o diff ainda é responsabilidade do checklist abaixo).
1172
+
1173
+ ⚠️ **Não use \`--trust-local-gates\` ao revisar um roadmap chegado por PR de terceiro** — use a CLI
1174
+ direta sem o flag (\`trackfw barrier <roadmap> --wave <n> --json\`) para que os gates sejam marcados
1175
+ como \`not_evaluated\` e não executados.
1176
+
1038
1177
  Este comando é **necessário mas não suficiente**. Ele verifica MLs concluídos, evidências e \`trackfw validate\`, mas não substitui as inspeções especializadas nem a auditoria de diff abaixo — nenhuma delas é avaliada pelo binário. Consulte a seção \`trackfw barrier\` em \`docs/cli-parity.md\` para o contrato completo (estados, exit codes, saída JSON).
1039
1178
 
1040
1179
  Se o comando retornar exit code não-zero (\`blocked\` ou erro de resolução): pare, reporte a falha ao usuário e não prossiga no checklist até que a wave passe.
@@ -1258,8 +1397,11 @@ async function installAmazonQ(cwd = process.cwd()) {
1258
1397
  */
1259
1398
  async function installIntegrationTarget(target, cwd = process.cwd(), scope = 'project', { onSkip } = {}) {
1260
1399
  const { execute, buildPlans } = require('../integrations')
1400
+ const { resolveAgentModels } = require('../config')
1401
+ const { models: agentModels, warning: agentModelsWarning } = resolveAgentModels(scope, homedir(), cwd)
1402
+ if (agentModelsWarning) process.stderr.write(agentModelsWarning + '\n')
1261
1403
  const roots = { projectRoot: cwd }
1262
- const options = { targets: [target], scope, onSkip }
1404
+ const options = { targets: [target], scope, onSkip, agentModels }
1263
1405
  // D5 — transparency: print resolved destinations before writing anything.
1264
1406
  // buildPlans has no side effects, so it is safe to call here purely to
1265
1407
  // enumerate destinations; the actual write happens below via execute().
@@ -1292,7 +1434,7 @@ function generateClaudeCommandsForce(rootDir) {
1292
1434
  * installSkillsForce — sobrescreve ~/.claude/skills/trackfw/SKILL.md sempre.
1293
1435
  */
1294
1436
  function installSkillsForce() {
1295
- const skillDir = path.join(os.homedir(), '.claude', 'skills', 'trackfw')
1437
+ const skillDir = path.join(homedir(), '.claude', 'skills', 'trackfw')
1296
1438
  fs.mkdirSync(skillDir, { recursive: true })
1297
1439
 
1298
1440
  const content = `---
@@ -1370,6 +1512,8 @@ module.exports = {
1370
1512
  generateClaudeCommands,
1371
1513
  generateClaudeCommandsForce,
1372
1514
  generateVaultIndex,
1515
+ generateGitAttributes,
1516
+ GITATTRIBUTES_BLOCK,
1373
1517
  installSkillsForce,
1374
1518
  installAgents,
1375
1519
  installGemini,
@@ -1383,4 +1527,12 @@ module.exports = {
1383
1527
  injectHooksDetected,
1384
1528
  trackfwRulesBlock,
1385
1529
  printArchitectNextSteps,
1530
+ // Template constants and builders — exported for scaffold doctor (ADR-2026-08-27).
1531
+ // Same single-source-of-truth principle as hooks.js exports: comparing against the
1532
+ // same object the write path uses makes drift structurally impossible.
1533
+ CLAUDE_COMMANDS,
1534
+ buildValidateScript,
1535
+ // CI workflow content builders (inline content from generateGitHub/GitLabCIWorkflow)
1536
+ buildGitHubActionsWorkflowContent,
1537
+ buildGitLabCIWorkflowContent,
1386
1538
  }
@@ -4,66 +4,21 @@ const path = require('path')
4
4
  const { localDateISO } = require('./date')
5
5
  const roadmapGen = require('./roadmap')
6
6
  const config = require('../config')
7
+ const { resolveAgentNamespaces, resolveReqFiles, reqWriteDir } = require('../validator')
8
+ const { normalizeCRLF } = require('../integrations/render')
7
9
 
8
10
  const VALID_STATES = roadmapGen.VALID_STATES
9
11
  const STATE_ORDER = roadmapGen.STATE_ORDER
10
12
 
11
13
  /**
12
- * listREQFiles — descoberta recursiva de REQs nos 3 layouts suportados, mesmo algoritmo usado
13
- * em Go/Python (ADR-2026-08-04): flat (reqDir/*.md), por-estado (reqDir/<estado>/*.md) e,
14
- * quando roadmapNamespacing === 'by_agent', by_agent (reqDir/<agente>/<estado>/*.md).
15
- * Os três conjuntos não são mutuamente exclusivos — concatena todos.
14
+ * listREQFiles — NÃO reimplementa a descoberta: delega ao ponto único de leitura do validador
15
+ * (resolveReqFiles — ADR-2026-09-03, D3/D4), o mesmo consumido pelas regras de validate. Duas
16
+ * noções de layout no mesmo runtime foram a causa do defeito da REQ-2026-08-30.
16
17
  * @param {object} cfg — config completo (ver npm/src/config)
17
- * @returns {string[]} paths completos, na ordem flat → por-estado → by_agent
18
+ * @returns {string[]} paths completos, união dos 4 layouts, deduplicados e ordenados
18
19
  */
19
20
  function listREQFiles(cfg) {
20
- const reqDir = cfg.reqDir
21
- const files = []
22
-
23
- // (a) flat legado — reqDir/*.md
24
- try {
25
- for (const f of fs.readdirSync(reqDir)) {
26
- if (!f.endsWith('.md')) continue
27
- const full = path.join(reqDir, f)
28
- try {
29
- if (!fs.statSync(full).isDirectory()) files.push(full)
30
- } catch (_) { /* ignora */ }
31
- }
32
- } catch (_) { /* reqDir não existe */ }
33
-
34
- // (b) por-estado, sem agente — reqDir/<estado>/*.md
35
- for (const state of STATE_ORDER) {
36
- const dir = path.join(reqDir, state)
37
- try {
38
- for (const f of fs.readdirSync(dir)) {
39
- if (f.endsWith('.md')) files.push(path.join(dir, f))
40
- }
41
- } catch (_) { /* estado não existe */ }
42
- }
43
-
44
- // (c) by_agent — reqDir/<agente>/<estado>/*.md
45
- if (cfg.roadmapNamespacing === config.NAMESPACING_BY_AGENT) {
46
- let agents = cfg.agents || []
47
- if (agents.length === 0) {
48
- try {
49
- agents = fs.readdirSync(reqDir).filter(f => {
50
- try { return fs.statSync(path.join(reqDir, f)).isDirectory() } catch (_) { return false }
51
- })
52
- } catch (_) { agents = [] }
53
- }
54
- for (const agent of agents) {
55
- for (const state of STATE_ORDER) {
56
- const dir = path.join(reqDir, agent, state)
57
- try {
58
- for (const f of fs.readdirSync(dir)) {
59
- if (f.endsWith('.md')) files.push(path.join(dir, f))
60
- }
61
- } catch (_) { /* agente/estado não existe */ }
62
- }
63
- }
64
- }
65
-
66
- return files
21
+ return resolveReqFiles(cfg)
67
22
  }
68
23
 
69
24
  /**
@@ -114,7 +69,11 @@ function parseREQStatus(filepath) {
114
69
  return 'unknown'
115
70
  }
116
71
 
72
+ // D1/D3 (ADR-2026-09-04-parser-de-frontmatter-tolera-crlf-na-fronteira-de-entrada, ML-5B):
73
+ // normaliza CRLF -> LF via normalizeCRLF na própria entrada, antes de qualquer casamento de
74
+ // "---\n" — reaproveita a única implementação Node, sem criar uma segunda cópia.
117
75
  function rewriteREQStatus(source, status) {
76
+ source = normalizeCRLF(String(source))
118
77
  if (!source.startsWith('---\n')) return { content: source, changed: false }
119
78
  const end = source.slice(4).indexOf('\n---')
120
79
  if (end < 0) return { content: source, changed: false }
@@ -286,7 +245,10 @@ function toSlug(s) {
286
245
  * @returns {Promise<void>}
287
246
  */
288
247
  async function newREQ(content) {
289
- const reqDir = require('../config').load().reqDir
248
+ // Ponto único de decisão de caminho de ESCRITA (ADR-2026-09-03, D2/D4): by_agent grava no
249
+ // canônico req_dir/<agente>/; flat grava em req_dir/ — o mesmo ponto que alimenta a união de leitura.
250
+ const cfg = require('../config').load()
251
+ const reqDir = reqWriteDir(cfg) || cfg.reqDir
290
252
  fs.mkdirSync(reqDir, { recursive: true })
291
253
 
292
254
  const slug = toSlug(content.title)