openbriefing 0.2.0 → 0.4.0

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/ghost.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `openbriefing ghost pull` and `ghost push`: briefing files to and from a
3
+ * Ghost site, through its Admin API. Push writes one thing, the block between
4
+ * the obs:start and obs:end comments in a post's Code injection header.
5
+ *
6
+ * The key comes from the environment and is never printed.
7
+ */
8
+ import { createHmac } from "node:crypto";
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { JSDOM } from "jsdom";
12
+ import { briefingFields, markTags, parseBriefing, postFile, readMarked, renderTags, replaceMarked } from "./briefing.js";
13
+ import { fingerprint, parseObs } from "./obs.js";
14
+ const b64url = (input) => Buffer.from(input).toString("base64url");
15
+ /** A short-lived token for Ghost's Admin API, signed with the key's secret. */
16
+ export function ghostToken(key, nowMs = Date.now()) {
17
+ const [id, secret] = key.split(":");
18
+ if (!id || !secret || !/^[0-9a-f]+$/iu.test(secret))
19
+ throw new Error("GHOST_ADMIN_API_KEY should look like id:secret, as Ghost shows it");
20
+ const iat = Math.floor(nowMs / 1000);
21
+ const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT", kid: id }));
22
+ const body = b64url(JSON.stringify({ iat, exp: iat + 300, aud: "/admin/" }));
23
+ const sig = createHmac("sha256", Buffer.from(secret, "hex")).update(`${head}.${body}`).digest("base64url");
24
+ return `${head}.${body}.${sig}`;
25
+ }
26
+ export class Ghost {
27
+ config;
28
+ base;
29
+ fetcher;
30
+ constructor(config) {
31
+ this.config = config;
32
+ this.base = `${config.url.replace(/\/+$/u, "")}/ghost/api/admin`;
33
+ this.fetcher = config.fetch ?? ((url, init) => fetch(url, init));
34
+ }
35
+ async call(method, path, body) {
36
+ const headers = {
37
+ authorization: `Ghost ${ghostToken(this.config.key, this.config.now?.() ?? Date.now())}`,
38
+ "accept-version": "v5.0",
39
+ accept: "application/json",
40
+ };
41
+ const init = { method, headers };
42
+ if (body !== undefined) {
43
+ headers["content-type"] = "application/json";
44
+ init.body = JSON.stringify(body);
45
+ }
46
+ const res = await this.fetcher(`${this.base}${path}`, init);
47
+ if (!res.ok) {
48
+ let detail = "";
49
+ try {
50
+ const err = (await res.json());
51
+ detail = err.errors?.[0]?.message ?? "";
52
+ }
53
+ catch {
54
+ /* the status says enough */
55
+ }
56
+ throw new Error(`Ghost answered ${res.status}${detail ? `: ${detail}` : ""}`);
57
+ }
58
+ return res.json();
59
+ }
60
+ /** Every published post, with its plain text. */
61
+ async posts(slug) {
62
+ const out = [];
63
+ const filter = slug ? `status:published+slug:${slug}` : "status:published";
64
+ for (let page = 1; page <= 200; page++) {
65
+ const res = (await this.call("GET", `/posts/?filter=${encodeURIComponent(filter)}&formats=plaintext&limit=50&page=${page}&order=${encodeURIComponent("published_at desc")}`));
66
+ out.push(...(res.posts ?? []));
67
+ if (page >= (res.meta?.pagination?.pages ?? 1))
68
+ break;
69
+ }
70
+ return out;
71
+ }
72
+ /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
73
+ async setHead(post, head) {
74
+ await this.call("PUT", `/posts/${post.id}/`, { posts: [{ codeinjection_head: head, updated_at: post.updated_at }] });
75
+ }
76
+ }
77
+ const META_OBS = /[ \t]*<meta\s+(?:property|name)\s*=\s*"obs:[^"]*"\s+content\s*=\s*"[^"]*"\s*\/?>[ \t]*\r?\n?/giu;
78
+ /** The brief a post's Code injection header carries now, when it has usable tags. */
79
+ export function briefInHead(head, url) {
80
+ if (!/obs:/u.test(head))
81
+ return undefined;
82
+ return parseObs(new JSDOM(`<!doctype html><html><head>${head}</head><body></body></html>`, { url }).window.document, url).brief;
83
+ }
84
+ /**
85
+ * The header with the new block in place. Tags written by hand outside the
86
+ * markers are taken out first, so the page never carries two sets. Returns
87
+ * undefined when hand-written tags remain that this cannot safely move.
88
+ */
89
+ export function headWith(head, block) {
90
+ const marked = readMarked(head);
91
+ if (marked !== undefined) {
92
+ const outside = head.replace(marked, "");
93
+ if (/obs:(?!start|end)/u.test(outside.replace(META_OBS, "")))
94
+ return undefined;
95
+ return replaceMarked(head.replace(marked, "\u0000").replace(META_OBS, "").replace("\u0000", () => marked), block);
96
+ }
97
+ const cleaned = head.replace(META_OBS, "");
98
+ if (/obs:/u.test(cleaned))
99
+ return undefined;
100
+ return replaceMarked(cleaned, block);
101
+ }
102
+ /** Ghost's slug shape. Anything else is never used as a file name or in a query. */
103
+ const SLUG = /^[a-z0-9][a-z0-9_-]*$/iu;
104
+ const briefingFiles = (folder) => readdirSync(folder)
105
+ .filter((f) => /\.(md|markdown|ya?ml)$/iu.test(f))
106
+ .sort();
107
+ /** Writes one file per published post into `folder`. A file that already exists is never touched. */
108
+ export async function pull(ghost, folder, slug) {
109
+ mkdirSync(folder, { recursive: true });
110
+ const lines = [];
111
+ if (slug !== undefined && !SLUG.test(slug))
112
+ return [{ slug, state: "error", note: "not a post slug" }];
113
+ for (const post of await ghost.posts(slug)) {
114
+ if (typeof post.slug !== "string" || !SLUG.test(post.slug)) {
115
+ lines.push({ slug: String(post.slug), state: "error", note: "the post's slug is not a plain slug, so no file is written" });
116
+ continue;
117
+ }
118
+ const path = join(folder, `${post.slug}.md`);
119
+ if (existsSync(path)) {
120
+ lines.push({ slug: post.slug, state: "kept", note: "the file is already here" });
121
+ continue;
122
+ }
123
+ // Tags already live on the post were approved by whoever put them there.
124
+ const live = briefInHead(post.codeinjection_head ?? "", post.url);
125
+ const obs = live ? briefingFields({ ...live, url: live.url ?? post.url }, "approved") : { url: post.url, status: "draft" };
126
+ writeFileSync(path, postFile({ slug: post.slug, title: post.title, obs }, post.plaintext ?? ""));
127
+ lines.push({ slug: post.slug, state: "pulled", note: live ? "with the tags the post already carries, as approved" : "a draft to fill in" });
128
+ }
129
+ return lines;
130
+ }
131
+ /** Writes each approved file's tags into its post. A draft is never written. */
132
+ export async function push(ghost, folder, options = {}) {
133
+ const lines = [];
134
+ const files = briefingFiles(folder).filter((f) => options.slug === undefined || f.replace(/\.[^.]+$/u, "") === options.slug);
135
+ if (files.length === 0)
136
+ return lines;
137
+ const only = files.length === 1 ? files[0].replace(/\.[^.]+$/u, "") : undefined;
138
+ const posts = new Map((await ghost.posts(only !== undefined && SLUG.test(only) ? only : undefined)).map((p) => [p.slug, p]));
139
+ for (const file of files) {
140
+ const briefing = parseBriefing(readFileSync(join(folder, file), "utf8"));
141
+ const slug = briefing.slug ?? file.replace(/\.[^.]+$/u, "");
142
+ if (briefing.status !== "approved") {
143
+ lines.push({ slug, state: "draft", note: "not approved, so not written" });
144
+ continue;
145
+ }
146
+ if (!briefing.brief) {
147
+ lines.push({ slug, state: "error", note: briefing.errors.join("; ") });
148
+ continue;
149
+ }
150
+ const post = posts.get(slug);
151
+ if (!post) {
152
+ lines.push({ slug, state: "error", note: "no published post has this slug" });
153
+ continue;
154
+ }
155
+ const head = post.codeinjection_head ?? "";
156
+ const marked = readMarked(head);
157
+ const live = marked === undefined ? undefined : briefInHead(marked, post.url);
158
+ const next = headWith(head, markTags(renderTags(briefing.brief)));
159
+ if (next === undefined) {
160
+ lines.push({ slug, state: "skipped", note: "the post has obs: code outside the markers that is not a plain meta tag; move it by hand" });
161
+ continue;
162
+ }
163
+ const outside = marked === undefined ? head : head.replace(marked, "");
164
+ const stray = outside.replace(META_OBS, "") !== outside;
165
+ if (live && !stray && fingerprint(live) === fingerprint(briefing.brief)) {
166
+ lines.push({ slug, state: "unchanged" });
167
+ continue;
168
+ }
169
+ if (options.dryRun) {
170
+ lines.push({ slug, state: "would write" });
171
+ continue;
172
+ }
173
+ try {
174
+ await ghost.setHead(post, next);
175
+ lines.push({ slug, state: "written" });
176
+ }
177
+ catch (err) {
178
+ lines.push({ slug, state: "error", note: err instanceof Error ? err.message : String(err) });
179
+ }
180
+ }
181
+ return lines;
182
+ }
package/dist/obs.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare const OBS_NS = "https://openbriefingstandard.org/ns#";
11
11
  export type ObsTier = "short" | "medium" | "long";
12
12
  export type DeriveMode = "verbatim" | "anchor" | "open";
13
13
  export type Preference = "summary" | "full";
14
+ /** Where a brief's modified time came from (§13). */
15
+ export type ModifiedFrom = "obs" | "article" | "feed" | "none";
14
16
  export interface ObsSummary {
15
17
  tier: ObsTier;
16
18
  text: string;
@@ -29,8 +31,10 @@ export interface ObsHighlight {
29
31
  }
30
32
  export interface ObsBrief {
31
33
  version: number;
32
- /** obs:modified, as given (ISO 8601). */
33
- modified: string;
34
+ /** The effective modified time, ISO 8601 (§13). Absent when no source gave one. */
35
+ modified?: string;
36
+ /** Where `modified` came from: obs:modified, article:modified_time, the feed item's date, or nowhere. */
37
+ modifiedFrom: ModifiedFrom;
34
38
  /** obs:url, normalized, when given. */
35
39
  url?: string;
36
40
  /** False when obs:url names content other than this page (§11). */
@@ -101,5 +105,19 @@ export declare function parseObs(doc: Document, pageUrl: string): ParseResult;
101
105
  export declare function parseFeedObs(item: Element, itemUrl?: string): ParseResult;
102
106
  /** Checks an object in the ObsBrief shape and rebuilds it under the same rules. */
103
107
  export declare function validateObs(input: unknown): ParseResult;
104
- /** The later obs:modified stands; on a tie, the page stands. */
108
+ /**
109
+ * The later effective modified time stands. When the two are equal, or either
110
+ * side has none, the page stands.
111
+ */
105
112
  export declare function mergeObs(page?: ObsBrief, feed?: ObsBrief): ObsBrief | undefined;
113
+ /**
114
+ * A stable hash of what a brief says: every text value and every setting, in a
115
+ * fixed order. Two briefs with the same fingerprint present the same. It leaves
116
+ * out the modified time, and everything that depends on where the tags were
117
+ * read rather than on what they say (the language fallback, describesThisPage,
118
+ * a highlight's inContent), so a page and its feed item agree. Readers compare
119
+ * fingerprints to tell a change, above all when there is no modified time.
120
+ *
121
+ * SHA-256 of the canonical JSON below, in hex, first 16 characters.
122
+ */
123
+ export declare function fingerprint(brief: ObsBrief): string;
package/dist/obs.js CHANGED
@@ -183,13 +183,22 @@ function build(bag, ctx, extraWarnings = []) {
183
183
  warnings.push(`obs:version ${n} is newer than this reader (v1); unknown tags are ignored`);
184
184
  }
185
185
  }
186
- // obs:modified (required)
187
- const modified = first(bag, "obs:modified", warnings)?.trim();
188
- if (modified === undefined || modified === "") {
189
- errors.push("obs:modified is missing; it is required (§3)");
186
+ // The effective modified time (§13): obs:modified, else the carrier's own date, else none.
187
+ let modified;
188
+ let modifiedFrom = "none";
189
+ const rawModified = first(bag, "obs:modified", warnings)?.trim();
190
+ if (rawModified !== undefined && rawModified !== "") {
191
+ if (validModified(rawModified)) {
192
+ modified = rawModified;
193
+ modifiedFrom = "obs";
194
+ }
195
+ else {
196
+ warnings.push(`obs:modified: "${rawModified}" is not an ISO 8601 date and time; it is ignored (§13)`);
197
+ }
190
198
  }
191
- else if (!validModified(modified)) {
192
- errors.push(`obs:modified: "${modified}" is not an ISO 8601 date and time (§3)`);
199
+ if (modified === undefined && ctx.alt !== undefined && validModified(ctx.alt.value)) {
200
+ modified = ctx.alt.value;
201
+ modifiedFrom = ctx.alt.from;
193
202
  }
194
203
  // Summaries (§4)
195
204
  const tiered = new Map();
@@ -283,7 +292,8 @@ function build(bag, ctx, extraWarnings = []) {
283
292
  }
284
293
  const brief = {
285
294
  version,
286
- modified: modified,
295
+ ...(modified !== undefined ? { modified } : {}),
296
+ modifiedFrom,
287
297
  describesThisPage,
288
298
  summaries,
289
299
  keep,
@@ -341,6 +351,17 @@ function guard(fn) {
341
351
  }
342
352
  // ---------------------------------------------------------------------------
343
353
  // Pages (§3.1)
354
+ function metaContent(doc, key) {
355
+ for (const meta of Array.from(doc.getElementsByTagName("meta"))) {
356
+ const k = (meta.getAttribute("property") ?? meta.getAttribute("name") ?? "").trim().toLowerCase();
357
+ if (k !== key)
358
+ continue;
359
+ const v = meta.getAttribute("content")?.trim();
360
+ if (v)
361
+ return v;
362
+ }
363
+ return undefined;
364
+ }
344
365
  /**
345
366
  * Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
346
367
  * from; the page's <link rel="canonical"> takes precedence for §11.
@@ -361,6 +382,10 @@ export function parseObs(doc, pageUrl) {
361
382
  continue;
362
383
  if (!inBody && doc.body && doc.body.contains(meta))
363
384
  inBody = true;
385
+ // An unescaped double quote ends the attribute early; the rest becomes attributes with a quote in their names.
386
+ if (Array.from(meta.attributes).some((a) => a.name.includes('"'))) {
387
+ warnings.push(`${key}: the value is cut short at a double quote; write a double quote inside a value as &quot; (§9)`);
388
+ }
364
389
  collect(bag, key, content, warnings);
365
390
  }
366
391
  if (isEmpty(bag))
@@ -375,6 +400,9 @@ export function parseObs(doc, pageUrl) {
375
400
  ctx.canonical = canonical;
376
401
  if (lang !== undefined)
377
402
  ctx.lang = lang;
403
+ const articleModified = metaContent(doc, "article:modified_time");
404
+ if (articleModified !== undefined)
405
+ ctx.alt = { value: articleModified, from: "article" };
378
406
  const result = build(bag, ctx, warnings);
379
407
  if (result.brief)
380
408
  checkContent(result.brief, doc.body?.textContent ?? undefined, result.warnings);
@@ -413,6 +441,24 @@ function itemLink(item) {
413
441
  }
414
442
  return undefined;
415
443
  }
444
+ /** The item's own date (§13): Atom <updated>, or RSS <pubDate> turned into ISO 8601. */
445
+ function itemDate(item) {
446
+ for (const el of Array.from(item.children)) {
447
+ if (el.namespaceURI === OBS_NS)
448
+ continue;
449
+ const text = el.textContent?.trim();
450
+ if (!text)
451
+ continue;
452
+ if (el.localName === "updated" && validModified(text))
453
+ return text;
454
+ if (el.localName === "pubDate") {
455
+ const ms = Date.parse(text);
456
+ if (!Number.isNaN(ms))
457
+ return new Date(ms).toISOString();
458
+ }
459
+ }
460
+ return undefined;
461
+ }
416
462
  /**
417
463
  * Reads the OBS elements of one RSS <item> or Atom <entry>. `itemUrl`
418
464
  * overrides the item's own link when given.
@@ -460,6 +506,9 @@ export function parseFeedObs(item, itemUrl) {
460
506
  const ctx = {};
461
507
  if (canonical !== undefined)
462
508
  ctx.canonical = canonical;
509
+ const date = itemDate(item);
510
+ if (date !== undefined)
511
+ ctx.alt = { value: date, from: "feed" };
463
512
  return build(bag, ctx, warnings);
464
513
  });
465
514
  }
@@ -482,7 +531,14 @@ export function validateObs(input) {
482
531
  else if (v !== undefined && v !== null)
483
532
  warnings.push(`${key} is not a string; it is ignored`);
484
533
  };
485
- set("obs:modified", o.modified);
534
+ // A modified time that came from the carrier is carried through with its source.
535
+ const ctx = {};
536
+ if ((o.modifiedFrom === "article" || o.modifiedFrom === "feed") && typeof o.modified === "string") {
537
+ ctx.alt = { value: o.modified, from: o.modifiedFrom };
538
+ }
539
+ else {
540
+ set("obs:modified", o.modified);
541
+ }
486
542
  if (typeof o.version === "number")
487
543
  put(bag, "obs:version", String(o.version));
488
544
  set("obs:url", o.url);
@@ -540,7 +596,9 @@ export function validateObs(input) {
540
596
  if (typeof d.combine === "boolean")
541
597
  put(bag, "obs:derive:combine", d.combine ? "yes" : "no");
542
598
  }
543
- const result = build(bag, {}, warnings);
599
+ if (isEmpty(bag))
600
+ return { errors: ["No summary: obs:summary or a tiered summary is required (§3)"], warnings };
601
+ const result = build(bag, ctx, warnings);
544
602
  if (result.brief && typeof o.describesThisPage === "boolean") {
545
603
  result.brief.describesThisPage = o.describesThisPage;
546
604
  }
@@ -549,11 +607,121 @@ export function validateObs(input) {
549
607
  }
550
608
  // ---------------------------------------------------------------------------
551
609
  // Feed against page (§14)
552
- /** The later obs:modified stands; on a tie, the page stands. */
610
+ /**
611
+ * The later effective modified time stands. When the two are equal, or either
612
+ * side has none, the page stands.
613
+ */
553
614
  export function mergeObs(page, feed) {
554
615
  if (!page)
555
616
  return feed;
556
617
  if (!feed)
557
618
  return page;
619
+ if (page.modified === undefined || feed.modified === undefined)
620
+ return page;
558
621
  return Date.parse(feed.modified) > Date.parse(page.modified) ? feed : page;
559
622
  }
623
+ // ---------------------------------------------------------------------------
624
+ // Change detection (§13)
625
+ /**
626
+ * A stable hash of what a brief says: every text value and every setting, in a
627
+ * fixed order. Two briefs with the same fingerprint present the same. It leaves
628
+ * out the modified time, and everything that depends on where the tags were
629
+ * read rather than on what they say (the language fallback, describesThisPage,
630
+ * a highlight's inContent), so a page and its feed item agree. Readers compare
631
+ * fingerprints to tell a change, above all when there is no modified time.
632
+ *
633
+ * SHA-256 of the canonical JSON below, in hex, first 16 characters.
634
+ */
635
+ export function fingerprint(brief) {
636
+ const canonical = JSON.stringify([
637
+ "obs1",
638
+ brief.version,
639
+ brief.url ?? null,
640
+ brief.title ?? null,
641
+ brief.headline ?? null,
642
+ brief.summaries.map((s) => [s.tier, s.text]),
643
+ brief.keep,
644
+ brief.highlights.map((h) => [h.text, h.label ?? null]),
645
+ [brief.derive.mode, brief.derive.translate, brief.derive.combine],
646
+ brief.attribution ?? null,
647
+ brief.preference,
648
+ ]);
649
+ return sha256Hex(utf8(canonical)).slice(0, 16);
650
+ }
651
+ /** UTF-8 bytes, without TextEncoder so the module needs no platform API. */
652
+ function utf8(text) {
653
+ const out = [];
654
+ for (const ch of text) {
655
+ const c = ch.codePointAt(0);
656
+ if (c < 0x80)
657
+ out.push(c);
658
+ else if (c < 0x800)
659
+ out.push(0xc0 | (c >> 6), 0x80 | (c & 63));
660
+ else if (c < 0x10000)
661
+ out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
662
+ else
663
+ out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 63), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63));
664
+ }
665
+ return out;
666
+ }
667
+ const SHA_K = [
668
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
669
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
670
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
671
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
672
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
673
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
674
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
675
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
676
+ ];
677
+ /** SHA-256 (FIPS 180-4), synchronous, so fingerprint() is too. */
678
+ function sha256Hex(bytes) {
679
+ const h = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19];
680
+ const bits = bytes.length * 8;
681
+ const msg = bytes.slice();
682
+ msg.push(0x80);
683
+ while (msg.length % 64 !== 56)
684
+ msg.push(0);
685
+ const hi = Math.floor(bits / 0x100000000);
686
+ const lo = bits >>> 0;
687
+ msg.push((hi >>> 24) & 255, (hi >>> 16) & 255, (hi >>> 8) & 255, hi & 255, (lo >>> 24) & 255, (lo >>> 16) & 255, (lo >>> 8) & 255, lo & 255);
688
+ const w = new Array(64).fill(0);
689
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
690
+ for (let i = 0; i < msg.length; i += 64) {
691
+ for (let t = 0; t < 16; t++)
692
+ w[t] = ((msg[i + t * 4] << 24) | (msg[i + t * 4 + 1] << 16) | (msg[i + t * 4 + 2] << 8) | msg[i + t * 4 + 3]) >>> 0;
693
+ for (let t = 16; t < 64; t++) {
694
+ const a = w[t - 15];
695
+ const b = w[t - 2];
696
+ const s0 = rotr(a, 7) ^ rotr(a, 18) ^ (a >>> 3);
697
+ const s1 = rotr(b, 17) ^ rotr(b, 19) ^ (b >>> 10);
698
+ w[t] = (w[t - 16] + s0 + w[t - 7] + s1) >>> 0;
699
+ }
700
+ let [a, b, c, d, e, f, g, k] = h;
701
+ for (let t = 0; t < 64; t++) {
702
+ const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
703
+ const ch = (e & f) ^ (~e & g);
704
+ const t1 = (k + S1 + ch + SHA_K[t] + w[t]) >>> 0;
705
+ const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
706
+ const maj = (a & b) ^ (a & c) ^ (b & c);
707
+ const t2 = (S0 + maj) >>> 0;
708
+ k = g;
709
+ g = f;
710
+ f = e;
711
+ e = (d + t1) >>> 0;
712
+ d = c;
713
+ c = b;
714
+ b = a;
715
+ a = (t1 + t2) >>> 0;
716
+ }
717
+ h[0] = (h[0] + a) >>> 0;
718
+ h[1] = (h[1] + b) >>> 0;
719
+ h[2] = (h[2] + c) >>> 0;
720
+ h[3] = (h[3] + d) >>> 0;
721
+ h[4] = (h[4] + e) >>> 0;
722
+ h[5] = (h[5] + f) >>> 0;
723
+ h[6] = (h[6] + g) >>> 0;
724
+ h[7] = (h[7] + k) >>> 0;
725
+ }
726
+ return h.map((x) => x.toString(16).padStart(8, "0")).join("");
727
+ }