dotmd-cli 0.69.0 → 0.70.1
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 +144 -964
- package/bin/dotmd.mjs +251 -202
- package/dotmd.config.example.mjs +5 -8
- package/package.json +6 -10
- package/src/agent-context.mjs +132 -0
- package/src/atomic-mutation.mjs +1505 -0
- package/src/baton.mjs +109 -114
- package/src/bulk-tag.mjs +7 -7
- package/src/check-collapse.mjs +2 -2
- package/src/commands.mjs +326 -12
- package/src/completions.mjs +38 -98
- package/src/config.mjs +18 -3
- package/src/diff.mjs +7 -3
- package/src/doctor.mjs +25 -15
- package/src/export.mjs +154 -25
- package/src/fix-refs.mjs +2 -0
- package/src/frontmatter-fix.mjs +9 -7
- package/src/frontmatter.mjs +3 -2
- package/src/git.mjs +722 -14
- package/src/graph.mjs +53 -25
- package/src/guard.mjs +163 -60
- package/src/hud.mjs +65 -76
- package/src/index-file.mjs +28 -16
- package/src/index.mjs +21 -13
- package/src/init.mjs +1 -1
- package/src/journal.mjs +145 -12
- package/src/lifecycle.mjs +596 -294
- package/src/lint.mjs +117 -56
- package/src/managed-path.mjs +192 -0
- package/src/migrate-prompts.mjs +2 -0
- package/src/migrate-template.mjs +2 -0
- package/src/migrate.mjs +7 -1
- package/src/new.mjs +135 -54
- package/src/output-identity.mjs +106 -0
- package/src/pickup-card.mjs +24 -10
- package/src/pickup.mjs +457 -0
- package/src/prompts.mjs +134 -75
- package/src/query.mjs +22 -10
- package/src/reference-planner.mjs +292 -0
- package/src/rename.mjs +65 -73
- package/src/render.mjs +24 -11
- package/src/runlist.mjs +109 -71
- package/src/section.mjs +2 -1
- package/src/ship.mjs +39 -20
- package/src/stats.mjs +1 -1
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +11 -26
- package/src/summary.mjs +14 -3
- package/src/update.mjs +38 -10
- package/src/use.mjs +4 -1
- package/src/util.mjs +1 -0
- package/src/validate.mjs +53 -17
- package/src/watch.mjs +6 -1
- package/src/notion.mjs +0 -528
package/src/new.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync,
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, fstatSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
4
|
import { toRepoPath, die, warn, nowIso, emitFilesFooter } from './util.mjs';
|
|
@@ -6,6 +6,8 @@ import { green, dim, bold } from './color.mjs';
|
|
|
6
6
|
import { isInteractive, promptText } from './prompt.mjs';
|
|
7
7
|
import { regenIndex } from './lifecycle.mjs';
|
|
8
8
|
import { extractFrontmatter, parseSimpleFrontmatter, normalizeEol } from './frontmatter.mjs';
|
|
9
|
+
import { authorizeManagedDestination } from './managed-path.mjs';
|
|
10
|
+
import { createFileExclusive, mutateFileSet, MutationConflictError } from './atomic-mutation.mjs';
|
|
9
11
|
|
|
10
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
13
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -281,6 +283,18 @@ export function readBodyInput(source) {
|
|
|
281
283
|
return source;
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
export function readPipedBodyInput() {
|
|
287
|
+
try {
|
|
288
|
+
const stat = fstatSync(0);
|
|
289
|
+
const isWindowsPipe = process.platform === 'win32' && !process.stdin.isTTY;
|
|
290
|
+
if (stat.isFIFO() || stat.isFile() || stat.isSocket() || isWindowsPipe) {
|
|
291
|
+
const piped = readFileSync(0, 'utf8');
|
|
292
|
+
return piped.length > 0 ? piped : null;
|
|
293
|
+
}
|
|
294
|
+
} catch { /* stdin not introspectable */ }
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
284
298
|
// Slug/title helpers shared by name resolution and runlist child generation.
|
|
285
299
|
export function slugify(s) {
|
|
286
300
|
return s.toLowerCase().replace(/[\s_]+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
@@ -289,6 +303,48 @@ export function titleize(s) {
|
|
|
289
303
|
return s.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
290
304
|
}
|
|
291
305
|
|
|
306
|
+
export function preparePromptDocument(name, bodyInput, config, { plan = null, dryRun = false } = {}) {
|
|
307
|
+
const template = resolveTemplate('prompt', config);
|
|
308
|
+
const typeStatuses = config.typeStatuses?.get('prompt');
|
|
309
|
+
const status = template.defaultStatus && (!typeStatuses || typeStatuses.has(template.defaultStatus))
|
|
310
|
+
? template.defaultStatus
|
|
311
|
+
: ([...(typeStatuses ?? [])][0] ?? 'pending');
|
|
312
|
+
if (!bodyInput?.trim()) die('`prompt` template requires a body.');
|
|
313
|
+
const slug = slugify(path.basename(name, '.md'));
|
|
314
|
+
const title = titleize(path.basename(name, '.md'));
|
|
315
|
+
let targetRoot = config.docsRoot;
|
|
316
|
+
let routed = false;
|
|
317
|
+
if (template.targetRoot) {
|
|
318
|
+
const match = (config.docsRoots || [config.docsRoot]).find(root => root.endsWith(template.targetRoot) || path.basename(root) === template.targetRoot);
|
|
319
|
+
if (match) { targetRoot = match; routed = true; }
|
|
320
|
+
}
|
|
321
|
+
const baseDir = template.dir && !routed ? path.join(targetRoot, template.dir) : targetRoot;
|
|
322
|
+
const filePath = path.join(baseDir, `${slug}.md`);
|
|
323
|
+
authorizeManagedDestination(filePath, config, { kind: 'Baton prompt destination' });
|
|
324
|
+
if (dryRun) return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content: null };
|
|
325
|
+
|
|
326
|
+
const today = nowIso();
|
|
327
|
+
const split = splitBodyFrontmatter(bodyInput);
|
|
328
|
+
const body = split.frontmatter ? split.body : bodyInput;
|
|
329
|
+
const ctx = { status, title, today, bodyInput: body };
|
|
330
|
+
let content;
|
|
331
|
+
if (typeof template === 'function') {
|
|
332
|
+
content = template(name, ctx);
|
|
333
|
+
if (plan) {
|
|
334
|
+
const end = content.indexOf('\n---\n', 4);
|
|
335
|
+
if (!content.startsWith('---\n') || end === -1) throw new Error('Custom prompt template must return frontmatter for baton plan binding.');
|
|
336
|
+
const fm = mergeBodyFrontmatter(content.slice(4, end), { plan }, 'prompt');
|
|
337
|
+
content = `---\n${fm}${content.slice(end)}`;
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
let fm = template.frontmatter(status, today, ctx);
|
|
341
|
+
if (split.frontmatter) fm = mergeBodyFrontmatter(fm, split.frontmatter, 'prompt');
|
|
342
|
+
if (plan) fm = mergeBodyFrontmatter(fm, { plan }, 'prompt');
|
|
343
|
+
content = `---\n${fm}\n---\n${template.body(title, ctx)}`;
|
|
344
|
+
}
|
|
345
|
+
return { slug, filePath, repoPath: toRepoPath(filePath, config.repoRoot), content };
|
|
346
|
+
}
|
|
347
|
+
|
|
292
348
|
// Resolve one `--runlist` token to a scaffolded child plan: a bare slug becomes
|
|
293
349
|
// `<hub>-NN-<slug>.md` (the documented runlist naming convention). `pos` is the
|
|
294
350
|
// 1-based position used for the zero-padded NN prefix. Tokens must be bare slugs
|
|
@@ -642,16 +698,11 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
642
698
|
// delivers stdin as an AF_UNIX socket). Probe this even for templates that
|
|
643
699
|
// don't accept bodies so the fail-fast guard below can reject accidental
|
|
644
700
|
// heredoc/input instead of silently scaffolding without it.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
bodyInput = piped;
|
|
651
|
-
bodyInputSource = 'piped stdin';
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
} catch { /* stdin not introspectable — skip auto-consume */ }
|
|
701
|
+
const piped = readPipedBodyInput();
|
|
702
|
+
if (piped !== null) {
|
|
703
|
+
bodyInput = piped;
|
|
704
|
+
bodyInputSource = 'piped stdin';
|
|
705
|
+
}
|
|
655
706
|
}
|
|
656
707
|
|
|
657
708
|
// If the body input has a leading `---…---` frontmatter block, lift its keys
|
|
@@ -757,6 +808,7 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
757
808
|
const baseDir = nameDir ? path.resolve(config.repoRoot, nameDir) : targetRoot;
|
|
758
809
|
const filePath = path.join(baseDir, slug + '.md');
|
|
759
810
|
const repoPath = toRepoPath(filePath, config.repoRoot);
|
|
811
|
+
const destinationAuthorization = authorizeManagedDestination(filePath, config, { kind: 'New document destination' });
|
|
760
812
|
|
|
761
813
|
if (existsSync(filePath)) {
|
|
762
814
|
die(`File already exists: ${repoPath}`);
|
|
@@ -766,40 +818,13 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
766
818
|
|
|
767
819
|
// Resolve runlist children from the hub slug (e.g. `extract` → hub-01-extract.md).
|
|
768
820
|
const runlistChildren = runlistTokens.map((tok, i) => planChildFromToken(slug, tok, i + 1));
|
|
769
|
-
const
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
|
|
775
|
-
const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
|
|
776
|
-
if (typeof template === 'function') {
|
|
777
|
-
content = template(name, tmplCtx);
|
|
778
|
-
} else {
|
|
779
|
-
let fm = template.frontmatter(status, today, tmplCtx);
|
|
780
|
-
if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
|
|
781
|
-
// Inject the hub-shape frontmatter (runlist array / coordination marker)
|
|
782
|
-
// on top of the standard plan scaffold, then swap in a purpose-built body.
|
|
783
|
-
if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
|
|
784
|
-
if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
|
|
785
|
-
if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
|
|
786
|
-
let body;
|
|
787
|
-
// A full authored body (own `## Section` headings) wins over every variant
|
|
788
|
-
// skeleton too — otherwise the whole document gets nested in the builder's
|
|
789
|
-
// single slot and the skeleton is appended below it (duplicate Scope /
|
|
790
|
-
// Ranked queue / Version History). The default plan body applies the same
|
|
791
|
-
// shortcut inside template.body, so only the variant branches need it here.
|
|
792
|
-
const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
|
|
793
|
-
const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
|
|
794
|
-
if (authored !== null) body = authored;
|
|
795
|
-
else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
|
|
796
|
-
else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
|
|
797
|
-
else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
|
|
798
|
-
else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
|
|
799
|
-
else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
|
|
800
|
-
else body = template.body(docTitle, tmplCtx);
|
|
801
|
-
content = `---\n${fm}\n---\n${body}`;
|
|
821
|
+
for (const child of runlistChildren) {
|
|
822
|
+
authorizeManagedDestination(path.join(baseDir, child.file), config, {
|
|
823
|
+
root: destinationAuthorization.root,
|
|
824
|
+
kind: 'Runlist child destination',
|
|
825
|
+
});
|
|
802
826
|
}
|
|
827
|
+
const childStatus = effective.has('planned') ? 'planned' : status;
|
|
803
828
|
|
|
804
829
|
// When the project has >1 root and `--root` was omitted, surface the choice
|
|
805
830
|
// so agents can see that an alternative root was available. Cheap visibility
|
|
@@ -820,34 +845,81 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
820
845
|
: isLite ? ' (lite plan)'
|
|
821
846
|
: isAudit ? ' (audit plan)'
|
|
822
847
|
: '';
|
|
848
|
+
const isCustomTemplate = Object.prototype.hasOwnProperty.call(config.raw?.templates ?? {}, typeName);
|
|
823
849
|
|
|
824
850
|
if (dryRun) {
|
|
825
|
-
|
|
851
|
+
if (isCustomTemplate) {
|
|
852
|
+
process.stdout.write(`${dim('[dry-run]')} Target: ${repoPath}\n`);
|
|
853
|
+
process.stdout.write(`${dim('[dry-run]')} Custom template rendering skipped; preview cannot confirm creation will succeed.\n`);
|
|
854
|
+
} else {
|
|
855
|
+
process.stdout.write(`${dim('[dry-run]')} Would create: ${repoPath}\n`);
|
|
856
|
+
}
|
|
826
857
|
process.stdout.write(`${dim('[dry-run]')} Type: ${typeName}${hubKind}\n`);
|
|
827
858
|
for (const c of runlistChildren) {
|
|
828
|
-
|
|
859
|
+
const childPath = path.join(baseDir, c.file);
|
|
860
|
+
const action = existsSync(childPath) ? 'Would leave existing child unchanged' : 'Would create child';
|
|
861
|
+
process.stdout.write(`${dim('[dry-run]')} ${action}: ${toRepoPath(childPath, config.repoRoot)}\n`);
|
|
829
862
|
}
|
|
830
863
|
if (rootHint) process.stdout.write(`${dim('[dry-run]')} ${rootHint}`);
|
|
831
864
|
return;
|
|
832
865
|
}
|
|
833
866
|
|
|
867
|
+
// Generate content only for a real create. Custom template functions are
|
|
868
|
+
// user code and may have side effects, so previews stop before invoking them.
|
|
869
|
+
let content;
|
|
870
|
+
const validSurfaces = config.raw?.taxonomy?.surfaces ?? (config.validSurfaces ? [...config.validSurfaces] : null);
|
|
871
|
+
const validModules = config.raw?.taxonomy?.modules ?? (config.validModules ? [...config.validModules] : null);
|
|
872
|
+
const tmplCtx = { status, title: docTitle, today, bodyInput, validSurfaces, validModules };
|
|
873
|
+
if (typeof template === 'function') {
|
|
874
|
+
content = template(name, tmplCtx);
|
|
875
|
+
} else {
|
|
876
|
+
let fm = template.frontmatter(status, today, tmplCtx);
|
|
877
|
+
if (bodyFrontmatter) fm = mergeBodyFrontmatter(fm, bodyFrontmatter, typeName);
|
|
878
|
+
if (isRunlistHub) fm = mergeBodyFrontmatter(fm, { runlist: runlistChildren.map(c => c.file) }, typeName);
|
|
879
|
+
if (isCoordinationHub) fm = mergeBodyFrontmatter(fm, { execution_mode: 'coordination' }, typeName);
|
|
880
|
+
if (isRoadmap) fm = mergeBodyFrontmatter(fm, { execution_mode: 'roadmap' }, typeName);
|
|
881
|
+
let body;
|
|
882
|
+
const variantBody = isRunlistHub || isCoordinationHub || isRoadmap || isLite || isAudit;
|
|
883
|
+
const authored = variantBody ? fullBodyShortcut(docTitle, bodyInput) : null;
|
|
884
|
+
if (authored !== null) body = authored;
|
|
885
|
+
else if (isRunlistHub) body = runlistHubBody(docTitle, slug, runlistChildren, bodyInput, today);
|
|
886
|
+
else if (isCoordinationHub) body = coordinationHubBody(docTitle, bodyInput, today);
|
|
887
|
+
else if (isRoadmap) body = roadmapHubBody(docTitle, slug, bodyInput, today);
|
|
888
|
+
else if (isLite) body = litePlanBody(docTitle, bodyInput, today);
|
|
889
|
+
else if (isAudit) body = auditPlanBody(docTitle, bodyInput, today);
|
|
890
|
+
else body = template.body(docTitle, tmplCtx);
|
|
891
|
+
content = `---\n${fm}\n---\n${body}`;
|
|
892
|
+
}
|
|
893
|
+
|
|
834
894
|
// Ensure parent dir exists (templates with `dir:` may target a new subdirectory)
|
|
835
895
|
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
836
896
|
|
|
837
|
-
writeFileSync(filePath, content, 'utf8');
|
|
838
|
-
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
|
|
839
|
-
if (rootHint) process.stdout.write(dim(rootHint));
|
|
840
|
-
|
|
841
|
-
// Scaffold runlist child stubs. An existing child file is never clobbered.
|
|
842
897
|
const childPaths = [];
|
|
898
|
+
const creations = [{ path: filePath, content }];
|
|
843
899
|
for (const c of runlistChildren) {
|
|
844
900
|
const childPath = path.join(baseDir, c.file);
|
|
845
901
|
if (existsSync(childPath)) {
|
|
846
902
|
warn(`Runlist child already exists, left as-is: ${toRepoPath(childPath, config.repoRoot)}`);
|
|
847
903
|
continue;
|
|
848
904
|
}
|
|
849
|
-
|
|
905
|
+
creations.push({ path: childPath, content: runlistChildContent(c.title, slug, docTitle, childStatus, today) });
|
|
850
906
|
childPaths.push(childPath);
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
if (runlistChildren.length > 0) mutateFileSet({ creations }, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
910
|
+
else createFileExclusive(filePath, content, { repoRoot: config.repoRoot, testHooks: opts.testHooks });
|
|
911
|
+
} catch (err) {
|
|
912
|
+
if (err instanceof MutationConflictError) die(`Could not create runlist atomically because a destination already exists: ${err.message}`);
|
|
913
|
+
throw err;
|
|
914
|
+
}
|
|
915
|
+
process.stdout.write(`${green('Created')}: ${repoPath} ${dim(`(${typeName}${hubKind})`)}\n`);
|
|
916
|
+
if (rootHint) process.stdout.write(dim(rootHint));
|
|
917
|
+
|
|
918
|
+
// Existing children discovered before the transaction are consistently left
|
|
919
|
+
// unchanged; absent children and the hub publish or roll back together.
|
|
920
|
+
for (const c of runlistChildren) {
|
|
921
|
+
const childPath = path.join(baseDir, c.file);
|
|
922
|
+
if (!childPaths.includes(childPath)) continue;
|
|
851
923
|
process.stdout.write(`${green('Created')}: ${toRepoPath(childPath, config.repoRoot)} ${dim(`(plan · runlist child, ${childStatus})`)}\n`);
|
|
852
924
|
}
|
|
853
925
|
|
|
@@ -873,15 +945,24 @@ export async function runNew(argv, config, opts = {}) {
|
|
|
873
945
|
}
|
|
874
946
|
} catch { /* git absent / not a repo — skip the note */ }
|
|
875
947
|
|
|
876
|
-
regenIndex(config);
|
|
948
|
+
const indexRegenerated = !opts.deferIndex ? regenIndex(config) : false;
|
|
877
949
|
|
|
878
950
|
if (showFiles) {
|
|
879
951
|
const touched = [filePath, ...childPaths];
|
|
880
|
-
if (config.indexPath) touched.push(config.indexPath);
|
|
952
|
+
if (config.indexPath && !opts.deferIndex) touched.push(config.indexPath);
|
|
881
953
|
emitFilesFooter(touched, config);
|
|
882
954
|
}
|
|
883
955
|
|
|
884
956
|
try { config.hooks.onNew?.({ path: repoPath, status, title: docTitle, type: typeName }); } catch (err) { warn(`Hook 'onNew' threw: ${err.message}`); }
|
|
957
|
+
const sessionLocal = typeName === 'prompt';
|
|
958
|
+
return {
|
|
959
|
+
operation: 'new',
|
|
960
|
+
repositoryFiles: sessionLocal ? [] : [repoPath, ...childPaths.map(item => toRepoPath(item, config.repoRoot))],
|
|
961
|
+
sessionFiles: sessionLocal ? [repoPath] : [],
|
|
962
|
+
generatedFiles: config.indexPath && indexRegenerated ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
963
|
+
deferredGeneratedFiles: config.indexPath && opts.deferIndex ? [toRepoPath(config.indexPath, config.repoRoot)] : [],
|
|
964
|
+
path: repoPath,
|
|
965
|
+
};
|
|
885
966
|
}
|
|
886
967
|
|
|
887
968
|
function resolveTemplate(name, config) {
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const HTML_FALLBACK_DIR = '__dotmd';
|
|
5
|
+
|
|
6
|
+
function normalizeRelative(value, kind) {
|
|
7
|
+
if (typeof value !== 'string' || value.includes('\0')) {
|
|
8
|
+
throw new Error(`${kind} must be a string without NUL bytes`);
|
|
9
|
+
}
|
|
10
|
+
const normalized = path.posix.normalize(value || '.');
|
|
11
|
+
if (path.posix.isAbsolute(normalized)) throw new Error(`${kind} must be relative: ${value}`);
|
|
12
|
+
return normalized;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isTraversal(value) {
|
|
16
|
+
return value === '..' || value.startsWith('../');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function pathsConflict(a, b) {
|
|
20
|
+
const left = a.toLowerCase();
|
|
21
|
+
const right = b.toLowerCase();
|
|
22
|
+
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateHtmlPath(value) {
|
|
26
|
+
const normalized = normalizeRelative(value, 'HTML output path');
|
|
27
|
+
if (normalized === '.' || isTraversal(normalized) || normalized !== value) {
|
|
28
|
+
throw new Error(`Unsafe HTML output path: ${value}`);
|
|
29
|
+
}
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function outputPathToUrl(value) {
|
|
34
|
+
return validateHtmlPath(value).split('/').map(encodeURIComponent).join('/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function allocateOutputIdentities(docs) {
|
|
38
|
+
const entries = [];
|
|
39
|
+
const logicalIds = new Set();
|
|
40
|
+
|
|
41
|
+
for (const doc of docs) {
|
|
42
|
+
const logicalId = normalizeRelative(doc.path, 'Document path');
|
|
43
|
+
if (logicalIds.has(logicalId)) throw new Error(`Duplicate document path: ${logicalId}`);
|
|
44
|
+
logicalIds.add(logicalId);
|
|
45
|
+
|
|
46
|
+
const root = normalizeRelative(doc.root || '.', 'Document root');
|
|
47
|
+
const relativeSource = path.posix.relative(root, logicalId);
|
|
48
|
+
if (!relativeSource || isTraversal(relativeSource) || path.posix.isAbsolute(relativeSource)) {
|
|
49
|
+
throw new Error(`Document path is not owned by its root: ${logicalId} (root: ${root})`);
|
|
50
|
+
}
|
|
51
|
+
if (!relativeSource.toLowerCase().endsWith('.md')) {
|
|
52
|
+
throw new Error(`Document path must end in .md: ${logicalId}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const preferred = validateHtmlPath(relativeSource.slice(0, -3) + '.html');
|
|
56
|
+
entries.push({
|
|
57
|
+
logicalId,
|
|
58
|
+
label: path.posix.basename(logicalId, path.posix.extname(logicalId)),
|
|
59
|
+
preferred,
|
|
60
|
+
conflict: preferred.toLowerCase() === 'index.html'
|
|
61
|
+
|| preferred.toLowerCase().startsWith('index.html/')
|
|
62
|
+
|| preferred.toLowerCase().startsWith(`${HTML_FALLBACK_DIR.toLowerCase()}/`),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
entries.sort((a, b) => a.logicalId.localeCompare(b.logicalId));
|
|
67
|
+
for (let i = 0; i < entries.length; i++) {
|
|
68
|
+
for (let j = i + 1; j < entries.length; j++) {
|
|
69
|
+
if (!pathsConflict(entries[i].preferred, entries[j].preferred)) continue;
|
|
70
|
+
entries[i].conflict = true;
|
|
71
|
+
entries[j].conflict = true;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const digestOwners = new Map();
|
|
76
|
+
for (const entry of entries) {
|
|
77
|
+
if (!entry.conflict) {
|
|
78
|
+
entry.htmlPath = entry.preferred;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const digest = createHash('sha256').update(entry.logicalId).digest('hex');
|
|
82
|
+
const owner = digestOwners.get(digest);
|
|
83
|
+
if (owner && owner !== entry.logicalId) {
|
|
84
|
+
throw new Error(`Output identity hash collision: ${owner} and ${entry.logicalId}`);
|
|
85
|
+
}
|
|
86
|
+
digestOwners.set(digest, entry.logicalId);
|
|
87
|
+
entry.htmlPath = `${HTML_FALLBACK_DIR}/${digest}.html`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const finalEntries = [...entries].sort((a, b) => a.htmlPath.localeCompare(b.htmlPath));
|
|
91
|
+
for (let i = 0; i < finalEntries.length; i++) {
|
|
92
|
+
validateHtmlPath(finalEntries[i].htmlPath);
|
|
93
|
+
for (let j = i + 1; j < finalEntries.length; j++) {
|
|
94
|
+
if (pathsConflict(finalEntries[i].htmlPath, finalEntries[j].htmlPath)) {
|
|
95
|
+
throw new Error(`Conflicting HTML output paths: ${finalEntries[i].htmlPath} and ${finalEntries[j].htmlPath}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return new Map(entries.map(entry => [entry.logicalId, {
|
|
101
|
+
logicalId: entry.logicalId,
|
|
102
|
+
label: entry.label,
|
|
103
|
+
htmlPath: entry.htmlPath,
|
|
104
|
+
htmlUrl: outputPathToUrl(entry.htmlPath),
|
|
105
|
+
}]));
|
|
106
|
+
}
|
package/src/pickup-card.mjs
CHANGED
|
@@ -70,7 +70,7 @@ function readRelatedSummary(rawList, config, docDir) {
|
|
|
70
70
|
|
|
71
71
|
// Build the structured card object. Pure: no IO beyond what's passed in.
|
|
72
72
|
export function buildCard(filePath, raw, config) {
|
|
73
|
-
const { frontmatter: fmRaw, body } = extractFrontmatter(raw);
|
|
73
|
+
const { frontmatter: fmRaw, body, bodyLineOffset } = extractFrontmatter(raw);
|
|
74
74
|
const fm = parseSimpleFrontmatter(fmRaw);
|
|
75
75
|
const sections = walkSections(body);
|
|
76
76
|
|
|
@@ -117,13 +117,23 @@ export function buildCard(filePath, raw, config) {
|
|
|
117
117
|
|
|
118
118
|
// Phases summary + active phase (pointer only, no body)
|
|
119
119
|
const phaseSummary = summarizePhases(sections);
|
|
120
|
+
const toFileLine = line => line + bodyLineOffset;
|
|
121
|
+
const phaseSummaryForCard = {
|
|
122
|
+
...phaseSummary,
|
|
123
|
+
phases: phaseSummary.phases.map(phase => ({
|
|
124
|
+
...phase,
|
|
125
|
+
lineStart: toFileLine(phase.lineStart),
|
|
126
|
+
lineEnd: toFileLine(phase.lineEnd),
|
|
127
|
+
bodyLineStart: toFileLine(phase.bodyLineStart),
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
120
130
|
const activePhase = findActivePhase(sections);
|
|
121
131
|
let activePhasePointer = null;
|
|
122
132
|
if (activePhase) {
|
|
123
133
|
activePhasePointer = {
|
|
124
134
|
heading: activePhase.heading,
|
|
125
|
-
lineStart: activePhase.lineStart,
|
|
126
|
-
lineEnd: activePhase.lineEnd,
|
|
135
|
+
lineStart: toFileLine(activePhase.lineStart),
|
|
136
|
+
lineEnd: toFileLine(activePhase.lineEnd),
|
|
127
137
|
marker: detectMarker(activePhase.heading),
|
|
128
138
|
};
|
|
129
139
|
}
|
|
@@ -136,8 +146,8 @@ export function buildCard(filePath, raw, config) {
|
|
|
136
146
|
if (lastH2) {
|
|
137
147
|
fallbackPointer = {
|
|
138
148
|
heading: lastH2.heading,
|
|
139
|
-
lineStart: lastH2.lineStart,
|
|
140
|
-
lineEnd: lastH2.lineEnd,
|
|
149
|
+
lineStart: toFileLine(lastH2.lineStart),
|
|
150
|
+
lineEnd: toFileLine(lastH2.lineEnd),
|
|
141
151
|
};
|
|
142
152
|
}
|
|
143
153
|
}
|
|
@@ -148,8 +158,8 @@ export function buildCard(filePath, raw, config) {
|
|
|
148
158
|
if (oqSection && oqSection.body.trim()) {
|
|
149
159
|
openQuestions = {
|
|
150
160
|
heading: oqSection.heading,
|
|
151
|
-
lineStart: oqSection.lineStart,
|
|
152
|
-
lineEnd: oqSection.lineEnd,
|
|
161
|
+
lineStart: toFileLine(oqSection.lineStart),
|
|
162
|
+
lineEnd: toFileLine(oqSection.lineEnd),
|
|
153
163
|
count: countBullets(oqSection.body),
|
|
154
164
|
};
|
|
155
165
|
}
|
|
@@ -165,11 +175,13 @@ export function buildCard(filePath, raw, config) {
|
|
|
165
175
|
// Outline = all H2 headings with line ranges; phase summary inline if Phases present
|
|
166
176
|
const outline = sections.filter(s => s.level === 2).map(s => {
|
|
167
177
|
const isPhases = /^phases?$/i.test(s.heading.replace(/[^\w\s]+$/, '').trim());
|
|
168
|
-
|
|
178
|
+
const lineStart = toFileLine(s.lineStart);
|
|
179
|
+
const lineEnd = toFileLine(s.lineEnd);
|
|
180
|
+
let suffix = `lines ${lineStart}-${lineEnd}`;
|
|
169
181
|
if (isPhases && phaseSummary.total > 0) {
|
|
170
182
|
suffix = `(${phaseSummary.total}: ${statusSummary(phaseSummary.counts)}) ${suffix}`;
|
|
171
183
|
}
|
|
172
|
-
return { heading: s.heading, lineStart
|
|
184
|
+
return { heading: s.heading, lineStart, lineEnd, suffix };
|
|
173
185
|
});
|
|
174
186
|
|
|
175
187
|
return {
|
|
@@ -181,13 +193,15 @@ export function buildCard(filePath, raw, config) {
|
|
|
181
193
|
currentState,
|
|
182
194
|
nextStep,
|
|
183
195
|
related,
|
|
184
|
-
phases:
|
|
196
|
+
phases: phaseSummaryForCard,
|
|
185
197
|
activePhase: activePhasePointer,
|
|
186
198
|
fallbackContent: fallbackPointer,
|
|
187
199
|
openQuestions,
|
|
188
200
|
lastVersion,
|
|
189
201
|
outline,
|
|
190
202
|
bodyBytes: body.length,
|
|
203
|
+
bodyLineOffset,
|
|
204
|
+
coordinateSpace: 'file',
|
|
191
205
|
};
|
|
192
206
|
}
|
|
193
207
|
|