dsh-featherhunter-plugin-base 0.0.1 → 0.0.2-drill

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.
@@ -1,33 +1,10 @@
1
1
  import type { Manifest, PluginEntry } from "../core/types";
2
+ import { BLOCK_MARKERS } from "./readme-sync";
2
3
  import type { BlockRenderOptions, VerifyResult } from "./types";
3
4
  export * from "./types";
4
5
  export * from "./readme-compare";
5
- /** Managed-block markers. Writers only ever touch the text between them. */
6
- export declare const BLOCK_MARKERS: {
7
- readonly contact: {
8
- readonly start: "<!-- dsh-base:contact:start -->";
9
- readonly end: "<!-- dsh-base:contact:end -->";
10
- };
11
- readonly starHistory: {
12
- readonly start: "<!-- dsh-base:star-history:start -->";
13
- readonly end: "<!-- dsh-base:star-history:end -->";
14
- };
15
- readonly thanks: {
16
- readonly start: "<!-- dsh-base:thanks:start -->";
17
- readonly end: "<!-- dsh-base:thanks:end -->";
18
- };
19
- readonly more: {
20
- readonly start: "<!-- dsh-base:more:start -->";
21
- readonly end: "<!-- dsh-base:more:end -->";
22
- };
23
- };
24
- /**
25
- * Render the contact block (README table of groups).
26
- *
27
- * Placeholder implementation: ticket 06 replaces it with the two-variant
28
- * renderer plus snapshot fixtures.
29
- */
30
- export declare function renderContactBlock(options?: BlockRenderOptions): string;
6
+ export * from "./renderers";
7
+ export * from "./readme-sync";
31
8
  /** Render the star-history block. Placeholder until ticket 10. */
32
9
  export declare function renderStarHistoryBlock(options?: BlockRenderOptions): string;
33
10
  /** Render the thanks block. Placeholder until ticket 10. */
