@intentius/chant-lexicon-aws 0.0.8 → 0.0.9
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/integrity.json +25 -10
- package/dist/manifest.json +1 -1
- package/dist/meta.json +5743 -896
- package/dist/rules/cf-refs.ts +99 -0
- package/dist/rules/ext001.ts +30 -21
- package/dist/rules/hardcoded-region.ts +1 -0
- package/dist/rules/iam-wildcard.ts +1 -0
- package/dist/rules/s3-encryption.ts +1 -0
- package/dist/rules/waw016.ts +86 -0
- package/dist/rules/waw017.ts +53 -0
- package/dist/rules/waw018.ts +71 -0
- package/dist/rules/waw019.ts +82 -0
- package/dist/rules/waw020.ts +64 -0
- package/dist/rules/waw021.ts +53 -0
- package/dist/rules/waw022.ts +43 -0
- package/dist/rules/waw023.ts +47 -0
- package/dist/rules/waw024.ts +54 -0
- package/dist/rules/waw025.ts +43 -0
- package/dist/rules/waw026.ts +46 -0
- package/dist/rules/waw027.ts +50 -0
- package/dist/rules/waw028.ts +47 -0
- package/dist/rules/waw029.ts +62 -0
- package/dist/rules/waw030.ts +246 -0
- package/dist/skills/chant-aws.md +388 -30
- package/dist/types/index.d.ts +1552 -1528
- package/package.json +2 -2
- package/src/actions/actions.test.ts +75 -0
- package/src/actions/dynamodb.ts +36 -0
- package/src/actions/ecr.ts +9 -0
- package/src/actions/ecs.ts +5 -0
- package/src/actions/iam.ts +3 -0
- package/src/actions/index.ts +9 -0
- package/src/actions/lambda.ts +11 -0
- package/src/actions/logs.ts +4 -0
- package/src/actions/s3.ts +34 -0
- package/src/actions/sns.ts +5 -0
- package/src/actions/sqs.ts +15 -0
- package/src/codegen/__snapshots__/snapshot.test.ts.snap +2 -2
- package/src/codegen/docs-links.test.ts +143 -0
- package/src/codegen/docs.ts +247 -132
- package/src/codegen/generate-lexicon.ts +8 -0
- package/src/codegen/generate-typescript.ts +25 -1
- package/src/composites/composites.test.ts +442 -0
- package/src/composites/fargate-alb.ts +253 -0
- package/src/composites/index.ts +20 -0
- package/src/composites/lambda-api.ts +20 -0
- package/src/composites/lambda-dynamodb.ts +64 -0
- package/src/composites/lambda-eventbridge.ts +36 -0
- package/src/composites/lambda-function.ts +76 -0
- package/src/composites/lambda-s3.ts +72 -0
- package/src/composites/lambda-sns.ts +30 -0
- package/src/composites/lambda-sqs.ts +44 -0
- package/src/composites/scheduled-lambda.ts +37 -0
- package/src/composites/vpc-default.ts +148 -0
- package/src/default-tags.test.ts +38 -0
- package/src/default-tags.ts +77 -0
- package/src/generated/index.d.ts +1552 -1528
- package/src/generated/lexicon-aws.json +5743 -896
- package/src/import/roundtrip-fixtures.test.ts +1 -1
- package/src/index.ts +21 -0
- package/src/integration.test.ts +71 -0
- package/src/intrinsics.ts +24 -13
- package/src/lint/post-synth/cf-refs.ts +99 -0
- package/src/lint/post-synth/ext001.test.ts +214 -31
- package/src/lint/post-synth/ext001.ts +30 -21
- package/src/lint/post-synth/waw013.test.ts +120 -0
- package/src/lint/post-synth/waw014.test.ts +121 -0
- package/src/lint/post-synth/waw015.test.ts +147 -0
- package/src/lint/post-synth/waw016.test.ts +141 -0
- package/src/lint/post-synth/waw016.ts +86 -0
- package/src/lint/post-synth/waw017.test.ts +130 -0
- package/src/lint/post-synth/waw017.ts +53 -0
- package/src/lint/post-synth/waw018.test.ts +109 -0
- package/src/lint/post-synth/waw018.ts +71 -0
- package/src/lint/post-synth/waw019.test.ts +138 -0
- package/src/lint/post-synth/waw019.ts +82 -0
- package/src/lint/post-synth/waw020.test.ts +125 -0
- package/src/lint/post-synth/waw020.ts +64 -0
- package/src/lint/post-synth/waw021.test.ts +81 -0
- package/src/lint/post-synth/waw021.ts +53 -0
- package/src/lint/post-synth/waw022.test.ts +54 -0
- package/src/lint/post-synth/waw022.ts +43 -0
- package/src/lint/post-synth/waw023.test.ts +53 -0
- package/src/lint/post-synth/waw023.ts +47 -0
- package/src/lint/post-synth/waw024.test.ts +64 -0
- package/src/lint/post-synth/waw024.ts +54 -0
- package/src/lint/post-synth/waw025.test.ts +42 -0
- package/src/lint/post-synth/waw025.ts +43 -0
- package/src/lint/post-synth/waw026.test.ts +54 -0
- package/src/lint/post-synth/waw026.ts +46 -0
- package/src/lint/post-synth/waw027.test.ts +63 -0
- package/src/lint/post-synth/waw027.ts +50 -0
- package/src/lint/post-synth/waw028.test.ts +68 -0
- package/src/lint/post-synth/waw028.ts +47 -0
- package/src/lint/post-synth/waw029.test.ts +179 -0
- package/src/lint/post-synth/waw029.ts +62 -0
- package/src/lint/post-synth/waw030.test.ts +800 -0
- package/src/lint/post-synth/waw030.ts +246 -0
- package/src/lint/rules/hardcoded-region.ts +1 -0
- package/src/lint/rules/iam-wildcard.ts +1 -0
- package/src/lint/rules/s3-encryption.ts +1 -0
- package/src/lsp/hover.ts +15 -0
- package/src/nested-stack-integration.test.ts +100 -0
- package/src/nested-stack.ts +1 -1
- package/src/plugin.ts +468 -36
- package/src/serializer.test.ts +330 -2
- package/src/serializer.ts +62 -1
- package/src/spec/fetch.ts +10 -0
- package/src/spec/parse.test.ts +141 -0
- package/src/spec/parse.ts +40 -0
- package/src/taggable.ts +44 -0
- package/src/testdata/nested-stacks/app.ts +26 -0
- package/src/testdata/nested-stacks/network/outputs.ts +17 -0
- package/src/testdata/nested-stacks/network/security.ts +17 -0
- package/src/testdata/nested-stacks/network/vpc.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": ["src/", "dist/"],
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"prepack": "bun run bundle && bun run validate"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@intentius/chant": "0.0.
|
|
25
|
+
"@intentius/chant": "0.0.9",
|
|
26
26
|
"fflate": "^0.8.2",
|
|
27
27
|
"js-yaml": "^4.1.0"
|
|
28
28
|
},
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { S3Actions } from "./s3";
|
|
3
|
+
import { LambdaActions } from "./lambda";
|
|
4
|
+
import { DynamoDBActions } from "./dynamodb";
|
|
5
|
+
import { SQSActions } from "./sqs";
|
|
6
|
+
import { SNSActions } from "./sns";
|
|
7
|
+
import { IAMActions } from "./iam";
|
|
8
|
+
import { ECRActions } from "./ecr";
|
|
9
|
+
import { LogsActions } from "./logs";
|
|
10
|
+
import { ECSActions } from "./ecs";
|
|
11
|
+
|
|
12
|
+
const allConstants = {
|
|
13
|
+
S3Actions,
|
|
14
|
+
LambdaActions,
|
|
15
|
+
DynamoDBActions,
|
|
16
|
+
SQSActions,
|
|
17
|
+
SNSActions,
|
|
18
|
+
IAMActions,
|
|
19
|
+
ECRActions,
|
|
20
|
+
LogsActions,
|
|
21
|
+
ECSActions,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("Action Constants", () => {
|
|
25
|
+
for (const [name, constant] of Object.entries(allConstants)) {
|
|
26
|
+
describe(name, () => {
|
|
27
|
+
test("every action string matches serviceName:actionName pattern", () => {
|
|
28
|
+
for (const [group, actions] of Object.entries(constant)) {
|
|
29
|
+
for (const action of actions) {
|
|
30
|
+
expect(action).toMatch(
|
|
31
|
+
/^[a-z][a-z0-9]*:[A-Z*][A-Za-z0-9*]*$/,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("no duplicate actions within a group", () => {
|
|
38
|
+
for (const [group, actions] of Object.entries(constant)) {
|
|
39
|
+
const unique = new Set(actions);
|
|
40
|
+
expect(unique.size).toBe(
|
|
41
|
+
actions.length,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("S3Actions broad groups are supersets", () => {
|
|
49
|
+
test("ReadWrite contains all ReadOnly actions", () => {
|
|
50
|
+
for (const action of S3Actions.ReadOnly) {
|
|
51
|
+
expect(S3Actions.ReadWrite).toContain(action);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("ReadWrite contains all WriteOnly actions", () => {
|
|
56
|
+
for (const action of S3Actions.WriteOnly) {
|
|
57
|
+
expect(S3Actions.ReadWrite).toContain(action);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("DynamoDBActions broad groups are supersets", () => {
|
|
63
|
+
test("ReadWrite contains all ReadOnly actions", () => {
|
|
64
|
+
for (const action of DynamoDBActions.ReadOnly) {
|
|
65
|
+
expect(DynamoDBActions.ReadWrite).toContain(action);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("ReadWrite contains all WriteOnly actions", () => {
|
|
70
|
+
for (const action of DynamoDBActions.WriteOnly) {
|
|
71
|
+
expect(DynamoDBActions.ReadWrite).toContain(action);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const DynamoDBActions = {
|
|
2
|
+
// Broad groups
|
|
3
|
+
ReadOnly: [
|
|
4
|
+
"dynamodb:GetItem",
|
|
5
|
+
"dynamodb:BatchGetItem",
|
|
6
|
+
"dynamodb:Query",
|
|
7
|
+
"dynamodb:Scan",
|
|
8
|
+
"dynamodb:DescribeTable",
|
|
9
|
+
"dynamodb:ConditionCheckItem",
|
|
10
|
+
],
|
|
11
|
+
WriteOnly: [
|
|
12
|
+
"dynamodb:PutItem",
|
|
13
|
+
"dynamodb:UpdateItem",
|
|
14
|
+
"dynamodb:DeleteItem",
|
|
15
|
+
"dynamodb:BatchWriteItem",
|
|
16
|
+
],
|
|
17
|
+
ReadWrite: [
|
|
18
|
+
"dynamodb:GetItem",
|
|
19
|
+
"dynamodb:BatchGetItem",
|
|
20
|
+
"dynamodb:Query",
|
|
21
|
+
"dynamodb:Scan",
|
|
22
|
+
"dynamodb:DescribeTable",
|
|
23
|
+
"dynamodb:ConditionCheckItem",
|
|
24
|
+
"dynamodb:PutItem",
|
|
25
|
+
"dynamodb:UpdateItem",
|
|
26
|
+
"dynamodb:DeleteItem",
|
|
27
|
+
"dynamodb:BatchWriteItem",
|
|
28
|
+
],
|
|
29
|
+
Full: ["dynamodb:*"],
|
|
30
|
+
|
|
31
|
+
// Operation-specific
|
|
32
|
+
GetItem: ["dynamodb:GetItem", "dynamodb:BatchGetItem"],
|
|
33
|
+
PutItem: ["dynamodb:PutItem", "dynamodb:BatchWriteItem"],
|
|
34
|
+
Query: ["dynamodb:Query"],
|
|
35
|
+
Scan: ["dynamodb:Scan"],
|
|
36
|
+
} as const;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { S3Actions } from "./s3";
|
|
2
|
+
export { LambdaActions } from "./lambda";
|
|
3
|
+
export { DynamoDBActions } from "./dynamodb";
|
|
4
|
+
export { SQSActions } from "./sqs";
|
|
5
|
+
export { SNSActions } from "./sns";
|
|
6
|
+
export { IAMActions } from "./iam";
|
|
7
|
+
export { ECRActions } from "./ecr";
|
|
8
|
+
export { LogsActions } from "./logs";
|
|
9
|
+
export { ECSActions } from "./ecs";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const LambdaActions = {
|
|
2
|
+
Invoke: ["lambda:InvokeFunction", "lambda:InvokeAsync"],
|
|
3
|
+
ReadOnly: [
|
|
4
|
+
"lambda:GetFunction",
|
|
5
|
+
"lambda:GetFunctionConfiguration",
|
|
6
|
+
"lambda:GetPolicy",
|
|
7
|
+
"lambda:ListVersionsByFunction",
|
|
8
|
+
"lambda:ListAliases",
|
|
9
|
+
],
|
|
10
|
+
Full: ["lambda:*"],
|
|
11
|
+
} as const;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const S3Actions = {
|
|
2
|
+
// Broad groups (from AWS managed policies)
|
|
3
|
+
ReadOnly: [
|
|
4
|
+
"s3:GetObject",
|
|
5
|
+
"s3:GetObjectVersion",
|
|
6
|
+
"s3:GetBucketLocation",
|
|
7
|
+
"s3:ListBucket",
|
|
8
|
+
"s3:ListBucketVersions",
|
|
9
|
+
],
|
|
10
|
+
WriteOnly: [
|
|
11
|
+
"s3:PutObject",
|
|
12
|
+
"s3:DeleteObject",
|
|
13
|
+
"s3:PutObjectAcl",
|
|
14
|
+
"s3:AbortMultipartUpload",
|
|
15
|
+
],
|
|
16
|
+
ReadWrite: [
|
|
17
|
+
"s3:GetObject",
|
|
18
|
+
"s3:GetObjectVersion",
|
|
19
|
+
"s3:GetBucketLocation",
|
|
20
|
+
"s3:ListBucket",
|
|
21
|
+
"s3:ListBucketVersions",
|
|
22
|
+
"s3:PutObject",
|
|
23
|
+
"s3:DeleteObject",
|
|
24
|
+
"s3:PutObjectAcl",
|
|
25
|
+
"s3:AbortMultipartUpload",
|
|
26
|
+
],
|
|
27
|
+
Full: ["s3:*"],
|
|
28
|
+
|
|
29
|
+
// Operation-specific
|
|
30
|
+
GetObject: ["s3:GetObject", "s3:GetObjectVersion"],
|
|
31
|
+
PutObject: ["s3:PutObject", "s3:AbortMultipartUpload"],
|
|
32
|
+
DeleteObject: ["s3:DeleteObject", "s3:DeleteObjectVersion"],
|
|
33
|
+
ListObjects: ["s3:ListBucket", "s3:ListBucketVersions"],
|
|
34
|
+
} as const;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const SQSActions = {
|
|
2
|
+
SendMessage: [
|
|
3
|
+
"sqs:SendMessage",
|
|
4
|
+
"sqs:GetQueueUrl",
|
|
5
|
+
"sqs:GetQueueAttributes",
|
|
6
|
+
],
|
|
7
|
+
ReceiveMessage: [
|
|
8
|
+
"sqs:ReceiveMessage",
|
|
9
|
+
"sqs:DeleteMessage",
|
|
10
|
+
"sqs:ChangeMessageVisibility",
|
|
11
|
+
"sqs:GetQueueUrl",
|
|
12
|
+
"sqs:GetQueueAttributes",
|
|
13
|
+
],
|
|
14
|
+
Full: ["sqs:*"],
|
|
15
|
+
} as const;
|
|
@@ -169,7 +169,7 @@ exports[`snapshot tests Bucket .d.ts class declaration 1`] = `
|
|
|
169
169
|
Tags?: Bucket_Tag[];
|
|
170
170
|
/** Enables multiple versions of all objects in this bucket. */
|
|
171
171
|
VersioningConfiguration?: Bucket_VersioningConfiguration;
|
|
172
|
-
});
|
|
172
|
+
}, attributes?: CFResourceAttributes);
|
|
173
173
|
readonly Arn: string;
|
|
174
174
|
}"
|
|
175
175
|
`;
|
|
@@ -191,7 +191,7 @@ exports[`snapshot tests Function .d.ts class declaration 1`] = `
|
|
|
191
191
|
MemorySize?: number;
|
|
192
192
|
/** The identifier of the function's runtime. */
|
|
193
193
|
Runtime?: "java17" | "java21" | "nodejs18.x" | "nodejs20.x" | "python3.11" | "python3.12";
|
|
194
|
-
});
|
|
194
|
+
}, attributes?: CFResourceAttributes);
|
|
195
195
|
readonly Arn: string;
|
|
196
196
|
}"
|
|
197
197
|
`;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
3
|
+
import { join, basename } from "path";
|
|
4
|
+
|
|
5
|
+
const docsDir = join(import.meta.dir, "..", "..", "docs", "src", "content", "docs");
|
|
6
|
+
const docsSource = join(import.meta.dir, "docs.ts");
|
|
7
|
+
const docsExist = existsSync(docsDir);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Collect all page slugs from the generated docs directory.
|
|
11
|
+
*/
|
|
12
|
+
function getPageSlugs(): Set<string> {
|
|
13
|
+
if (!docsExist) return new Set();
|
|
14
|
+
const slugs = new Set<string>();
|
|
15
|
+
for (const file of readdirSync(docsDir)) {
|
|
16
|
+
if (file.endsWith(".mdx")) {
|
|
17
|
+
slugs.add(basename(file, ".mdx"));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return slugs;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Extract markdown links: [text](href)
|
|
25
|
+
*/
|
|
26
|
+
function extractMarkdownLinks(content: string): Array<{ text: string; href: string; line: number }> {
|
|
27
|
+
const links: Array<{ text: string; href: string; line: number }> = [];
|
|
28
|
+
const lines = content.split("\n");
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
|
|
31
|
+
let match;
|
|
32
|
+
while ((match = regex.exec(lines[i])) !== null) {
|
|
33
|
+
const href = match[2];
|
|
34
|
+
if (href.startsWith("http://") || href.startsWith("https://")) continue;
|
|
35
|
+
if (href.startsWith("#")) continue;
|
|
36
|
+
links.push({ text: match[1], href, line: i + 1 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return links;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if a relative link target exists as a page slug.
|
|
44
|
+
*/
|
|
45
|
+
function resolveTarget(href: string, slugs: Set<string>): string | null {
|
|
46
|
+
const pathPart = href.split("#")[0].replace(/\/$/, "");
|
|
47
|
+
if (!pathPart) return null;
|
|
48
|
+
|
|
49
|
+
let target: string | undefined;
|
|
50
|
+
if (pathPart.startsWith("./")) target = pathPart.slice(2);
|
|
51
|
+
else if (pathPart.startsWith("../")) target = pathPart.slice(3);
|
|
52
|
+
else if (pathPart.startsWith("/chant/lexicons/aws/")) {
|
|
53
|
+
target = pathPart.replace("/chant/lexicons/aws/", "").replace(/\/$/, "") || "index";
|
|
54
|
+
} else if (!pathPart.includes("/") && !pathPart.startsWith(".")) {
|
|
55
|
+
target = pathPart;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (target === undefined) return null;
|
|
59
|
+
return slugs.has(target) ? null : `target page "${target}" does not exist`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("docs internal links", () => {
|
|
63
|
+
const slugs = getPageSlugs();
|
|
64
|
+
|
|
65
|
+
test("page slugs are discovered", () => {
|
|
66
|
+
if (!docsExist) return; // generated docs not present (e.g. CI)
|
|
67
|
+
expect(slugs.size).toBeGreaterThan(5);
|
|
68
|
+
expect(slugs.has("composites")).toBe(true);
|
|
69
|
+
expect(slugs.has("nested-stacks")).toBe(true);
|
|
70
|
+
expect(slugs.has("index")).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Validate generated MDX files (skip if docs not generated)
|
|
74
|
+
for (const file of (docsExist ? readdirSync(docsDir) : [])) {
|
|
75
|
+
if (!file.endsWith(".mdx")) continue;
|
|
76
|
+
const slug = basename(file, ".mdx");
|
|
77
|
+
|
|
78
|
+
test(`${slug}.mdx — internal links resolve to existing pages`, () => {
|
|
79
|
+
const content = readFileSync(join(docsDir, file), "utf-8");
|
|
80
|
+
const links = extractMarkdownLinks(content);
|
|
81
|
+
const errors: string[] = [];
|
|
82
|
+
for (const link of links) {
|
|
83
|
+
const error = resolveTarget(link.href, slugs);
|
|
84
|
+
if (error) errors.push(`line ${link.line}: [${link.text}](${link.href}) — ${error}`);
|
|
85
|
+
}
|
|
86
|
+
if (errors.length > 0) {
|
|
87
|
+
throw new Error(`Broken links in ${file}:\n${errors.join("\n")}`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test(`${slug}.mdx — non-index pages use ../ not ./ for cross-page links`, () => {
|
|
92
|
+
if (slug === "index") return;
|
|
93
|
+
const content = readFileSync(join(docsDir, file), "utf-8");
|
|
94
|
+
const links = extractMarkdownLinks(content);
|
|
95
|
+
const errors: string[] = [];
|
|
96
|
+
for (const link of links) {
|
|
97
|
+
const pathPart = link.href.split("#")[0];
|
|
98
|
+
if (pathPart.startsWith("./")) {
|
|
99
|
+
const target = pathPart.slice(2).replace(/\/$/, "");
|
|
100
|
+
if (slugs.has(target)) {
|
|
101
|
+
errors.push(`line ${link.line}: [${link.text}](${link.href}) — use "../${target}/" instead`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (errors.length > 0) {
|
|
106
|
+
throw new Error(`Broken ./ links in non-index page ${file}:\n${errors.join("\n")}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Validate source docs.ts — catches broken links before regeneration
|
|
112
|
+
test("docs.ts source — cross-page links use ../ not ./", () => {
|
|
113
|
+
if (!docsExist) return; // needs generated slugs for validation
|
|
114
|
+
const content = readFileSync(docsSource, "utf-8");
|
|
115
|
+
const links = extractMarkdownLinks(content);
|
|
116
|
+
const errors: string[] = [];
|
|
117
|
+
for (const link of links) {
|
|
118
|
+
const pathPart = link.href.split("#")[0];
|
|
119
|
+
// Links in docs.ts extraPages are rendered on non-index pages,
|
|
120
|
+
// so they must use ../ to navigate to sibling pages
|
|
121
|
+
if (pathPart.startsWith("./") && slugs.has(pathPart.slice(2).replace(/\/$/, ""))) {
|
|
122
|
+
errors.push(`line ${link.line}: [${link.text}](${link.href}) — use "../" prefix for cross-page links`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (errors.length > 0) {
|
|
126
|
+
throw new Error(`docs.ts has ./ links that will break on non-index pages:\n${errors.join("\n")}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("docs.ts source — link targets exist as pages", () => {
|
|
131
|
+
if (!docsExist) return; // needs generated slugs for validation
|
|
132
|
+
const content = readFileSync(docsSource, "utf-8");
|
|
133
|
+
const links = extractMarkdownLinks(content);
|
|
134
|
+
const errors: string[] = [];
|
|
135
|
+
for (const link of links) {
|
|
136
|
+
const error = resolveTarget(link.href, slugs);
|
|
137
|
+
if (error) errors.push(`line ${link.line}: [${link.text}](${link.href}) — ${error}`);
|
|
138
|
+
}
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
throw new Error(`docs.ts has links to non-existent pages:\n${errors.join("\n")}`);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|