@hugojosefson/cli 0.14.0 → 0.14.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/esm/deno.js CHANGED
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  "name": "@hugojosefson/cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "license": "MIT",
5
5
  "hj": {
6
6
  "commandName": "hj"
@@ -1,4 +1,4 @@
1
- const pattern = /<!-- hj:readme ([\w:-]+) ([a-f0-9]+) -->\n[\s\S]*?\n<!-- \/hj:readme -->\n?/g;
1
+ import { contributionPattern as pattern } from "../readme/badge-layout.js";
2
2
  /** Restore reached owners when a later captured snapshot predates their block. */
3
3
  export function partitionReadmeContributions(baseline, features, final) {
4
4
  const provider = features.find((feature) => feature.plan.featureId === "readme-build" &&
@@ -0,0 +1,16 @@
1
+ import { badgeLayoutResolution, layoutBadges } from "../readme/badge-layout.js";
2
+ export function badgeLayoutDetection(text, path) {
3
+ if (layoutBadges(text) === text)
4
+ return undefined;
5
+ return {
6
+ state: "drifted",
7
+ evidence: [],
8
+ issues: [{
9
+ code: "readme-badge-layout",
10
+ kind: "readme",
11
+ subject: { kind: "repository-path", identifier: path },
12
+ observation: `${path} badges are not one ordered row after the first paragraph.`,
13
+ resolution: badgeLayoutResolution(text, path),
14
+ }],
15
+ };
16
+ }
@@ -1,11 +1,13 @@
1
1
  /** @module Generated README operation safety checks. */
2
+ import { layoutBadges } from "../readme/badge-layout.js";
2
3
  import { fileAccess } from "../repository/file-access.js";
3
4
  import { sameJson } from "../operations/local-plan-state.js";
4
5
  import { denoTaskDefinitions, readmeTaskDefinition } from "./deno-tasks.js";
5
6
  import { inspectReadmeBuild, readmeBuildDirectoryPath, readmeBuildFeatureId, readmeBuildRootPath, } from "./readme-build-state.js";
6
7
  export async function checkEnableReadmeBuild(context) {
7
8
  const state = await inspectReadmeBuild(context);
8
- if (exact(state)) {
9
+ if (exact(state) && state.source.kind === "file" &&
10
+ layoutBadges(state.source.content) === state.source.content) {
9
11
  return noOp("Generated README is already adopted.");
10
12
  }
11
13
  if (state.legacy.kind === "conflict")
@@ -1,4 +1,5 @@
1
1
  /** @module Generated README feature detection. */
2
+ import { badgeLayoutDetection } from "./readme-badge-layout.js";
2
3
  import { fileAccess } from "../repository/file-access.js";
3
4
  import { inspectReadmeBuild, readmeBuildRootPath, } from "./readme-build-state.js";
4
5
  /** Detects exact generated README source, task, aggregate, output, and mode. */
@@ -35,6 +36,9 @@ export async function detectReadmeBuild(context) {
35
36
  if (state.exactTask && state.exactDefault && state.rootMatches &&
36
37
  state.root.kind === "file" && !fileAccess(state.root).writable &&
37
38
  state.source.kind === "file" && fileAccess(state.source).writable) {
39
+ const badges = badgeLayoutDetection(state.source.content, "readme/README.md");
40
+ if (badges)
41
+ return badges;
38
42
  return {
39
43
  state: "enabled",
40
44
  evidence: [{
@@ -1,4 +1,5 @@
1
1
  /** @module Guarded generated README change plans. */
2
+ import { layoutBadges } from "../readme/badge-layout.js";
2
3
  import { fileAccess, repairFileMode } from "../repository/file-access.js";
3
4
  import { legacyReadmePreview } from "./readme-build-checks.js";
4
5
  import { denoTaskDefinitions, readmeTaskDefinition } from "./deno-tasks.js";
@@ -21,7 +22,7 @@ export async function planEnableReadmeBuild(context, allowed) {
21
22
  const initialSource = state.source.kind === "file"
22
23
  ? state.source.content
23
24
  : state.initialSource;
24
- const sourceContent = buildSourceContent(context, state.legacy.kind === "recognized" ? state.legacy.source : initialSource);
25
+ const sourceContent = layoutBadges(buildSourceContent(context, state.legacy.kind === "recognized" ? state.legacy.source : initialSource));
25
26
  if (state.source.kind === "absent") {
26
27
  changes.push({ kind: "create-directory", path: readmeBuildDirectoryPath }, {
27
28
  kind: "write-file",
@@ -140,6 +140,10 @@ export async function reconcileReadmePlans(context, plans) {
140
140
  ?.enabled !== false &&
141
141
  (!npmName || context.detections.get(npmBadgeOwner)?.state === "ambiguous" ||
142
142
  context.detections.get(npmBadgeOwner) === undefined);
143
+ // Ambiguous badge ownership cannot safely be composed with other README edits.
144
+ if (context.detections.get(npmBadgeOwner)?.state === "ambiguous" &&
145
+ preserveNpmBadge)
146
+ return plans;
143
147
  const provider = build ? "readme-build" : "readme-static";
144
148
  // Remove unchanged legacy JSR API blocks before the library adds its block.
145
149
  const owners = [
@@ -1,4 +1,5 @@
1
1
  /** @module Built-in exact starter README feature. */
2
+ import { badgeLayoutDetection } from "./readme-badge-layout.js";
2
3
  import { fileAccess } from "../repository/file-access.js";
3
4
  import { inspectReadmeStatic, readmeStaticFeatureId, readmeStaticIssue, readmeStaticSubject, } from "./readme-static-artifact.js";
4
5
  import { checkDisableReadmeStatic, checkEnableReadmeStatic, planDisableReadmeStatic, planEnableReadmeStatic, } from "./readme-static-operations.js";
@@ -18,6 +19,9 @@ async function detectReadmeStatic(context) {
18
19
  if (inspection.result !== "unreadable" &&
19
20
  inspection.observation.kind === "file" &&
20
21
  fileAccess(inspection.observation).writable) {
22
+ const badges = badgeLayoutDetection(inspection.observation.content, "README.md");
23
+ if (badges)
24
+ return badges;
21
25
  return {
22
26
  state: "enabled",
23
27
  evidence: [{
@@ -1,4 +1,5 @@
1
1
  /** @module Exact starter README checks and plans. */
2
+ import { layoutBadges } from "../readme/badge-layout.js";
2
3
  import { fileAccess } from "../repository/file-access.js";
3
4
  import { planArtifactCreation, planArtifactRemoval, } from "../artifacts/plan-artifact-change.js";
4
5
  import { inspectReadmeStatic, readmeStaticBlocker, readmeStaticFeatureId, readmeStaticPath, } from "./readme-static-artifact.js";
@@ -12,6 +13,26 @@ export async function checkEnableReadmeStatic(context) {
12
13
  if (inspection.result !== "absent" && inspection.result !== "unreadable" &&
13
14
  inspection.observation.kind === "file" &&
14
15
  fileAccess(inspection.observation).writable) {
16
+ if (layoutBadges(inspection.observation.content) !==
17
+ inspection.observation.content) {
18
+ return context.repair?.kind === "all-drifted" ||
19
+ context.repair?.kind === "features" &&
20
+ context.repair.featureIds.includes(readmeStaticFeatureId)
21
+ ? allowed()
22
+ : {
23
+ result: "blocked",
24
+ warnings: [],
25
+ blockers: [{
26
+ code: "readme-badge-layout",
27
+ message: "README.md badge layout differs.",
28
+ subjects: [{
29
+ kind: "repository-path",
30
+ identifier: readmeStaticPath,
31
+ }],
32
+ resolution: "Use --repair to move the badges after the first paragraph.",
33
+ }],
34
+ };
35
+ }
15
36
  return {
16
37
  result: "no-op",
17
38
  reason: "README.md is already writable.",
@@ -90,7 +111,18 @@ export async function checkDisableReadmeStatic(context) {
90
111
  export async function planEnableReadmeStatic(context, allowed) {
91
112
  if (replacesBuild(context))
92
113
  return changePlan("enable", allowed, []);
93
- const plan = planArtifactCreation(await inspectReadmeStatic(context));
114
+ const inspection = await inspectReadmeStatic(context);
115
+ if (inspection.result !== "absent" && inspection.result !== "unreadable" &&
116
+ inspection.observation.kind === "file" &&
117
+ fileAccess(inspection.observation).writable) {
118
+ return changePlan("enable", allowed, [{
119
+ kind: "write-file",
120
+ path: readmeStaticPath,
121
+ content: layoutBadges(inspection.observation.content),
122
+ expectedDigest: inspection.observation.digest,
123
+ }]);
124
+ }
125
+ const plan = planArtifactCreation(inspection);
94
126
  const repair = await planRepairReadmeStatic(context, allowed);
95
127
  if (repair)
96
128
  return repair;
@@ -0,0 +1,150 @@
1
+ /** @module Shared README badge ordering and placement without claiming custom badges. */
2
+ /** Markers may surround block content or appear inline between badges. */
3
+ export const contributionPattern = /<!-- hj:readme ([\w:-]+) ([a-f0-9]+) -->([\s\S]*?)<!-- \/hj:readme -->/g;
4
+ const start = "<!-- deno-fmt-ignore-start -->";
5
+ const end = "<!-- deno-fmt-ignore-end -->";
6
+ const target = String.raw `\((?:[^()\n]|\([^()\n]*\))*\)|\[[^\]\n]*\]`;
7
+ const image = String.raw `!\[[^\]\n]*\](?:${target})`;
8
+ const badgePattern = new RegExp(String
9
+ .raw `\[${image}\](?:${target})|${image}|<a\b[^>]*>\s*<img\b[^>]*>\s*</a>|<img\b[^>]*>`, "gi");
10
+ function isBadge(text, document) {
11
+ const references = [...text.matchAll(/\[([^\]\n]+)\]/g)].map((match) => match[1].toLowerCase());
12
+ const destinations = document.split("\n").filter((line) => {
13
+ const label = /^ {0,3}\[([^\]]+)\]:/.exec(line)?.[1].toLowerCase();
14
+ return label && references.includes(label);
15
+ }).join(" ");
16
+ return /badge|shields\.io|badgen\.net|coveralls\.io/i.test(text + destinations) ||
17
+ /(?:!\[|alt=["'])(?:ci|npm|jsr|build|status|coverage|license|version|score)(?:[\]"' ]|$)/i
18
+ .test(text);
19
+ }
20
+ /** Keep fenced examples, inline code, comments and non-badge owned sections intact. */
21
+ function visibleText(text) {
22
+ let fence;
23
+ return text.split(/(?<=\n)/).map((line) => {
24
+ const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
25
+ const hidden = !!fence || !!marker || /^(?: {4}|\t)/.test(line);
26
+ if (marker) {
27
+ if (!fence)
28
+ fence = marker;
29
+ else if (marker[0] === fence[0] && marker.length >= fence.length) {
30
+ fence = undefined;
31
+ }
32
+ }
33
+ return hidden ? line.replace(/[^\n]/g, " ") : line;
34
+ }).join("").replace(/(`+)[^\n]*?\1/g, (code) => " ".repeat(code.length));
35
+ }
36
+ function badges(text) {
37
+ let visible = visibleText(text);
38
+ const result = [];
39
+ for (const match of visible.matchAll(contributionPattern)) {
40
+ const body = match[3].trim();
41
+ if (/:badges?$/.test(match[1]) && body.replace(badgePattern, "").trim() === "") {
42
+ result.push({
43
+ start: match.index,
44
+ end: match.index + match[0].length,
45
+ text: text.slice(match.index, match.index + match[0].length),
46
+ owner: match[1].split(":")[0],
47
+ });
48
+ }
49
+ visible = visible.slice(0, match.index) +
50
+ match[0].replace(/[^\n]/g, " ") +
51
+ visible.slice(match.index + match[0].length);
52
+ }
53
+ visible = visible.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "));
54
+ for (const match of visible.matchAll(badgePattern)) {
55
+ // A linked screenshot is still an illustration. Recognize badge labels,
56
+ // provider URLs and reference destinations without adopting their ownership.
57
+ if (!isBadge(match[0], text))
58
+ continue;
59
+ result.push({
60
+ start: match.index,
61
+ end: match.index + match[0].length,
62
+ text: text.slice(match.index, match.index + match[0].length),
63
+ });
64
+ }
65
+ return result.sort((a, b) => a.start - b.start);
66
+ }
67
+ function order(badge) {
68
+ const owner = badge.owner === "jsr-package"
69
+ ? "github-release-publish-jsr"
70
+ : badge.owner;
71
+ return owner?.startsWith("github-release-publish-")
72
+ ? `0:${owner}`
73
+ : owner
74
+ ? `1:${owner}`
75
+ : "2:";
76
+ }
77
+ /** Locate the first prose paragraph, skipping titles, metadata and other blocks. */
78
+ function paragraphEnd(text) {
79
+ const visible = visibleText(text).replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "));
80
+ let offset = 0;
81
+ let frontmatter = visible.startsWith("---\n");
82
+ for (const block of visible.split(/\n[ \t]*\n/)) {
83
+ const at = visible.indexOf(block, offset);
84
+ offset = at + block.length;
85
+ if (frontmatter) {
86
+ const close = block.indexOf("\n---", 3);
87
+ if (close >= 0)
88
+ frontmatter = false;
89
+ continue;
90
+ }
91
+ const body = block.trim();
92
+ // A section body is not the README introduction. With no introduction,
93
+ // place badges after the title instead of nesting them inside an owned guide.
94
+ if (/^#{2,6}\s/.test(body))
95
+ break;
96
+ if (!body ||
97
+ /^(?:#{1,6}\s|[>|]|[-*+]\s|\d+[.)]\s|@@include\(|<|\[[^\]]+\]:)/.test(body) || /\n[=-]+\s*$/.test(body))
98
+ continue;
99
+ const containing = [...text.matchAll(contributionPattern)].find((match) => match.index < offset && offset < match.index + match[0].length);
100
+ return containing ? containing.index + containing[0].length : offset;
101
+ }
102
+ const title = /^# .*(?:\n|$)/m.exec(visible);
103
+ return title ? title.index + title[0].trimEnd().length : 0;
104
+ }
105
+ /** Returns a stable single-line badge row directly after the introduction. */
106
+ export function layoutBadges(text) {
107
+ const found = badges(text);
108
+ if (!found.length) {
109
+ return text.replace(new RegExp(`${start}\\s*${end}\\n?`, "g"), "");
110
+ }
111
+ const removed = "\0hj-badge\0";
112
+ let remaining = text;
113
+ for (const badge of [...found].reverse()) {
114
+ remaining = remaining.slice(0, badge.start) + removed +
115
+ remaining.slice(badge.end);
116
+ }
117
+ // Retire only a formatter wrapper containing badges and whitespace.
118
+ remaining = remaining.replace(new RegExp(`${start}\\s*(?:${removed}\\s*)+${end}`, "g"), removed);
119
+ const gaps = new RegExp(`[ \\t\\r\\n]*(?:${removed}[ \\t\\r\\n]*)+`, "g");
120
+ remaining = remaining.replace(gaps, (gap, at) => {
121
+ if (gap.includes("\n")) {
122
+ return at === 0
123
+ ? ""
124
+ : at + gap.length === remaining.length
125
+ ? "\n"
126
+ : "\n\n";
127
+ }
128
+ return /\s/.test(gap) ? " " : "";
129
+ });
130
+ const row = found.sort((a, b) => order(a).localeCompare(order(b)))
131
+ .map((badge) => badge.text.replace(/\s+/g, " ").trim()).join(" ");
132
+ // An initial comment must be a separate HTML block so the badges render as Markdown.
133
+ const rendered = row.replace(/^(<!-- hj:readme [\w:-]+ [a-f0-9]+ -->) /, "$1\n\n");
134
+ const at = paragraphEnd(remaining);
135
+ const before = remaining.slice(0, at).trimEnd();
136
+ const after = remaining.slice(at).replace(/^\s*\n/, "");
137
+ const result = (before ? before + "\n\n" : "") + start + "\n" + rendered +
138
+ "\n" +
139
+ end + "\n" +
140
+ (after ? "\n" + after : "");
141
+ const withoutWrapper = (value) => value.replaceAll(start + "\n", "").replaceAll("\n" + end, "");
142
+ return withoutWrapper(result) === withoutWrapper(text) ? text : result;
143
+ }
144
+ /** Specific repair details, including the resulting badge identities and order. */
145
+ export function badgeLayoutResolution(text, path) {
146
+ const names = badges(text).sort((a, b) => order(a).localeCompare(order(b)))
147
+ .map((badge) => badge.owner ?? badge.text);
148
+ return `Move all badges in ${path} onto one horizontal line immediately after the first paragraph. ` +
149
+ `Set badge order to: ${names.join("; ")}. Preserve badge links and ownership comments.`;
150
+ }
@@ -1,6 +1,6 @@
1
1
  /** @module Individually owned README blocks that preserve user edits. */
2
2
  import { digestBytes } from "../repository/digest-bytes.js";
3
- const pattern = /<!-- hj:readme ([\w:-]+) ([a-f0-9]+) -->\n([\s\S]*?)\n<!-- \/hj:readme -->\n?/g;
3
+ import { contributionPattern as pattern, layoutBadges, } from "./badge-layout.js";
4
4
  export async function blockHash(content) {
5
5
  return await digestBytes(new TextEncoder().encode(content));
6
6
  }
@@ -53,9 +53,8 @@ export async function reconcileBlocks(text, desired, owner) {
53
53
  text = text.replace(match[0], await wrap(item));
54
54
  else {
55
55
  const at = text.indexOf(match[0]);
56
- const before = text.slice(0, at).replace(/\n+$/, "");
57
- const after = text.slice(at + match[0].length).replace(/^\n+/, "");
58
- text = before + (after ? "\n\n" + after : "\n");
56
+ text = text.slice(0, at) + text.slice(at + match[0].length);
57
+ text = text.replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
59
58
  }
60
59
  }
61
60
  for (const item of pending.values()) {
@@ -72,23 +71,12 @@ export async function reconcileBlocks(text, desired, owner) {
72
71
  ? text.includes("[![JSR")
73
72
  : item.id === "github-ci:badge"
74
73
  ? text.includes("[![CI]")
75
- : npmBadgeImages(text).length > 0))
74
+ : item.id === "github-release-publish-npm:badge" &&
75
+ npmBadgeImages(text).length > 0))
76
76
  continue;
77
77
  const block = await wrap(item);
78
78
  if (item.position === "badges") {
79
- const section = text.search(/^## /m);
80
- const marker = text.indexOf("<!-- hj:readme ");
81
- let at = Math.min(section < 0 ? text.length : section, marker < 0 ? text.length : marker);
82
- const order = [
83
- "jsr-package:badges",
84
- "github-release-publish-npm:badge",
85
- "github-ci:badge",
86
- ];
87
- const earlier = [...text.matchAll(pattern)].filter((match) => order.indexOf(match[1]) >= 0 &&
88
- order.indexOf(match[1]) < order.indexOf(item.id)).at(-1);
89
- if (earlier)
90
- at = earlier.index + earlier[0].length;
91
- text = insert(text, at, block);
79
+ text = insert(text, text.length, block);
92
80
  }
93
81
  else {
94
82
  const order = [
@@ -104,10 +92,10 @@ export async function reconcileBlocks(text, desired, owner) {
104
92
  text = insert(text, at, block);
105
93
  }
106
94
  }
107
- return text;
95
+ return layoutBadges(text);
108
96
  }
109
97
  async function wrap(item) {
110
- return `<!-- hj:readme ${item.id} ${await contributionHash(item.content)} -->\n${item.content}\n<!-- /hj:readme -->\n`;
98
+ return `<!-- hj:readme ${item.id} ${await contributionHash(item.content)} -->\n${item.content}\n<!-- /hj:readme -->`;
111
99
  }
112
100
  function insert(text, at, block) {
113
101
  const before = text.slice(0, at).replace(/\n*$/, "");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hugojosefson/cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
4
4
  "description": "Repository tools for Deno, Node.js and Bun.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,7 +10,7 @@
10
10
  "bin": {
11
11
  "hj": "./esm/hj.js"
12
12
  },
13
- "gitHead": "a1e58878cc4eb0639cc013e09fc9373e87025cdf",
13
+ "gitHead": "c532d6081af46b7cbadf62d754784ed5baa3b158",
14
14
  "type": "module",
15
15
  "engines": {
16
16
  "node": ">=24.0.0",
@@ -29,7 +29,7 @@
29
29
  "access": "public",
30
30
  "registry": "https://registry.npmjs.org/"
31
31
  },
32
- "hjNpmArchive": "hugojosefson-cli-0.14.0.tgz",
32
+ "hjNpmArchive": "hugojosefson-cli-0.14.1.tgz",
33
33
  "_generatedBy": "dnt@0.43.2",
34
34
  "files": [
35
35
  "esm",