@majeanson/lac 3.4.5 → 3.5.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/dist/index.mjs +1951 -176
- package/dist/index.mjs.map +1 -1
- package/dist/lsp.mjs +4 -0
- package/dist/mcp.mjs +35 -2
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -3781,6 +3781,10 @@ const FeatureSchema$1 = object$1({
|
|
|
3781
3781
|
lastVerifiedDate: string$2().regex(/^\d{4}-\d{2}-\d{2}$/, "lastVerifiedDate must be YYYY-MM-DD").optional(),
|
|
3782
3782
|
codeSnippets: array$1(CodeSnippetSchema$1).optional(),
|
|
3783
3783
|
implementationNotes: array$1(string$2()).optional(),
|
|
3784
|
+
pmSummary: string$2().optional(),
|
|
3785
|
+
testStrategy: string$2().optional(),
|
|
3786
|
+
releaseVersion: string$2().optional(),
|
|
3787
|
+
acceptanceCriteria: array$1(string$2()).optional(),
|
|
3784
3788
|
fieldLocks: array$1(FieldLockSchema$1).optional(),
|
|
3785
3789
|
featureLocked: boolean$2().optional()
|
|
3786
3790
|
});
|
|
@@ -7725,6 +7729,10 @@ const FeatureSchema = object({
|
|
|
7725
7729
|
lastVerifiedDate: string().regex(/^\d{4}-\d{2}-\d{2}$/, "lastVerifiedDate must be YYYY-MM-DD").optional(),
|
|
7726
7730
|
codeSnippets: array(CodeSnippetSchema).optional(),
|
|
7727
7731
|
implementationNotes: array(string()).optional(),
|
|
7732
|
+
pmSummary: string().optional(),
|
|
7733
|
+
testStrategy: string().optional(),
|
|
7734
|
+
releaseVersion: string().optional(),
|
|
7735
|
+
acceptanceCriteria: array(string()).optional(),
|
|
7728
7736
|
fieldLocks: array(FieldLockSchema).optional(),
|
|
7729
7737
|
featureLocked: boolean().optional()
|
|
7730
7738
|
});
|
|
@@ -8142,6 +8150,25 @@ Return ONLY a valid JSON array of plain strings — no other text, no markdown f
|
|
|
8142
8150
|
|
|
8143
8151
|
If there are no notable implementation notes, return an empty array: []`,
|
|
8144
8152
|
userSuffix: "Extract free-form implementation notes for this feature."
|
|
8153
|
+
},
|
|
8154
|
+
pmSummary: {
|
|
8155
|
+
system: `You are a product manager writing for a non-technical executive audience. Given a feature.json, write a 1–2 sentence business-value summary. Focus on the outcome and the user or business benefit — not the technical implementation. Avoid words like "component", "schema", "API", "TypeScript". Write as if you are telling a stakeholder why this feature matters. Return only the summary text, no JSON wrapper, no heading.`,
|
|
8156
|
+
userSuffix: "Write a 1–2 sentence PM/exec summary for this feature."
|
|
8157
|
+
},
|
|
8158
|
+
testStrategy: {
|
|
8159
|
+
system: `You are a software testing expert. Given a feature.json, describe in 2-4 sentences how this feature should be tested. Cover: what type of tests are appropriate (unit, integration, E2E, manual), what the hardest thing to test is, and any test setup or environment requirements implied by the feature. Be specific — name the actual components or flows that need coverage. Return only the strategy text, no JSON wrapper, no heading.`,
|
|
8160
|
+
userSuffix: "Describe the test strategy for this feature."
|
|
8161
|
+
},
|
|
8162
|
+
releaseVersion: {
|
|
8163
|
+
system: `You are a software engineering analyst. Given a feature.json, return the release version string this feature first shipped in, if it can be inferred from the statusHistory dates, revisions, or annotations. If not determinable, return an empty string. Return only the version string (e.g. "3.5.0", "v2", "2026-Q2") or an empty string — nothing else.`,
|
|
8164
|
+
userSuffix: "Identify the release version this feature first shipped in, or return empty string if unknown."
|
|
8165
|
+
},
|
|
8166
|
+
acceptanceCriteria: {
|
|
8167
|
+
system: `You are a software testing expert. Given a feature.json, break the successCriteria and problem statement into 3-8 discrete, testable acceptance criteria. Each criterion must be a single concrete, verifiable statement (e.g. "Camera roll permission prompt appears on first launch only", "Denying permission shows a recovery screen with an Open Settings button"). No vague statements. No compound criteria (one thing per item).
|
|
8168
|
+
|
|
8169
|
+
Return ONLY a valid JSON array of strings — no other text:
|
|
8170
|
+
["criterion 1", "criterion 2", "criterion 3"]`,
|
|
8171
|
+
userSuffix: "Generate structured acceptance criteria for this feature."
|
|
8145
8172
|
}
|
|
8146
8173
|
};
|
|
8147
8174
|
const JSON_FIELDS = new Set([
|
|
@@ -8152,7 +8179,9 @@ const JSON_FIELDS = new Set([
|
|
|
8152
8179
|
"npmPackages",
|
|
8153
8180
|
"publicInterface",
|
|
8154
8181
|
"externalDependencies",
|
|
8155
|
-
"codeSnippets"
|
|
8182
|
+
"codeSnippets",
|
|
8183
|
+
"implementationNotes",
|
|
8184
|
+
"acceptanceCriteria"
|
|
8156
8185
|
]);
|
|
8157
8186
|
const ALL_FILLABLE_FIELDS = [
|
|
8158
8187
|
"analysis",
|
|
@@ -8162,6 +8191,9 @@ const ALL_FILLABLE_FIELDS = [
|
|
|
8162
8191
|
"tags",
|
|
8163
8192
|
"successCriteria",
|
|
8164
8193
|
"userGuide",
|
|
8194
|
+
"pmSummary",
|
|
8195
|
+
"testStrategy",
|
|
8196
|
+
"acceptanceCriteria",
|
|
8165
8197
|
"domain",
|
|
8166
8198
|
"componentFile",
|
|
8167
8199
|
"npmPackages",
|
|
@@ -8169,7 +8201,8 @@ const ALL_FILLABLE_FIELDS = [
|
|
|
8169
8201
|
"externalDependencies",
|
|
8170
8202
|
"lastVerifiedDate",
|
|
8171
8203
|
"codeSnippets",
|
|
8172
|
-
"implementationNotes"
|
|
8204
|
+
"implementationNotes",
|
|
8205
|
+
"releaseVersion"
|
|
8173
8206
|
];
|
|
8174
8207
|
function getMissingFields(feature) {
|
|
8175
8208
|
return ALL_FILLABLE_FIELDS.filter((field) => {
|
|
@@ -8186,7 +8219,7 @@ const GEN_PROMPTS = {
|
|
|
8186
8219
|
userSuffix: "Generate a React TypeScript component for this feature."
|
|
8187
8220
|
},
|
|
8188
8221
|
test: {
|
|
8189
|
-
system: `You are an expert software testing engineer. You will be given a feature.json. Generate a comprehensive test suite using Vitest.
|
|
8222
|
+
system: `You are an expert software testing engineer. You will be given a feature.json. Generate a comprehensive test suite using Vitest. Primary inputs: use acceptanceCriteria[] for happy-path test cases (one test per criterion), testStrategy to determine the appropriate test type (unit/integration/E2E), and knownLimitations for edge-case tests. If acceptanceCriteria is absent, derive tests from successCriteria. Return only the test code, no explanation.`,
|
|
8190
8223
|
userSuffix: "Generate a Vitest test suite for this feature."
|
|
8191
8224
|
},
|
|
8192
8225
|
migration: {
|
|
@@ -8196,6 +8229,10 @@ const GEN_PROMPTS = {
|
|
|
8196
8229
|
docs: {
|
|
8197
8230
|
system: `You are a technical writer. You will be given a feature.json. Generate user-facing documentation for this feature. Write it clearly enough that any end user can understand it (not developer-focused). Cover: what it does, how to use it, and known limitations. Use Markdown. Return only the documentation, no explanation.`,
|
|
8198
8231
|
userSuffix: "Generate user-facing documentation for this feature."
|
|
8232
|
+
},
|
|
8233
|
+
mock: {
|
|
8234
|
+
system: `You are an expert TypeScript developer. You will be given a feature.json with a publicInterface[] array describing exported types, components, hooks, services, and functions. Generate realistic TypeScript mock factories for each entry. Use hand-crafted realistic static values (no external faker library). Export a factory function for each interface named \`mock<Name>(overrides?: Partial<Type>): Type\`. Where the shape is not fully defined in the feature, make reasonable assumptions from the name and description. Return only the TypeScript code, no explanation.`,
|
|
8235
|
+
userSuffix: "Generate TypeScript mock factories for this feature."
|
|
8199
8236
|
}
|
|
8200
8237
|
};
|
|
8201
8238
|
const PROMPT_LOG_FILENAME = "prompt.log.jsonl";
|
|
@@ -8468,29 +8505,183 @@ async function genFromFeature(options) {
|
|
|
8468
8505
|
const result = validateFeature(JSON.parse(raw));
|
|
8469
8506
|
if (!result.success) throw new Error(`Invalid feature.json: ${result.errors.join(", ")}`);
|
|
8470
8507
|
const feature = result.data;
|
|
8508
|
+
if (type === "types") return writeOrPrint(genTypes(feature), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}.types.ts`), dryRun, type);
|
|
8509
|
+
if (type === "adr") return writeOrPrint(genAdr(feature), options.outFile ?? path.join(featureDir, "ARCHITECTURE-DECISIONS.md"), dryRun, type);
|
|
8510
|
+
if (type === "snippets") return writeOrPrint(genSnippets(feature), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}-snippets.md`), dryRun, type);
|
|
8471
8511
|
const promptConfig = GEN_PROMPTS[type];
|
|
8472
|
-
if (!promptConfig) throw new Error(`Unknown generation type: "${type}". Available: component, test, migration, docs`);
|
|
8512
|
+
if (!promptConfig) throw new Error(`Unknown generation type: "${type}". Available: component, test, migration, docs, types, adr, snippets, mock`);
|
|
8473
8513
|
const client = createClient();
|
|
8474
|
-
process$1.stdout.write(`\nGenerating ${type} for ${feature
|
|
8514
|
+
process$1.stdout.write(`\nGenerating ${type} for ${feature["featureKey"]} (${feature["title"]})...\n`);
|
|
8475
8515
|
process$1.stdout.write(`Model: ${model}\n\n`);
|
|
8476
|
-
const contextStr = contextToString(buildContext(featureDir,
|
|
8477
|
-
|
|
8516
|
+
const contextStr = contextToString(buildContext(featureDir, result.data));
|
|
8517
|
+
return writeOrPrint(await generateText(client, promptConfig.system, `${contextStr}\n\n${promptConfig.userSuffix}`, model), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}${typeToExt(type)}`), dryRun, type);
|
|
8518
|
+
}
|
|
8519
|
+
function writeOrPrint(content, outFile, dryRun, type) {
|
|
8478
8520
|
if (dryRun) {
|
|
8479
|
-
process$1.stdout.write(
|
|
8480
|
-
process$1.stdout.write(
|
|
8481
|
-
return
|
|
8521
|
+
process$1.stdout.write(content);
|
|
8522
|
+
process$1.stdout.write(`\n\n [dry-run] No file written.\n`);
|
|
8523
|
+
return content;
|
|
8524
|
+
}
|
|
8525
|
+
fs.writeFileSync(outFile, content, "utf-8");
|
|
8526
|
+
process$1.stdout.write(` ✓ Generated ${type} → ${outFile}\n\n`);
|
|
8527
|
+
return content;
|
|
8528
|
+
}
|
|
8529
|
+
function genTypes(feature) {
|
|
8530
|
+
const iface = feature["publicInterface"];
|
|
8531
|
+
const lines = [
|
|
8532
|
+
`// Generated by lac gen --type types`,
|
|
8533
|
+
`// Feature: ${feature["featureKey"]} — ${feature["title"]}`,
|
|
8534
|
+
`// Do not edit manually — regenerate with: lac gen --type types`,
|
|
8535
|
+
``
|
|
8536
|
+
];
|
|
8537
|
+
if (!iface || iface.length === 0) {
|
|
8538
|
+
lines.push(`// No publicInterface entries found in feature.json`);
|
|
8539
|
+
lines.push(`// Add publicInterface[] to the feature to generate type declarations`);
|
|
8540
|
+
return lines.join("\n");
|
|
8541
|
+
}
|
|
8542
|
+
for (const entry of iface) {
|
|
8543
|
+
if (entry.description) lines.push(`/** ${entry.description} */`);
|
|
8544
|
+
const lower = entry.type.toLowerCase();
|
|
8545
|
+
const name = entry.name;
|
|
8546
|
+
if (lower.includes("react component") || lower.includes("component") && !lower.includes("context")) {
|
|
8547
|
+
lines.push(`export interface ${name}Props {`);
|
|
8548
|
+
lines.push(` // TODO: define props`);
|
|
8549
|
+
lines.push(`}`);
|
|
8550
|
+
lines.push(`export declare function ${name}(props: ${name}Props): JSX.Element`);
|
|
8551
|
+
} else if (lower.includes("context") || lower.includes("provider")) {
|
|
8552
|
+
const valueName = `${name}Value`;
|
|
8553
|
+
lines.push(`export interface ${valueName} {`);
|
|
8554
|
+
lines.push(` // TODO: define context value shape`);
|
|
8555
|
+
lines.push(`}`);
|
|
8556
|
+
lines.push(`export declare const ${name}: React.Context<${valueName}>`);
|
|
8557
|
+
} else if (lower.includes("hook") || name.startsWith("use")) lines.push(`export declare function ${name}(): unknown // TODO: define return type`);
|
|
8558
|
+
else if (lower.includes("service") || lower.includes("manager") || lower.includes("class")) {
|
|
8559
|
+
lines.push(`export declare class ${name} {`);
|
|
8560
|
+
lines.push(` // TODO: define service methods`);
|
|
8561
|
+
lines.push(`}`);
|
|
8562
|
+
} else if (lower.includes("function") || lower.includes("util") || lower.includes("helper")) lines.push(`export declare function ${name}(...args: unknown[]): unknown // TODO: define signature`);
|
|
8563
|
+
else {
|
|
8564
|
+
lines.push(`export interface ${name} {`);
|
|
8565
|
+
lines.push(` // TODO: define interface shape`);
|
|
8566
|
+
lines.push(`}`);
|
|
8567
|
+
}
|
|
8568
|
+
lines.push(``);
|
|
8569
|
+
}
|
|
8570
|
+
return lines.join("\n");
|
|
8571
|
+
}
|
|
8572
|
+
function genAdr(feature) {
|
|
8573
|
+
const decisions = feature["decisions"];
|
|
8574
|
+
const featureKey = feature["featureKey"];
|
|
8575
|
+
const title = feature["title"];
|
|
8576
|
+
const problem = feature["problem"];
|
|
8577
|
+
const analysis = feature["analysis"];
|
|
8578
|
+
const lines = [
|
|
8579
|
+
`# Architecture Decision Records — ${title}`,
|
|
8580
|
+
``,
|
|
8581
|
+
`> Feature: \`${featureKey}\` `,
|
|
8582
|
+
`> Generated by: \`lac gen --type adr\``,
|
|
8583
|
+
``,
|
|
8584
|
+
`---`,
|
|
8585
|
+
``
|
|
8586
|
+
];
|
|
8587
|
+
if (!decisions || decisions.length === 0) {
|
|
8588
|
+
lines.push(`*No decisions recorded in this feature.json.*`);
|
|
8589
|
+
lines.push(``);
|
|
8590
|
+
lines.push(`Add \`decisions[]\` to the feature to generate ADRs.`);
|
|
8591
|
+
return lines.join("\n");
|
|
8592
|
+
}
|
|
8593
|
+
decisions.forEach((d, i) => {
|
|
8594
|
+
d.decision.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
|
|
8595
|
+
lines.push(`## ADR-${String(i + 1).padStart(3, "0")}: ${d.decision}`);
|
|
8596
|
+
lines.push(``);
|
|
8597
|
+
lines.push(`**Status:** Accepted `);
|
|
8598
|
+
if (d.date) lines.push(`**Date:** ${d.date} `);
|
|
8599
|
+
lines.push(`**Feature:** \`${featureKey}\``);
|
|
8600
|
+
lines.push(``);
|
|
8601
|
+
lines.push(`### Context`);
|
|
8602
|
+
lines.push(``);
|
|
8603
|
+
if (problem) {
|
|
8604
|
+
lines.push(problem);
|
|
8605
|
+
lines.push(``);
|
|
8606
|
+
}
|
|
8607
|
+
if (analysis) {
|
|
8608
|
+
lines.push(analysis);
|
|
8609
|
+
lines.push(``);
|
|
8610
|
+
}
|
|
8611
|
+
lines.push(`### Decision`);
|
|
8612
|
+
lines.push(``);
|
|
8613
|
+
lines.push(d.decision);
|
|
8614
|
+
lines.push(``);
|
|
8615
|
+
lines.push(`### Rationale`);
|
|
8616
|
+
lines.push(``);
|
|
8617
|
+
lines.push(d.rationale);
|
|
8618
|
+
lines.push(``);
|
|
8619
|
+
if (d.alternativesConsidered && d.alternativesConsidered.length > 0) {
|
|
8620
|
+
lines.push(`### Alternatives Considered`);
|
|
8621
|
+
lines.push(``);
|
|
8622
|
+
for (const alt of d.alternativesConsidered) lines.push(`- ${alt}`);
|
|
8623
|
+
lines.push(``);
|
|
8624
|
+
}
|
|
8625
|
+
lines.push(`---`);
|
|
8626
|
+
lines.push(``);
|
|
8627
|
+
});
|
|
8628
|
+
return lines.join("\n");
|
|
8629
|
+
}
|
|
8630
|
+
function genSnippets(feature) {
|
|
8631
|
+
const snippets = feature["codeSnippets"];
|
|
8632
|
+
const featureKey = feature["featureKey"];
|
|
8633
|
+
const lines = [
|
|
8634
|
+
`# Code Snippets — ${feature["title"]}`,
|
|
8635
|
+
``,
|
|
8636
|
+
`> Feature: \`${featureKey}\` `,
|
|
8637
|
+
`> Generated by: \`lac gen --type snippets\``,
|
|
8638
|
+
``,
|
|
8639
|
+
`---`,
|
|
8640
|
+
``
|
|
8641
|
+
];
|
|
8642
|
+
if (!snippets || snippets.length === 0) {
|
|
8643
|
+
lines.push(`*No codeSnippets recorded in this feature.json.*`);
|
|
8644
|
+
lines.push(``);
|
|
8645
|
+
lines.push(`Add \`codeSnippets[]\` to the feature to generate a snippet reference.`);
|
|
8646
|
+
return lines.join("\n");
|
|
8647
|
+
}
|
|
8648
|
+
for (const s of snippets) {
|
|
8649
|
+
lines.push(`## ${s.label}`);
|
|
8650
|
+
lines.push(``);
|
|
8651
|
+
lines.push("```");
|
|
8652
|
+
lines.push(s.snippet);
|
|
8653
|
+
lines.push("```");
|
|
8654
|
+
lines.push(``);
|
|
8655
|
+
}
|
|
8656
|
+
return lines.join("\n");
|
|
8657
|
+
}
|
|
8658
|
+
/**
|
|
8659
|
+
* Runs a custom AI generator against a single feature.json.
|
|
8660
|
+
* Used by the `lac gen --generator <name>` plugin system for type:"ai" generators.
|
|
8661
|
+
*/
|
|
8662
|
+
async function genWithCustomPrompt(options) {
|
|
8663
|
+
const { featureDir, systemPrompt, dryRun = false, model = "claude-sonnet-4-6" } = options;
|
|
8664
|
+
const featurePath = path.join(featureDir, "feature.json");
|
|
8665
|
+
let raw;
|
|
8666
|
+
try {
|
|
8667
|
+
raw = fs.readFileSync(featurePath, "utf-8");
|
|
8668
|
+
} catch {
|
|
8669
|
+
throw new Error(`No feature.json found at "${featurePath}"`);
|
|
8482
8670
|
}
|
|
8483
|
-
const
|
|
8484
|
-
|
|
8485
|
-
|
|
8486
|
-
|
|
8671
|
+
const result = validateFeature(JSON.parse(raw));
|
|
8672
|
+
if (!result.success) throw new Error(`Invalid feature.json: ${result.errors.join(", ")}`);
|
|
8673
|
+
const feature = result.data;
|
|
8674
|
+
const client = createClient();
|
|
8675
|
+
process$1.stdout.write(` Running AI generator for ${feature["featureKey"]} (${feature["title"]})...\n`);
|
|
8676
|
+
return writeOrPrint(await generateText(client, systemPrompt, `${contextToString(buildContext(featureDir, result.data))}\n\nGenerate the requested output for this feature.`, model), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}-generated.txt`), dryRun, "custom");
|
|
8487
8677
|
}
|
|
8488
8678
|
function typeToExt(type) {
|
|
8489
8679
|
return {
|
|
8490
8680
|
component: ".tsx",
|
|
8491
8681
|
test: ".test.ts",
|
|
8492
8682
|
migration: ".sql",
|
|
8493
|
-
docs: ".md"
|
|
8683
|
+
docs: ".md",
|
|
8684
|
+
mock: ".mock.ts"
|
|
8494
8685
|
}[type] ?? ".txt";
|
|
8495
8686
|
}
|
|
8496
8687
|
function askUser$1(question) {
|
|
@@ -9018,7 +9209,9 @@ const DEFAULTS = {
|
|
|
9018
9209
|
restrictedFields: [],
|
|
9019
9210
|
requireAlternatives: false,
|
|
9020
9211
|
freezeRequiresHumanRevision: false
|
|
9021
|
-
}
|
|
9212
|
+
},
|
|
9213
|
+
views: {},
|
|
9214
|
+
generators: {}
|
|
9022
9215
|
};
|
|
9023
9216
|
function loadConfig(fromDir) {
|
|
9024
9217
|
const configPath = findLacConfig(fromDir ?? process$1.cwd());
|
|
@@ -9038,7 +9231,9 @@ function loadConfig(fromDir) {
|
|
|
9038
9231
|
restrictedFields: parsed.guardlock?.restrictedFields ?? DEFAULTS.guardlock.restrictedFields,
|
|
9039
9232
|
requireAlternatives: parsed.guardlock?.requireAlternatives ?? DEFAULTS.guardlock.requireAlternatives,
|
|
9040
9233
|
freezeRequiresHumanRevision: parsed.guardlock?.freezeRequiresHumanRevision ?? DEFAULTS.guardlock.freezeRequiresHumanRevision
|
|
9041
|
-
}
|
|
9234
|
+
},
|
|
9235
|
+
views: parsed.views ?? DEFAULTS.views,
|
|
9236
|
+
generators: parsed.generators ?? DEFAULTS.generators
|
|
9042
9237
|
};
|
|
9043
9238
|
} catch {
|
|
9044
9239
|
process$1.stderr.write(`Warning: could not parse lac.config.json at "${configPath}" — using defaults\n`);
|
|
@@ -9184,22 +9379,85 @@ const configCommand = new Command("config").description("Show the resolved lac.c
|
|
|
9184
9379
|
});
|
|
9185
9380
|
//#endregion
|
|
9186
9381
|
//#region src/commands/gen.ts
|
|
9187
|
-
const
|
|
9382
|
+
const FEATURE_GEN_TYPES = [
|
|
9188
9383
|
"component",
|
|
9189
9384
|
"test",
|
|
9190
9385
|
"migration",
|
|
9191
|
-
"docs"
|
|
9386
|
+
"docs",
|
|
9387
|
+
"types",
|
|
9388
|
+
"adr",
|
|
9389
|
+
"snippets",
|
|
9390
|
+
"mock"
|
|
9192
9391
|
];
|
|
9193
|
-
const
|
|
9392
|
+
const WORKSPACE_GEN_TYPES = ["index"];
|
|
9393
|
+
const ALL_GEN_TYPES = [...FEATURE_GEN_TYPES, ...WORKSPACE_GEN_TYPES];
|
|
9394
|
+
const genCommand = new Command("gen").description("Generate code artifacts from feature.json(s)").argument("[dir]", "Feature folder or workspace root (default: nearest feature.json / cwd)").option(`--type <type>`, `Built-in generator: ${ALL_GEN_TYPES.join(", ")}`).option("--generator <name>", "Run a named generator from lac.config.json generators block").option("--list", "List all available generators (built-in + config-defined)").option("--dry-run", "Print generated content without writing to disk").option("--out <file>", "Output file path (default: auto-named)").option("--model <model>", "Claude model to use (default: claude-sonnet-4-6)").action(async (dir, options) => {
|
|
9395
|
+
const cwd = process$1.cwd();
|
|
9396
|
+
const startDir = dir ? resolve(dir) : cwd;
|
|
9397
|
+
const config = loadConfig(startDir);
|
|
9398
|
+
const configFilePath = findLacConfig(startDir);
|
|
9399
|
+
const configDir = configFilePath ? dirname(configFilePath) : startDir;
|
|
9400
|
+
if (options.list) {
|
|
9401
|
+
process$1.stdout.write("\nBuilt-in generator types:\n");
|
|
9402
|
+
for (const t of ALL_GEN_TYPES) {
|
|
9403
|
+
const scope = WORKSPACE_GEN_TYPES.includes(t) ? "workspace" : "feature";
|
|
9404
|
+
process$1.stdout.write(` ${t.padEnd(12)} [${scope}]\n`);
|
|
9405
|
+
}
|
|
9406
|
+
const customEntries = Object.entries(config.generators);
|
|
9407
|
+
if (customEntries.length === 0) {
|
|
9408
|
+
process$1.stdout.write("\nCustom generators (lac.config.json): none\n");
|
|
9409
|
+
process$1.stdout.write(" Add a \"generators\" block to your lac.config.json to define custom generators.\n");
|
|
9410
|
+
} else {
|
|
9411
|
+
process$1.stdout.write("\nCustom generators (lac.config.json):\n");
|
|
9412
|
+
for (const [name, gen] of customEntries) {
|
|
9413
|
+
const scope = gen.scope ?? "feature";
|
|
9414
|
+
const desc = gen.description ? ` — ${gen.description}` : "";
|
|
9415
|
+
process$1.stdout.write(` ${name.padEnd(16)} [${gen.type}/${scope}]${desc}\n`);
|
|
9416
|
+
}
|
|
9417
|
+
}
|
|
9418
|
+
process$1.stdout.write("\n");
|
|
9419
|
+
return;
|
|
9420
|
+
}
|
|
9421
|
+
if (options.generator) {
|
|
9422
|
+
const genDef = config.generators[options.generator];
|
|
9423
|
+
if (!genDef) {
|
|
9424
|
+
const available = Object.keys(config.generators);
|
|
9425
|
+
process$1.stderr.write(`Unknown generator "${options.generator}". Available: ${available.length > 0 ? available.join(", ") : "(none — add generators to lac.config.json)"}\n`);
|
|
9426
|
+
process$1.exit(1);
|
|
9427
|
+
}
|
|
9428
|
+
try {
|
|
9429
|
+
await runPluginGenerator(options.generator, genDef, startDir, configDir, {
|
|
9430
|
+
dryRun: options.dryRun ?? false,
|
|
9431
|
+
out: options.out,
|
|
9432
|
+
model: options.model
|
|
9433
|
+
});
|
|
9434
|
+
} catch (err) {
|
|
9435
|
+
process$1.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
9436
|
+
process$1.exit(1);
|
|
9437
|
+
}
|
|
9438
|
+
return;
|
|
9439
|
+
}
|
|
9194
9440
|
const type = options.type ?? "component";
|
|
9195
|
-
if (!
|
|
9196
|
-
process$1.stderr.write(`Unknown type "${type}". Available: ${
|
|
9441
|
+
if (!ALL_GEN_TYPES.includes(type)) {
|
|
9442
|
+
process$1.stderr.write(`Unknown type "${type}". Available: ${ALL_GEN_TYPES.join(", ")}\n`);
|
|
9197
9443
|
process$1.exit(1);
|
|
9198
9444
|
}
|
|
9445
|
+
if (type === "index") {
|
|
9446
|
+
try {
|
|
9447
|
+
await runIndexGenerator(startDir, {
|
|
9448
|
+
dryRun: options.dryRun ?? false,
|
|
9449
|
+
out: options.out
|
|
9450
|
+
});
|
|
9451
|
+
} catch (err) {
|
|
9452
|
+
process$1.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
9453
|
+
process$1.exit(1);
|
|
9454
|
+
}
|
|
9455
|
+
return;
|
|
9456
|
+
}
|
|
9199
9457
|
let featureDir;
|
|
9200
9458
|
if (dir) featureDir = resolve(dir);
|
|
9201
9459
|
else {
|
|
9202
|
-
const found = findNearestFeatureJson$1(
|
|
9460
|
+
const found = findNearestFeatureJson$1(cwd);
|
|
9203
9461
|
if (!found) {
|
|
9204
9462
|
process$1.stderr.write("No feature.json found from current directory.\nRun \"lac init\" to create one, or pass a path: lac gen src/auth/ --type test\n");
|
|
9205
9463
|
process$1.exit(1);
|
|
@@ -9219,6 +9477,145 @@ const genCommand = new Command("gen").description("Generate code artifacts from
|
|
|
9219
9477
|
process$1.exit(1);
|
|
9220
9478
|
}
|
|
9221
9479
|
});
|
|
9480
|
+
async function runIndexGenerator(startDir, opts) {
|
|
9481
|
+
process$1.stdout.write(`\nScanning workspace for publicInterface entries...\n`);
|
|
9482
|
+
const scanned = await scanFeatures(startDir);
|
|
9483
|
+
const lines = [
|
|
9484
|
+
`// Generated by lac gen --type index`,
|
|
9485
|
+
`// Workspace public-interface barrel`,
|
|
9486
|
+
`// Do not edit manually — regenerate with: lac gen --type index`,
|
|
9487
|
+
`// Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`,
|
|
9488
|
+
``
|
|
9489
|
+
];
|
|
9490
|
+
let totalExports = 0;
|
|
9491
|
+
let featuresWithInterface = 0;
|
|
9492
|
+
for (const { feature } of scanned) {
|
|
9493
|
+
const feat = feature;
|
|
9494
|
+
const iface = feat["publicInterface"];
|
|
9495
|
+
if (!iface || iface.length === 0) continue;
|
|
9496
|
+
featuresWithInterface++;
|
|
9497
|
+
const file = typeof feat["componentFile"] === "string" ? feat["componentFile"] : void 0;
|
|
9498
|
+
lines.push(`// ── ${feature.featureKey} — ${feature.title}`);
|
|
9499
|
+
for (const e of iface) {
|
|
9500
|
+
const importPath = file ? file.split(",")[0]?.trim() ?? feature.featureKey : `./${feature.featureKey}`;
|
|
9501
|
+
lines.push(`// export { ${e.name} } from '${importPath}'`);
|
|
9502
|
+
totalExports++;
|
|
9503
|
+
}
|
|
9504
|
+
lines.push(``);
|
|
9505
|
+
}
|
|
9506
|
+
if (totalExports === 0) {
|
|
9507
|
+
lines.push(`// No publicInterface entries found across ${scanned.length} feature(s).`);
|
|
9508
|
+
lines.push(`// Add publicInterface[] to your feature.jsons to populate this barrel.`);
|
|
9509
|
+
lines.push(``);
|
|
9510
|
+
}
|
|
9511
|
+
const content = lines.join("\n");
|
|
9512
|
+
const outPath = opts.out ?? resolve(startDir, "index.generated.ts");
|
|
9513
|
+
if (opts.dryRun) {
|
|
9514
|
+
process$1.stdout.write(content);
|
|
9515
|
+
process$1.stdout.write(`\n\n [dry-run] No file written.\n`);
|
|
9516
|
+
return;
|
|
9517
|
+
}
|
|
9518
|
+
writeFileSync(outPath, content, "utf-8");
|
|
9519
|
+
process$1.stdout.write(` ✓ index → ${outPath}\n ${totalExports} export(s) from ${featuresWithInterface}/${scanned.length} features\n\n`);
|
|
9520
|
+
}
|
|
9521
|
+
async function runPluginGenerator(name, gen, startDir, configDir, opts) {
|
|
9522
|
+
const scope = gen.scope ?? "feature";
|
|
9523
|
+
let features;
|
|
9524
|
+
if (scope === "workspace") features = (await scanFeatures(startDir)).map((s) => s.feature).filter((f) => {
|
|
9525
|
+
if (gen.filterStatus && !gen.filterStatus.includes(f["status"])) return false;
|
|
9526
|
+
if (gen.filterTags) {
|
|
9527
|
+
const ftags = f["tags"] ?? [];
|
|
9528
|
+
if (!gen.filterTags.some((t) => ftags.includes(t))) return false;
|
|
9529
|
+
}
|
|
9530
|
+
return true;
|
|
9531
|
+
});
|
|
9532
|
+
else {
|
|
9533
|
+
const found = findNearestFeatureJson$1(startDir);
|
|
9534
|
+
if (!found) throw new Error(`No feature.json found from "${startDir}"`);
|
|
9535
|
+
const raw = readFileSync(found, "utf-8");
|
|
9536
|
+
features = [JSON.parse(raw)];
|
|
9537
|
+
}
|
|
9538
|
+
process$1.stdout.write(`\nGenerator "${name}" [${gen.type}/${scope}] — ${features.length} feature(s)\n`);
|
|
9539
|
+
if (gen.type === "script") {
|
|
9540
|
+
if (!gen.script) throw new Error(`Generator "${name}": missing "script" path.`);
|
|
9541
|
+
const scriptPath = resolve(configDir, gen.script);
|
|
9542
|
+
const input = JSON.stringify(scope === "workspace" ? features : features[0], null, 2);
|
|
9543
|
+
const result = spawnSync("node", [scriptPath], {
|
|
9544
|
+
input,
|
|
9545
|
+
encoding: "utf-8",
|
|
9546
|
+
cwd: configDir
|
|
9547
|
+
});
|
|
9548
|
+
if (result.error || result.status !== 0) {
|
|
9549
|
+
const msg = result.stderr || (result.error?.message ?? `exit ${result.status}`);
|
|
9550
|
+
throw new Error(`Script failed: ${msg}`);
|
|
9551
|
+
}
|
|
9552
|
+
writeOrPrintPlugin(result.stdout ?? "", name, gen, features, configDir, opts);
|
|
9553
|
+
} else if (gen.type === "template") {
|
|
9554
|
+
if (!gen.template) throw new Error(`Generator "${name}": missing "template" path.`);
|
|
9555
|
+
const templatePath = resolve(configDir, gen.template);
|
|
9556
|
+
let template;
|
|
9557
|
+
try {
|
|
9558
|
+
template = readFileSync(templatePath, "utf-8");
|
|
9559
|
+
} catch {
|
|
9560
|
+
throw new Error(`Cannot read template: ${templatePath}`);
|
|
9561
|
+
}
|
|
9562
|
+
if (scope === "workspace") {
|
|
9563
|
+
const ctx = {
|
|
9564
|
+
features,
|
|
9565
|
+
count: features.length
|
|
9566
|
+
};
|
|
9567
|
+
writeOrPrintPlugin(renderTemplate(template, ctx), name, gen, features, configDir, opts);
|
|
9568
|
+
} else for (const f of features) writeOrPrintPlugin(renderTemplate(template, f), name, gen, [f], configDir, opts);
|
|
9569
|
+
} else if (gen.type === "ai") {
|
|
9570
|
+
if (!gen.systemPrompt) throw new Error(`Generator "${name}": missing "systemPrompt".`);
|
|
9571
|
+
let systemPrompt;
|
|
9572
|
+
if (gen.systemPrompt.endsWith(".md") || gen.systemPrompt.endsWith(".txt")) try {
|
|
9573
|
+
systemPrompt = readFileSync(resolve(configDir, gen.systemPrompt), "utf-8");
|
|
9574
|
+
} catch {
|
|
9575
|
+
throw new Error(`Cannot read systemPrompt file: ${gen.systemPrompt}`);
|
|
9576
|
+
}
|
|
9577
|
+
else systemPrompt = gen.systemPrompt;
|
|
9578
|
+
for (const f of features) {
|
|
9579
|
+
const featureDir = startDir;
|
|
9580
|
+
const outFile = resolveOutputPath(opts.out ?? gen.outputFile, f, configDir);
|
|
9581
|
+
await genWithCustomPrompt({
|
|
9582
|
+
featureDir,
|
|
9583
|
+
systemPrompt,
|
|
9584
|
+
dryRun: opts.dryRun,
|
|
9585
|
+
outFile,
|
|
9586
|
+
model: opts.model
|
|
9587
|
+
});
|
|
9588
|
+
}
|
|
9589
|
+
}
|
|
9590
|
+
}
|
|
9591
|
+
/** Lightweight template engine: replaces {{field}} with feature values */
|
|
9592
|
+
function renderTemplate(template, ctx) {
|
|
9593
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
9594
|
+
const val = ctx[key];
|
|
9595
|
+
if (val === void 0 || val === null) return "";
|
|
9596
|
+
if (typeof val === "string") return val;
|
|
9597
|
+
return JSON.stringify(val);
|
|
9598
|
+
});
|
|
9599
|
+
}
|
|
9600
|
+
function resolveOutputPath(pattern, f, startDir) {
|
|
9601
|
+
if (!pattern) return void 0;
|
|
9602
|
+
const slug = String(f["title"] ?? "untitled").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
9603
|
+
return resolve(startDir, pattern.replace(/\{featureKey\}/g, String(f["featureKey"] ?? "unknown")).replace(/\{domain\}/g, String(f["domain"] ?? "misc")).replace(/\{status\}/g, String(f["status"] ?? "draft")).replace(/\{title\}/g, slug));
|
|
9604
|
+
}
|
|
9605
|
+
function writeOrPrintPlugin(output, name, gen, features, baseDir, opts) {
|
|
9606
|
+
if (opts.dryRun) {
|
|
9607
|
+
process$1.stdout.write(output);
|
|
9608
|
+
process$1.stdout.write(`\n\n [dry-run] No file written.\n`);
|
|
9609
|
+
return;
|
|
9610
|
+
}
|
|
9611
|
+
const outPath = resolveOutputPath(opts.out ?? gen.outputFile, features[0] ?? {}, baseDir);
|
|
9612
|
+
if (!outPath) {
|
|
9613
|
+
process$1.stdout.write(output);
|
|
9614
|
+
return;
|
|
9615
|
+
}
|
|
9616
|
+
writeFileSync(outPath, output, "utf-8");
|
|
9617
|
+
process$1.stdout.write(` ✓ "${name}" → ${outPath}\n`);
|
|
9618
|
+
}
|
|
9222
9619
|
//#endregion
|
|
9223
9620
|
//#region src/commands/guardlock.ts
|
|
9224
9621
|
/**
|
|
@@ -10076,7 +10473,7 @@ const doctorCommand = new Command("doctor").description("Check workspace health
|
|
|
10076
10473
|
function esc$12(s) {
|
|
10077
10474
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
10078
10475
|
}
|
|
10079
|
-
function generateHtmlWiki(features, projectName, viewLabel, viewName) {
|
|
10476
|
+
function generateHtmlWiki(features, projectName, viewLabel, viewName, renderMode) {
|
|
10080
10477
|
const dataJson = JSON.stringify(features).replace(/<\/script>/gi, "<\\/script>");
|
|
10081
10478
|
features.filter((f) => f.status === "active").length, features.filter((f) => f.status === "frozen").length, features.filter((f) => f.status === "draft").length, features.filter((f) => f.status === "deprecated").length;
|
|
10082
10479
|
const domains = [...new Set(features.map((f) => f.domain).filter(Boolean))].sort();
|
|
@@ -10685,7 +11082,7 @@ function statusColor(s) {
|
|
|
10685
11082
|
|
|
10686
11083
|
// ── Tree building ────────────────────────────────────────────────────────────
|
|
10687
11084
|
|
|
10688
|
-
const VIEW = '${viewName || ""}';
|
|
11085
|
+
const VIEW = '${renderMode || viewName || ""}';
|
|
10689
11086
|
const byKey = new Map(FEATURES.map(f => [f.featureKey, f]));
|
|
10690
11087
|
|
|
10691
11088
|
function getChildren(key) {
|
|
@@ -21449,158 +21846,1266 @@ gsearch.addEventListener('keydown', e => {
|
|
|
21449
21846
|
</html>`;
|
|
21450
21847
|
}
|
|
21451
21848
|
//#endregion
|
|
21452
|
-
//#region src/lib/
|
|
21453
|
-
/**
|
|
21454
|
-
|
|
21455
|
-
|
|
21456
|
-
|
|
21457
|
-
|
|
21458
|
-
|
|
21459
|
-
|
|
21460
|
-
primary: true
|
|
21461
|
-
},
|
|
21462
|
-
{
|
|
21463
|
-
file: "lac-story.html",
|
|
21464
|
-
label: "Product Story",
|
|
21465
|
-
description: "Long-form narrative case study built from feature data",
|
|
21466
|
-
icon: "📰",
|
|
21467
|
-
primary: true
|
|
21468
|
-
},
|
|
21469
|
-
{
|
|
21470
|
-
file: "lac-wiki.html",
|
|
21471
|
-
label: "Feature Wiki",
|
|
21472
|
-
description: "Complete searchable wiki — all fields, all features, sidebar navigation",
|
|
21473
|
-
icon: "🗂️",
|
|
21474
|
-
primary: false
|
|
21475
|
-
},
|
|
21476
|
-
{
|
|
21477
|
-
file: "lac-kanban.html",
|
|
21478
|
-
label: "Kanban Board",
|
|
21479
|
-
description: "Active / Frozen / Draft columns with sortable, filterable cards",
|
|
21480
|
-
icon: "📋",
|
|
21481
|
-
primary: false
|
|
21482
|
-
},
|
|
21483
|
-
{
|
|
21484
|
-
file: "lac-health.html",
|
|
21485
|
-
label: "Health Scorecard",
|
|
21486
|
-
description: "Completeness, coverage, tech-debt score, and field fill rates",
|
|
21487
|
-
icon: "🏥",
|
|
21488
|
-
primary: false
|
|
21489
|
-
},
|
|
21490
|
-
{
|
|
21491
|
-
file: "lac-decisions.html",
|
|
21492
|
-
label: "Decision Log",
|
|
21493
|
-
description: "All architectural decisions consolidated and searchable by domain",
|
|
21494
|
-
icon: "⚖️",
|
|
21495
|
-
primary: false
|
|
21496
|
-
},
|
|
21497
|
-
{
|
|
21498
|
-
file: "lac-heatmap.html",
|
|
21499
|
-
label: "Completeness Heatmap",
|
|
21500
|
-
description: "Field x feature completeness grid — quickly spot gaps",
|
|
21501
|
-
icon: "🔥",
|
|
21502
|
-
primary: false
|
|
21503
|
-
},
|
|
21504
|
-
{
|
|
21505
|
-
file: "lac-graph.html",
|
|
21506
|
-
label: "Lineage Graph",
|
|
21507
|
-
description: "Interactive force-directed feature dependency graph",
|
|
21508
|
-
icon: "🕸️",
|
|
21509
|
-
primary: false
|
|
21510
|
-
},
|
|
21511
|
-
{
|
|
21512
|
-
file: "lac-print.html",
|
|
21513
|
-
label: "Print",
|
|
21514
|
-
description: "Print-ready A4 document — all features in clean two-column layout",
|
|
21515
|
-
icon: "🖨️",
|
|
21516
|
-
primary: false
|
|
21517
|
-
},
|
|
21518
|
-
{
|
|
21519
|
-
file: "lac-raw.html",
|
|
21520
|
-
label: "Raw Dump",
|
|
21521
|
-
description: "Field-by-field dump of every feature.json with sidebar navigation",
|
|
21522
|
-
icon: "🔩",
|
|
21523
|
-
primary: false
|
|
21524
|
-
}
|
|
21525
|
-
];
|
|
21526
|
-
function generateHub(projectName, stats, entries, generatedAt = (/* @__PURE__ */ new Date()).toISOString(), prefix) {
|
|
21849
|
+
//#region src/lib/changelogGenerator.ts
|
|
21850
|
+
/**
|
|
21851
|
+
* generateChangelog — aggregates all revision[] entries across the workspace,
|
|
21852
|
+
* sorts by date descending, groups by month, and renders a structured HTML changelog.
|
|
21853
|
+
*
|
|
21854
|
+
* Output: lac-changelog.html
|
|
21855
|
+
*/
|
|
21856
|
+
function generateChangelog(features, projectName, since) {
|
|
21527
21857
|
function esc(s) {
|
|
21528
21858
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
21529
21859
|
}
|
|
21530
|
-
const
|
|
21531
|
-
|
|
21532
|
-
|
|
21533
|
-
|
|
21534
|
-
|
|
21535
|
-
|
|
21536
|
-
|
|
21537
|
-
|
|
21538
|
-
|
|
21539
|
-
|
|
21540
|
-
|
|
21541
|
-
|
|
21542
|
-
|
|
21543
|
-
|
|
21544
|
-
|
|
21545
|
-
|
|
21546
|
-
|
|
21547
|
-
<div class="primary-card-desc">${esc(e.description)}</div>
|
|
21548
|
-
</div>
|
|
21549
|
-
<div class="primary-card-arrow">→</div>
|
|
21550
|
-
</a>`;
|
|
21860
|
+
const entries = [];
|
|
21861
|
+
for (const f of features) {
|
|
21862
|
+
const revisions = f["revisions"];
|
|
21863
|
+
if (!revisions) continue;
|
|
21864
|
+
for (const r of revisions) {
|
|
21865
|
+
if (since && r.date < since) continue;
|
|
21866
|
+
entries.push({
|
|
21867
|
+
date: r.date,
|
|
21868
|
+
author: r.author,
|
|
21869
|
+
fields_changed: r.fields_changed,
|
|
21870
|
+
reason: r.reason,
|
|
21871
|
+
featureKey: f.featureKey,
|
|
21872
|
+
featureTitle: f.title,
|
|
21873
|
+
featureDomain: f.domain ?? "misc",
|
|
21874
|
+
featureStatus: f.status
|
|
21875
|
+
});
|
|
21876
|
+
}
|
|
21551
21877
|
}
|
|
21552
|
-
|
|
21553
|
-
|
|
21554
|
-
|
|
21555
|
-
|
|
21556
|
-
|
|
21557
|
-
|
|
21558
|
-
|
|
21878
|
+
entries.sort((a, b) => b.date.localeCompare(a.date));
|
|
21879
|
+
const byMonth = /* @__PURE__ */ new Map();
|
|
21880
|
+
for (const e of entries) {
|
|
21881
|
+
const month = e.date.slice(0, 7);
|
|
21882
|
+
const group = byMonth.get(month) ?? [];
|
|
21883
|
+
group.push(e);
|
|
21884
|
+
byMonth.set(month, group);
|
|
21885
|
+
}
|
|
21886
|
+
const months = [...byMonth.keys()].sort((a, b) => b.localeCompare(a));
|
|
21887
|
+
function monthLabel(ym) {
|
|
21888
|
+
const [y, m] = ym.split("-");
|
|
21889
|
+
return new Date(Number(y), Number(m) - 1, 1).toLocaleString("en-US", {
|
|
21890
|
+
month: "long",
|
|
21891
|
+
year: "numeric"
|
|
21892
|
+
});
|
|
21559
21893
|
}
|
|
21560
|
-
|
|
21894
|
+
function statusColor(s) {
|
|
21895
|
+
return s === "frozen" ? "var(--status-frozen)" : s === "active" ? "var(--status-active)" : s === "draft" ? "var(--status-draft)" : "var(--status-deprecated)";
|
|
21896
|
+
}
|
|
21897
|
+
const navHtml = months.map((m, i) => `<a class="nav-item${i === 0 ? " active" : ""}" data-month="${esc(m)}" href="#${esc(m)}">${esc(monthLabel(m))} <span class="nav-count">${byMonth.get(m).length}</span></a>`).join("\n");
|
|
21898
|
+
const sectionsHtml = months.map((m, i) => {
|
|
21899
|
+
const revs = byMonth.get(m);
|
|
21900
|
+
const items = revs.map((r) => `
|
|
21901
|
+
<div class="rev-card">
|
|
21902
|
+
<div class="rev-card-top">
|
|
21903
|
+
<div class="rev-feature">
|
|
21904
|
+
<span class="rev-key">${esc(r.featureKey)}</span>
|
|
21905
|
+
<span class="rev-title">${esc(r.featureTitle)}</span>
|
|
21906
|
+
<span class="rev-domain">${esc(r.featureDomain.replace(/-/g, " "))}</span>
|
|
21907
|
+
<span class="rev-status" style="color:${statusColor(r.featureStatus)}">${esc(r.featureStatus)}</span>
|
|
21908
|
+
</div>
|
|
21909
|
+
<div class="rev-meta">
|
|
21910
|
+
<span class="rev-date">${esc(r.date)}</span>
|
|
21911
|
+
<span class="rev-author">${esc(r.author)}</span>
|
|
21912
|
+
</div>
|
|
21913
|
+
</div>
|
|
21914
|
+
<div class="rev-reason">${esc(r.reason)}</div>
|
|
21915
|
+
<div class="rev-fields">${r.fields_changed.map((f) => `<span class="rev-field">${esc(f)}</span>`).join("")}</div>
|
|
21916
|
+
</div>`).join("");
|
|
21917
|
+
return `<section id="${esc(m)}" class="month-section${i > 0 ? " hidden" : ""}">
|
|
21918
|
+
<div class="month-header">
|
|
21919
|
+
<div class="month-title">${esc(monthLabel(m))}</div>
|
|
21920
|
+
<div class="month-count">${revs.length} change${revs.length !== 1 ? "s" : ""}</div>
|
|
21921
|
+
</div>
|
|
21922
|
+
${items}
|
|
21923
|
+
</section>`;
|
|
21924
|
+
}).join("\n");
|
|
21925
|
+
const sinceNote = since ? ` · since ${since}` : "";
|
|
21561
21926
|
return `<!DOCTYPE html>
|
|
21562
21927
|
<html lang="en">
|
|
21563
21928
|
<head>
|
|
21564
21929
|
<meta charset="UTF-8">
|
|
21565
21930
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
21566
|
-
<title>${esc(projectName)} —
|
|
21931
|
+
<title>${esc(projectName)} — Changelog</title>
|
|
21567
21932
|
<style>
|
|
21568
21933
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
21569
21934
|
:root {
|
|
21570
|
-
--bg:
|
|
21571
|
-
--
|
|
21572
|
-
--
|
|
21573
|
-
--
|
|
21574
|
-
--text: #ece3d8;
|
|
21575
|
-
--text-mid: #b0a494;
|
|
21576
|
-
--text-soft: #736455;
|
|
21577
|
-
--accent: #c4a255;
|
|
21578
|
-
--accent-w: #e8b865;
|
|
21579
|
-
--mono: 'Cascadia Code','Fira Code','JetBrains Mono','Consolas',monospace;
|
|
21580
|
-
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
21581
|
-
--frozen: #5b82cc; --active: #4aad72; --draft: #c4a255; --deprecated: #cc5b5b;
|
|
21935
|
+
--bg: #0f0d0b; --bg-sidebar: #0b0a08; --bg-card: #181512; --bg-hover: #1e1a16; --bg-active: #231e17;
|
|
21936
|
+
--border: #262018; --border-soft: #1e1a14; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
|
|
21937
|
+
--accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
|
21938
|
+
--status-frozen: #5b82cc; --status-active: #4aad72; --status-draft: #c4a255; --status-deprecated: #cc5b5b;
|
|
21582
21939
|
}
|
|
21583
21940
|
html { scroll-behavior: smooth; }
|
|
21584
|
-
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6;
|
|
21585
|
-
|
|
21586
|
-
|
|
21587
|
-
.topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 28px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
21941
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
21942
|
+
.shell { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
21943
|
+
.topbar { flex-shrink: 0; height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); }
|
|
21588
21944
|
.topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
|
|
21589
|
-
.topbar-sep
|
|
21945
|
+
.topbar-sep { color: var(--border); font-size: 18px; }
|
|
21590
21946
|
.topbar-title { font-size: 13px; color: var(--text-mid); }
|
|
21591
|
-
.topbar-
|
|
21592
|
-
|
|
21593
|
-
|
|
21594
|
-
.
|
|
21595
|
-
|
|
21596
|
-
|
|
21597
|
-
.
|
|
21598
|
-
.
|
|
21599
|
-
.
|
|
21600
|
-
.
|
|
21601
|
-
|
|
21602
|
-
|
|
21603
|
-
.
|
|
21947
|
+
.topbar-meta { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
|
|
21948
|
+
.body-row { display: flex; flex: 1; min-height: 0; }
|
|
21949
|
+
.sidebar { width: 220px; flex-shrink: 0; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
|
21950
|
+
.sidebar-header { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
|
|
21951
|
+
.sidebar-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-soft); }
|
|
21952
|
+
.nav-tree { flex: 1; overflow-y: auto; padding: 8px 0 32px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
21953
|
+
.nav-item { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; font-size: 12px; color: var(--text-mid); cursor: pointer; text-decoration: none; border-left: 2px solid transparent; transition: background 0.1s; }
|
|
21954
|
+
.nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
21955
|
+
.nav-item.active { background: var(--bg-active); border-left-color: var(--accent); color: var(--text); }
|
|
21956
|
+
.nav-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); background: var(--bg-card); padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border); }
|
|
21957
|
+
.content { flex: 1; min-width: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
21958
|
+
.month-section { max-width: 760px; margin: 0 auto; padding: 40px 40px 80px; }
|
|
21959
|
+
.month-section.hidden { display: none; }
|
|
21960
|
+
.month-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 28px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
|
|
21961
|
+
.month-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: -0.01em; }
|
|
21962
|
+
.month-count { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
|
|
21963
|
+
.rev-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 12px; }
|
|
21964
|
+
.rev-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
|
|
21965
|
+
.rev-feature { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
21966
|
+
.rev-key { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
|
|
21967
|
+
.rev-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
|
21968
|
+
.rev-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); padding: 1px 7px; background: var(--bg); border: 1px solid var(--border); border-radius: 999px; }
|
|
21969
|
+
.rev-status { font-family: var(--mono); font-size: 10px; }
|
|
21970
|
+
.rev-meta { text-align: right; flex-shrink: 0; }
|
|
21971
|
+
.rev-date { font-family: var(--mono); font-size: 11px; color: var(--text-soft); display: block; }
|
|
21972
|
+
.rev-author { font-family: var(--mono); font-size: 10px; color: var(--accent); }
|
|
21973
|
+
.rev-reason { font-size: 13px; color: var(--text-mid); line-height: 1.65; margin-bottom: 10px; }
|
|
21974
|
+
.rev-fields { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
21975
|
+
.rev-field { font-family: var(--mono); font-size: 10px; color: var(--text-soft); background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 1px 7px; }
|
|
21976
|
+
.empty-state { text-align: center; padding: 80px 40px; color: var(--text-soft); }
|
|
21977
|
+
.empty-title { font-size: 18px; color: var(--text-mid); margin-bottom: 8px; }
|
|
21978
|
+
</style>
|
|
21979
|
+
</head>
|
|
21980
|
+
<body>
|
|
21981
|
+
<div class="shell">
|
|
21982
|
+
<div class="topbar">
|
|
21983
|
+
<span class="topbar-brand">lac·changelog</span>
|
|
21984
|
+
<span class="topbar-sep">/</span>
|
|
21985
|
+
<span class="topbar-title">${esc(projectName)}</span>
|
|
21986
|
+
<span class="topbar-meta">${entries.length} revision${entries.length !== 1 ? "s" : ""}${sinceNote}</span>
|
|
21987
|
+
</div>
|
|
21988
|
+
<div class="body-row">
|
|
21989
|
+
<div class="sidebar">
|
|
21990
|
+
<div class="sidebar-header"><div class="sidebar-label">By Month</div></div>
|
|
21991
|
+
<nav class="nav-tree">${navHtml}</nav>
|
|
21992
|
+
</div>
|
|
21993
|
+
<main class="content" id="content">
|
|
21994
|
+
${entries.length === 0 ? `<div class="empty-state"><div class="empty-title">No revisions found</div><p>Add revision entries to your feature.jsons${since ? ` after ${since}` : ""}.</p></div>` : sectionsHtml}
|
|
21995
|
+
</main>
|
|
21996
|
+
</div>
|
|
21997
|
+
</div>
|
|
21998
|
+
<script>
|
|
21999
|
+
function showMonth(month) {
|
|
22000
|
+
document.querySelectorAll('.month-section').forEach(s => s.classList.add('hidden'))
|
|
22001
|
+
const sec = document.getElementById(month)
|
|
22002
|
+
if (sec) { sec.classList.remove('hidden'); document.getElementById('content').scrollTop = 0 }
|
|
22003
|
+
document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.month === month))
|
|
22004
|
+
history.replaceState(null, '', '#' + month)
|
|
22005
|
+
}
|
|
22006
|
+
document.querySelectorAll('.nav-item').forEach(el => {
|
|
22007
|
+
el.addEventListener('click', e => { e.preventDefault(); showMonth(el.dataset.month) })
|
|
22008
|
+
})
|
|
22009
|
+
const hash = window.location.hash.slice(1)
|
|
22010
|
+
if (hash && document.getElementById(hash)) showMonth(hash)
|
|
22011
|
+
<\/script>
|
|
22012
|
+
</body>
|
|
22013
|
+
</html>`;
|
|
22014
|
+
}
|
|
22015
|
+
//#endregion
|
|
22016
|
+
//#region src/lib/releaseNotesGenerator.ts
|
|
22017
|
+
/**
|
|
22018
|
+
* generateReleaseNotes — user-facing release notes filtered by date or releaseVersion.
|
|
22019
|
+
*
|
|
22020
|
+
* Features included: those that transitioned to "frozen" after `since` date,
|
|
22021
|
+
* OR those whose `releaseVersion` matches the `release` option.
|
|
22022
|
+
* Falls back to all frozen features if neither filter is provided.
|
|
22023
|
+
*
|
|
22024
|
+
* Output: lac-release-notes.html — communication-ready, PM/user tone.
|
|
22025
|
+
*/
|
|
22026
|
+
function generateReleaseNotes(features, projectName, opts) {
|
|
22027
|
+
function esc(s) {
|
|
22028
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
22029
|
+
}
|
|
22030
|
+
function mdToHtml(raw) {
|
|
22031
|
+
function inline(s) {
|
|
22032
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/\*([^*\n]+?)\*/g, "<em>$1</em>").replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
22033
|
+
}
|
|
22034
|
+
return raw.split(/\n{2,}/).map((block) => {
|
|
22035
|
+
const lines = block.split("\n");
|
|
22036
|
+
if (lines.every((l) => /^\s*[-*]\s/.test(l))) return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*]\s/, ""))}</li>`).join("")}</ul>`;
|
|
22037
|
+
return `<p>${lines.map((l) => inline(l.trim())).filter(Boolean).join(" ")}</p>`;
|
|
22038
|
+
}).join("\n");
|
|
22039
|
+
}
|
|
22040
|
+
let releaseFeatures = features.filter((f) => {
|
|
22041
|
+
const feat = f;
|
|
22042
|
+
if (opts.release && feat["releaseVersion"] === opts.release) return true;
|
|
22043
|
+
if (opts.since) {
|
|
22044
|
+
const history = feat["statusHistory"];
|
|
22045
|
+
if (history) return history.some((t) => t.to === "frozen" && t.date >= opts.since);
|
|
22046
|
+
}
|
|
22047
|
+
if (!opts.since && !opts.release) return f.status === "frozen";
|
|
22048
|
+
return false;
|
|
22049
|
+
});
|
|
22050
|
+
releaseFeatures = releaseFeatures.sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
|
|
22051
|
+
const domainOrder = [];
|
|
22052
|
+
const seen = /* @__PURE__ */ new Set();
|
|
22053
|
+
for (const f of releaseFeatures) {
|
|
22054
|
+
const d = f.domain ?? "other";
|
|
22055
|
+
if (!seen.has(d)) {
|
|
22056
|
+
seen.add(d);
|
|
22057
|
+
domainOrder.push(d);
|
|
22058
|
+
}
|
|
22059
|
+
}
|
|
22060
|
+
const PALETTE = [
|
|
22061
|
+
"#c4a255",
|
|
22062
|
+
"#e8674a",
|
|
22063
|
+
"#4aad72",
|
|
22064
|
+
"#5b82cc",
|
|
22065
|
+
"#b87fda",
|
|
22066
|
+
"#4ab8cc",
|
|
22067
|
+
"#cc5b5b",
|
|
22068
|
+
"#a2cc4a"
|
|
22069
|
+
];
|
|
22070
|
+
const domainColor = {};
|
|
22071
|
+
domainOrder.forEach((d, i) => {
|
|
22072
|
+
domainColor[d] = PALETTE[i % PALETTE.length];
|
|
22073
|
+
});
|
|
22074
|
+
const filterLabel = opts.release ? `v${opts.release}` : opts.since ? `since ${opts.since}` : "all frozen features";
|
|
22075
|
+
function renderFeatureCard(f) {
|
|
22076
|
+
const feat = f;
|
|
22077
|
+
const domain = f.domain ?? "other";
|
|
22078
|
+
const color = domainColor[domain] ?? "#c4a255";
|
|
22079
|
+
const userGuide = typeof feat["userGuide"] === "string" ? feat["userGuide"] : "";
|
|
22080
|
+
const pmSummary = typeof feat["pmSummary"] === "string" ? feat["pmSummary"] : "";
|
|
22081
|
+
const limitations = feat["knownLimitations"] ?? [];
|
|
22082
|
+
const releaseVer = typeof feat["releaseVersion"] === "string" ? feat["releaseVersion"] : "";
|
|
22083
|
+
return `<div class="feature-card" id="${esc(f.featureKey)}">
|
|
22084
|
+
<div class="card-domain-eyebrow" style="color:${color}">${esc(domain.replace(/-/g, " "))}${releaseVer ? ` · v${esc(releaseVer)}` : ""}</div>
|
|
22085
|
+
<div class="card-title">${esc(f.title)}</div>
|
|
22086
|
+
${pmSummary ? `<div class="card-pm-summary">${esc(pmSummary)}</div>` : ""}
|
|
22087
|
+
${userGuide ? `<div class="card-guide-block"><div class="card-guide-label">How to use</div><div class="card-guide-text">${mdToHtml(userGuide)}</div></div>` : ""}
|
|
22088
|
+
${limitations.length > 0 ? `<div class="card-limits"><div class="card-limits-label">Known limitations</div><ul class="card-limits-list">${limitations.map((l) => `<li>${esc(l)}</li>`).join("")}</ul></div>` : ""}
|
|
22089
|
+
</div>`;
|
|
22090
|
+
}
|
|
22091
|
+
const domainSectionsHtml = domainOrder.map((domain) => {
|
|
22092
|
+
const domFeatures = releaseFeatures.filter((f) => (f.domain ?? "other") === domain);
|
|
22093
|
+
return `<div class="domain-section">
|
|
22094
|
+
<div class="domain-header">
|
|
22095
|
+
<div class="domain-pip" style="background:${domainColor[domain]}"></div>
|
|
22096
|
+
<div class="domain-title">${esc(domain.replace(/-/g, " "))}</div>
|
|
22097
|
+
<div class="domain-count">${domFeatures.length} feature${domFeatures.length !== 1 ? "s" : ""}</div>
|
|
22098
|
+
</div>
|
|
22099
|
+
${domFeatures.map(renderFeatureCard).join("")}
|
|
22100
|
+
</div>`;
|
|
22101
|
+
}).join("");
|
|
22102
|
+
return `<!DOCTYPE html>
|
|
22103
|
+
<html lang="en">
|
|
22104
|
+
<head>
|
|
22105
|
+
<meta charset="UTF-8">
|
|
22106
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
22107
|
+
<title>${esc(projectName)} — Release Notes</title>
|
|
22108
|
+
<style>
|
|
22109
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
22110
|
+
:root {
|
|
22111
|
+
--bg: #0f0d0b; --bg-card: #181512; --bg-hover: #1e1a16; --border: #262018; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
|
|
22112
|
+
--accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
|
22113
|
+
}
|
|
22114
|
+
html { scroll-behavior: smooth; }
|
|
22115
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; }
|
|
22116
|
+
.topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
22117
|
+
.topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
|
|
22118
|
+
.topbar-sep { color: var(--border); font-size: 18px; }
|
|
22119
|
+
.topbar-title { font-size: 13px; color: var(--text-mid); }
|
|
22120
|
+
.topbar-filter { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text-soft); padding: 3px 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); }
|
|
22121
|
+
.main { max-width: 820px; margin: 0 auto; padding: 56px 40px 100px; }
|
|
22122
|
+
.page-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent); margin-bottom: 14px; }
|
|
22123
|
+
.page-title { font-size: 36px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 10px; }
|
|
22124
|
+
.page-sub { font-size: 15px; color: var(--text-mid); margin-bottom: 48px; max-width: 560px; }
|
|
22125
|
+
.stats-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 48px; }
|
|
22126
|
+
.stat-card { padding: 14px 18px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; min-width: 100px; }
|
|
22127
|
+
.stat-num { font-family: var(--mono); font-size: 24px; font-weight: 700; color: var(--accent); }
|
|
22128
|
+
.stat-lbl { font-size: 11px; color: var(--text-soft); margin-top: 4px; }
|
|
22129
|
+
.domain-section { margin-bottom: 56px; }
|
|
22130
|
+
.domain-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
|
22131
|
+
.domain-pip { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
22132
|
+
.domain-title { font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text); }
|
|
22133
|
+
.domain-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-left: auto; }
|
|
22134
|
+
.feature-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; margin-bottom: 16px; }
|
|
22135
|
+
.card-domain-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 8px; }
|
|
22136
|
+
.card-title { font-size: 20px; font-weight: 800; color: var(--text); letter-spacing: -0.01em; margin-bottom: 10px; }
|
|
22137
|
+
.card-pm-summary { font-size: 14px; color: var(--text-mid); line-height: 1.7; margin-bottom: 16px; font-style: italic; }
|
|
22138
|
+
.card-guide-block { margin-bottom: 16px; }
|
|
22139
|
+
.card-guide-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); margin-bottom: 10px; }
|
|
22140
|
+
.card-guide-text { font-size: 14px; color: var(--text-mid); line-height: 1.8; }
|
|
22141
|
+
.card-guide-text p { margin-bottom: 0.75em; }
|
|
22142
|
+
.card-guide-text p:last-child { margin-bottom: 0; }
|
|
22143
|
+
.card-guide-text ul { margin: 0.4em 0 0.8em 1.4em; }
|
|
22144
|
+
.card-guide-text li { margin-bottom: 0.3em; }
|
|
22145
|
+
.card-guide-text strong { color: var(--text); font-weight: 600; }
|
|
22146
|
+
.card-guide-text code { font-family: var(--mono); font-size: 12px; background: var(--bg-hover); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; }
|
|
22147
|
+
.card-limits { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
|
|
22148
|
+
.card-limits-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-soft); margin-bottom: 8px; }
|
|
22149
|
+
.card-limits-list { list-style: none; padding: 0; }
|
|
22150
|
+
.card-limits-list li { font-size: 12px; color: var(--text-soft); line-height: 1.6; margin-bottom: 4px; padding-left: 14px; position: relative; }
|
|
22151
|
+
.card-limits-list li::before { content: '—'; position: absolute; left: 0; opacity: 0.4; }
|
|
22152
|
+
.empty-state { text-align: center; padding: 80px 40px; color: var(--text-soft); }
|
|
22153
|
+
.empty-title { font-size: 20px; color: var(--text-mid); margin-bottom: 10px; font-weight: 700; }
|
|
22154
|
+
</style>
|
|
22155
|
+
</head>
|
|
22156
|
+
<body>
|
|
22157
|
+
<div class="topbar">
|
|
22158
|
+
<span class="topbar-brand">lac·release-notes</span>
|
|
22159
|
+
<span class="topbar-sep">/</span>
|
|
22160
|
+
<span class="topbar-title">${esc(projectName)}</span>
|
|
22161
|
+
<span class="topbar-filter">${esc(filterLabel)}</span>
|
|
22162
|
+
</div>
|
|
22163
|
+
<main class="main">
|
|
22164
|
+
<div class="page-eyebrow">release notes</div>
|
|
22165
|
+
<div class="page-title">${esc(projectName)}</div>
|
|
22166
|
+
<div class="page-sub">What shipped — ${esc(filterLabel)}. ${releaseFeatures.length} feature${releaseFeatures.length !== 1 ? "s" : ""} across ${domainOrder.length} domain${domainOrder.length !== 1 ? "s" : ""}.</div>
|
|
22167
|
+
<div class="stats-row">
|
|
22168
|
+
<div class="stat-card"><div class="stat-num">${releaseFeatures.length}</div><div class="stat-lbl">features shipped</div></div>
|
|
22169
|
+
<div class="stat-card"><div class="stat-num">${domainOrder.length}</div><div class="stat-lbl">domains</div></div>
|
|
22170
|
+
<div class="stat-card"><div class="stat-num">${releaseFeatures.filter((f) => f["userGuide"]).length}</div><div class="stat-lbl">with user guides</div></div>
|
|
22171
|
+
</div>
|
|
22172
|
+
${releaseFeatures.length === 0 ? `<div class="empty-state"><div class="empty-title">No features matched the filter</div><p>${opts.since ? `No features frozen after ${opts.since}.` : opts.release ? `No features with releaseVersion "${opts.release}".` : "No frozen features found."}</p></div>` : domainSectionsHtml}
|
|
22173
|
+
</main>
|
|
22174
|
+
</body>
|
|
22175
|
+
</html>`;
|
|
22176
|
+
}
|
|
22177
|
+
//#endregion
|
|
22178
|
+
//#region src/lib/sprintGenerator.ts
|
|
22179
|
+
/**
|
|
22180
|
+
* generateSprint — compact sprint planning view.
|
|
22181
|
+
*
|
|
22182
|
+
* Shows draft + active features sorted by priority.
|
|
22183
|
+
* Summary density: title, status badge, priority, problem (first sentence), successCriteria, tags.
|
|
22184
|
+
* Designed to be scannable in a standup or sprint planning meeting.
|
|
22185
|
+
*
|
|
22186
|
+
* Output: lac-sprint.html
|
|
22187
|
+
*/
|
|
22188
|
+
function generateSprint(features, projectName) {
|
|
22189
|
+
function esc(s) {
|
|
22190
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
22191
|
+
}
|
|
22192
|
+
const sorted = [...features].sort((a, b) => {
|
|
22193
|
+
if (a.status !== b.status) return a.status === "active" ? -1 : 1;
|
|
22194
|
+
return (a.priority ?? 99) - (b.priority ?? 99);
|
|
22195
|
+
});
|
|
22196
|
+
const activeCount = sorted.filter((f) => f.status === "active").length;
|
|
22197
|
+
const draftCount = sorted.filter((f) => f.status === "draft").length;
|
|
22198
|
+
const domains = [...new Set(sorted.map((f) => f.domain).filter(Boolean))];
|
|
22199
|
+
function priorityLabel(p) {
|
|
22200
|
+
if (!p) return "";
|
|
22201
|
+
const color = {
|
|
22202
|
+
1: "#cc5b5b",
|
|
22203
|
+
2: "#e8674a",
|
|
22204
|
+
3: "#c4a255",
|
|
22205
|
+
4: "#4aad72",
|
|
22206
|
+
5: "#5b82cc"
|
|
22207
|
+
}[p] ?? "#736455";
|
|
22208
|
+
return `<span class="priority-badge" style="background:${color}20;color:${color};border-color:${color}40">P${p}</span>`;
|
|
22209
|
+
}
|
|
22210
|
+
function firstSentence(s) {
|
|
22211
|
+
const match = s.match(/^[^.!?]+[.!?]/);
|
|
22212
|
+
return match ? match[0] : s.length > 120 ? s.slice(0, 120) + "…" : s;
|
|
22213
|
+
}
|
|
22214
|
+
const activeFeatures = sorted.filter((f) => f.status === "active");
|
|
22215
|
+
const draftFeatures = sorted.filter((f) => f.status === "draft");
|
|
22216
|
+
function renderCard(f) {
|
|
22217
|
+
const tags = (f.tags ?? []).slice(0, 4).map((t) => `<span class="tag">${esc(t)}</span>`).join("");
|
|
22218
|
+
const successCriteria = f.successCriteria ?? "";
|
|
22219
|
+
const domain = f.domain ?? "";
|
|
22220
|
+
return `<div class="sprint-card">
|
|
22221
|
+
<div class="card-top">
|
|
22222
|
+
<div class="card-header">
|
|
22223
|
+
${f.priority ? priorityLabel(f.priority) : ""}
|
|
22224
|
+
<span class="status-dot ${esc(f.status)}"></span>
|
|
22225
|
+
<span class="card-key">${esc(f.featureKey)}</span>
|
|
22226
|
+
</div>
|
|
22227
|
+
<div class="card-title">${esc(f.title)}</div>
|
|
22228
|
+
${domain ? `<div class="card-domain">${esc(domain.replace(/-/g, " "))}</div>` : ""}
|
|
22229
|
+
</div>
|
|
22230
|
+
<div class="card-problem">${esc(firstSentence(f.problem))}</div>
|
|
22231
|
+
${successCriteria ? `<div class="card-criteria"><span class="criteria-label">Done when:</span> ${esc(firstSentence(successCriteria))}</div>` : ""}
|
|
22232
|
+
${tags ? `<div class="card-tags">${tags}</div>` : ""}
|
|
22233
|
+
</div>`;
|
|
22234
|
+
}
|
|
22235
|
+
function renderGroup(title, groupFeatures, statusClass) {
|
|
22236
|
+
if (groupFeatures.length === 0) return "";
|
|
22237
|
+
return `<section class="group-section">
|
|
22238
|
+
<div class="group-header">
|
|
22239
|
+
<div class="group-dot ${statusClass}"></div>
|
|
22240
|
+
<div class="group-title">${esc(title)}</div>
|
|
22241
|
+
<div class="group-count">${groupFeatures.length}</div>
|
|
22242
|
+
</div>
|
|
22243
|
+
<div class="cards-grid">
|
|
22244
|
+
${groupFeatures.map(renderCard).join("")}
|
|
22245
|
+
</div>
|
|
22246
|
+
</section>`;
|
|
22247
|
+
}
|
|
22248
|
+
return `<!DOCTYPE html>
|
|
22249
|
+
<html lang="en">
|
|
22250
|
+
<head>
|
|
22251
|
+
<meta charset="UTF-8">
|
|
22252
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
22253
|
+
<title>${esc(projectName)} — Sprint Board</title>
|
|
22254
|
+
<style>
|
|
22255
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
22256
|
+
:root {
|
|
22257
|
+
--bg: #0f0d0b; --bg-card: #181512; --bg-hover: #1e1a16; --border: #262018; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
|
|
22258
|
+
--accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
|
22259
|
+
--active: #4aad72; --draft: #c4a255;
|
|
22260
|
+
}
|
|
22261
|
+
html { scroll-behavior: smooth; }
|
|
22262
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; }
|
|
22263
|
+
.topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 24px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
22264
|
+
.topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
|
|
22265
|
+
.topbar-sep { color: var(--border); font-size: 18px; }
|
|
22266
|
+
.topbar-title { font-size: 13px; color: var(--text-mid); }
|
|
22267
|
+
.topbar-stats { margin-left: auto; display: flex; gap: 16px; }
|
|
22268
|
+
.stat { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
|
|
22269
|
+
.stat strong { color: var(--text-mid); }
|
|
22270
|
+
.main { max-width: 1100px; margin: 0 auto; padding: 40px 32px 80px; }
|
|
22271
|
+
.page-title { font-size: 26px; font-weight: 800; color: var(--text); letter-spacing: -0.015em; margin-bottom: 4px; }
|
|
22272
|
+
.page-sub { font-size: 13px; color: var(--text-soft); margin-bottom: 40px; }
|
|
22273
|
+
.group-section { margin-bottom: 48px; }
|
|
22274
|
+
.group-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
|
22275
|
+
.group-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
22276
|
+
.group-dot.active { background: var(--active); }
|
|
22277
|
+
.group-dot.draft { background: var(--draft); }
|
|
22278
|
+
.group-title { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text); }
|
|
22279
|
+
.group-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-left: auto; }
|
|
22280
|
+
.cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
|
22281
|
+
.sprint-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px 18px; transition: border-color 0.15s; }
|
|
22282
|
+
.sprint-card:hover { border-color: var(--text-soft); }
|
|
22283
|
+
.card-top { margin-bottom: 10px; }
|
|
22284
|
+
.card-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
|
|
22285
|
+
.status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
|
|
22286
|
+
.status-dot.active { background: var(--active); }
|
|
22287
|
+
.status-dot.draft { background: var(--draft); }
|
|
22288
|
+
.card-key { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
|
|
22289
|
+
.priority-badge { font-family: var(--mono); font-size: 9px; padding: 1px 6px; border-radius: 3px; border: 1px solid; letter-spacing: 0.05em; }
|
|
22290
|
+
.card-title { font-size: 13px; font-weight: 700; color: var(--text); line-height: 1.35; }
|
|
22291
|
+
.card-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-top: 3px; }
|
|
22292
|
+
.card-problem { font-size: 12px; color: var(--text-mid); line-height: 1.6; margin-bottom: 8px; }
|
|
22293
|
+
.card-criteria { font-size: 11px; color: var(--text-soft); line-height: 1.5; margin-bottom: 8px; }
|
|
22294
|
+
.criteria-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-soft); }
|
|
22295
|
+
.card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
|
|
22296
|
+
.tag { font-family: var(--mono); font-size: 9px; color: var(--text-soft); background: var(--bg); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; }
|
|
22297
|
+
.empty-state { text-align: center; padding: 80px 40px; color: var(--text-soft); }
|
|
22298
|
+
</style>
|
|
22299
|
+
</head>
|
|
22300
|
+
<body>
|
|
22301
|
+
<div class="topbar">
|
|
22302
|
+
<span class="topbar-brand">lac·sprint</span>
|
|
22303
|
+
<span class="topbar-sep">/</span>
|
|
22304
|
+
<span class="topbar-title">${esc(projectName)}</span>
|
|
22305
|
+
<div class="topbar-stats">
|
|
22306
|
+
<span class="stat"><strong>${activeCount}</strong> active</span>
|
|
22307
|
+
<span class="stat"><strong>${draftCount}</strong> draft</span>
|
|
22308
|
+
<span class="stat"><strong>${domains.length}</strong> domains</span>
|
|
22309
|
+
</div>
|
|
22310
|
+
</div>
|
|
22311
|
+
<main class="main">
|
|
22312
|
+
<div class="page-title">${esc(projectName)} — Sprint</div>
|
|
22313
|
+
<div class="page-sub">${sorted.length} features in flight · sorted by priority</div>
|
|
22314
|
+
${sorted.length === 0 ? `<div class="empty-state">No active or draft features found.</div>` : renderGroup("Active", activeFeatures, "active") + renderGroup("Draft", draftFeatures, "draft")}
|
|
22315
|
+
</main>
|
|
22316
|
+
</body>
|
|
22317
|
+
</html>`;
|
|
22318
|
+
}
|
|
22319
|
+
//#endregion
|
|
22320
|
+
//#region src/lib/apiSurfaceGenerator.ts
|
|
22321
|
+
/**
|
|
22322
|
+
* generateApiSurface — aggregates all publicInterface[] entries across the workspace.
|
|
22323
|
+
*
|
|
22324
|
+
* Groups by interface type (React Components, Hooks, Contexts, Services, Functions, Other).
|
|
22325
|
+
* Full-text search. Each entry links back to the feature wiki.
|
|
22326
|
+
*
|
|
22327
|
+
* Output: lac-api-surface.html
|
|
22328
|
+
*/
|
|
22329
|
+
function generateApiSurface(features, projectName) {
|
|
22330
|
+
function esc(s) {
|
|
22331
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
22332
|
+
}
|
|
22333
|
+
const entries = [];
|
|
22334
|
+
for (const f of features) {
|
|
22335
|
+
const iface = f["publicInterface"];
|
|
22336
|
+
if (!iface || iface.length === 0) continue;
|
|
22337
|
+
for (const e of iface) entries.push({
|
|
22338
|
+
name: e.name,
|
|
22339
|
+
type: e.type,
|
|
22340
|
+
description: e.description ?? "",
|
|
22341
|
+
featureKey: f.featureKey,
|
|
22342
|
+
featureTitle: f.title,
|
|
22343
|
+
featureDomain: f.domain ?? "misc",
|
|
22344
|
+
componentFile: typeof f["componentFile"] === "string" ? f["componentFile"] : void 0
|
|
22345
|
+
});
|
|
22346
|
+
}
|
|
22347
|
+
function classifyType(t) {
|
|
22348
|
+
const lower = t.toLowerCase();
|
|
22349
|
+
if (lower.includes("react component") || lower.includes("component") || lower.includes("view") || lower.includes("screen")) return "Components";
|
|
22350
|
+
if (lower.includes("hook") || lower.startsWith("use")) return "Hooks";
|
|
22351
|
+
if (lower.includes("context") || lower.includes("provider")) return "Contexts";
|
|
22352
|
+
if (lower.includes("service") || lower.includes("class") || lower.includes("manager")) return "Services";
|
|
22353
|
+
if (lower.includes("function") || lower.includes("util") || lower.includes("helper")) return "Functions";
|
|
22354
|
+
if (lower.includes("type") || lower.includes("interface") || lower.includes("enum")) return "Types";
|
|
22355
|
+
return "Other";
|
|
22356
|
+
}
|
|
22357
|
+
const groupOrder = [
|
|
22358
|
+
"Components",
|
|
22359
|
+
"Hooks",
|
|
22360
|
+
"Contexts",
|
|
22361
|
+
"Services",
|
|
22362
|
+
"Functions",
|
|
22363
|
+
"Types",
|
|
22364
|
+
"Other"
|
|
22365
|
+
];
|
|
22366
|
+
const groups = /* @__PURE__ */ new Map();
|
|
22367
|
+
for (const e of entries) {
|
|
22368
|
+
const g = classifyType(e.type);
|
|
22369
|
+
const list = groups.get(g) ?? [];
|
|
22370
|
+
list.push(e);
|
|
22371
|
+
groups.set(g, list);
|
|
22372
|
+
}
|
|
22373
|
+
const presentGroups = groupOrder.filter((g) => groups.has(g));
|
|
22374
|
+
const groupIcons = {
|
|
22375
|
+
Components: "🧩",
|
|
22376
|
+
Hooks: "🪝",
|
|
22377
|
+
Contexts: "🔗",
|
|
22378
|
+
Services: "⚙️",
|
|
22379
|
+
Functions: "𝑓",
|
|
22380
|
+
Types: "📐",
|
|
22381
|
+
Other: "◻️"
|
|
22382
|
+
};
|
|
22383
|
+
const navHtml = presentGroups.map((g) => `<a class="nav-item" data-group="${esc(g)}" href="#group-${esc(g)}">${groupIcons[g] ?? ""} ${esc(g)} <span class="nav-count">${groups.get(g).length}</span></a>`).join("\n");
|
|
22384
|
+
function renderEntry(e) {
|
|
22385
|
+
return `<div class="api-entry" data-search="${esc(`${e.name} ${e.type} ${e.description} ${e.featureTitle} ${e.featureDomain}`).toLowerCase()}">
|
|
22386
|
+
<div class="entry-top">
|
|
22387
|
+
<div class="entry-name"><code class="entry-code">${esc(e.name)}</code></div>
|
|
22388
|
+
<div class="entry-type">${esc(e.type)}</div>
|
|
22389
|
+
</div>
|
|
22390
|
+
${e.description ? `<div class="entry-desc">${esc(e.description)}</div>` : ""}
|
|
22391
|
+
<div class="entry-source">
|
|
22392
|
+
<a class="entry-feature-link" href="./lac-wiki.html#${esc(e.featureKey)}">${esc(e.featureTitle)}</a>
|
|
22393
|
+
<span class="entry-domain">${esc(e.featureDomain.replace(/-/g, " "))}</span>
|
|
22394
|
+
${e.componentFile ? `<span class="entry-file">${esc(e.componentFile.split(",")[0]?.trim() ?? "")}</span>` : ""}
|
|
22395
|
+
</div>
|
|
22396
|
+
</div>`;
|
|
22397
|
+
}
|
|
22398
|
+
const groupSectionsHtml = presentGroups.map((g) => `<section id="group-${esc(g)}" class="group-section">
|
|
22399
|
+
<div class="group-header">
|
|
22400
|
+
<span class="group-icon">${groupIcons[g] ?? ""}</span>
|
|
22401
|
+
<span class="group-title">${esc(g)}</span>
|
|
22402
|
+
<span class="group-count">${groups.get(g).length}</span>
|
|
22403
|
+
</div>
|
|
22404
|
+
<div class="entries-list">
|
|
22405
|
+
${groups.get(g).map(renderEntry).join("")}
|
|
22406
|
+
</div>
|
|
22407
|
+
</section>`).join("");
|
|
22408
|
+
const searchData = JSON.stringify(entries.map((e) => ({
|
|
22409
|
+
name: e.name,
|
|
22410
|
+
type: e.type,
|
|
22411
|
+
description: e.description,
|
|
22412
|
+
featureTitle: e.featureTitle,
|
|
22413
|
+
featureDomain: e.featureDomain,
|
|
22414
|
+
featureKey: e.featureKey,
|
|
22415
|
+
group: classifyType(e.type)
|
|
22416
|
+
})));
|
|
22417
|
+
return `<!DOCTYPE html>
|
|
22418
|
+
<html lang="en">
|
|
22419
|
+
<head>
|
|
22420
|
+
<meta charset="UTF-8">
|
|
22421
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
22422
|
+
<title>${esc(projectName)} — API Surface</title>
|
|
22423
|
+
<style>
|
|
22424
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
22425
|
+
:root {
|
|
22426
|
+
--bg: #0f0d0b; --bg-sidebar: #0b0a08; --bg-card: #181512; --bg-hover: #1e1a16; --bg-active: #231e17;
|
|
22427
|
+
--border: #262018; --border-soft: #1e1a14; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
|
|
22428
|
+
--accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
|
22429
|
+
}
|
|
22430
|
+
html { scroll-behavior: smooth; }
|
|
22431
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
22432
|
+
.shell { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
22433
|
+
.topbar { flex-shrink: 0; height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); }
|
|
22434
|
+
.topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
|
|
22435
|
+
.topbar-sep { color: var(--border); font-size: 18px; }
|
|
22436
|
+
.topbar-title { font-size: 13px; color: var(--text-mid); }
|
|
22437
|
+
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
22438
|
+
.topbar-count { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
|
|
22439
|
+
.search-wrap { position: relative; }
|
|
22440
|
+
#gsearch { background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 5px 10px 5px 28px; font-family: var(--mono); font-size: 11px; color: var(--text); outline: none; width: 180px; transition: border-color 0.15s, width 0.2s; }
|
|
22441
|
+
#gsearch:focus { border-color: var(--accent); width: 240px; }
|
|
22442
|
+
#gsearch::placeholder { color: var(--text-soft); }
|
|
22443
|
+
.search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); color: var(--text-soft); pointer-events: none; }
|
|
22444
|
+
.body-row { display: flex; flex: 1; min-height: 0; }
|
|
22445
|
+
.sidebar { width: 200px; flex-shrink: 0; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
|
|
22446
|
+
.sidebar-header { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
|
|
22447
|
+
.sidebar-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-soft); }
|
|
22448
|
+
.nav-tree { flex: 1; overflow-y: auto; padding: 8px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
22449
|
+
.nav-item { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; font-size: 12px; color: var(--text-mid); cursor: pointer; text-decoration: none; border-left: 2px solid transparent; transition: background 0.1s; gap: 6px; }
|
|
22450
|
+
.nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
|
22451
|
+
.nav-item.active { background: var(--bg-active); border-left-color: var(--accent); color: var(--text); }
|
|
22452
|
+
.nav-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); background: var(--bg-card); padding: 1px 5px; border-radius: 999px; border: 1px solid var(--border); margin-left: auto; }
|
|
22453
|
+
.content { flex: 1; min-width: 0; overflow-y: auto; padding: 32px 40px 80px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
22454
|
+
.group-section { margin-bottom: 48px; max-width: 800px; }
|
|
22455
|
+
.group-section.hidden { display: none; }
|
|
22456
|
+
.group-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
|
|
22457
|
+
.group-icon { font-size: 16px; }
|
|
22458
|
+
.group-title { font-family: var(--mono); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text); }
|
|
22459
|
+
.group-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-left: auto; }
|
|
22460
|
+
.entries-list { display: flex; flex-direction: column; gap: 8px; }
|
|
22461
|
+
.api-entry { background: var(--bg-card); border: 1px solid var(--border); border-radius: 7px; padding: 14px 18px; transition: border-color 0.15s; }
|
|
22462
|
+
.api-entry:hover { border-color: var(--text-soft); }
|
|
22463
|
+
.api-entry.filtered-out { display: none; }
|
|
22464
|
+
.entry-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; }
|
|
22465
|
+
.entry-name { flex-shrink: 0; }
|
|
22466
|
+
.entry-code { font-family: var(--mono); font-size: 13px; color: var(--accent); background: transparent; }
|
|
22467
|
+
.entry-type { font-family: var(--mono); font-size: 10px; color: var(--text-soft); padding: 1px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; }
|
|
22468
|
+
.entry-desc { font-size: 12px; color: var(--text-mid); line-height: 1.6; margin-bottom: 8px; }
|
|
22469
|
+
.entry-source { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
|
22470
|
+
.entry-feature-link { font-family: var(--mono); font-size: 10px; color: var(--accent); text-decoration: none; opacity: 0.7; }
|
|
22471
|
+
.entry-feature-link:hover { opacity: 1; text-decoration: underline; }
|
|
22472
|
+
.entry-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); padding: 1px 7px; background: var(--bg); border: 1px solid var(--border); border-radius: 999px; }
|
|
22473
|
+
.entry-file { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
|
|
22474
|
+
.empty-state { text-align: center; padding: 60px 40px; color: var(--text-soft); }
|
|
22475
|
+
#no-results { display: none; padding: 40px; text-align: center; color: var(--text-soft); font-size: 13px; }
|
|
22476
|
+
</style>
|
|
22477
|
+
</head>
|
|
22478
|
+
<body>
|
|
22479
|
+
<div class="shell">
|
|
22480
|
+
<div class="topbar">
|
|
22481
|
+
<span class="topbar-brand">lac·api-surface</span>
|
|
22482
|
+
<span class="topbar-sep">/</span>
|
|
22483
|
+
<span class="topbar-title">${esc(projectName)}</span>
|
|
22484
|
+
<div class="topbar-right">
|
|
22485
|
+
<span class="topbar-count">${entries.length} exports</span>
|
|
22486
|
+
<div class="search-wrap">
|
|
22487
|
+
<svg class="search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
22488
|
+
<input type="text" id="gsearch" placeholder="Search API…" autocomplete="off" spellcheck="false">
|
|
22489
|
+
</div>
|
|
22490
|
+
</div>
|
|
22491
|
+
</div>
|
|
22492
|
+
<div class="body-row">
|
|
22493
|
+
<div class="sidebar">
|
|
22494
|
+
<div class="sidebar-header"><div class="sidebar-label">By Type</div></div>
|
|
22495
|
+
<nav class="nav-tree">
|
|
22496
|
+
<a class="nav-item active" data-group="all" href="#">All <span class="nav-count">${entries.length}</span></a>
|
|
22497
|
+
${navHtml}
|
|
22498
|
+
</nav>
|
|
22499
|
+
</div>
|
|
22500
|
+
<main class="content" id="content">
|
|
22501
|
+
${entries.length === 0 ? `<div class="empty-state">No publicInterface entries found. Add publicInterface[] to your feature.jsons.</div>` : groupSectionsHtml}
|
|
22502
|
+
<div id="no-results">No matches found.</div>
|
|
22503
|
+
</main>
|
|
22504
|
+
</div>
|
|
22505
|
+
</div>
|
|
22506
|
+
<script>
|
|
22507
|
+
const SEARCH_DATA = ${searchData};
|
|
22508
|
+
const gsearch = document.getElementById('gsearch');
|
|
22509
|
+
function filterEntries(q) {
|
|
22510
|
+
const lower = q.toLowerCase().trim();
|
|
22511
|
+
let anyVisible = false;
|
|
22512
|
+
document.querySelectorAll('.api-entry').forEach(el => {
|
|
22513
|
+
const searchStr = el.dataset.search ?? '';
|
|
22514
|
+
const match = !lower || searchStr.includes(lower);
|
|
22515
|
+
el.classList.toggle('filtered-out', !match);
|
|
22516
|
+
if (match) anyVisible = true;
|
|
22517
|
+
});
|
|
22518
|
+
document.getElementById('no-results').style.display = (lower && !anyVisible) ? 'block' : 'none';
|
|
22519
|
+
}
|
|
22520
|
+
gsearch.addEventListener('input', () => filterEntries(gsearch.value));
|
|
22521
|
+
document.querySelectorAll('.nav-item').forEach(el => {
|
|
22522
|
+
el.addEventListener('click', e => {
|
|
22523
|
+
e.preventDefault();
|
|
22524
|
+
const group = el.dataset.group;
|
|
22525
|
+
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
|
22526
|
+
el.classList.add('active');
|
|
22527
|
+
if (group === 'all') {
|
|
22528
|
+
document.querySelectorAll('.group-section').forEach(s => s.classList.remove('hidden'));
|
|
22529
|
+
} else {
|
|
22530
|
+
document.querySelectorAll('.group-section').forEach(s => {
|
|
22531
|
+
s.classList.toggle('hidden', s.id !== 'group-' + group);
|
|
22532
|
+
});
|
|
22533
|
+
const target = document.getElementById('group-' + group);
|
|
22534
|
+
if (target) { document.getElementById('content').scrollTop = target.offsetTop - 20; }
|
|
22535
|
+
}
|
|
22536
|
+
});
|
|
22537
|
+
});
|
|
22538
|
+
<\/script>
|
|
22539
|
+
</body>
|
|
22540
|
+
</html>`;
|
|
22541
|
+
}
|
|
22542
|
+
//#endregion
|
|
22543
|
+
//#region src/lib/dependencyMapGenerator.ts
|
|
22544
|
+
/**
|
|
22545
|
+
* generateDependencyMap — visualizes cross-feature runtime dependencies from externalDependencies[].
|
|
22546
|
+
*
|
|
22547
|
+
* Distinct from the lineage graph (which shows parent/child hierarchy).
|
|
22548
|
+
* This shows the RUNTIME dependency web — which features call into other features at runtime.
|
|
22549
|
+
*
|
|
22550
|
+
* Uses a force-directed canvas graph with:
|
|
22551
|
+
* - Node size = number of dependents (high coupling = larger node)
|
|
22552
|
+
* - Node color = domain
|
|
22553
|
+
* - Edge direction = dependency (A → B means A depends on B)
|
|
22554
|
+
* - Red edges = potential cycles
|
|
22555
|
+
* - Detail panel on click
|
|
22556
|
+
*
|
|
22557
|
+
* Output: lac-depmap.html
|
|
22558
|
+
*/
|
|
22559
|
+
function generateDependencyMap(features, projectName) {
|
|
22560
|
+
function esc(s) {
|
|
22561
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
22562
|
+
}
|
|
22563
|
+
const nodeMap = /* @__PURE__ */ new Map();
|
|
22564
|
+
for (const f of features) nodeMap.set(f.featureKey, {
|
|
22565
|
+
key: f.featureKey,
|
|
22566
|
+
title: f.title,
|
|
22567
|
+
domain: f.domain ?? "misc",
|
|
22568
|
+
status: f.status,
|
|
22569
|
+
deps: [],
|
|
22570
|
+
dependents: []
|
|
22571
|
+
});
|
|
22572
|
+
for (const f of features) {
|
|
22573
|
+
const externalDeps = f["externalDependencies"];
|
|
22574
|
+
if (!externalDeps || externalDeps.length === 0) continue;
|
|
22575
|
+
const node = nodeMap.get(f.featureKey);
|
|
22576
|
+
for (const dep of externalDeps) if (nodeMap.has(dep)) {
|
|
22577
|
+
node.deps.push(dep);
|
|
22578
|
+
nodeMap.get(dep).dependents.push(f.featureKey);
|
|
22579
|
+
}
|
|
22580
|
+
}
|
|
22581
|
+
const nodes = [...nodeMap.values()];
|
|
22582
|
+
const edges = nodes.flatMap((n) => n.deps.map((dep) => ({
|
|
22583
|
+
from: n.key,
|
|
22584
|
+
to: dep
|
|
22585
|
+
})));
|
|
22586
|
+
function hasCycle(from, to, visited = /* @__PURE__ */ new Set()) {
|
|
22587
|
+
if (from === to) return true;
|
|
22588
|
+
if (visited.has(from)) return false;
|
|
22589
|
+
visited.add(from);
|
|
22590
|
+
return nodeMap.get(from)?.deps.some((dep) => hasCycle(dep, to, new Set(visited))) ?? false;
|
|
22591
|
+
}
|
|
22592
|
+
const cycleEdges = new Set(edges.filter((e) => hasCycle(e.to, e.from)).map((e) => `${e.from}→${e.to}`));
|
|
22593
|
+
const domains = [...new Set(nodes.map((n) => n.domain))];
|
|
22594
|
+
const PALETTE = [
|
|
22595
|
+
"#c4a255",
|
|
22596
|
+
"#e8674a",
|
|
22597
|
+
"#4aad72",
|
|
22598
|
+
"#5b82cc",
|
|
22599
|
+
"#b87fda",
|
|
22600
|
+
"#4ab8cc",
|
|
22601
|
+
"#cc5b5b",
|
|
22602
|
+
"#a2cc4a",
|
|
22603
|
+
"#e8b865",
|
|
22604
|
+
"#736455"
|
|
22605
|
+
];
|
|
22606
|
+
const domainColor = {};
|
|
22607
|
+
domains.forEach((d, i) => {
|
|
22608
|
+
domainColor[d] = PALETTE[i % PALETTE.length];
|
|
22609
|
+
});
|
|
22610
|
+
const graphData = JSON.stringify({
|
|
22611
|
+
nodes: nodes.map((n) => ({
|
|
22612
|
+
key: n.key,
|
|
22613
|
+
title: n.title,
|
|
22614
|
+
domain: n.domain,
|
|
22615
|
+
status: n.status,
|
|
22616
|
+
depCount: n.deps.length,
|
|
22617
|
+
dependentCount: n.dependents.length,
|
|
22618
|
+
color: domainColor[n.domain] ?? "#736455"
|
|
22619
|
+
})),
|
|
22620
|
+
edges: edges.map((e) => ({
|
|
22621
|
+
from: e.from,
|
|
22622
|
+
to: e.to,
|
|
22623
|
+
isCycle: cycleEdges.has(`${e.from}→${e.to}`)
|
|
22624
|
+
}))
|
|
22625
|
+
});
|
|
22626
|
+
const isolatedCount = nodes.filter((n) => n.deps.length === 0 && n.dependents.length === 0).length;
|
|
22627
|
+
const highCouplingCount = nodes.filter((n) => n.dependents.length >= 3).length;
|
|
22628
|
+
return `<!DOCTYPE html>
|
|
22629
|
+
<html lang="en">
|
|
22630
|
+
<head>
|
|
22631
|
+
<meta charset="UTF-8">
|
|
22632
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
22633
|
+
<title>${esc(projectName)} — Dependency Map</title>
|
|
22634
|
+
<style>
|
|
22635
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
22636
|
+
:root {
|
|
22637
|
+
--bg: #0f0d0b; --bg-card: #181512; --bg-hover: #1e1a16; --border: #262018; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
|
|
22638
|
+
--accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
|
|
22639
|
+
}
|
|
22640
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
22641
|
+
.topbar { flex-shrink: 0; height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: #0b0a08; border-bottom: 1px solid var(--border); }
|
|
22642
|
+
.topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
|
|
22643
|
+
.topbar-sep { color: var(--border); font-size: 18px; }
|
|
22644
|
+
.topbar-title { font-size: 13px; color: var(--text-mid); }
|
|
22645
|
+
.topbar-stats { margin-left: auto; display: flex; gap: 16px; }
|
|
22646
|
+
.topbar-stat { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
|
|
22647
|
+
.topbar-stat strong { color: var(--text-mid); }
|
|
22648
|
+
.main { display: flex; flex: 1; min-height: 0; position: relative; }
|
|
22649
|
+
canvas { flex: 1; cursor: grab; display: block; }
|
|
22650
|
+
canvas:active { cursor: grabbing; }
|
|
22651
|
+
.legend { position: absolute; top: 16px; left: 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; min-width: 180px; }
|
|
22652
|
+
.legend-title { font-family: var(--mono); font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-soft); margin-bottom: 10px; }
|
|
22653
|
+
.legend-items { display: flex; flex-direction: column; gap: 6px; }
|
|
22654
|
+
.legend-item { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-mid); }
|
|
22655
|
+
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
|
22656
|
+
.legend-line { width: 20px; height: 2px; flex-shrink: 0; }
|
|
22657
|
+
.detail-panel { position: absolute; top: 16px; right: 16px; width: 280px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 18px 20px; display: none; }
|
|
22658
|
+
.detail-panel.visible { display: block; }
|
|
22659
|
+
.detail-key { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-bottom: 4px; }
|
|
22660
|
+
.detail-title { font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 8px; line-height: 1.3; }
|
|
22661
|
+
.detail-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-bottom: 12px; }
|
|
22662
|
+
.detail-section { margin-bottom: 10px; }
|
|
22663
|
+
.detail-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-soft); margin-bottom: 4px; }
|
|
22664
|
+
.detail-links { display: flex; flex-direction: column; gap: 3px; }
|
|
22665
|
+
.detail-link { font-family: var(--mono); font-size: 11px; color: var(--accent); opacity: 0.7; cursor: pointer; }
|
|
22666
|
+
.detail-link:hover { opacity: 1; }
|
|
22667
|
+
.detail-close { position: absolute; top: 10px; right: 12px; font-size: 16px; color: var(--text-soft); cursor: pointer; }
|
|
22668
|
+
.detail-close:hover { color: var(--text); }
|
|
22669
|
+
.detail-count { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
|
|
22670
|
+
.empty-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--text-soft); pointer-events: none; }
|
|
22671
|
+
.empty-title { font-size: 18px; color: var(--text-mid); font-weight: 700; }
|
|
22672
|
+
</style>
|
|
22673
|
+
</head>
|
|
22674
|
+
<body>
|
|
22675
|
+
<div class="topbar">
|
|
22676
|
+
<span class="topbar-brand">lac·depmap</span>
|
|
22677
|
+
<span class="topbar-sep">/</span>
|
|
22678
|
+
<span class="topbar-title">${esc(projectName)}</span>
|
|
22679
|
+
<div class="topbar-stats">
|
|
22680
|
+
<span class="topbar-stat"><strong>${nodes.length}</strong> features</span>
|
|
22681
|
+
<span class="topbar-stat"><strong>${edges.length}</strong> dependencies</span>
|
|
22682
|
+
${highCouplingCount > 0 ? `<span class="topbar-stat" style="color:#cc5b5b"><strong>${highCouplingCount}</strong> high-coupling</span>` : ""}
|
|
22683
|
+
${isolatedCount > 0 ? `<span class="topbar-stat"><strong>${isolatedCount}</strong> isolated</span>` : ""}
|
|
22684
|
+
</div>
|
|
22685
|
+
</div>
|
|
22686
|
+
<div class="main">
|
|
22687
|
+
<canvas id="canvas"></canvas>
|
|
22688
|
+
<div class="legend">
|
|
22689
|
+
<div class="legend-title">Legend</div>
|
|
22690
|
+
<div class="legend-items">
|
|
22691
|
+
<div class="legend-item"><div class="legend-line" style="background:#5b82cc;height:1.5px"></div> depends on</div>
|
|
22692
|
+
<div class="legend-item"><div class="legend-line" style="background:#cc5b5b;height:2px"></div> cycle risk</div>
|
|
22693
|
+
<div class="legend-item"><span style="font-family:var(--mono);font-size:11px;color:var(--text-soft)">size = dependents</span></div>
|
|
22694
|
+
${domains.map((d) => `<div class="legend-item"><div class="legend-dot" style="background:${domainColor[d] ?? "#736455"}"></div>${esc(d.replace(/-/g, " "))}</div>`).join("")}
|
|
22695
|
+
</div>
|
|
22696
|
+
</div>
|
|
22697
|
+
<div class="detail-panel" id="detail">
|
|
22698
|
+
<span class="detail-close" onclick="closeDetail()">×</span>
|
|
22699
|
+
<div class="detail-key" id="detail-key"></div>
|
|
22700
|
+
<div class="detail-title" id="detail-title"></div>
|
|
22701
|
+
<div class="detail-domain" id="detail-domain"></div>
|
|
22702
|
+
<div class="detail-section" id="detail-deps-section">
|
|
22703
|
+
<div class="detail-label">Depends on</div>
|
|
22704
|
+
<div class="detail-links" id="detail-deps"></div>
|
|
22705
|
+
</div>
|
|
22706
|
+
<div class="detail-section" id="detail-dependents-section">
|
|
22707
|
+
<div class="detail-label">Depended on by</div>
|
|
22708
|
+
<div class="detail-links" id="detail-dependents"></div>
|
|
22709
|
+
</div>
|
|
22710
|
+
<div class="detail-count" id="detail-isolated"></div>
|
|
22711
|
+
</div>
|
|
22712
|
+
${edges.length === 0 ? `<div class="empty-overlay"><div class="empty-title">No dependencies mapped</div><p>Add featureKeys to externalDependencies[] in your feature.jsons.</p></div>` : ""}
|
|
22713
|
+
</div>
|
|
22714
|
+
<script>
|
|
22715
|
+
const GRAPH = ${graphData};
|
|
22716
|
+
const canvas = document.getElementById('canvas');
|
|
22717
|
+
const ctx = canvas.getContext('2d');
|
|
22718
|
+
let W, H, dpr, nodes, edges;
|
|
22719
|
+
let pan = { x: 0, y: 0 }, zoom = 1, dragging = false, lastMouse = null, hoveredKey = null, selectedKey = null;
|
|
22720
|
+
|
|
22721
|
+
function resize() {
|
|
22722
|
+
dpr = window.devicePixelRatio || 1;
|
|
22723
|
+
W = canvas.clientWidth; H = canvas.clientHeight;
|
|
22724
|
+
canvas.width = W * dpr; canvas.height = H * dpr;
|
|
22725
|
+
ctx.scale(dpr, dpr);
|
|
22726
|
+
}
|
|
22727
|
+
|
|
22728
|
+
function initNodes() {
|
|
22729
|
+
nodes = GRAPH.nodes.map((n, i) => {
|
|
22730
|
+
const angle = (i / GRAPH.nodes.length) * 2 * Math.PI;
|
|
22731
|
+
const r = Math.min(W, H) * 0.3;
|
|
22732
|
+
return { ...n, x: W/2 + r * Math.cos(angle), y: H/2 + r * Math.sin(angle), vx: 0, vy: 0 };
|
|
22733
|
+
});
|
|
22734
|
+
edges = GRAPH.edges;
|
|
22735
|
+
}
|
|
22736
|
+
|
|
22737
|
+
const nodeByKey = () => new Map(nodes.map(n => [n.key, n]));
|
|
22738
|
+
|
|
22739
|
+
function tick() {
|
|
22740
|
+
const map = nodeByKey();
|
|
22741
|
+
// Repulsion
|
|
22742
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
22743
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
22744
|
+
const a = nodes[i], b = nodes[j];
|
|
22745
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
22746
|
+
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
22747
|
+
const force = 8000 / (dist * dist);
|
|
22748
|
+
const fx = (dx / dist) * force, fy = (dy / dist) * force;
|
|
22749
|
+
a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
|
|
22750
|
+
}
|
|
22751
|
+
}
|
|
22752
|
+
// Spring edges
|
|
22753
|
+
for (const e of edges) {
|
|
22754
|
+
const a = map.get(e.from), b = map.get(e.to);
|
|
22755
|
+
if (!a || !b) continue;
|
|
22756
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
22757
|
+
const dist = Math.sqrt(dx*dx + dy*dy) || 1;
|
|
22758
|
+
const target = 120;
|
|
22759
|
+
const force = (dist - target) * 0.04;
|
|
22760
|
+
const fx = (dx / dist) * force, fy = (dy / dist) * force;
|
|
22761
|
+
a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
|
|
22762
|
+
}
|
|
22763
|
+
// Gravity to center
|
|
22764
|
+
for (const n of nodes) {
|
|
22765
|
+
n.vx += (W/2 - n.x) * 0.003;
|
|
22766
|
+
n.vy += (H/2 - n.y) * 0.003;
|
|
22767
|
+
}
|
|
22768
|
+
// Apply + dampen
|
|
22769
|
+
for (const n of nodes) {
|
|
22770
|
+
n.x += n.vx * 0.4; n.y += n.vy * 0.4;
|
|
22771
|
+
n.vx *= 0.7; n.vy *= 0.7;
|
|
22772
|
+
}
|
|
22773
|
+
}
|
|
22774
|
+
|
|
22775
|
+
function draw() {
|
|
22776
|
+
ctx.clearRect(0, 0, W, H);
|
|
22777
|
+
ctx.save();
|
|
22778
|
+
ctx.translate(pan.x, pan.y);
|
|
22779
|
+
ctx.scale(zoom, zoom);
|
|
22780
|
+
const map = nodeByKey();
|
|
22781
|
+
// Edges
|
|
22782
|
+
for (const e of edges) {
|
|
22783
|
+
const a = map.get(e.from), b = map.get(e.to);
|
|
22784
|
+
if (!a || !b) continue;
|
|
22785
|
+
const isHighlighted = selectedKey && (e.from === selectedKey || e.to === selectedKey);
|
|
22786
|
+
ctx.globalAlpha = isHighlighted ? 1 : (selectedKey ? 0.15 : 0.5);
|
|
22787
|
+
ctx.strokeStyle = e.isCycle ? '#cc5b5b' : '#5b82cc';
|
|
22788
|
+
ctx.lineWidth = isHighlighted ? 2 : 1;
|
|
22789
|
+
ctx.beginPath();
|
|
22790
|
+
ctx.moveTo(a.x, a.y);
|
|
22791
|
+
// Arrow to B
|
|
22792
|
+
const dx = b.x - a.x, dy = b.y - a.y;
|
|
22793
|
+
const dist = Math.sqrt(dx*dx + dy*dy);
|
|
22794
|
+
const bR = nodeRadius(b);
|
|
22795
|
+
const ex = b.x - (dx/dist) * bR;
|
|
22796
|
+
const ey = b.y - (dy/dist) * bR;
|
|
22797
|
+
ctx.lineTo(ex, ey);
|
|
22798
|
+
ctx.stroke();
|
|
22799
|
+
// Arrowhead
|
|
22800
|
+
const angle = Math.atan2(dy, dx);
|
|
22801
|
+
ctx.fillStyle = e.isCycle ? '#cc5b5b' : '#5b82cc';
|
|
22802
|
+
ctx.beginPath();
|
|
22803
|
+
ctx.moveTo(ex, ey);
|
|
22804
|
+
ctx.lineTo(ex - 8*Math.cos(angle-0.4), ey - 8*Math.sin(angle-0.4));
|
|
22805
|
+
ctx.lineTo(ex - 8*Math.cos(angle+0.4), ey - 8*Math.sin(angle+0.4));
|
|
22806
|
+
ctx.closePath();
|
|
22807
|
+
ctx.fill();
|
|
22808
|
+
}
|
|
22809
|
+
// Nodes
|
|
22810
|
+
for (const n of nodes) {
|
|
22811
|
+
const r = nodeRadius(n);
|
|
22812
|
+
const isHovered = n.key === hoveredKey;
|
|
22813
|
+
const isSelected = n.key === selectedKey;
|
|
22814
|
+
ctx.globalAlpha = selectedKey && !isSelected ? 0.3 : 1;
|
|
22815
|
+
ctx.beginPath();
|
|
22816
|
+
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
|
|
22817
|
+
ctx.fillStyle = n.color;
|
|
22818
|
+
ctx.globalAlpha *= isHovered ? 1 : 0.85;
|
|
22819
|
+
ctx.fill();
|
|
22820
|
+
if (isSelected || isHovered) {
|
|
22821
|
+
ctx.strokeStyle = '#ece3d8';
|
|
22822
|
+
ctx.lineWidth = 2;
|
|
22823
|
+
ctx.stroke();
|
|
22824
|
+
}
|
|
22825
|
+
ctx.globalAlpha = selectedKey && !isSelected ? 0.3 : 1;
|
|
22826
|
+
ctx.fillStyle = '#ece3d8';
|
|
22827
|
+
ctx.font = \`\${Math.max(9, Math.min(12, r * 0.7))}px monospace\`;
|
|
22828
|
+
ctx.textAlign = 'center';
|
|
22829
|
+
ctx.textBaseline = 'middle';
|
|
22830
|
+
const label = n.title.length > 16 ? n.title.slice(0, 14) + '…' : n.title;
|
|
22831
|
+
ctx.fillText(label, n.x, n.y);
|
|
22832
|
+
}
|
|
22833
|
+
ctx.restore();
|
|
22834
|
+
}
|
|
22835
|
+
|
|
22836
|
+
function nodeRadius(n) {
|
|
22837
|
+
return Math.max(18, 18 + (n.dependentCount ?? 0) * 4);
|
|
22838
|
+
}
|
|
22839
|
+
|
|
22840
|
+
function hitTest(mx, my) {
|
|
22841
|
+
const wx = (mx - pan.x) / zoom, wy = (my - pan.y) / zoom;
|
|
22842
|
+
for (const n of [...nodes].reverse()) {
|
|
22843
|
+
const r = nodeRadius(n);
|
|
22844
|
+
if ((wx-n.x)**2 + (wy-n.y)**2 < r*r) return n;
|
|
22845
|
+
}
|
|
22846
|
+
return null;
|
|
22847
|
+
}
|
|
22848
|
+
|
|
22849
|
+
function showDetail(n) {
|
|
22850
|
+
const map = nodeByKey();
|
|
22851
|
+
selectedKey = n.key;
|
|
22852
|
+
document.getElementById('detail-key').textContent = n.key;
|
|
22853
|
+
document.getElementById('detail-title').textContent = n.title;
|
|
22854
|
+
document.getElementById('detail-domain').textContent = n.domain;
|
|
22855
|
+
const depsEl = document.getElementById('detail-deps');
|
|
22856
|
+
const depsSection = document.getElementById('detail-deps-section');
|
|
22857
|
+
depsEl.innerHTML = (n.depCount > 0) ? GRAPH.nodes.filter(x => {
|
|
22858
|
+
const node = nodes.find(nd => nd.key === n.key);
|
|
22859
|
+
return node && GRAPH.edges.some(e => e.from === n.key && e.to === x.key);
|
|
22860
|
+
}).map(x => \`<span class="detail-link" onclick="showDetail(nodes.find(nd=>nd.key==='\${x.key}'))">\${x.title}</span>\`).join('') : '';
|
|
22861
|
+
depsSection.style.display = n.depCount > 0 ? '' : 'none';
|
|
22862
|
+
const depEl = document.getElementById('detail-dependents');
|
|
22863
|
+
const depSection = document.getElementById('detail-dependents-section');
|
|
22864
|
+
depEl.innerHTML = (n.dependentCount > 0) ? GRAPH.nodes.filter(x => {
|
|
22865
|
+
return GRAPH.edges.some(e => e.from === x.key && e.to === n.key);
|
|
22866
|
+
}).map(x => \`<span class="detail-link" onclick="showDetail(nodes.find(nd=>nd.key==='\${x.key}'))">\${x.title}</span>\`).join('') : '';
|
|
22867
|
+
depSection.style.display = n.dependentCount > 0 ? '' : 'none';
|
|
22868
|
+
const isolatedEl = document.getElementById('detail-isolated');
|
|
22869
|
+
isolatedEl.textContent = (n.depCount === 0 && n.dependentCount === 0) ? 'Isolated — no runtime dependencies' : '';
|
|
22870
|
+
document.getElementById('detail').classList.add('visible');
|
|
22871
|
+
}
|
|
22872
|
+
|
|
22873
|
+
function closeDetail() {
|
|
22874
|
+
selectedKey = null;
|
|
22875
|
+
document.getElementById('detail').classList.remove('visible');
|
|
22876
|
+
}
|
|
22877
|
+
|
|
22878
|
+
window.addEventListener('resize', () => { resize(); });
|
|
22879
|
+
canvas.addEventListener('mousedown', e => { dragging = true; lastMouse = { x: e.clientX, y: e.clientY }; });
|
|
22880
|
+
canvas.addEventListener('mouseup', e => {
|
|
22881
|
+
if (dragging && lastMouse && Math.abs(e.clientX - lastMouse.x) < 3 && Math.abs(e.clientY - lastMouse.y) < 3) {
|
|
22882
|
+
const hit = hitTest(e.clientX - canvas.getBoundingClientRect().left, e.clientY - canvas.getBoundingClientRect().top);
|
|
22883
|
+
if (hit) showDetail(hit); else closeDetail();
|
|
22884
|
+
}
|
|
22885
|
+
dragging = false; lastMouse = null;
|
|
22886
|
+
});
|
|
22887
|
+
canvas.addEventListener('mousemove', e => {
|
|
22888
|
+
const rect = canvas.getBoundingClientRect();
|
|
22889
|
+
if (dragging && lastMouse) {
|
|
22890
|
+
pan.x += e.clientX - lastMouse.x; pan.y += e.clientY - lastMouse.y;
|
|
22891
|
+
lastMouse = { x: e.clientX, y: e.clientY };
|
|
22892
|
+
}
|
|
22893
|
+
const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
|
|
22894
|
+
hoveredKey = hit ? hit.key : null;
|
|
22895
|
+
canvas.style.cursor = hit ? 'pointer' : (dragging ? 'grabbing' : 'grab');
|
|
22896
|
+
});
|
|
22897
|
+
canvas.addEventListener('wheel', e => {
|
|
22898
|
+
e.preventDefault();
|
|
22899
|
+
const f = e.deltaY < 0 ? 1.1 : 0.9;
|
|
22900
|
+
const rect = canvas.getBoundingClientRect();
|
|
22901
|
+
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
|
|
22902
|
+
pan.x = mx - (mx - pan.x) * f;
|
|
22903
|
+
pan.y = my - (my - pan.y) * f;
|
|
22904
|
+
zoom *= f;
|
|
22905
|
+
}, { passive: false });
|
|
22906
|
+
|
|
22907
|
+
resize();
|
|
22908
|
+
initNodes();
|
|
22909
|
+
let frame = 0;
|
|
22910
|
+
function loop() {
|
|
22911
|
+
if (frame < 200) tick();
|
|
22912
|
+
frame++;
|
|
22913
|
+
draw();
|
|
22914
|
+
requestAnimationFrame(loop);
|
|
22915
|
+
}
|
|
22916
|
+
loop();
|
|
22917
|
+
<\/script>
|
|
22918
|
+
</body>
|
|
22919
|
+
</html>`;
|
|
22920
|
+
}
|
|
22921
|
+
//#endregion
|
|
22922
|
+
//#region src/lib/hubGenerator.ts
|
|
22923
|
+
/** Canonical ordered entry definitions for all standard LAC outputs. */
|
|
22924
|
+
const ALL_HUB_ENTRIES = [
|
|
22925
|
+
{
|
|
22926
|
+
file: "lac-guide.html",
|
|
22927
|
+
label: "User Guide",
|
|
22928
|
+
description: "How to use every user-facing feature — generated from userGuide fields",
|
|
22929
|
+
icon: "📖",
|
|
22930
|
+
primary: true
|
|
22931
|
+
},
|
|
22932
|
+
{
|
|
22933
|
+
file: "lac-story.html",
|
|
22934
|
+
label: "Product Story",
|
|
22935
|
+
description: "Long-form narrative case study built from feature data",
|
|
22936
|
+
icon: "📰",
|
|
22937
|
+
primary: true
|
|
22938
|
+
},
|
|
22939
|
+
{
|
|
22940
|
+
file: "lac-release-notes.html",
|
|
22941
|
+
label: "Release Notes",
|
|
22942
|
+
description: "User-facing release notes — features that shipped recently",
|
|
22943
|
+
icon: "🚀",
|
|
22944
|
+
primary: true
|
|
22945
|
+
},
|
|
22946
|
+
{
|
|
22947
|
+
file: "lac-sprint.html",
|
|
22948
|
+
label: "Sprint Board",
|
|
22949
|
+
description: "Active + draft features sorted by priority — sprint planning at a glance",
|
|
22950
|
+
icon: "⚡",
|
|
22951
|
+
primary: false
|
|
22952
|
+
},
|
|
22953
|
+
{
|
|
22954
|
+
file: "lac-wiki.html",
|
|
22955
|
+
label: "Feature Wiki",
|
|
22956
|
+
description: "Complete searchable wiki — all fields, all features, sidebar navigation",
|
|
22957
|
+
icon: "🗂️",
|
|
22958
|
+
primary: false
|
|
22959
|
+
},
|
|
22960
|
+
{
|
|
22961
|
+
file: "lac-kanban.html",
|
|
22962
|
+
label: "Kanban Board",
|
|
22963
|
+
description: "Active / Frozen / Draft columns with sortable, filterable cards",
|
|
22964
|
+
icon: "📋",
|
|
22965
|
+
primary: false
|
|
22966
|
+
},
|
|
22967
|
+
{
|
|
22968
|
+
file: "lac-changelog.html",
|
|
22969
|
+
label: "Changelog",
|
|
22970
|
+
description: "Feature revision history grouped by month — all changes across the workspace",
|
|
22971
|
+
icon: "📅",
|
|
22972
|
+
primary: false
|
|
22973
|
+
},
|
|
22974
|
+
{
|
|
22975
|
+
file: "lac-decisions.html",
|
|
22976
|
+
label: "Decision Log",
|
|
22977
|
+
description: "All architectural decisions consolidated and searchable by domain",
|
|
22978
|
+
icon: "⚖️",
|
|
22979
|
+
primary: false
|
|
22980
|
+
},
|
|
22981
|
+
{
|
|
22982
|
+
file: "lac-api-surface.html",
|
|
22983
|
+
label: "API Surface",
|
|
22984
|
+
description: "Aggregated public interface reference — all exported components, hooks, and types",
|
|
22985
|
+
icon: "🔌",
|
|
22986
|
+
primary: false
|
|
22987
|
+
},
|
|
22988
|
+
{
|
|
22989
|
+
file: "lac-depmap.html",
|
|
22990
|
+
label: "Dependency Map",
|
|
22991
|
+
description: "Runtime cross-feature dependency graph from externalDependencies[]",
|
|
22992
|
+
icon: "🕸️",
|
|
22993
|
+
primary: false
|
|
22994
|
+
},
|
|
22995
|
+
{
|
|
22996
|
+
file: "lac-health.html",
|
|
22997
|
+
label: "Health Scorecard",
|
|
22998
|
+
description: "Completeness, coverage, tech-debt score, and field fill rates",
|
|
22999
|
+
icon: "🏥",
|
|
23000
|
+
primary: false
|
|
23001
|
+
},
|
|
23002
|
+
{
|
|
23003
|
+
file: "lac-heatmap.html",
|
|
23004
|
+
label: "Completeness Heatmap",
|
|
23005
|
+
description: "Field x feature completeness grid — quickly spot gaps",
|
|
23006
|
+
icon: "🔥",
|
|
23007
|
+
primary: false
|
|
23008
|
+
},
|
|
23009
|
+
{
|
|
23010
|
+
file: "lac-graph.html",
|
|
23011
|
+
label: "Lineage Graph",
|
|
23012
|
+
description: "Interactive force-directed feature lineage graph",
|
|
23013
|
+
icon: "🌐",
|
|
23014
|
+
primary: false
|
|
23015
|
+
},
|
|
23016
|
+
{
|
|
23017
|
+
file: "lac-print.html",
|
|
23018
|
+
label: "Print",
|
|
23019
|
+
description: "Print-ready A4 document — all features in clean two-column layout",
|
|
23020
|
+
icon: "🖨️",
|
|
23021
|
+
primary: false
|
|
23022
|
+
},
|
|
23023
|
+
{
|
|
23024
|
+
file: "lac-raw.html",
|
|
23025
|
+
label: "Raw Dump",
|
|
23026
|
+
description: "Field-by-field dump of every feature.json with sidebar navigation",
|
|
23027
|
+
icon: "🔩",
|
|
23028
|
+
primary: false
|
|
23029
|
+
}
|
|
23030
|
+
];
|
|
23031
|
+
function generateHub(projectName, stats, entries, generatedAt = (/* @__PURE__ */ new Date()).toISOString(), prefix) {
|
|
23032
|
+
function esc(s) {
|
|
23033
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
23034
|
+
}
|
|
23035
|
+
const urlPrefix = prefix ? "/" + (/[A-Za-z]:[/\\]/.test(prefix) ? prefix.split(/[/\\]/).filter(Boolean).pop() ?? "" : prefix.replace(/^\/+/, "").replace(/\/+$/, "")) : "";
|
|
23036
|
+
function href(file) {
|
|
23037
|
+
return urlPrefix ? `${urlPrefix}/${file}` : `./${file}`;
|
|
23038
|
+
}
|
|
23039
|
+
const primaryEntries = entries.filter((e) => e.primary);
|
|
23040
|
+
const secondaryEntries = entries.filter((e) => !e.primary);
|
|
23041
|
+
const date = new Date(generatedAt).toLocaleDateString("en-US", {
|
|
23042
|
+
year: "numeric",
|
|
23043
|
+
month: "long",
|
|
23044
|
+
day: "numeric"
|
|
23045
|
+
});
|
|
23046
|
+
function primaryCard(e) {
|
|
23047
|
+
return `
|
|
23048
|
+
<a href="${esc(href(e.file))}" class="primary-card">
|
|
23049
|
+
<div class="primary-card-icon">${e.icon}</div>
|
|
23050
|
+
<div class="primary-card-body">
|
|
23051
|
+
<div class="primary-card-label">${esc(e.label)}</div>
|
|
23052
|
+
<div class="primary-card-desc">${esc(e.description)}</div>
|
|
23053
|
+
</div>
|
|
23054
|
+
<div class="primary-card-arrow">→</div>
|
|
23055
|
+
</a>`;
|
|
23056
|
+
}
|
|
23057
|
+
function secondaryCard(e) {
|
|
23058
|
+
return `
|
|
23059
|
+
<a href="${esc(href(e.file))}" class="secondary-card">
|
|
23060
|
+
<div class="secondary-card-icon">${e.icon}</div>
|
|
23061
|
+
<div class="secondary-card-label">${esc(e.label)}</div>
|
|
23062
|
+
<div class="secondary-card-desc">${esc(e.description)}</div>
|
|
23063
|
+
</a>`;
|
|
23064
|
+
}
|
|
23065
|
+
const domainList = stats.domains.length > 0 ? stats.domains.slice(0, 6).map((d) => `<span class="domain-tag">${esc(d)}</span>`).join("") + (stats.domains.length > 6 ? `<span class="domain-tag">+${stats.domains.length - 6}</span>` : "") : "";
|
|
23066
|
+
return `<!DOCTYPE html>
|
|
23067
|
+
<html lang="en">
|
|
23068
|
+
<head>
|
|
23069
|
+
<meta charset="UTF-8">
|
|
23070
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
23071
|
+
<title>${esc(projectName)} — Life-as-Code Hub</title>
|
|
23072
|
+
<style>
|
|
23073
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
23074
|
+
:root {
|
|
23075
|
+
--bg: #0f0d0b;
|
|
23076
|
+
--bg-card: #181512;
|
|
23077
|
+
--bg-hover: #1e1a16;
|
|
23078
|
+
--border: #262018;
|
|
23079
|
+
--text: #ece3d8;
|
|
23080
|
+
--text-mid: #b0a494;
|
|
23081
|
+
--text-soft: #736455;
|
|
23082
|
+
--accent: #c4a255;
|
|
23083
|
+
--accent-w: #e8b865;
|
|
23084
|
+
--mono: 'Cascadia Code','Fira Code','JetBrains Mono','Consolas',monospace;
|
|
23085
|
+
--sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
23086
|
+
--frozen: #5b82cc; --active: #4aad72; --draft: #c4a255; --deprecated: #cc5b5b;
|
|
23087
|
+
}
|
|
23088
|
+
html { scroll-behavior: smooth; }
|
|
23089
|
+
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; min-height: 100vh; }
|
|
23090
|
+
|
|
23091
|
+
/* ── Topbar ── */
|
|
23092
|
+
.topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 28px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
23093
|
+
.topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
|
|
23094
|
+
.topbar-sep { color: var(--border); font-size: 18px; line-height: 1; }
|
|
23095
|
+
.topbar-title { font-size: 13px; color: var(--text-mid); }
|
|
23096
|
+
.topbar-date { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
|
|
23097
|
+
|
|
23098
|
+
/* ── Layout ── */
|
|
23099
|
+
.page { max-width: 880px; margin: 0 auto; padding: 56px 28px 100px; }
|
|
23100
|
+
|
|
23101
|
+
/* ── Hero ── */
|
|
23102
|
+
.hero { margin-bottom: 48px; }
|
|
23103
|
+
.hero-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent); margin-bottom: 12px; }
|
|
23104
|
+
.hero-title { font-size: 40px; font-weight: 800; color: var(--text); letter-spacing: -0.025em; line-height: 1.1; margin-bottom: 10px; }
|
|
23105
|
+
.hero-sub { font-size: 15px; color: var(--text-mid); max-width: 520px; line-height: 1.7; margin-bottom: 28px; }
|
|
23106
|
+
|
|
23107
|
+
/* ── Stats row ── */
|
|
23108
|
+
.stats-row { display: flex; flex-wrap: wrap; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
|
|
21604
23109
|
.stat { flex: 1; min-width: 90px; padding: 14px 20px; background: var(--bg-card); }
|
|
21605
23110
|
.stat-num { font-family: var(--mono); font-size: 22px; font-weight: 700; line-height: 1; }
|
|
21606
23111
|
.stat-lbl { font-size: 11px; color: var(--text-soft); margin-top: 3px; }
|
|
@@ -21725,6 +23230,16 @@ function lacGo(file){location.href=location.href.replace(/[^\\/]*$/,'')+file}
|
|
|
21725
23230
|
}
|
|
21726
23231
|
//#endregion
|
|
21727
23232
|
//#region src/lib/views.ts
|
|
23233
|
+
/** Fields always shown at summary density regardless of view */
|
|
23234
|
+
const SUMMARY_FIELDS = new Set([
|
|
23235
|
+
"featureKey",
|
|
23236
|
+
"title",
|
|
23237
|
+
"status",
|
|
23238
|
+
"domain",
|
|
23239
|
+
"priority",
|
|
23240
|
+
"tags",
|
|
23241
|
+
"problem"
|
|
23242
|
+
]);
|
|
21728
23243
|
const VIEW_NAMES = [
|
|
21729
23244
|
"dev",
|
|
21730
23245
|
"product",
|
|
@@ -21776,10 +23291,13 @@ const VIEWS = {
|
|
|
21776
23291
|
"problem",
|
|
21777
23292
|
"analysis",
|
|
21778
23293
|
"userGuide",
|
|
23294
|
+
"pmSummary",
|
|
21779
23295
|
"successCriteria",
|
|
23296
|
+
"acceptanceCriteria",
|
|
21780
23297
|
"decisions",
|
|
21781
23298
|
"knownLimitations",
|
|
21782
|
-
"tags"
|
|
23299
|
+
"tags",
|
|
23300
|
+
"releaseVersion"
|
|
21783
23301
|
])
|
|
21784
23302
|
},
|
|
21785
23303
|
dev: {
|
|
@@ -21793,8 +23311,11 @@ const VIEWS = {
|
|
|
21793
23311
|
"problem",
|
|
21794
23312
|
"analysis",
|
|
21795
23313
|
"implementation",
|
|
23314
|
+
"implementationNotes",
|
|
21796
23315
|
"userGuide",
|
|
21797
23316
|
"successCriteria",
|
|
23317
|
+
"acceptanceCriteria",
|
|
23318
|
+
"testStrategy",
|
|
21798
23319
|
"decisions",
|
|
21799
23320
|
"knownLimitations",
|
|
21800
23321
|
"tags",
|
|
@@ -21819,8 +23340,12 @@ const VIEWS = {
|
|
|
21819
23340
|
"problem",
|
|
21820
23341
|
"analysis",
|
|
21821
23342
|
"implementation",
|
|
23343
|
+
"implementationNotes",
|
|
21822
23344
|
"userGuide",
|
|
23345
|
+
"pmSummary",
|
|
21823
23346
|
"successCriteria",
|
|
23347
|
+
"acceptanceCriteria",
|
|
23348
|
+
"testStrategy",
|
|
21824
23349
|
"decisions",
|
|
21825
23350
|
"knownLimitations",
|
|
21826
23351
|
"tags",
|
|
@@ -21834,6 +23359,7 @@ const VIEWS = {
|
|
|
21834
23359
|
"externalDependencies",
|
|
21835
23360
|
"codeSnippets",
|
|
21836
23361
|
"lastVerifiedDate",
|
|
23362
|
+
"releaseVersion",
|
|
21837
23363
|
"superseded_by",
|
|
21838
23364
|
"superseded_from",
|
|
21839
23365
|
"merged_into",
|
|
@@ -21871,6 +23397,74 @@ function applyViewForHtml(feature, view) {
|
|
|
21871
23397
|
for (const key of Object.keys(feature)) if (view.fields.has(key) || HTML_NAV_FIELDS.has(key)) result[key] = feature[key];
|
|
21872
23398
|
return result;
|
|
21873
23399
|
}
|
|
23400
|
+
/**
|
|
23401
|
+
* Apply density filtering to a feature.
|
|
23402
|
+
* - summary: only SUMMARY_FIELDS (title, status, domain, priority, tags, problem snippet)
|
|
23403
|
+
* - standard: pass through unchanged (generators decide what to show)
|
|
23404
|
+
* - verbose: pass through unchanged but callers should render VERBOSE_EXTRA_FIELDS too
|
|
23405
|
+
*
|
|
23406
|
+
* Returns the filtered feature and the resolved density level.
|
|
23407
|
+
*/
|
|
23408
|
+
function applyDensity(feature, density) {
|
|
23409
|
+
if (density === "standard" || density === "verbose") return feature;
|
|
23410
|
+
const result = {};
|
|
23411
|
+
for (const key of Object.keys(feature)) if (SUMMARY_FIELDS.has(key)) if (key === "problem" && typeof feature[key] === "string") {
|
|
23412
|
+
const prob = feature[key];
|
|
23413
|
+
const firstSentence = prob.split(/[.!?]\s/)[0] ?? prob;
|
|
23414
|
+
result[key] = firstSentence.length < prob.length ? firstSentence + "." : prob;
|
|
23415
|
+
} else result[key] = feature[key];
|
|
23416
|
+
return result;
|
|
23417
|
+
}
|
|
23418
|
+
/**
|
|
23419
|
+
* Resolve a view name against both built-in views and custom views from lac.config.json.
|
|
23420
|
+
*
|
|
23421
|
+
* Resolution order:
|
|
23422
|
+
* 1. If `name` matches a key in `customViews` (from lac.config.json), build a ViewConfig from it.
|
|
23423
|
+
* If it has `extends`, merge on top of the built-in base.
|
|
23424
|
+
* 2. Otherwise fall back to VIEWS[name].
|
|
23425
|
+
* 3. If neither matches, return undefined.
|
|
23426
|
+
*/
|
|
23427
|
+
function resolveView(name, customViews = {}) {
|
|
23428
|
+
const custom = customViews[name];
|
|
23429
|
+
if (custom) {
|
|
23430
|
+
const base = custom.extends ? VIEWS[custom.extends] : void 0;
|
|
23431
|
+
const baseFields = base ? new Set(base.fields) : /* @__PURE__ */ new Set();
|
|
23432
|
+
const fields = custom.fields ? new Set(custom.fields) : baseFields;
|
|
23433
|
+
for (const f of IDENTITY) fields.add(f);
|
|
23434
|
+
return {
|
|
23435
|
+
name,
|
|
23436
|
+
label: custom.label ?? base?.label ?? name,
|
|
23437
|
+
description: custom.description ?? base?.description ?? `Custom view: ${name}`,
|
|
23438
|
+
fields,
|
|
23439
|
+
density: custom.density,
|
|
23440
|
+
groupBy: custom.groupBy,
|
|
23441
|
+
sortBy: custom.sortBy,
|
|
23442
|
+
filterStatus: custom.filterStatus,
|
|
23443
|
+
sections: custom.sections
|
|
23444
|
+
};
|
|
23445
|
+
}
|
|
23446
|
+
return VIEW_NAMES.includes(name) ? VIEWS[name] : void 0;
|
|
23447
|
+
}
|
|
23448
|
+
/**
|
|
23449
|
+
* Sort and filter a feature list according to a resolved view profile.
|
|
23450
|
+
* This is called before passing features to any generator.
|
|
23451
|
+
*/
|
|
23452
|
+
function applyViewTransforms(features, profile) {
|
|
23453
|
+
let result = [...features];
|
|
23454
|
+
if (profile.filterStatus && profile.filterStatus.length > 0) result = result.filter((f) => profile.filterStatus.includes(f["status"]));
|
|
23455
|
+
if (profile.sortBy === "priority") result.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
|
|
23456
|
+
else if (profile.sortBy === "title") result.sort((a, b) => String(a["title"] ?? "").localeCompare(String(b["title"] ?? "")));
|
|
23457
|
+
else if (profile.sortBy === "status") {
|
|
23458
|
+
const order = {
|
|
23459
|
+
active: 0,
|
|
23460
|
+
draft: 1,
|
|
23461
|
+
frozen: 2,
|
|
23462
|
+
deprecated: 3
|
|
23463
|
+
};
|
|
23464
|
+
result.sort((a, b) => (order[a["status"]] ?? 9) - (order[b["status"]] ?? 9));
|
|
23465
|
+
} else if (profile.sortBy === "lastVerifiedDate") result.sort((a, b) => String(b["lastVerifiedDate"] ?? "").localeCompare(String(a["lastVerifiedDate"] ?? "")));
|
|
23466
|
+
return result;
|
|
23467
|
+
}
|
|
21874
23468
|
//#endregion
|
|
21875
23469
|
//#region src/commands/export.ts
|
|
21876
23470
|
/**
|
|
@@ -22085,7 +23679,7 @@ function buildReconstructionPrompt(features, projectName, promptDir) {
|
|
|
22085
23679
|
lines.push("");
|
|
22086
23680
|
return lines.join("\n");
|
|
22087
23681
|
}
|
|
22088
|
-
const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML view").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki").option("--raw [dir]", "Raw field-by-field HTML dump with sidebar navigation").option("--print [dir]", "Print-ready HTML document (A4, all features, @media print CSS)").option("--postcard", "Beautiful single-feature shareable card (nearest feature.json)").option("--resume [dir]", "Portfolio page from all frozen features").option("--slide [dir]", "Full-screen HTML slideshow, one slide per feature").option("--graph [dir]", "Interactive force-directed feature lineage graph").option("--heatmap [dir]", "Completeness heatmap — fields × features grid").option("--quiz [dir]", "Flashcard-style quiz to test knowledge of your feature set").option("--story [dir]", "Long-form narrative document — product case study from feature data").option("--treemap [dir]", "Rectangular treemap — features sized by decisions × completeness, grouped by domain").option("--kanban [dir]", "Kanban board — Active / Frozen / Draft columns with sortable, filterable cards").option("--health [dir]", "Project health scorecard — completeness, coverage, tech debt, and health score").option("--embed [dir]", "Compact embeddable stats widget (iframe-ready)").option("--decisions [dir]", "Consolidated ADR — all decisions from all features, searchable by domain").option("--guide [dir]", "User guide — one page per feature that has a non-empty userGuide field").option("--hub [dir]", "Hub landing page linking to all generated views → lac-hub.html").option("--all [dir]", "Generate all HTML views + hub index.html → --out dir (default: ./lac-output)").option("--prefix <prefix>", "URL prefix for hub links (no leading slash), e.g. lac → hrefs become /lac/lac-guide.html").option("--diff <dir-b>", "Compare cwd workspace against <dir-b> and show added/removed/changed").option("--site <dir>", "Generate a multi-page static site → --out dir (default: ./lac-site)").option("--prompt [dir]", "AI reconstruction prompt for all features (stdout or --out file)").option("--markdown", "Single feature as Markdown (nearest feature.json)").option("--tags <tags>", "Comma-separated tags to filter by (OR logic) — applies to all multi-feature modes").option("--sort <mode>", "Sort order for multi-feature modes: key (default) | build-order (parents before children)").option("--view <name>", `Audience view —
|
|
23682
|
+
const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML view").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki").option("--raw [dir]", "Raw field-by-field HTML dump with sidebar navigation").option("--print [dir]", "Print-ready HTML document (A4, all features, @media print CSS)").option("--postcard", "Beautiful single-feature shareable card (nearest feature.json)").option("--resume [dir]", "Portfolio page from all frozen features").option("--slide [dir]", "Full-screen HTML slideshow, one slide per feature").option("--graph [dir]", "Interactive force-directed feature lineage graph").option("--heatmap [dir]", "Completeness heatmap — fields × features grid").option("--quiz [dir]", "Flashcard-style quiz to test knowledge of your feature set").option("--story [dir]", "Long-form narrative document — product case study from feature data").option("--treemap [dir]", "Rectangular treemap — features sized by decisions × completeness, grouped by domain").option("--kanban [dir]", "Kanban board — Active / Frozen / Draft columns with sortable, filterable cards").option("--health [dir]", "Project health scorecard — completeness, coverage, tech debt, and health score").option("--embed [dir]", "Compact embeddable stats widget (iframe-ready)").option("--decisions [dir]", "Consolidated ADR — all decisions from all features, searchable by domain").option("--guide [dir]", "User guide — one page per feature that has a non-empty userGuide field").option("--hub [dir]", "Hub landing page linking to all generated views → lac-hub.html").option("--all [dir]", "Generate all HTML views + hub index.html → --out dir (default: ./lac-output)").option("--prefix <prefix>", "URL prefix for hub links (no leading slash), e.g. lac → hrefs become /lac/lac-guide.html").option("--diff <dir-b>", "Compare cwd workspace against <dir-b> and show added/removed/changed").option("--site <dir>", "Generate a multi-page static site → --out dir (default: ./lac-site)").option("--prompt [dir]", "AI reconstruction prompt for all features (stdout or --out file)").option("--markdown", "Single feature as Markdown (nearest feature.json)").option("--changelog [dir]", "Structured changelog grouped by month — from revisions[] across all features").option("--since <date>", "Filter --changelog and --release-notes to entries after this date (YYYY-MM-DD)").option("--release-notes [dir]", "User-facing release notes — features that went frozen since --since date or --release version").option("--release <version>", "Filter --release-notes to features matching this releaseVersion (e.g. 3.5.0)").option("--sprint [dir]", "Sprint planning view — draft+active features sorted by priority, summary density").option("--api-surface [dir]", "Aggregated publicInterface[] reference across all features → lac-api-surface.html").option("--dependency-map [dir]", "Runtime dependency graph from externalDependencies[] → lac-depmap.html").option("--tags <tags>", "Comma-separated tags to filter by (OR logic) — applies to all multi-feature modes").option("--sort <mode>", "Sort order for multi-feature modes: key (default) | build-order (parents before children)").option("--view <name>", `Audience view — built-in (${VIEW_NAMES.join(", ")}) or custom name from lac.config.json views`).option("--density <level>", "Content density: summary | standard | verbose (default: standard)").addHelpText("after", `
|
|
22089
23683
|
Examples:
|
|
22090
23684
|
lac export --html HTML wiki (cwd) → lac-wiki.html
|
|
22091
23685
|
lac export --raw Raw field dump → lac-raw.html
|
|
@@ -22121,11 +23715,16 @@ Views (--view):
|
|
|
22121
23715
|
product Business problem, success criteria, and strategic decisions (no code)
|
|
22122
23716
|
dev Full implementation context — code, decisions, snippets, and lineage
|
|
22123
23717
|
tech Complete technical record — all fields including history and revisions`).action(async (options) => {
|
|
22124
|
-
|
|
23718
|
+
const config = loadConfig(process$1.cwd());
|
|
23719
|
+
let activeView = options.view ? resolveView(options.view, config.views) : void 0;
|
|
23720
|
+
const activeViewRenderMode = options.view && config.views[options.view] ? config.views[options.view].extends : void 0;
|
|
22125
23721
|
if (options.view && !activeView) {
|
|
22126
|
-
|
|
23722
|
+
const customNames = Object.keys(config.views);
|
|
23723
|
+
const allNames = [...VIEW_NAMES, ...customNames];
|
|
23724
|
+
process$1.stderr.write(`Error: unknown view "${options.view}". Available: ${allNames.join(", ")}\n`);
|
|
22127
23725
|
process$1.exit(1);
|
|
22128
23726
|
}
|
|
23727
|
+
const activeDensity = options.density === "summary" || options.density === "verbose" ? options.density : activeView && "density" in activeView && activeView.density ? activeView.density : "standard";
|
|
22129
23728
|
if (options.sort && options.sort !== "key" && options.sort !== "build-order") {
|
|
22130
23729
|
process$1.stderr.write(`Error: unknown sort mode "${options.sort}". Valid modes: key, build-order\n`);
|
|
22131
23730
|
process$1.exit(1);
|
|
@@ -22183,7 +23782,8 @@ Views (--view):
|
|
|
22183
23782
|
process$1.exit(0);
|
|
22184
23783
|
}
|
|
22185
23784
|
const projectName = basename(htmlDir);
|
|
22186
|
-
const
|
|
23785
|
+
const densityFeatures = withDensity(features);
|
|
23786
|
+
const html = generateHtmlWiki(activeView ? densityFeatures.map((f) => applyViewForHtml(f.feature, activeView)) : densityFeatures.map((f) => f.feature), projectName, activeView?.label, activeView?.name, activeViewRenderMode);
|
|
22187
23787
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-wiki.html");
|
|
22188
23788
|
try {
|
|
22189
23789
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22215,7 +23815,8 @@ Views (--view):
|
|
|
22215
23815
|
process$1.exit(0);
|
|
22216
23816
|
}
|
|
22217
23817
|
const projectName = basename(rawDir);
|
|
22218
|
-
const
|
|
23818
|
+
const densityRaw = withDensity(features);
|
|
23819
|
+
const html = generateRawHtml(activeView ? densityRaw.map((f) => applyViewForHtml(f.feature, activeView)) : densityRaw.map((f) => f.feature), projectName, activeView?.label, activeView?.name);
|
|
22219
23820
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-raw.html");
|
|
22220
23821
|
try {
|
|
22221
23822
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22276,7 +23877,23 @@ Views (--view):
|
|
|
22276
23877
|
const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
|
|
22277
23878
|
features = features.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
|
|
22278
23879
|
}
|
|
22279
|
-
|
|
23880
|
+
if (activeView && ("filterStatus" in activeView || "sortBy" in activeView)) {
|
|
23881
|
+
const transformed = applyViewTransforms(features.map((f) => f.feature), {
|
|
23882
|
+
filterStatus: activeView.filterStatus,
|
|
23883
|
+
sortBy: activeView.sortBy
|
|
23884
|
+
});
|
|
23885
|
+
const transformedKeys = new Set(transformed.map((f) => f["featureKey"]));
|
|
23886
|
+
features = features.filter((f) => transformedKeys.has(f.feature.featureKey));
|
|
23887
|
+
}
|
|
23888
|
+
features = applySort(features);
|
|
23889
|
+
return features;
|
|
23890
|
+
}
|
|
23891
|
+
function withDensity(featureList) {
|
|
23892
|
+
if (activeDensity === "standard") return featureList;
|
|
23893
|
+
return featureList.map((f) => ({
|
|
23894
|
+
...f,
|
|
23895
|
+
feature: applyDensity(f.feature, activeDensity)
|
|
23896
|
+
}));
|
|
22280
23897
|
}
|
|
22281
23898
|
if (options.print !== void 0) {
|
|
22282
23899
|
const dir = typeof options.print === "string" ? resolve(options.print) : resolve(process$1.cwd());
|
|
@@ -22285,7 +23902,8 @@ Views (--view):
|
|
|
22285
23902
|
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
22286
23903
|
process$1.exit(0);
|
|
22287
23904
|
}
|
|
22288
|
-
const
|
|
23905
|
+
const dPrint = withDensity(features);
|
|
23906
|
+
const html = generatePrint(activeView ? dPrint.map((f) => applyViewForHtml(f.feature, activeView)) : dPrint.map((f) => f.feature), basename(dir), activeView?.label);
|
|
22289
23907
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-print.html");
|
|
22290
23908
|
try {
|
|
22291
23909
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22303,7 +23921,7 @@ Views (--view):
|
|
|
22303
23921
|
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
22304
23922
|
process$1.exit(0);
|
|
22305
23923
|
}
|
|
22306
|
-
const html = generateResume(features.map((f) => f.feature), basename(dir));
|
|
23924
|
+
const html = generateResume(withDensity(features).map((f) => f.feature), basename(dir));
|
|
22307
23925
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-resume.html");
|
|
22308
23926
|
try {
|
|
22309
23927
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22321,7 +23939,8 @@ Views (--view):
|
|
|
22321
23939
|
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
22322
23940
|
process$1.exit(0);
|
|
22323
23941
|
}
|
|
22324
|
-
const
|
|
23942
|
+
const dSlide = withDensity(features);
|
|
23943
|
+
const html = generateSlides(activeView ? dSlide.map((f) => applyViewForHtml(f.feature, activeView)) : dSlide.map((f) => f.feature), basename(dir), activeView?.label);
|
|
22325
23944
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-slides.html");
|
|
22326
23945
|
try {
|
|
22327
23946
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22357,7 +23976,7 @@ Views (--view):
|
|
|
22357
23976
|
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
22358
23977
|
process$1.exit(0);
|
|
22359
23978
|
}
|
|
22360
|
-
const html = generateStory(features.map((f) => f.feature), basename(dir));
|
|
23979
|
+
const html = generateStory(withDensity(features).map((f) => f.feature), basename(dir));
|
|
22361
23980
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-story.html");
|
|
22362
23981
|
try {
|
|
22363
23982
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22393,7 +24012,7 @@ Views (--view):
|
|
|
22393
24012
|
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
22394
24013
|
process$1.exit(0);
|
|
22395
24014
|
}
|
|
22396
|
-
const html = generateKanban(features.map((f) => f.feature), basename(dir));
|
|
24015
|
+
const html = generateKanban(withDensity(features).map((f) => f.feature), basename(dir));
|
|
22397
24016
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-kanban.html");
|
|
22398
24017
|
try {
|
|
22399
24018
|
await writeFile(outFile, html, "utf-8");
|
|
@@ -22447,7 +24066,7 @@ Views (--view):
|
|
|
22447
24066
|
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
22448
24067
|
process$1.exit(0);
|
|
22449
24068
|
}
|
|
22450
|
-
const fs = features.map((f) => f.feature);
|
|
24069
|
+
const fs = withDensity(features).map((f) => f.feature);
|
|
22451
24070
|
const totalDecisions = fs.reduce((n, f) => n + (f.decisions?.length ?? 0), 0);
|
|
22452
24071
|
const html = generateDecisionLog(fs, basename(dir));
|
|
22453
24072
|
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-decisions.html");
|
|
@@ -22507,6 +24126,104 @@ Views (--view):
|
|
|
22507
24126
|
}
|
|
22508
24127
|
return;
|
|
22509
24128
|
}
|
|
24129
|
+
if (options.changelog !== void 0) {
|
|
24130
|
+
const dir = typeof options.changelog === "string" ? resolve(options.changelog) : resolve(process$1.cwd());
|
|
24131
|
+
const features = await scanAndFilter(dir);
|
|
24132
|
+
if (features.length === 0) {
|
|
24133
|
+
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
24134
|
+
process$1.exit(0);
|
|
24135
|
+
}
|
|
24136
|
+
const fs = features.map((f) => f.feature);
|
|
24137
|
+
const html = generateChangelog(fs, basename(dir), options.since);
|
|
24138
|
+
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-changelog.html");
|
|
24139
|
+
try {
|
|
24140
|
+
await writeFile(outFile, html, "utf-8");
|
|
24141
|
+
const totalRevisions = fs.reduce((n, f) => n + (f["revisions"]?.length ?? 0), 0);
|
|
24142
|
+
process$1.stdout.write(`✓ Changelog (${totalRevisions} revisions across ${features.length} features) → ${options.out ?? "lac-changelog.html"}\n`);
|
|
24143
|
+
} catch (err) {
|
|
24144
|
+
process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
|
|
24145
|
+
process$1.exit(1);
|
|
24146
|
+
}
|
|
24147
|
+
return;
|
|
24148
|
+
}
|
|
24149
|
+
if (options.releaseNotes !== void 0) {
|
|
24150
|
+
const dir = typeof options.releaseNotes === "string" ? resolve(options.releaseNotes) : resolve(process$1.cwd());
|
|
24151
|
+
const features = await scanAndFilter(dir);
|
|
24152
|
+
if (features.length === 0) {
|
|
24153
|
+
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
24154
|
+
process$1.exit(0);
|
|
24155
|
+
}
|
|
24156
|
+
const html = generateReleaseNotes(features.map((f) => f.feature), basename(dir), {
|
|
24157
|
+
since: options.since,
|
|
24158
|
+
release: options.release
|
|
24159
|
+
});
|
|
24160
|
+
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-release-notes.html");
|
|
24161
|
+
try {
|
|
24162
|
+
await writeFile(outFile, html, "utf-8");
|
|
24163
|
+
process$1.stdout.write(`✓ Release notes → ${options.out ?? "lac-release-notes.html"}\n`);
|
|
24164
|
+
} catch (err) {
|
|
24165
|
+
process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
|
|
24166
|
+
process$1.exit(1);
|
|
24167
|
+
}
|
|
24168
|
+
return;
|
|
24169
|
+
}
|
|
24170
|
+
if (options.sprint !== void 0) {
|
|
24171
|
+
const dir = typeof options.sprint === "string" ? resolve(options.sprint) : resolve(process$1.cwd());
|
|
24172
|
+
const features = await scanAndFilter(dir);
|
|
24173
|
+
if (features.length === 0) {
|
|
24174
|
+
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
24175
|
+
process$1.exit(0);
|
|
24176
|
+
}
|
|
24177
|
+
const sprintFeatures = features.filter((f) => f.feature.status === "draft" || f.feature.status === "active");
|
|
24178
|
+
const html = generateSprint(sprintFeatures.map((f) => f.feature), basename(dir));
|
|
24179
|
+
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-sprint.html");
|
|
24180
|
+
try {
|
|
24181
|
+
await writeFile(outFile, html, "utf-8");
|
|
24182
|
+
process$1.stdout.write(`✓ Sprint (${sprintFeatures.length} active+draft features) → ${options.out ?? "lac-sprint.html"}\n`);
|
|
24183
|
+
} catch (err) {
|
|
24184
|
+
process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
|
|
24185
|
+
process$1.exit(1);
|
|
24186
|
+
}
|
|
24187
|
+
return;
|
|
24188
|
+
}
|
|
24189
|
+
if (options.apiSurface !== void 0) {
|
|
24190
|
+
const dir = typeof options.apiSurface === "string" ? resolve(options.apiSurface) : resolve(process$1.cwd());
|
|
24191
|
+
const features = await scanAndFilter(dir);
|
|
24192
|
+
if (features.length === 0) {
|
|
24193
|
+
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
24194
|
+
process$1.exit(0);
|
|
24195
|
+
}
|
|
24196
|
+
const fs = features.map((f) => f.feature);
|
|
24197
|
+
const totalEntries = fs.reduce((n, f) => n + (f["publicInterface"]?.length ?? 0), 0);
|
|
24198
|
+
const html = generateApiSurface(fs, basename(dir));
|
|
24199
|
+
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-api-surface.html");
|
|
24200
|
+
try {
|
|
24201
|
+
await writeFile(outFile, html, "utf-8");
|
|
24202
|
+
process$1.stdout.write(`✓ API surface (${totalEntries} entries across ${features.length} features) → ${options.out ?? "lac-api-surface.html"}\n`);
|
|
24203
|
+
} catch (err) {
|
|
24204
|
+
process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
|
|
24205
|
+
process$1.exit(1);
|
|
24206
|
+
}
|
|
24207
|
+
return;
|
|
24208
|
+
}
|
|
24209
|
+
if (options.dependencyMap !== void 0) {
|
|
24210
|
+
const dir = typeof options.dependencyMap === "string" ? resolve(options.dependencyMap) : resolve(process$1.cwd());
|
|
24211
|
+
const features = await scanAndFilter(dir);
|
|
24212
|
+
if (features.length === 0) {
|
|
24213
|
+
process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
|
|
24214
|
+
process$1.exit(0);
|
|
24215
|
+
}
|
|
24216
|
+
const html = generateDependencyMap(features.map((f) => f.feature), basename(dir));
|
|
24217
|
+
const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-depmap.html");
|
|
24218
|
+
try {
|
|
24219
|
+
await writeFile(outFile, html, "utf-8");
|
|
24220
|
+
process$1.stdout.write(`✓ Dependency map (${features.length} features) → ${options.out ?? "lac-depmap.html"}\n`);
|
|
24221
|
+
} catch (err) {
|
|
24222
|
+
process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
|
|
24223
|
+
process$1.exit(1);
|
|
24224
|
+
}
|
|
24225
|
+
return;
|
|
24226
|
+
}
|
|
22510
24227
|
if (options.all !== void 0) {
|
|
22511
24228
|
const dir = typeof options.all === "string" ? resolve(options.all) : resolve(process$1.cwd());
|
|
22512
24229
|
const outDir = resolve(options.out ?? "./lac-output");
|
|
@@ -22543,15 +24260,73 @@ Views (--view):
|
|
|
22543
24260
|
await write("lac-graph.html", generateGraph(fs, projectName));
|
|
22544
24261
|
await write("lac-print.html", generatePrint(fs, projectName));
|
|
22545
24262
|
await write("lac-raw.html", generateRawHtml(fs, projectName));
|
|
22546
|
-
await write("
|
|
24263
|
+
await write("lac-changelog.html", generateChangelog(fs, projectName));
|
|
24264
|
+
await write("lac-release-notes.html", generateReleaseNotes(fs, projectName, {}));
|
|
24265
|
+
await write("lac-sprint.html", generateSprint(fs.filter((f) => f.status === "draft" || f.status === "active"), projectName));
|
|
24266
|
+
await write("lac-api-surface.html", generateApiSurface(fs, projectName));
|
|
24267
|
+
await write("lac-depmap.html", generateDependencyMap(fs, projectName));
|
|
24268
|
+
const stats = {
|
|
22547
24269
|
total: fs.length,
|
|
22548
24270
|
frozen: fs.filter((f) => f.status === "frozen").length,
|
|
22549
24271
|
active: fs.filter((f) => f.status === "active").length,
|
|
22550
24272
|
draft: fs.filter((f) => f.status === "draft").length,
|
|
22551
24273
|
deprecated: fs.filter((f) => f.status === "deprecated").length,
|
|
22552
24274
|
domains: [...new Set(fs.map((f) => f.domain).filter((d) => Boolean(d)))]
|
|
22553
|
-
}
|
|
22554
|
-
|
|
24275
|
+
};
|
|
24276
|
+
const customEntries = [];
|
|
24277
|
+
const VIEW_ICON_MAP = {
|
|
24278
|
+
musician: "🎵",
|
|
24279
|
+
sprint: "⚡",
|
|
24280
|
+
"dev-deep": "🔬",
|
|
24281
|
+
"code-focus": "📦",
|
|
24282
|
+
shipped: "🚀",
|
|
24283
|
+
onboarding: "🎓",
|
|
24284
|
+
architect: "🏛️",
|
|
24285
|
+
support: "🛟",
|
|
24286
|
+
user: "👤",
|
|
24287
|
+
dev: "💻",
|
|
24288
|
+
product: "📊",
|
|
24289
|
+
tech: "🔧"
|
|
24290
|
+
};
|
|
24291
|
+
function pickViewIcon(name, label) {
|
|
24292
|
+
if (VIEW_ICON_MAP[name]) return VIEW_ICON_MAP[name];
|
|
24293
|
+
const lc = label.toLowerCase();
|
|
24294
|
+
if (lc.includes("music")) return "🎵";
|
|
24295
|
+
if (lc.includes("sprint") || lc.includes("active")) return "⚡";
|
|
24296
|
+
if (lc.includes("dev") || lc.includes("engineer")) return "🔬";
|
|
24297
|
+
if (lc.includes("code") || lc.includes("snippet")) return "📦";
|
|
24298
|
+
if (lc.includes("ship") || lc.includes("release") || lc.includes("frozen")) return "🚀";
|
|
24299
|
+
if (lc.includes("onboard") || lc.includes("guide") || lc.includes("new")) return "🎓";
|
|
24300
|
+
if (lc.includes("architect") || lc.includes("decision")) return "🏛️";
|
|
24301
|
+
if (lc.includes("support") || lc.includes("qa")) return "🛟";
|
|
24302
|
+
return "📄";
|
|
24303
|
+
}
|
|
24304
|
+
for (const [viewName, viewDef] of Object.entries(config.views)) {
|
|
24305
|
+
const resolved = resolveView(viewName, config.views);
|
|
24306
|
+
if (!resolved) continue;
|
|
24307
|
+
const label = viewDef.label ?? viewName;
|
|
24308
|
+
const description = viewDef.description ?? `Custom view: ${viewName}`;
|
|
24309
|
+
const icon = pickViewIcon(viewName, label);
|
|
24310
|
+
const filename = `view-${viewName}.html`;
|
|
24311
|
+
let viewFeatures = features.map((f) => f.feature);
|
|
24312
|
+
viewFeatures = applyViewTransforms(viewFeatures, {
|
|
24313
|
+
filterStatus: resolved.filterStatus,
|
|
24314
|
+
sortBy: resolved.sortBy
|
|
24315
|
+
});
|
|
24316
|
+
const viewHtmlFeatures = viewFeatures.map((f) => applyViewForHtml(f, resolved));
|
|
24317
|
+
const renderMode = viewDef.extends;
|
|
24318
|
+
await write(filename, generateHtmlWiki(viewHtmlFeatures, projectName, label, viewName, renderMode));
|
|
24319
|
+
customEntries.push({
|
|
24320
|
+
file: filename,
|
|
24321
|
+
label,
|
|
24322
|
+
description,
|
|
24323
|
+
icon,
|
|
24324
|
+
primary: false
|
|
24325
|
+
});
|
|
24326
|
+
}
|
|
24327
|
+
await write("index.html", generateHub(projectName, stats, [...ALL_HUB_ENTRIES, ...customEntries], (/* @__PURE__ */ new Date()).toISOString(), options.prefix));
|
|
24328
|
+
const totalFiles = 15 + customEntries.length + 1;
|
|
24329
|
+
process$1.stdout.write(`Done — ${features.length} features, ${totalFiles} files written to ${outDir}\n`);
|
|
22555
24330
|
return;
|
|
22556
24331
|
}
|
|
22557
24332
|
if (options.graph !== void 0) {
|