astro-smart-links 1.0.1 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,11 @@ No routes file, no second build. The integration validates links against the rea
12
12
 
13
13
  > Migrating from `rehype-smart-links@0.x`? See [Migration](#migration-from-rehype-smart-links).
14
14
 
15
+ ## Requirements
16
+
17
+ - Node.js 18.17 or newer.
18
+ - Astro 4 or newer. Both Astro 7's default Sätteri Markdown processor and the unified processor from `@astrojs/markdown-remark` are supported.
19
+
15
20
  ## Installation
16
21
 
17
22
  ```bash
@@ -113,6 +118,7 @@ npx astro-smart-links check --all --extensions html pdf zip
113
118
  | `--json` | Print the report to stdout |
114
119
  | `-a, --all` | Treat every file type as a valid route |
115
120
  | `-e, --extensions <ext...>` | File extensions to include (default `html`) |
121
+ | `--skip-classes <class...>` | Skip links inside elements with these classes |
116
122
  | `--fail-on-broken` | Exit with code 1 when broken links are found |
117
123
  | `-q, --quiet` | Only print the summary |
118
124
 
@@ -128,6 +134,7 @@ npx astro-smart-links check --all --extensions html pdf zip
128
134
  | `target` | `string \| null` | `'_blank'` | `target` attribute for external links |
129
135
  | `rel` | `string \| null` | `'noopener noreferrer'` | `rel` attribute for external links |
130
136
  | `ignore` | `(string \| RegExp)[]` | `[]` | Hrefs that are never processed (prefix match or RegExp) |
137
+ | `skipClasses` | `string[]` | `[]` | Skip links inside elements with these classes (e.g. `['not-content']`) |
131
138
  | `routes` | `string[]` | — | Explicit route list for broken-link detection |
132
139
  | `routesFile` | `string` | — | JSON file with a route list |
133
140
  | `publicDir` | `string` | — | Directory to scan for routes |
package/README.zh-CN.md CHANGED
@@ -12,6 +12,11 @@
12
12
 
13
13
  > 从 `rehype-smart-links@0.x` 迁移?见 [迁移指南](#从-rehype-smart-links-迁移)。
14
14
 
15
+ ## 环境要求
16
+
17
+ - Node.js 18.17 或更高版本。
18
+ - Astro 4 或更高版本。同时支持 Astro 7 默认的 Sätteri Markdown 处理器和 `@astrojs/markdown-remark` 的 unified 处理器。
19
+
15
20
  ## 安装
16
21
 
17
22
  ```bash
@@ -113,6 +118,7 @@ npx astro-smart-links check --all --extensions html pdf zip
113
118
  | `--json` | 以 JSON 输出到 stdout |
114
119
  | `-a, --all` | 将所有文件类型视为有效路由 |
115
120
  | `-e, --extensions <ext...>` | 要包含的文件扩展名(默认 `html`) |
121
+ | `--skip-classes <class...>` | 跳过位于这些类名元素内的链接 |
116
122
  | `--fail-on-broken` | 发现断链时以退出码 1 结束 |
117
123
  | `-q, --quiet` | 只输出摘要 |
118
124
 
@@ -128,6 +134,7 @@ npx astro-smart-links check --all --extensions html pdf zip
128
134
  | `target` | `string \| null` | `'_blank'` | 外部链接的 `target` |
129
135
  | `rel` | `string \| null` | `'noopener noreferrer'` | 外部链接的 `rel` |
130
136
  | `ignore` | `(string \| RegExp)[]` | `[]` | 不处理的链接(前缀匹配或正则) |
137
+ | `skipClasses` | `string[]` | `[]` | 跳过位于这些类名元素内的链接(如 `['not-content']`) |
131
138
  | `routes` | `string[]` | — | 显式路由列表 |
132
139
  | `routesFile` | `string` | — | 包含路由列表的 JSON 文件 |
133
140
  | `publicDir` | `string` | — | 扫描路由的目录 |
@@ -188,7 +188,6 @@ import { join as join2, relative } from "path";
188
188
  import rehypeParse from "rehype-parse";
189
189
  import rehypeStringify from "rehype-stringify";
190
190
  import { unified } from "unified";
191
- import { visit } from "unist-util-visit";
192
191
  function collectHtmlFiles(directory) {
193
192
  const files = [];
194
193
  const walk = (currentDir) => {
@@ -209,8 +208,14 @@ function routeFromHtmlFile(distDir, htmlFile) {
209
208
  relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
210
209
  return normalizeRoute(relativePath);
211
210
  }
211
+ function hasAnyClass(node, classes) {
212
+ const current = node.properties?.className;
213
+ const list = Array.isArray(current) ? current.map(String) : typeof current === "string" ? current.split(/\s+/) : [];
214
+ return classes.some((className) => list.includes(className));
215
+ }
212
216
  function checkDirectory(directory, options = {}) {
213
217
  const routes = options.routes ?? scanRoutes(directory, options);
218
+ const skipClasses = options.skipClasses ?? [];
214
219
  const broken = [];
215
220
  let checkedLinks = 0;
216
221
  for (const htmlFile of collectHtmlFiles(directory)) {
@@ -221,30 +226,37 @@ function checkDirectory(directory, options = {}) {
221
226
  const tree = processor.parse(source);
222
227
  const pagePath = routeFromHtmlFile(directory, htmlFile);
223
228
  let changed = false;
224
- visit(tree, "element", (node) => {
225
- if (node.tagName !== "a" || !node.properties)
226
- return;
227
- const href = node.properties.href;
228
- if (typeof href !== "string" || href.length === 0)
229
- return;
230
- const classified = classifyHref(href, {
231
- base: options.base,
232
- routes,
233
- ignore: options.ignore,
234
- pagePath
235
- });
236
- if (classified.type === "ignored")
237
- return;
238
- checkedLinks++;
239
- if (classified.type !== "broken")
240
- return;
241
- broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
242
- if (options.rewrite) {
243
- removeClass(node, options.internalLinkClass);
244
- addClass(node, options.brokenLinkClass);
245
- changed = true;
229
+ const walk = (parent, insideSkipped) => {
230
+ for (const child of parent.children) {
231
+ if (child.type !== "element")
232
+ continue;
233
+ const skipped = insideSkipped || skipClasses.length > 0 && hasAnyClass(child, skipClasses);
234
+ if (child.tagName === "a" && !skipped && child.properties) {
235
+ const href = child.properties.href;
236
+ if (typeof href === "string" && href.length > 0) {
237
+ const classified = classifyHref(href, {
238
+ base: options.base,
239
+ routes,
240
+ ignore: options.ignore,
241
+ pagePath
242
+ });
243
+ if (classified.type !== "ignored") {
244
+ checkedLinks++;
245
+ if (classified.type === "broken") {
246
+ broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
247
+ if (options.rewrite) {
248
+ removeClass(child, options.internalLinkClass);
249
+ addClass(child, options.brokenLinkClass);
250
+ changed = true;
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ walk(child, skipped);
246
257
  }
247
- });
258
+ };
259
+ walk(tree, false);
248
260
  if (changed)
249
261
  writeFileSync2(htmlFile, processor.stringify(tree));
250
262
  }
package/dist/cli.cjs CHANGED
@@ -35,7 +35,6 @@ var import_node_path2 = require("path");
35
35
  var import_rehype_parse = __toESM(require("rehype-parse"), 1);
36
36
  var import_rehype_stringify = __toESM(require("rehype-stringify"), 1);
37
37
  var import_unified = require("unified");
38
- var import_unist_util_visit = require("unist-util-visit");
39
38
 
40
39
  // src/core/normalize.ts
41
40
  var SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
@@ -212,8 +211,14 @@ function routeFromHtmlFile(distDir, htmlFile) {
212
211
  relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
213
212
  return normalizeRoute(relativePath);
214
213
  }
214
+ function hasAnyClass(node, classes) {
215
+ const current = node.properties?.className;
216
+ const list = Array.isArray(current) ? current.map(String) : typeof current === "string" ? current.split(/\s+/) : [];
217
+ return classes.some((className) => list.includes(className));
218
+ }
215
219
  function checkDirectory(directory, options = {}) {
216
220
  const routes = options.routes ?? scanRoutes(directory, options);
221
+ const skipClasses = options.skipClasses ?? [];
217
222
  const broken = [];
218
223
  let checkedLinks = 0;
219
224
  for (const htmlFile of collectHtmlFiles(directory)) {
@@ -224,30 +229,37 @@ function checkDirectory(directory, options = {}) {
224
229
  const tree = processor.parse(source);
225
230
  const pagePath = routeFromHtmlFile(directory, htmlFile);
226
231
  let changed = false;
227
- (0, import_unist_util_visit.visit)(tree, "element", (node) => {
228
- if (node.tagName !== "a" || !node.properties)
229
- return;
230
- const href = node.properties.href;
231
- if (typeof href !== "string" || href.length === 0)
232
- return;
233
- const classified = classifyHref(href, {
234
- base: options.base,
235
- routes,
236
- ignore: options.ignore,
237
- pagePath
238
- });
239
- if (classified.type === "ignored")
240
- return;
241
- checkedLinks++;
242
- if (classified.type !== "broken")
243
- return;
244
- broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
245
- if (options.rewrite) {
246
- removeClass(node, options.internalLinkClass);
247
- addClass(node, options.brokenLinkClass);
248
- changed = true;
232
+ const walk = (parent, insideSkipped) => {
233
+ for (const child of parent.children) {
234
+ if (child.type !== "element")
235
+ continue;
236
+ const skipped = insideSkipped || skipClasses.length > 0 && hasAnyClass(child, skipClasses);
237
+ if (child.tagName === "a" && !skipped && child.properties) {
238
+ const href = child.properties.href;
239
+ if (typeof href === "string" && href.length > 0) {
240
+ const classified = classifyHref(href, {
241
+ base: options.base,
242
+ routes,
243
+ ignore: options.ignore,
244
+ pagePath
245
+ });
246
+ if (classified.type !== "ignored") {
247
+ checkedLinks++;
248
+ if (classified.type === "broken") {
249
+ broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
250
+ if (options.rewrite) {
251
+ removeClass(child, options.internalLinkClass);
252
+ addClass(child, options.brokenLinkClass);
253
+ changed = true;
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+ walk(child, skipped);
249
260
  }
250
- });
261
+ };
262
+ walk(tree, false);
251
263
  if (changed)
252
264
  (0, import_node_fs2.writeFileSync)(htmlFile, processor.stringify(tree));
253
265
  }
@@ -350,8 +362,8 @@ function escapeHtml(value) {
350
362
 
351
363
  // src/cli.ts
352
364
  var program = new import_commander.Command();
353
- program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.0.1");
354
- program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
365
+ program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.1.1");
366
+ program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--skip-classes <class...>", "Skip links inside elements with these classes").option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
355
367
  const buildDir = (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.dir);
356
368
  const routesFile = options.output ? (0, import_node_path3.resolve)(import_node_process.default.cwd(), options.output) : void 0;
357
369
  if (!(0, import_node_fs3.existsSync)(buildDir)) {
@@ -361,6 +373,7 @@ program.command("check", { isDefault: true }).description("Check a build directo
361
373
  const { routes, broken, checkedLinks } = checkDirectory(buildDir, {
362
374
  includeAllFiles: options.all,
363
375
  includeFileExtensions: options.extensions,
376
+ skipClasses: options.skipClasses,
364
377
  rewrite: false,
365
378
  routes: scanRoutes(buildDir, {
366
379
  includeAllFiles: options.all,
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  reportToJSON,
8
8
  scanRoutes,
9
9
  writeRoutesFile
10
- } from "./chunk-67PNI3F6.js";
10
+ } from "./chunk-JNP2MFPB.js";
11
11
 
12
12
  // src/cli.ts
13
13
  import { existsSync, writeFileSync } from "fs";
@@ -15,8 +15,8 @@ import { resolve } from "path";
15
15
  import process from "process";
16
16
  import { Command } from "commander";
17
17
  var program = new Command();
18
- program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.0.1");
19
- program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
18
+ program.name("astro-smart-links").description("CLI utility for astro-smart-links").version("1.1.1");
19
+ program.command("check", { isDefault: true }).description("Check a build directory for broken internal links").option("-d, --dir <path>", "Build directory path", "./dist").option("-o, --output <path>", "Write the report to a file").option("--format <format>", "Report format: json or html", "json").option("--json", "Print the report to stdout as JSON", false).option("-a, --all", "Treat every file type as a valid route", false).option("-e, --extensions <ext...>", "File extensions to treat as routes", ["html"]).option("--skip-classes <class...>", "Skip links inside elements with these classes").option("--fail-on-broken", "Exit with code 1 when broken links are found", false).option("-q, --quiet", "Only print the summary line", false).action((options) => {
20
20
  const buildDir = resolve(process.cwd(), options.dir);
21
21
  const routesFile = options.output ? resolve(process.cwd(), options.output) : void 0;
22
22
  if (!existsSync(buildDir)) {
@@ -26,6 +26,7 @@ program.command("check", { isDefault: true }).description("Check a build directo
26
26
  const { routes, broken, checkedLinks } = checkDirectory(buildDir, {
27
27
  includeAllFiles: options.all,
28
28
  includeFileExtensions: options.extensions,
29
+ skipClasses: options.skipClasses,
29
30
  rewrite: false,
30
31
  routes: scanRoutes(buildDir, {
31
32
  includeAllFiles: options.all,
package/dist/index.cjs CHANGED
@@ -200,7 +200,7 @@ function writeRoutesFile(routes, routesFilePath) {
200
200
  var import_node_fs4 = require("fs");
201
201
  var import_node_path3 = require("path");
202
202
  var import_node_process = __toESM(require("process"), 1);
203
- var import_node_url = require("url");
203
+ var import_node_url2 = require("url");
204
204
 
205
205
  // src/core/check.ts
206
206
  var import_node_fs2 = require("fs");
@@ -208,7 +208,6 @@ var import_node_path2 = require("path");
208
208
  var import_rehype_parse = __toESM(require("rehype-parse"), 1);
209
209
  var import_rehype_stringify = __toESM(require("rehype-stringify"), 1);
210
210
  var import_unified = require("unified");
211
- var import_unist_util_visit = require("unist-util-visit");
212
211
 
213
212
  // src/core/classify.ts
214
213
  function classifyHref(href, context = {}) {
@@ -282,8 +281,14 @@ function routeFromHtmlFile(distDir, htmlFile) {
282
281
  relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
283
282
  return normalizeRoute(relativePath);
284
283
  }
284
+ function hasAnyClass(node, classes) {
285
+ const current = node.properties?.className;
286
+ const list = Array.isArray(current) ? current.map(String) : typeof current === "string" ? current.split(/\s+/) : [];
287
+ return classes.some((className) => list.includes(className));
288
+ }
285
289
  function checkDirectory(directory, options = {}) {
286
290
  const routes = options.routes ?? scanRoutes(directory, options);
291
+ const skipClasses = options.skipClasses ?? [];
287
292
  const broken = [];
288
293
  let checkedLinks = 0;
289
294
  for (const htmlFile of collectHtmlFiles(directory)) {
@@ -294,46 +299,50 @@ function checkDirectory(directory, options = {}) {
294
299
  const tree = processor.parse(source);
295
300
  const pagePath = routeFromHtmlFile(directory, htmlFile);
296
301
  let changed = false;
297
- (0, import_unist_util_visit.visit)(tree, "element", (node) => {
298
- if (node.tagName !== "a" || !node.properties)
299
- return;
300
- const href = node.properties.href;
301
- if (typeof href !== "string" || href.length === 0)
302
- return;
303
- const classified = classifyHref(href, {
304
- base: options.base,
305
- routes,
306
- ignore: options.ignore,
307
- pagePath
308
- });
309
- if (classified.type === "ignored")
310
- return;
311
- checkedLinks++;
312
- if (classified.type !== "broken")
313
- return;
314
- broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
315
- if (options.rewrite) {
316
- removeClass(node, options.internalLinkClass);
317
- addClass(node, options.brokenLinkClass);
318
- changed = true;
302
+ const walk = (parent, insideSkipped) => {
303
+ for (const child of parent.children) {
304
+ if (child.type !== "element")
305
+ continue;
306
+ const skipped = insideSkipped || skipClasses.length > 0 && hasAnyClass(child, skipClasses);
307
+ if (child.tagName === "a" && !skipped && child.properties) {
308
+ const href = child.properties.href;
309
+ if (typeof href === "string" && href.length > 0) {
310
+ const classified = classifyHref(href, {
311
+ base: options.base,
312
+ routes,
313
+ ignore: options.ignore,
314
+ pagePath
315
+ });
316
+ if (classified.type !== "ignored") {
317
+ checkedLinks++;
318
+ if (classified.type === "broken") {
319
+ broken.push({ href, pathname: classified.pathname ?? href, sourceFile: htmlFile });
320
+ if (options.rewrite) {
321
+ removeClass(child, options.internalLinkClass);
322
+ addClass(child, options.brokenLinkClass);
323
+ changed = true;
324
+ }
325
+ }
326
+ }
327
+ }
328
+ }
329
+ walk(child, skipped);
319
330
  }
320
- });
331
+ };
332
+ walk(tree, false);
321
333
  if (changed)
322
334
  (0, import_node_fs2.writeFileSync)(htmlFile, processor.stringify(tree));
323
335
  }
324
336
  return { routes, broken, checkedLinks };
325
337
  }
326
338
 
327
- // src/rehype.ts
328
- var import_node_fs3 = require("fs");
339
+ // src/core/satteri.ts
340
+ var import_node_url = require("url");
329
341
 
330
342
  // src/core/transform.ts
331
- var import_unist_util_visit2 = require("unist-util-visit");
332
- function getPagePath(file, pagesDir) {
333
- if (!pagesDir)
334
- return void 0;
335
- const filePath = file?.path ?? file?.history?.[0];
336
- if (!filePath)
343
+ var import_unist_util_visit = require("unist-util-visit");
344
+ function getPagePath(filePath, pagesDir) {
345
+ if (!pagesDir || !filePath)
337
346
  return void 0;
338
347
  const normalizedPagesDir = pagesDir.replace(/[\\/]+$/, "");
339
348
  if (!filePath.startsWith(normalizedPagesDir))
@@ -343,94 +352,155 @@ function getPagePath(file, pagesDir) {
343
352
  relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
344
353
  return relativePath ? `/${relativePath}` : "/";
345
354
  }
346
- function replaceNode(parent, index, node, replacement) {
347
- if (replacement !== node && parent && typeof index === "number")
348
- parent.children[index] = replacement;
349
- return replacement;
350
- }
351
- function applyWrapper(node, type, className, meta, options, parent, index) {
352
- if (!options.wrapperTemplate)
353
- return;
354
- const result = options.wrapperTemplate(node, type, meta);
355
- if (result && result !== node)
356
- replaceNode(parent, index, node, result);
355
+ function transformAnchor(node, href, context, hooks) {
356
+ const { options, routes } = context;
357
+ const classified = classifyHref(href, {
358
+ base: options.base,
359
+ pagePath: context.pagePath,
360
+ routes,
361
+ ignore: options.ignore
362
+ });
363
+ if (classified.type === "ignored")
364
+ return void 0;
365
+ const type = classified.type;
366
+ const className = type === "internal" ? options.internalLinkClass : type === "external" ? options.externalLinkClass : options.brokenLinkClass;
367
+ const meta = {
368
+ href,
369
+ pathname: classified.pathname,
370
+ className,
371
+ sourceFile: context.sourceFile
372
+ };
373
+ const record = {
374
+ type,
375
+ href,
376
+ pathname: classified.pathname,
377
+ sourceFile: context.sourceFile
378
+ };
379
+ options.onLink?.(record);
380
+ const applyWrapper = () => {
381
+ if (!options.wrapperTemplate)
382
+ return false;
383
+ const result = options.wrapperTemplate(node, type, meta);
384
+ if (result && result !== node)
385
+ hooks.replace(node, result);
386
+ return true;
387
+ };
388
+ if (type === "external") {
389
+ if (options.customExternalLinkTransform) {
390
+ options.customExternalLinkTransform(node, meta);
391
+ return record;
392
+ }
393
+ if (options.target)
394
+ node.properties.target = options.target;
395
+ if (options.rel)
396
+ node.properties.rel = options.rel;
397
+ if (applyWrapper())
398
+ return record;
399
+ addClass(node, options.externalLinkClass);
400
+ if (options.content)
401
+ hooks.appendChild(node, createContentElement(options.contentClass, options.content.value));
402
+ return record;
403
+ }
404
+ if (type === "internal") {
405
+ if (options.customInternalLinkTransform) {
406
+ options.customInternalLinkTransform(node, meta);
407
+ return record;
408
+ }
409
+ if (applyWrapper())
410
+ return record;
411
+ addClass(node, options.internalLinkClass);
412
+ return record;
413
+ }
414
+ if (options.customBrokenLinkTransform) {
415
+ options.customBrokenLinkTransform(node, meta);
416
+ return record;
417
+ }
418
+ if (applyWrapper())
419
+ return record;
420
+ removeClass(node, options.internalLinkClass);
421
+ addClass(node, options.brokenLinkClass);
422
+ return record;
357
423
  }
358
424
  function transformTree(tree, file, context) {
359
- const { options, routes } = context;
360
- const pagePath = context.pagePath ?? getPagePath(file, options.pagesDir);
361
- const sourceFile = file?.path ?? file?.history?.[0];
425
+ const sourceFile = context.sourceFile ?? file?.path ?? file?.history?.[0];
426
+ const pagePath = context.pagePath ?? getPagePath(sourceFile, context.options.pagesDir);
362
427
  const counts = { internal: 0, external: 0, broken: 0 };
363
428
  const records = [];
364
- (0, import_unist_util_visit2.visit)(tree, "element", (node, index, parent) => {
429
+ (0, import_unist_util_visit.visit)(tree, "element", (node, index, parent) => {
365
430
  if (node.tagName !== "a" || !node.properties)
366
431
  return;
367
432
  const href = node.properties.href;
368
433
  if (typeof href !== "string" || href.length === 0)
369
434
  return;
370
- const classified = classifyHref(href, {
371
- base: options.base,
372
- pagePath,
373
- routes,
374
- ignore: options.ignore
375
- });
376
- if (classified.type === "ignored")
377
- return;
378
- const type = classified.type;
379
- const className = type === "internal" ? options.internalLinkClass : type === "external" ? options.externalLinkClass : options.brokenLinkClass;
380
- const meta = {
435
+ const record = transformAnchor(
436
+ node,
381
437
  href,
382
- pathname: classified.pathname,
383
- className,
384
- sourceFile
385
- };
386
- counts[type]++;
387
- const record = { type, href, pathname: classified.pathname, sourceFile };
388
- records.push(record);
389
- options.onLink?.(record);
390
- if (type === "external") {
391
- if (options.customExternalLinkTransform) {
392
- options.customExternalLinkTransform(node, meta);
393
- return;
394
- }
395
- if (options.target)
396
- node.properties.target = options.target;
397
- if (options.rel)
398
- node.properties.rel = options.rel;
399
- if (options.wrapperTemplate) {
400
- applyWrapper(node, "external", options.externalLinkClass, meta, options, parent, index);
401
- return;
402
- }
403
- addClass(node, options.externalLinkClass);
404
- if (options.content)
405
- node.children.push(createContentElement(options.contentClass, options.content.value));
406
- return;
407
- }
408
- if (type === "internal") {
409
- if (options.customInternalLinkTransform) {
410
- options.customInternalLinkTransform(node, meta);
411
- return;
412
- }
413
- if (options.wrapperTemplate) {
414
- applyWrapper(node, "internal", options.internalLinkClass, meta, options, parent, index);
415
- return;
438
+ { ...context, pagePath, sourceFile },
439
+ {
440
+ replace: (_target, replacement) => {
441
+ if (parent && typeof index === "number")
442
+ parent.children[index] = replacement;
443
+ },
444
+ appendChild: (target, child) => {
445
+ target.children.push(child);
446
+ }
416
447
  }
417
- addClass(node, options.internalLinkClass);
418
- return;
419
- }
420
- if (options.customBrokenLinkTransform) {
421
- options.customBrokenLinkTransform(node, meta);
422
- return;
423
- }
424
- if (options.wrapperTemplate) {
425
- applyWrapper(node, "broken", options.brokenLinkClass, meta, options, parent, index);
426
- return;
448
+ );
449
+ if (record) {
450
+ counts[record.type]++;
451
+ records.push(record);
427
452
  }
428
- removeClass(node, options.internalLinkClass);
429
- addClass(node, options.brokenLinkClass);
430
453
  });
431
454
  return { counts, records };
432
455
  }
433
456
 
457
+ // src/core/satteri.ts
458
+ function cloneHastNode(node) {
459
+ const clone = { ...node };
460
+ if (Array.isArray(clone.children))
461
+ clone.children = clone.children.map((child) => cloneHastNode(child));
462
+ if (clone.properties && typeof clone.properties === "object")
463
+ clone.properties = { ...clone.properties };
464
+ return clone;
465
+ }
466
+ function satteriSmartLinksPlugin(options, routes) {
467
+ return ({ fileURL }) => {
468
+ const sourceFile = fileURL ? (0, import_node_url.fileURLToPath)(fileURL) : void 0;
469
+ const pagePath = getPagePath(sourceFile, options.pagesDir);
470
+ return {
471
+ name: "astro-smart-links",
472
+ element: {
473
+ filter: ["a"],
474
+ visit: (node, ctx) => {
475
+ const href = node.properties?.href;
476
+ if (typeof href !== "string" || href.length === 0)
477
+ return;
478
+ const clone = cloneHastNode(node);
479
+ let result = clone;
480
+ transformAnchor(
481
+ clone,
482
+ href,
483
+ { options, routes, pagePath, sourceFile },
484
+ {
485
+ replace: (_target, replacement) => {
486
+ result = replacement;
487
+ },
488
+ appendChild: (target, child) => {
489
+ target.children.push(child);
490
+ }
491
+ }
492
+ );
493
+ if (result !== node)
494
+ ctx.replaceNode(node, result);
495
+ }
496
+ }
497
+ };
498
+ };
499
+ }
500
+
501
+ // src/rehype.ts
502
+ var import_node_fs3 = require("fs");
503
+
434
504
  // src/types.ts
435
505
  var defaultOptions = {
436
506
  content: { type: "text", value: "\u2197" },
@@ -639,7 +709,7 @@ function smartLinks(options = {}) {
639
709
  hooks: {
640
710
  "astro:config:setup": ({ config, updateConfig, logger }) => {
641
711
  records.length = 0;
642
- projectRoot = (0, import_node_url.fileURLToPath)(config.root);
712
+ projectRoot = (0, import_node_url2.fileURLToPath)(config.root);
643
713
  const adapter = {
644
714
  debug: (message) => logger.debug(message),
645
715
  info: (message) => logger.info(message),
@@ -655,29 +725,37 @@ function smartLinks(options = {}) {
655
725
  resolved = resolveOptions({
656
726
  ...linkOptions,
657
727
  base: config.base,
658
- pagesDir: (0, import_node_url.fileURLToPath)(new URL("pages/", config.srcDir)),
728
+ pagesDir: (0, import_node_url2.fileURLToPath)(new URL("pages/", config.srcDir)),
659
729
  logger: adapter,
660
730
  onLink: (record) => {
661
731
  records.push(record);
662
732
  linkOptions.onLink?.(record);
663
733
  }
664
734
  });
665
- updateConfig({
666
- markdown: {
667
- rehypePlugins: [
668
- [rehypeSmartLinks, resolved]
669
- ]
670
- }
671
- });
735
+ const processor = config.markdown?.processor;
736
+ if (processor?.options && Array.isArray(processor.options.hastPlugins)) {
737
+ processor.options.hastPlugins.push(satteriSmartLinksPlugin(resolved));
738
+ } else if (processor?.options && Array.isArray(processor.options.rehypePlugins)) {
739
+ processor.options.rehypePlugins.push([rehypeSmartLinks, resolved]);
740
+ } else {
741
+ updateConfig({
742
+ markdown: {
743
+ rehypePlugins: [
744
+ [rehypeSmartLinks, resolved]
745
+ ]
746
+ }
747
+ });
748
+ }
672
749
  },
673
750
  "astro:build:done": async ({ dir, logger }) => {
674
751
  const current = resolved;
675
752
  if (!current)
676
753
  return;
677
- const distDir = (0, import_node_url.fileURLToPath)(dir);
754
+ const distDir = (0, import_node_url2.fileURLToPath)(dir);
678
755
  const { routes, broken } = checkDirectory(distDir, {
679
756
  base: current.base,
680
757
  ignore: current.ignore,
758
+ skipClasses: current.skipClasses,
681
759
  includeAllFiles: current.includeAllFiles,
682
760
  includeFileExtensions: current.includeFileExtensions,
683
761
  rewrite: true,
package/dist/index.d.cts CHANGED
@@ -56,6 +56,12 @@ interface SmartLinksOptions {
56
56
  * normalized pathname (for strings: prefix match).
57
57
  */
58
58
  ignore?: (string | RegExp)[];
59
+ /**
60
+ * Links inside elements carrying one of these classes are skipped by the
61
+ * build-time check and are never rewritten. Useful for component or demo
62
+ * markup that opts out of content styles (e.g. Starlight's `not-content`).
63
+ */
64
+ skipClasses?: string[];
59
65
  /** Explicit list of known routes. When provided, broken links are detected. */
60
66
  routes?: string[];
61
67
  /** Path to a JSON file containing a list of known routes. */
@@ -147,6 +153,12 @@ interface CheckDirectoryOptions extends ScanRoutesOptions {
147
153
  /** Known routes. When omitted, routes are collected by scanning the directory. */
148
154
  routes?: Set<string>;
149
155
  ignore?: (string | RegExp)[];
156
+ /**
157
+ * Links inside elements carrying one of these classes are skipped entirely:
158
+ * they are not checked and never rewritten. Useful for component or demo
159
+ * markup that opts out of content styles (e.g. Starlight's `not-content`).
160
+ */
161
+ skipClasses?: string[];
150
162
  /** Rewrite generated HTML so broken links receive `brokenLinkClass`. */
151
163
  rewrite?: boolean;
152
164
  internalLinkClass?: string;
package/dist/index.d.ts CHANGED
@@ -56,6 +56,12 @@ interface SmartLinksOptions {
56
56
  * normalized pathname (for strings: prefix match).
57
57
  */
58
58
  ignore?: (string | RegExp)[];
59
+ /**
60
+ * Links inside elements carrying one of these classes are skipped by the
61
+ * build-time check and are never rewritten. Useful for component or demo
62
+ * markup that opts out of content styles (e.g. Starlight's `not-content`).
63
+ */
64
+ skipClasses?: string[];
59
65
  /** Explicit list of known routes. When provided, broken links are detected. */
60
66
  routes?: string[];
61
67
  /** Path to a JSON file containing a list of known routes. */
@@ -147,6 +153,12 @@ interface CheckDirectoryOptions extends ScanRoutesOptions {
147
153
  /** Known routes. When omitted, routes are collected by scanning the directory. */
148
154
  routes?: Set<string>;
149
155
  ignore?: (string | RegExp)[];
156
+ /**
157
+ * Links inside elements carrying one of these classes are skipped entirely:
158
+ * they are not checked and never rewritten. Useful for component or demo
159
+ * markup that opts out of content styles (e.g. Starlight's `not-content`).
160
+ */
161
+ skipClasses?: string[];
150
162
  /** Rewrite generated HTML so broken links receive `brokenLinkClass`. */
151
163
  rewrite?: boolean;
152
164
  internalLinkClass?: string;
package/dist/index.js CHANGED
@@ -20,24 +20,21 @@ import {
20
20
  routeFromHtmlFile,
21
21
  scanRoutes,
22
22
  writeRoutesFile
23
- } from "./chunk-67PNI3F6.js";
23
+ } from "./chunk-JNP2MFPB.js";
24
24
 
25
25
  // src/integration.ts
26
26
  import { writeFileSync } from "fs";
27
27
  import { resolve } from "path";
28
28
  import process from "process";
29
- import { fileURLToPath } from "url";
29
+ import { fileURLToPath as fileURLToPath2 } from "url";
30
30
 
31
- // src/rehype.ts
32
- import { existsSync } from "fs";
31
+ // src/core/satteri.ts
32
+ import { fileURLToPath } from "url";
33
33
 
34
34
  // src/core/transform.ts
35
35
  import { visit } from "unist-util-visit";
36
- function getPagePath(file, pagesDir) {
37
- if (!pagesDir)
38
- return void 0;
39
- const filePath = file?.path ?? file?.history?.[0];
40
- if (!filePath)
36
+ function getPagePath(filePath, pagesDir) {
37
+ if (!pagesDir || !filePath)
41
38
  return void 0;
42
39
  const normalizedPagesDir = pagesDir.replace(/[\\/]+$/, "");
43
40
  if (!filePath.startsWith(normalizedPagesDir))
@@ -47,22 +44,78 @@ function getPagePath(file, pagesDir) {
47
44
  relativePath = relativePath.replace(/(?:^|\/)index$/i, "");
48
45
  return relativePath ? `/${relativePath}` : "/";
49
46
  }
50
- function replaceNode(parent, index, node, replacement) {
51
- if (replacement !== node && parent && typeof index === "number")
52
- parent.children[index] = replacement;
53
- return replacement;
54
- }
55
- function applyWrapper(node, type, className, meta, options, parent, index) {
56
- if (!options.wrapperTemplate)
57
- return;
58
- const result = options.wrapperTemplate(node, type, meta);
59
- if (result && result !== node)
60
- replaceNode(parent, index, node, result);
47
+ function transformAnchor(node, href, context, hooks) {
48
+ const { options, routes } = context;
49
+ const classified = classifyHref(href, {
50
+ base: options.base,
51
+ pagePath: context.pagePath,
52
+ routes,
53
+ ignore: options.ignore
54
+ });
55
+ if (classified.type === "ignored")
56
+ return void 0;
57
+ const type = classified.type;
58
+ const className = type === "internal" ? options.internalLinkClass : type === "external" ? options.externalLinkClass : options.brokenLinkClass;
59
+ const meta = {
60
+ href,
61
+ pathname: classified.pathname,
62
+ className,
63
+ sourceFile: context.sourceFile
64
+ };
65
+ const record = {
66
+ type,
67
+ href,
68
+ pathname: classified.pathname,
69
+ sourceFile: context.sourceFile
70
+ };
71
+ options.onLink?.(record);
72
+ const applyWrapper = () => {
73
+ if (!options.wrapperTemplate)
74
+ return false;
75
+ const result = options.wrapperTemplate(node, type, meta);
76
+ if (result && result !== node)
77
+ hooks.replace(node, result);
78
+ return true;
79
+ };
80
+ if (type === "external") {
81
+ if (options.customExternalLinkTransform) {
82
+ options.customExternalLinkTransform(node, meta);
83
+ return record;
84
+ }
85
+ if (options.target)
86
+ node.properties.target = options.target;
87
+ if (options.rel)
88
+ node.properties.rel = options.rel;
89
+ if (applyWrapper())
90
+ return record;
91
+ addClass(node, options.externalLinkClass);
92
+ if (options.content)
93
+ hooks.appendChild(node, createContentElement(options.contentClass, options.content.value));
94
+ return record;
95
+ }
96
+ if (type === "internal") {
97
+ if (options.customInternalLinkTransform) {
98
+ options.customInternalLinkTransform(node, meta);
99
+ return record;
100
+ }
101
+ if (applyWrapper())
102
+ return record;
103
+ addClass(node, options.internalLinkClass);
104
+ return record;
105
+ }
106
+ if (options.customBrokenLinkTransform) {
107
+ options.customBrokenLinkTransform(node, meta);
108
+ return record;
109
+ }
110
+ if (applyWrapper())
111
+ return record;
112
+ removeClass(node, options.internalLinkClass);
113
+ addClass(node, options.brokenLinkClass);
114
+ return record;
61
115
  }
62
116
  function transformTree(tree, file, context) {
63
- const { options, routes } = context;
64
- const pagePath = context.pagePath ?? getPagePath(file, options.pagesDir);
65
- const sourceFile = file?.path ?? file?.history?.[0];
117
+ const sourceFile = context.sourceFile ?? file?.path ?? file?.history?.[0];
118
+ const pagePath = context.pagePath ?? getPagePath(sourceFile, context.options.pagesDir);
66
119
  const counts = { internal: 0, external: 0, broken: 0 };
67
120
  const records = [];
68
121
  visit(tree, "element", (node, index, parent) => {
@@ -71,70 +124,75 @@ function transformTree(tree, file, context) {
71
124
  const href = node.properties.href;
72
125
  if (typeof href !== "string" || href.length === 0)
73
126
  return;
74
- const classified = classifyHref(href, {
75
- base: options.base,
76
- pagePath,
77
- routes,
78
- ignore: options.ignore
79
- });
80
- if (classified.type === "ignored")
81
- return;
82
- const type = classified.type;
83
- const className = type === "internal" ? options.internalLinkClass : type === "external" ? options.externalLinkClass : options.brokenLinkClass;
84
- const meta = {
127
+ const record = transformAnchor(
128
+ node,
85
129
  href,
86
- pathname: classified.pathname,
87
- className,
88
- sourceFile
89
- };
90
- counts[type]++;
91
- const record = { type, href, pathname: classified.pathname, sourceFile };
92
- records.push(record);
93
- options.onLink?.(record);
94
- if (type === "external") {
95
- if (options.customExternalLinkTransform) {
96
- options.customExternalLinkTransform(node, meta);
97
- return;
98
- }
99
- if (options.target)
100
- node.properties.target = options.target;
101
- if (options.rel)
102
- node.properties.rel = options.rel;
103
- if (options.wrapperTemplate) {
104
- applyWrapper(node, "external", options.externalLinkClass, meta, options, parent, index);
105
- return;
106
- }
107
- addClass(node, options.externalLinkClass);
108
- if (options.content)
109
- node.children.push(createContentElement(options.contentClass, options.content.value));
110
- return;
111
- }
112
- if (type === "internal") {
113
- if (options.customInternalLinkTransform) {
114
- options.customInternalLinkTransform(node, meta);
115
- return;
116
- }
117
- if (options.wrapperTemplate) {
118
- applyWrapper(node, "internal", options.internalLinkClass, meta, options, parent, index);
119
- return;
130
+ { ...context, pagePath, sourceFile },
131
+ {
132
+ replace: (_target, replacement) => {
133
+ if (parent && typeof index === "number")
134
+ parent.children[index] = replacement;
135
+ },
136
+ appendChild: (target, child) => {
137
+ target.children.push(child);
138
+ }
120
139
  }
121
- addClass(node, options.internalLinkClass);
122
- return;
123
- }
124
- if (options.customBrokenLinkTransform) {
125
- options.customBrokenLinkTransform(node, meta);
126
- return;
127
- }
128
- if (options.wrapperTemplate) {
129
- applyWrapper(node, "broken", options.brokenLinkClass, meta, options, parent, index);
130
- return;
140
+ );
141
+ if (record) {
142
+ counts[record.type]++;
143
+ records.push(record);
131
144
  }
132
- removeClass(node, options.internalLinkClass);
133
- addClass(node, options.brokenLinkClass);
134
145
  });
135
146
  return { counts, records };
136
147
  }
137
148
 
149
+ // src/core/satteri.ts
150
+ function cloneHastNode(node) {
151
+ const clone = { ...node };
152
+ if (Array.isArray(clone.children))
153
+ clone.children = clone.children.map((child) => cloneHastNode(child));
154
+ if (clone.properties && typeof clone.properties === "object")
155
+ clone.properties = { ...clone.properties };
156
+ return clone;
157
+ }
158
+ function satteriSmartLinksPlugin(options, routes) {
159
+ return ({ fileURL }) => {
160
+ const sourceFile = fileURL ? fileURLToPath(fileURL) : void 0;
161
+ const pagePath = getPagePath(sourceFile, options.pagesDir);
162
+ return {
163
+ name: "astro-smart-links",
164
+ element: {
165
+ filter: ["a"],
166
+ visit: (node, ctx) => {
167
+ const href = node.properties?.href;
168
+ if (typeof href !== "string" || href.length === 0)
169
+ return;
170
+ const clone = cloneHastNode(node);
171
+ let result = clone;
172
+ transformAnchor(
173
+ clone,
174
+ href,
175
+ { options, routes, pagePath, sourceFile },
176
+ {
177
+ replace: (_target, replacement) => {
178
+ result = replacement;
179
+ },
180
+ appendChild: (target, child) => {
181
+ target.children.push(child);
182
+ }
183
+ }
184
+ );
185
+ if (result !== node)
186
+ ctx.replaceNode(node, result);
187
+ }
188
+ }
189
+ };
190
+ };
191
+ }
192
+
193
+ // src/rehype.ts
194
+ import { existsSync } from "fs";
195
+
138
196
  // src/types.ts
139
197
  var defaultOptions = {
140
198
  content: { type: "text", value: "\u2197" },
@@ -249,7 +307,7 @@ function smartLinks(options = {}) {
249
307
  hooks: {
250
308
  "astro:config:setup": ({ config, updateConfig, logger }) => {
251
309
  records.length = 0;
252
- projectRoot = fileURLToPath(config.root);
310
+ projectRoot = fileURLToPath2(config.root);
253
311
  const adapter = {
254
312
  debug: (message) => logger.debug(message),
255
313
  info: (message) => logger.info(message),
@@ -265,29 +323,37 @@ function smartLinks(options = {}) {
265
323
  resolved = resolveOptions({
266
324
  ...linkOptions,
267
325
  base: config.base,
268
- pagesDir: fileURLToPath(new URL("pages/", config.srcDir)),
326
+ pagesDir: fileURLToPath2(new URL("pages/", config.srcDir)),
269
327
  logger: adapter,
270
328
  onLink: (record) => {
271
329
  records.push(record);
272
330
  linkOptions.onLink?.(record);
273
331
  }
274
332
  });
275
- updateConfig({
276
- markdown: {
277
- rehypePlugins: [
278
- [rehypeSmartLinks, resolved]
279
- ]
280
- }
281
- });
333
+ const processor = config.markdown?.processor;
334
+ if (processor?.options && Array.isArray(processor.options.hastPlugins)) {
335
+ processor.options.hastPlugins.push(satteriSmartLinksPlugin(resolved));
336
+ } else if (processor?.options && Array.isArray(processor.options.rehypePlugins)) {
337
+ processor.options.rehypePlugins.push([rehypeSmartLinks, resolved]);
338
+ } else {
339
+ updateConfig({
340
+ markdown: {
341
+ rehypePlugins: [
342
+ [rehypeSmartLinks, resolved]
343
+ ]
344
+ }
345
+ });
346
+ }
282
347
  },
283
348
  "astro:build:done": async ({ dir, logger }) => {
284
349
  const current = resolved;
285
350
  if (!current)
286
351
  return;
287
- const distDir = fileURLToPath(dir);
352
+ const distDir = fileURLToPath2(dir);
288
353
  const { routes, broken } = checkDirectory(distDir, {
289
354
  base: current.base,
290
355
  ignore: current.ignore,
356
+ skipClasses: current.skipClasses,
291
357
  includeAllFiles: current.includeAllFiles,
292
358
  includeFileExtensions: current.includeFileExtensions,
293
359
  rewrite: true,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "astro-smart-links",
3
3
  "type": "module",
4
- "version": "1.0.1",
4
+ "version": "1.1.1",
5
5
  "packageManager": "pnpm@11.8.0",
6
6
  "description": "An Astro integration that adds smart styling and broken-link detection to internal and external links",
7
7
  "author": "EveSunMaple",
@@ -80,11 +80,11 @@
80
80
  },
81
81
  "devDependencies": {
82
82
  "@antfu/eslint-config": "^3.16.0",
83
- "@types/node": "^18.19.86",
83
+ "@types/node": "^24.0.0",
84
84
  "@typescript-eslint/eslint-plugin": "^8.29.0",
85
85
  "@typescript-eslint/parser": "^8.29.0",
86
86
  "@vitest/ui": "3.1.1",
87
- "astro": "^5.6.0",
87
+ "astro": "^7.3.6",
88
88
  "cheerio": "^1.0.0",
89
89
  "eslint": "^9.24.0",
90
90
  "eslint-plugin-astro": "^1.3.1",