@@ -2,9 +2,9 @@
2
2
  var BASELINE = {
3
3
  "manifest": {
4
4
  "schema": 1,
5
- "version": "0.0.1",
6
- "revision": 1,
7
- "updatedAt": "2026-10-23T00:00:00+08:00",
5
+ "version": "0.0.2-drill",
6
+ "revision": 2,
7
+ "updatedAt": "2026-10-31T00:00:00+08:00",
8
8
  "fallbackUrl": "https://github.com/FeatherHunter/featherhunter-plugin-manage",
9
9
  "groups": [
10
10
  {
@@ -79,10 +79,10 @@ var BASELINE = {
79
79
  "zh": "DSH \u63D2\u4EF6\u4EA4\u6D41\u7FA4\u5FAE\u4FE1\u7FA4\u4E8C\u7EF4\u7801",
80
80
  "en": "DSH plugin community group WeChat QR code"
81
81
  },
82
- "card": "card-wechat-2026-10-23.png",
82
+ "card": "card-wechat-2026-11-06.png",
83
83
  "qr": {
84
84
  "kind": "remote",
85
- "path": "qr-wechat-2026-10-23.png"
85
+ "path": "qr-wechat-2026-11-06.png"
86
86
  },
87
87
  "title": {
88
88
  "zh": "DSH \u63D2\u4EF6\u4EA4\u6D41\u7FA4",
@@ -92,7 +92,7 @@ var BASELINE = {
92
92
  "zh": "\u7FA4\u804A\uFF1ADSH \u63D2\u4EF6\u4EA4\u6D41\u7FA4",
93
93
  "en": "Group chat: DSH Plugin Community"
94
94
  },
95
- "validUntil": "2026-10-30"
95
+ "validUntil": "2026-11-06"
96
96
  }
97
97
  ],
98
98
  "plugins": [
@@ -174,6 +174,19 @@ function sortByOrder(items) {
174
174
  return [...items].sort((a, b) => a.order - b.order);
175
175
  }
176
176
 
177
+ // src/node/readme-sync.ts
178
+ import { randomBytes } from "node:crypto";
179
+ import {
180
+ chmodSync,
181
+ existsSync,
182
+ readFileSync,
183
+ renameSync,
184
+ statSync,
185
+ unlinkSync,
186
+ writeFileSync
187
+ } from "node:fs";
188
+ import { basename, dirname, join, resolve } from "node:path";
189
+
177
190
  // src/node/readme-compare.ts
178
191
  var MORE_HEADING = /\bMORE\b|作者的其他作品|More from the author|More by the author/i;
179
192
  var THANKS_HEADING = /\bTHANKS\b|致谢|\bThanks\b|\bContributors\b/i;
@@ -788,35 +801,372 @@ function compareReadmeBlocks(expected, actual, options = {}) {
788
801
  };
789
802
  }
790
803
 
791
- // src/node/index.ts
792
- var BLOCK_MARKERS = {
793
- contact: { start: "<!-- dsh-base:contact:start -->", end: "<!-- dsh-base:contact:end -->" },
794
- starHistory: {
795
- start: "<!-- dsh-base:star-history:start -->",
796
- end: "<!-- dsh-base:star-history:end -->"
804
+ // src/node/renderers.ts
805
+ var DEFAULT_CDN_BASE = "https://cdn.jsdelivr.net/npm/dsh-featherhunter-plugin-base";
806
+ var CONTACT_CARD_WIDTH = 260;
807
+ var CONTACT_TEXT = {
808
+ sub: "CONNECT",
809
+ heading: {
810
+ zh: "\u53CD\u9988\u4E0E\u8054\u7CFB",
811
+ en: "Feedback and Contact"
797
812
  },
798
- thanks: { start: "<!-- dsh-base:thanks:start -->", end: "<!-- dsh-base:thanks:end -->" },
799
- more: { start: "<!-- dsh-base:more:start -->", end: "<!-- dsh-base:more:end -->" }
813
+ welcome: {
814
+ zh: "\u{1F44F}\u6B22\u8FCE\u63D0\u4EA4PR\u3001ISSUE\u3002\u661F\u661F\u4E4B\u706B\u53EF\u4EE5\u71CE\u539F\u{1F525}\u6709\u4EFB\u4F55\u5EFA\u8BAE\u3001\u610F\u89C1\u3001\u60F3\u6CD5\u3001\u5410\u69FD\u3001idea\u90FD\u53EF\u4EE5\u8FDB\u7FA4\u4EA4\u6D41\u3002",
815
+ en: "\u{1F44F}Welcome PRs and issues. A single spark can start a prairie fire \u{1F525}. Any suggestion, opinion, idea or rant \u2014 come join the group and talk."
816
+ }
800
817
  };
818
+ var DATE_IN_FILENAME = /(\d{4})-(\d{2})-(\d{2})/;
819
+ var DATE_IN_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})/;
801
820
  function resolveManifest(options) {
802
821
  return options.manifest ?? BASELINE.manifest;
803
822
  }
804
823
  function resolveLang(options) {
805
824
  return options.lang ?? "zh";
806
825
  }
826
+ function escapeAttribute(value) {
827
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
828
+ }
829
+ function contactCardCacheDate(group, manifest) {
830
+ const fromCard = DATE_IN_FILENAME.exec(group.card);
831
+ if (fromCard !== null) return fromCard[1] + fromCard[2] + fromCard[3];
832
+ if (group.validUntil === void 0) return null;
833
+ const fromManifest = DATE_IN_TIMESTAMP.exec(manifest.updatedAt);
834
+ if (fromManifest === null) {
835
+ throw new Error(
836
+ "manifest.updatedAt must start with YYYY-MM-DD when a group has validUntil (group: " + group.id + ")"
837
+ );
838
+ }
839
+ return fromManifest[1] + fromManifest[2] + fromManifest[3];
840
+ }
841
+ function contactCardUrl(group, manifest, options = {}) {
842
+ const base = (options.cdnBase ?? DEFAULT_CDN_BASE).replace(/\/+$/, "");
843
+ if (!/^https:\/\/[^\s"'<>]+$/i.test(base)) {
844
+ throw new Error('contact CDN base must be an absolute https URL (got "' + base + '")');
845
+ }
846
+ const card = group.card.trim();
847
+ if (card === "") throw new Error('contact group "' + group.id + '" has an empty card file name');
848
+ if (card.includes("/") || card.includes("\\") || /^[a-z]+:/i.test(card)) {
849
+ throw new Error('contact card must be a bare file name under data/img/ (got "' + card + '")');
850
+ }
851
+ const version = options.version ?? manifest.version;
852
+ if (version === "") throw new Error("contact CDN URL needs a manifest version");
853
+ let url = base + "@" + version + "/data/img/" + card;
854
+ const cacheDate = contactCardCacheDate(group, manifest);
855
+ if (cacheDate !== null) url += "?v=" + cacheDate;
856
+ return url;
857
+ }
858
+ function contactHeading(lang, options) {
859
+ const text = CONTACT_TEXT.heading[lang];
860
+ if (options.variant === "markdown-heading") return "## " + text;
861
+ return '<h2 align="center"><sub>' + CONTACT_TEXT.sub + "</sub><br>" + text + "</h2>";
862
+ }
807
863
  function renderContactBlock(options = {}) {
808
864
  const manifest = resolveManifest(options);
809
865
  const lang = resolveLang(options);
810
866
  const groups = sortByOrder(manifest.groups);
811
- const rows = groups.map((group) => `| ${pickText(group.readmeTitle, lang)} | ![${pickText(group.alt, lang)}](${group.card}) |`).join("\n");
867
+ if (groups.length === 0) throw new Error("contact block needs at least one group in the manifest");
868
+ const headerCells = groups.map((group) => '<td align="center"><b>' + escapeAttribute(pickText(group.readmeTitle, lang)) + "</b></td>").join("");
869
+ const cardCells = groups.map((group) => {
870
+ const src = contactCardUrl(group, manifest, options);
871
+ const alt = escapeAttribute(pickText(group.alt, lang));
872
+ return '<td><img src="' + src + '" width="' + CONTACT_CARD_WIDTH + '" alt="' + alt + '" /></td>';
873
+ }).join("");
812
874
  return [
813
- BLOCK_MARKERS.contact.start,
814
- "| \u7FA4 | \u4E8C\u7EF4\u7801 |",
815
- "| --- | --- |",
816
- rows,
817
- BLOCK_MARKERS.contact.end
875
+ contactHeading(lang, options),
876
+ "",
877
+ '<div align="center">',
878
+ "",
879
+ CONTACT_TEXT.welcome[lang],
880
+ "",
881
+ '<table align="center"><tr>' + headerCells + "</tr><tr>" + cardCells + "</tr></table>",
882
+ "",
883
+ "</div>"
818
884
  ].join("\n");
819
885
  }
886
+
887
+ // src/node/readme-sync.ts
888
+ var THANKS_ZH_MARKERS = {
889
+ start: "<!-- THANKS-TABLE-ZH-START -->",
890
+ end: "<!-- THANKS-TABLE-ZH-END -->"
891
+ };
892
+ var THANKS_EN_MARKERS = {
893
+ start: "<!-- THANKS-TABLE-EN-START -->",
894
+ end: "<!-- THANKS-TABLE-EN-END -->"
895
+ };
896
+ var BLOCK_MARKERS = {
897
+ contact: { start: "<!-- CONTACT-START -->", end: "<!-- CONTACT-END -->" },
898
+ more: { start: "<!-- MORE-START -->", end: "<!-- MORE-END -->" },
899
+ starHistory: { start: "<!-- STAR-HISTORY-START -->", end: "<!-- STAR-HISTORY-END -->" },
900
+ thanks: {
901
+ ...THANKS_ZH_MARKERS,
902
+ zh: THANKS_ZH_MARKERS,
903
+ en: THANKS_EN_MARKERS
904
+ }
905
+ };
906
+ function detectContact(raw) {
907
+ const parsed = parseContactBlock(raw);
908
+ const heading = parsed.heading?.text ?? "";
909
+ const lang = /[A-Za-z]/.test(heading) ? "en" : "zh";
910
+ const variant = parsed.variant === "markdown-heading" ? "markdown-heading" : "html-heading";
911
+ return { lang, variant };
912
+ }
913
+ function locateLegacyContact(markdown) {
914
+ const headingPattern = /^(?:<h2\b[^\n]*?(?:CONNECT|反馈与联系|Feedback and Contact)[^\n]*?|<sub>\s*CONNECT\s*<\/sub>[^\n]*?|<h2\b[^\n]*?>[^\n]*?(?:反馈与联系|Feedback and Contact)[^\n]*?>|##\s+[^\n]*(?:反馈与联系|Feedback and Contact)[^\n]*)\s*$/im;
915
+ const heading = headingPattern.exec(markdown);
916
+ if (heading === null) return null;
917
+ const start = heading.index;
918
+ const closingDiv = /^[ \t]*<\/div>[ \t]*$/m;
919
+ const rest = markdown.slice(start + heading[0].length);
920
+ const closing = closingDiv.exec(rest);
921
+ if (closing === null) return null;
922
+ return { start, end: start + heading[0].length + closing.index + closing[0].length };
923
+ }
924
+ var BLOCK_DEFINITIONS = [
925
+ {
926
+ name: "contact",
927
+ label: "contact",
928
+ markers: [{ start: BLOCK_MARKERS.contact.start, end: BLOCK_MARKERS.contact.end }],
929
+ render: (context) => renderContactBlock(context),
930
+ locateLegacy: locateLegacyContact,
931
+ detect: detectContact
932
+ },
933
+ // Reserved for ticket 09: renderer + legacy anchor for the author-plugins
934
+ // (MORE) block. Marker pair is fixed now so init/sync never invents one.
935
+ {
936
+ name: "more",
937
+ label: "more",
938
+ markers: [{ start: BLOCK_MARKERS.more.start, end: BLOCK_MARKERS.more.end }]
939
+ },
940
+ // Reserved for ticket 10.
941
+ {
942
+ name: "star-history",
943
+ label: "star-history",
944
+ markers: [{ start: BLOCK_MARKERS.starHistory.start, end: BLOCK_MARKERS.starHistory.end }]
945
+ },
946
+ // Reserved for ticket 10. One marker pair per language; which pair exists
947
+ // tells the language of the README.
948
+ {
949
+ name: "thanks",
950
+ label: "thanks",
951
+ markers: [
952
+ { lang: "zh", start: THANKS_ZH_MARKERS.start, end: THANKS_ZH_MARKERS.end },
953
+ { lang: "en", start: THANKS_EN_MARKERS.start, end: THANKS_EN_MARKERS.end }
954
+ ]
955
+ }
956
+ ];
957
+ function blockDefinition(name) {
958
+ return BLOCK_DEFINITIONS.find((definition) => definition.name === name);
959
+ }
960
+ function implementedBlockDefinitions() {
961
+ return BLOCK_DEFINITIONS.filter((definition) => definition.render !== void 0);
962
+ }
963
+ function writeFileAtomic(filePath, content, hooks = {}) {
964
+ const directory = dirname(filePath);
965
+ const tempPath = join(
966
+ directory,
967
+ "." + basename(filePath) + "." + process.pid + "." + randomBytes(6).toString("hex") + ".tmp"
968
+ );
969
+ let mode;
970
+ try {
971
+ mode = statSync(filePath).mode & 511;
972
+ } catch {
973
+ mode = void 0;
974
+ }
975
+ try {
976
+ writeFileSync(tempPath, content, "utf8");
977
+ if (mode !== void 0) chmodSync(tempPath, mode);
978
+ hooks.beforeRename?.(tempPath);
979
+ renameSync(tempPath, filePath);
980
+ } catch (error) {
981
+ try {
982
+ if (existsSync(tempPath)) unlinkSync(tempPath);
983
+ } catch {
984
+ }
985
+ throw error;
986
+ }
987
+ }
988
+ function detectLineEnding(markdown) {
989
+ return markdown.includes("\r\n") ? "\r\n" : "\n";
990
+ }
991
+ function markerIndexes(markdown, token) {
992
+ const indexes = [];
993
+ let from = 0;
994
+ for (; ; ) {
995
+ const index = markdown.indexOf(token, from);
996
+ if (index === -1) return indexes;
997
+ indexes.push(index);
998
+ from = index + token.length;
999
+ }
1000
+ }
1001
+ function renderSegment(definition, markerSet, context) {
1002
+ if (definition.render === void 0) throw new Error("block has no renderer: " + definition.name);
1003
+ return definition.render({ ...context, lang: markerSet.lang ?? context.lang });
1004
+ }
1005
+ function applyMarkerSet(markdown, definition, markerSet, context, command) {
1006
+ const errors = [];
1007
+ const { start, end } = markerSet;
1008
+ const starts = markerIndexes(markdown, start);
1009
+ const ends = markerIndexes(markdown, end);
1010
+ const eol = detectLineEnding(markdown);
1011
+ if (starts.length === 0 && ends.length === 0) {
1012
+ if (definition.render === void 0) {
1013
+ return { markdown, changed: false, status: "skipped", errors };
1014
+ }
1015
+ if (command === "sync") {
1016
+ errors.push(definition.label + " markers are missing; run readme:init first");
1017
+ return { markdown, changed: false, status: "skipped", errors };
1018
+ }
1019
+ const legacy = definition.locateLegacy?.(markdown) ?? null;
1020
+ if (legacy === null) {
1021
+ errors.push(
1022
+ "cannot locate the legacy " + definition.label + " block; add " + start + " / " + end + " markers manually"
1023
+ );
1024
+ return { markdown, changed: false, status: "skipped", errors };
1025
+ }
1026
+ const content = renderSegment(definition, markerSet, context).split(/\r?\n/).join(eol);
1027
+ const replacement = start + eol + content + eol + end;
1028
+ return {
1029
+ markdown: markdown.slice(0, legacy.start) + replacement + markdown.slice(legacy.end),
1030
+ changed: true,
1031
+ status: "inserted",
1032
+ errors
1033
+ };
1034
+ }
1035
+ if (starts.length === 1 && ends.length === 1 && starts[0] < ends[0]) {
1036
+ if (definition.render === void 0) {
1037
+ return { markdown, changed: false, status: "skipped", errors };
1038
+ }
1039
+ const startIndex = starts[0];
1040
+ const endIndex = ends[0];
1041
+ const inner = markdown.slice(startIndex + start.length, endIndex);
1042
+ const content = renderSegment(definition, markerSet, context).split(/\r?\n/).join(eol);
1043
+ const expectedInner = eol + content + eol;
1044
+ if (inner === expectedInner) {
1045
+ return { markdown, changed: false, status: "unchanged", errors };
1046
+ }
1047
+ const next = markdown.slice(0, startIndex + start.length) + expectedInner + markdown.slice(endIndex);
1048
+ return { markdown: next, changed: true, status: "updated", errors };
1049
+ }
1050
+ if (starts.length > 1 || ends.length > 1) {
1051
+ errors.push(
1052
+ "duplicate " + definition.label + " markers (" + start + " x" + starts.length + ", " + end + " x" + ends.length + "); refusing to guess"
1053
+ );
1054
+ } else if (starts.length !== ends.length) {
1055
+ errors.push(
1056
+ "unpaired " + definition.label + " markers (" + start + " x" + starts.length + ", " + end + " x" + ends.length + ")"
1057
+ );
1058
+ } else {
1059
+ errors.push("end marker appears before start marker for the " + definition.label + " block");
1060
+ }
1061
+ return { markdown, changed: false, status: "skipped", errors };
1062
+ }
1063
+ function detectContext(markdown, options) {
1064
+ let lang = options.lang;
1065
+ let variant = options.variant;
1066
+ for (const definition of BLOCK_DEFINITIONS) {
1067
+ if (definition.detect === void 0) continue;
1068
+ let raw = null;
1069
+ for (const markerSet of definition.markers) {
1070
+ const starts = markerIndexes(markdown, markerSet.start);
1071
+ const ends = markerIndexes(markdown, markerSet.end);
1072
+ if (starts.length === 1 && ends.length === 1 && starts[0] < ends[0]) {
1073
+ raw = markdown.slice(starts[0] + markerSet.start.length, ends[0]);
1074
+ break;
1075
+ }
1076
+ }
1077
+ if (raw === null) {
1078
+ const legacy = definition.locateLegacy?.(markdown) ?? null;
1079
+ if (legacy !== null) raw = markdown.slice(legacy.start, legacy.end);
1080
+ }
1081
+ if (raw === null) continue;
1082
+ const detected = definition.detect(raw);
1083
+ lang ??= detected.lang;
1084
+ variant ??= detected.variant;
1085
+ }
1086
+ return {
1087
+ manifest: options.manifest,
1088
+ version: options.version,
1089
+ cdnBase: options.cdnBase,
1090
+ lang: lang ?? "zh",
1091
+ variant
1092
+ };
1093
+ }
1094
+ function updateReadme(options) {
1095
+ const readmePath = resolve(options.root, options.readme ?? "README.md");
1096
+ const command = options.command ?? "sync";
1097
+ const result = {
1098
+ readmePath,
1099
+ ok: false,
1100
+ changed: false,
1101
+ written: false,
1102
+ blocks: [],
1103
+ errors: []
1104
+ };
1105
+ let markdown;
1106
+ try {
1107
+ markdown = readFileSync(readmePath, "utf8");
1108
+ } catch (error) {
1109
+ const code = error.code;
1110
+ result.errors.push(
1111
+ code === "ENOENT" ? "README not found: " + readmePath : "cannot read " + readmePath + ": " + error.message
1112
+ );
1113
+ return result;
1114
+ }
1115
+ const suspicious = findSuspiciousLines(markdown);
1116
+ if (suspicious.length > 0) {
1117
+ for (const line of suspicious) {
1118
+ result.errors.push(
1119
+ readmePath + ":" + line.line + ": suspicious merge-conflict residue (" + line.reason + '): "' + line.text + '"; refusing to modify the README, fix it manually first'
1120
+ );
1121
+ }
1122
+ return result;
1123
+ }
1124
+ const context = detectContext(markdown, options);
1125
+ let next = markdown;
1126
+ for (const definition of implementedBlockDefinitions()) {
1127
+ for (const markerSet of definition.markers) {
1128
+ const applied = applyMarkerSet(next, definition, markerSet, context, command);
1129
+ next = applied.markdown;
1130
+ result.blocks.push({ name: definition.name, status: applied.status });
1131
+ for (const error of applied.errors) result.errors.push(readmePath + ": " + error);
1132
+ }
1133
+ }
1134
+ if (result.errors.length > 0) return result;
1135
+ result.changed = next !== markdown;
1136
+ if (options.check === true) {
1137
+ if (result.changed) {
1138
+ result.errors.push(
1139
+ readmePath + ": managed blocks are out of date; run readme:" + command + " to fix"
1140
+ );
1141
+ return result;
1142
+ }
1143
+ result.ok = true;
1144
+ return result;
1145
+ }
1146
+ if (result.changed) {
1147
+ writeFileAtomic(readmePath, next);
1148
+ result.written = true;
1149
+ }
1150
+ result.ok = true;
1151
+ return result;
1152
+ }
1153
+ function updateReadmes(options) {
1154
+ const files = options.roots.map(
1155
+ (root) => updateReadme({
1156
+ root,
1157
+ command: options.command ?? "sync",
1158
+ check: options.check,
1159
+ manifest: options.manifest,
1160
+ version: options.version,
1161
+ cdnBase: options.cdnBase,
1162
+ lang: options.lang,
1163
+ variant: options.variant
1164
+ })
1165
+ );
1166
+ return { ok: files.every((file) => file.ok), files };
1167
+ }
1168
+
1169
+ // src/node/index.ts
820
1170
  function renderStarHistoryBlock(options = {}) {
821
1171
  return [BLOCK_MARKERS.starHistory.start, "", BLOCK_MARKERS.starHistory.end].join("\n");
822
1172
  }
@@ -837,12 +1187,20 @@ function sortedPlugins(manifest, selfId) {
837
1187
  return sortByOrder(manifest.plugins).filter((plugin) => plugin.id !== selfId);
838
1188
  }
839
1189
  export {
1190
+ BLOCK_DEFINITIONS,
840
1191
  BLOCK_MARKERS,
1192
+ CONTACT_CARD_WIDTH,
1193
+ CONTACT_TEXT,
1194
+ DEFAULT_CDN_BASE,
841
1195
  assetName,
1196
+ blockDefinition,
842
1197
  canonicalizeMarkup,
843
1198
  compareBlocks,
844
1199
  compareReadmeBlocks,
1200
+ contactCardCacheDate,
1201
+ contactCardUrl,
845
1202
  findSuspiciousLines,
1203
+ implementedBlockDefinitions,
846
1204
  locateReadmeBlocks,
847
1205
  parseAttributes,
848
1206
  parseContactBlock,
@@ -855,5 +1213,8 @@ export {
855
1213
  renderStarHistoryBlock,
856
1214
  renderThanksBlock,
857
1215
  sortedPlugins,
858
- verifyBlock
1216
+ updateReadme,
1217
+ updateReadmes,
1218
+ verifyBlock,
1219
+ writeFileAtomic
859
1220
  };
@@ -0,0 +1,143 @@
1
+ import type { Lang } from "../core/types";
2
+ import type { BlockName, BlockRenderOptions, HeadingVariant, ReadmeSyncOptions } from "./types";
3
+ /** One marker pair managed inside a README. */
4
+ export interface BlockMarkerSet {
5
+ /** Language of the segment; unset = the block's default language. */
6
+ lang?: Lang;
7
+ start: string;
8
+ end: string;
9
+ }
10
+ /** Options handed to a block renderer; `lang` is always resolved. */
11
+ export interface BlockRenderContext extends BlockRenderOptions {
12
+ lang: Lang;
13
+ }
14
+ /** Half-open character range of a legacy (pre-marker) block in a README. */
15
+ export interface LegacyBlockSpan {
16
+ start: number;
17
+ end: number;
18
+ }
19
+ /**
20
+ * One managed README block.
21
+ *
22
+ * Ticket 09/10 only need to fill in `render` (and, when the block is new like
23
+ * the MORE block on ilife, `locateLegacy`) for the reserved entries.
24
+ */
25
+ export interface BlockDefinition {
26
+ name: BlockName;
27
+ /** Human-readable name used in CLI diagnostics. */
28
+ label: string;
29
+ /** Marker pairs the engine owns. `thanks` has one pair per language. */
30
+ markers: readonly BlockMarkerSet[];
31
+ /**
32
+ * Render the content between the marker pair. A definition without a
33
+ * renderer is reserved: its markers are validated when present but never
34
+ * rewritten.
35
+ */
36
+ render?: (context: BlockRenderContext) => string;
37
+ /** Locate the pre-marker block extent in a README (used by `readme:init`). */
38
+ locateLegacy?: (markdown: string) => LegacyBlockSpan | null;
39
+ /** Recover the language and heading variant from a current block text. */
40
+ detect?: (raw: string) => {
41
+ lang?: Lang;
42
+ variant?: HeadingVariant;
43
+ };
44
+ }
45
+ export declare const BLOCK_MARKERS: {
46
+ readonly contact: {
47
+ readonly start: "<!-- CONTACT-START -->";
48
+ readonly end: "<!-- CONTACT-END -->";
49
+ };
50
+ readonly more: {
51
+ readonly start: "<!-- MORE-START -->";
52
+ readonly end: "<!-- MORE-END -->";
53
+ };
54
+ readonly starHistory: {
55
+ readonly start: "<!-- STAR-HISTORY-START -->";
56
+ readonly end: "<!-- STAR-HISTORY-END -->";
57
+ };
58
+ readonly thanks: {
59
+ readonly start: "<!-- THANKS-TABLE-ZH-START -->";
60
+ readonly end: "<!-- THANKS-TABLE-ZH-END -->";
61
+ readonly zh: {
62
+ readonly start: "<!-- THANKS-TABLE-ZH-START -->";
63
+ readonly end: "<!-- THANKS-TABLE-ZH-END -->";
64
+ };
65
+ readonly en: {
66
+ readonly start: "<!-- THANKS-TABLE-EN-START -->";
67
+ readonly end: "<!-- THANKS-TABLE-EN-END -->";
68
+ };
69
+ };
70
+ };
71
+ /**
72
+ * Registry of the managed README blocks.
73
+ *
74
+ * Order is documentation order, not README order: the engine locates every
75
+ * block by its markers, so block order inside a README never matters.
76
+ */
77
+ export declare const BLOCK_DEFINITIONS: readonly BlockDefinition[];
78
+ /** Look up one block definition by name. */
79
+ export declare function blockDefinition(name: BlockName): BlockDefinition | undefined;
80
+ /** Definitions that can be rendered today (reserved blocks are skipped). */
81
+ export declare function implementedBlockDefinitions(): BlockDefinition[];
82
+ /** Result of updating one README file. */
83
+ export interface ReadmeFileResult {
84
+ /** Absolute path of the README. */
85
+ readmePath: string;
86
+ ok: boolean;
87
+ /** The README content must change (or would change when `check`). */
88
+ changed: boolean;
89
+ /** The engine actually wrote the file. */
90
+ written: boolean;
91
+ blocks: Array<{
92
+ name: BlockName;
93
+ status: "inserted" | "updated" | "unchanged" | "skipped";
94
+ }>;
95
+ errors: string[];
96
+ }
97
+ /** Options for one README target. */
98
+ export interface ReadmeTargetOptions extends BlockRenderOptions {
99
+ /** Directory that contains the README (`--root`). */
100
+ root: string;
101
+ /** README path relative to `root`; default `README.md`. */
102
+ readme?: string;
103
+ }
104
+ /** Options for one `readme:init` / `readme:sync` file update. */
105
+ export interface ReadmeUpdateOptions extends ReadmeTargetOptions {
106
+ /** `init` may insert missing markers; `sync` only rewrites marked text. */
107
+ command?: "init" | "sync";
108
+ /** Report drift without writing. */
109
+ check?: boolean;
110
+ }
111
+ /** Result of updating several README files. */
112
+ export interface ReadmeBatchResult {
113
+ ok: boolean;
114
+ files: ReadmeFileResult[];
115
+ }
116
+ /** Hooks used by tests to observe the atomic-write window. */
117
+ export interface AtomicWriteHooks {
118
+ /** Called after the temp file is complete but before the rename. */
119
+ beforeRename?: (tempPath: string) => void;
120
+ }
121
+ /**
122
+ * Write a file atomically: full content goes to a sibling temp file first, the
123
+ * original permissions are copied, and only then the temp file is renamed over
124
+ * the destination. A failure before the rename leaves the original untouched
125
+ * and removes the temp file.
126
+ */
127
+ export declare function writeFileAtomic(filePath: string, content: string, hooks?: AtomicWriteHooks): void;
128
+ /**
129
+ * Update one README.
130
+ *
131
+ * `init` seeds missing marker pairs at the legacy block position; `sync`
132
+ * only rewrites already-marked text. In both modes a README with suspicious
133
+ * merge-conflict residue or broken markers is left byte-identical.
134
+ */
135
+ export declare function updateReadme(options: ReadmeUpdateOptions): ReadmeFileResult;
136
+ /**
137
+ * Update every target root (CLI `--root`, repeatable).
138
+ *
139
+ * All targets are processed even when one fails, so a maintainer sees every
140
+ * problem in one run; `--all` (the CLI flag) merely mirrors that policy for
141
+ * the multi-repository workflow.
142
+ */
143
+ export declare function updateReadmes(options: ReadmeSyncOptions): ReadmeBatchResult;