add support for v2 hashing algorithm.

update README.md and demo file to be more in sync.
update to v0.10.0.
This commit is contained in:
2026-03-29 22:34:43 -04:00
parent d5363c3960
commit c776b5df6a
33 changed files with 2255 additions and 1524 deletions

187
README.md
View File

@@ -12,14 +12,30 @@ These functions include:
[metadata](https://twtxt.dev/exts/metadata.html) in the file). [metadata](https://twtxt.dev/exts/metadata.html) in the file).
- `loadAndParseTwtxt`: fetches a twtxt file from _the internet_ and parses it into an object (as above). - `loadAndParseTwtxt`: fetches a twtxt file from _the internet_ and parses it into an object (as above).
## Features
- Isomorphic, available as an ([optionally minified](/dist-browser/twtxt-lib.min.js))
[ES6+ library for the browser](/dist-browser/twtxt-lib.js), with NPM and JSR versions coming soon.
or as a package (from [NPM](https://www.npmjs.com/package/twtxt-lib) or
[JSR](https://jsr.io/@itsericwoodward/twtxt-lib)).
- Fully typed and source-mapped (I hope).
- Built as an [ES6 Module](https://caniuse.com/es6-module) (and [ESM only](https://antfu.me/posts/move-on-to-esm-only)).
- Includes sample files and an [interactive demo](https://twtxt-lib.itsericwoodward.com/).
## Installation ## Installation
This library can be installed in several different ways: This library can be installed several different ways:
### For the Browser ### For the Browser
1. Grab the latest copy of the `twtxt-lib.js` file, either by downloading it from the [git repo](https://git.itsericwoodward.com/eric/twtxt-lib/raw/branch/main/dist-browser/twtxt-lib.js), the [website](https://twtxt-lib.itsericwoodward.com/dist-browser/twtxt-lib.js), or doing a `git clone https://git.itsericwoodward.com/eric/twtxt-lib.git` and pulling it out of the `dist-browser` folder. 1. Grab the latest copy of the `twtxt-lib.js` file, either by downloading it from the
- Alternatively, you can grab the minified version from the [same](https://git.itsericwoodward.com/eric/twtxt-lib/raw/branch/main/dist-browser/twtxt-lib.min.js) [sources](https://twtxt-lib.itsericwoodward.com/dist-browser/twtxt-lib.min.js). [git repo](https://git.itsericwoodward.com/eric/twtxt-lib/raw/branch/main/dist-browser/twtxt-lib.js),
the [website](https://twtxt-lib.itsericwoodward.com/dist-browser/twtxt-lib.js), or by doing a
`git clone https://git.itsericwoodward.com/eric/twtxt-lib.git` and pulling it out of the
`dist-browser` folder.
- Alternatively, you can grab the minified version from the
[same](https://git.itsericwoodward.com/eric/twtxt-lib/raw/branch/main/dist-browser/twtxt-lib.min.js)
[sources](https://twtxt-lib.itsericwoodward.com/dist-browser/twtxt-lib.min.js).
2. Add the newly acquired file to your static site / progressive web app / over-engineered blog. 2. Add the newly acquired file to your static site / progressive web app / over-engineered blog.
3. Import the desired function(s) via ESM: `import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js";` 3. Import the desired function(s) via ESM: `import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js";`
@@ -27,88 +43,28 @@ This library can be installed in several different ways:
1. Add the package to your project. 1. Add the package to your project.
- Via [NPM](https://www.npmjs.com/package/twtxt-lib): `yarn add twtxt-lib` - Via [NPM](https://www.npmjs.com/package/twtxt-lib): `yarn add twtxt-lib`
- Via [JSR](https://jsr.io/@itsericwoodward/twtxt-lib): `yarn add jsr:@itsericwoodward/twtxt-lib` - Via [JSR](https://jsr.io/@itsericwoodward/twtxt-lib):
2. Import the desired function(s) into your code: `import { hashTwt, loadAndParseTwtxtFile } from "twtxt-lib";` `yarn add jsr:@itsericwoodward/twtxt-lib`
2. Import the desired function(s) via ESM:
## Features `import { hashTwt, loadAndParseTwtxtFile, parseTwtxt } from "twtxt-lib";`
- Isomorphic, available as an ([optionally minified](/dist-browser/twtxt-lib.min.js))
[ES6+ library for the browser](/dist-browser/twtxt-lib.js), with NPM and JSR versions coming soon.
- Fully typed and source-mapped.
- [ES6 Module](https://caniuse.com/es6-module) (and [ESM only](https://antfu.me/posts/move-on-to-esm-only)).
- Includes sample files and an [interactive demo page](https://twtxt-lib.itsericwoodward.com/).
## Usage ## Usage
### loadAndParseText See the included tests and demo file for more information on how to use it.
This is arguably the most useful function:
```
import { loadAndParseTwtxtFile } from "/web/dist/twtxt-lib.js";
// run in an IIFE (or event listener) to avoid issues with top-level await
(async () => {
try {
const parsedFile = await loadAndParseTwtxtFile(
"/twtxt-demos/demo-hipster-twtxt.txt",
);
console.log(parsedFile);
} catch (err) {
console.error(err);
}
})();
```
Result:
```
{
"following": [
{
"nick": "demo_hipster",
"url": "https://example.com/demo-hipster-twtxt.txt"
},
{
"nick": "demo_pirate",
"url": "https://example.org/~pirate/twtxt.txt"
},
{
"nick": "demo_sagan",
"url": "https://example.net/~saganos/twtxt.txt"
}
],
"metadata": {
"nick": "demo_hipster",
"url": "https://example.com/demo-hipster-twtxt.txt",
"avatar": "https://i.pravatar.cc/150?img=67",
"description": "Kitsch ut post-ironic, bruh tilde non shabby chic iceland fixie consequat?"
},
"twts": [
{
"content": "Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.",
"created": "2025-06-02T18:47:01+01:00",
"createdUTC": "2025-06-02T17:47:01.000Z",
"hash": "ymiydvq"
},
{
"content": "Cardigan jean shorts eu 90's. Kitsch knausgaard culpa, marfa mumblecore portland raclette banjo retro exercitation pariatur snackwave williamsburg.",
"created": "2025-07-05T00:17:46+02:00",
"createdUTC": "2025-07-04T22:17:46.000Z",
"hash": "c6bm4sq"
},
...
],
"lastModified": "2026-02-22T20:56:59.000Z"
}
```
When using in a web browser, be aware of potential the potential for issues with [Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS).
### hashTwt ### hashTwt
A function that takes the constituent parts of a "twt" and generates an
[extension-compatible hash](https://twtxt.dev/exts/twt-hash.html) for it, which is then returned.
Version 0.10.0 and above includes support for
[V2 of the Hashing Spec](https://git.mills.io/yarnsocial/twtxt.dev/pulls/28):
- A specific hashing version can be provided as an optional argument.
- When no version argument is provided, it defaults to using version 1 for all twts with a created
date before the epoch date (`2026-07-01T00:00:00Z`), and version 2 for all twts created on or
after the epoch.
``` ```
import { hashTwt } from "/web/dist/twtxt-lib.js"; import { hashTwt } from "/web/dist/twtxt-lib.js";
@@ -129,6 +85,10 @@ Hash: 7uftieq
### parseTwtxt ### parseTwtxt
A function that parses a twtxt file string, returning an object with information about the file and
its owner (including generating [hashes](#hashTwt) for each twt and
any [metadata](https://twtxt.dev/exts/metadata.html) in the file).
``` ```
import { base32Encode, hashTwt, loadAndParseTwtxt, parseTwtxt } from "twtxt-lib"; import { base32Encode, hashTwt, loadAndParseTwtxt, parseTwtxt } from "twtxt-lib";
@@ -213,7 +173,74 @@ Result:
} }
``` ```
See the included tests and demo file for more information on how to use it. ### loadAndParseText
Aan async function that fetches a `twtxt.txt`-compatible file from a URL and parses it,
returning the extracted data as an object.
```
import { loadAndParseTwtxtFile } from "/web/dist/twtxt-lib.js";
// run in an IIFE (or event listener) to avoid issues with top-level await
(async () => {
try {
const parsedFile = await loadAndParseTwtxtFile(
"/twtxt-demos/demo-hipster-twtxt.txt",
);
console.log(parsedFile);
} catch (err) {
console.error(err);
}
})();
```
Result:
```
{
"following": [
{
"nick": "demo_hipster",
"url": "https://example.com/demo-hipster-twtxt.txt"
},
{
"nick": "demo_pirate",
"url": "https://example.org/~pirate/twtxt.txt"
},
{
"nick": "demo_sagan",
"url": "https://example.net/~saganos/twtxt.txt"
}
],
"metadata": {
"nick": "demo_hipster",
"url": "https://example.com/demo-hipster-twtxt.txt",
"avatar": "https://i.pravatar.cc/150?img=67",
"description": "Kitsch ut post-ironic, bruh tilde non shabby chic iceland fixie consequat?"
},
"twts": [
{
"content": "Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.",
"created": "2025-06-02T18:47:01+01:00",
"createdUTC": "2025-06-02T17:47:01.000Z",
"hash": "ymiydvq"
},
{
"content": "Cardigan jean shorts eu 90's. Kitsch knausgaard culpa, marfa mumblecore portland raclette banjo retro exercitation pariatur snackwave williamsburg.",
"created": "2025-07-05T00:17:46+02:00",
"createdUTC": "2025-07-04T22:17:46.000Z",
"hash": "c6bm4sq"
},
...
],
"lastModified": "2026-02-22T20:56:59.000Z"
}
```
When using in a web browser, be aware of the potential for issues with
[Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS).
## License ## License

View File

@@ -1 +1 @@
export declare const __dirname: string; export declare const HASH_V2_EPOCH = "2026-07-01T00:00:00Z";

View File

@@ -1,2 +1,3 @@
import { Twt } from './types.ts'; import { Twt } from './types.ts';
export default function hashTwt(twt: Twt): string; export type HashableTwt = Pick<Twt, "content" | "created" | "url"> & Partial<Omit<Twt, "content" | "created" | "url">>;
export default function hashTwt(twt: HashableTwt, version?: number): string;

View File

@@ -2900,6 +2900,7 @@ const base32Encode = (payload) => {
return encoder.write(payload).finalize(); return encoder.write(payload).finalize();
}; };
const getValueOrFirstEntry = (value) => Array.isArray(value) && value.length ? value[0] : value; const getValueOrFirstEntry = (value) => Array.isArray(value) && value.length ? value[0] : value;
const HASH_V2_EPOCH = `2026-07-01T00:00:00Z`;
const dateRegex = /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/; const dateRegex = /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/;
const formatRFC3339 = (date) => { const formatRFC3339 = (date) => {
const pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`; const pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`;
@@ -2928,10 +2929,14 @@ const formatRFC3339 = (date) => {
offset offset
].join(""); ].join("");
}; };
function hashTwt(twt) { function hashTwt(twt, version = 0) {
const created = formatRFC3339(twt.created); const created = formatRFC3339(twt.created);
const payload = [twt.url, created, twt.content].join("\n"); const payload = [twt.url, created, twt.content].join("\n");
return base32Encode(blakejsExports.blake2b(payload, void 0, 32)).toLowerCase().slice(-7); const encoded = base32Encode(blakejsExports.blake2b(payload, void 0, 32)).toLowerCase();
const createdDate = new Date(twt.created), epochDate = new Date(HASH_V2_EPOCH);
if (version === 1 || !version && createdDate < epochDate)
return encoded.slice(-7);
return encoded.slice(0, 12);
} }
var dayjs_min$1 = { exports: {} }; var dayjs_min$1 = { exports: {} };
var dayjs_min = dayjs_min$1.exports; var dayjs_min = dayjs_min$1.exports;

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -9,17 +9,22 @@ formHash.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
const content = formHash.elements["content"].value; const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value; const created = formHash.elements["created"].value;
const version = parseInt(formHash.elements["version"].value, 10);
const url = formHash.elements["url"].value; const url = formHash.elements["url"].value;
const hash = hashTwt({ const hash = hashTwt(
content, {
created, content,
url, created,
}); url,
},
version,
);
const result = [ const result = [
`content: ${content}`, `content: ${content}`,
`created: ${created}`, `created: ${created}`,
`url: ${url}`, `url: ${url}`,
`version: ${version}`,
`hash: ${hash}`, `hash: ${hash}`,
].join("\n"); ].join("\n");

View File

@@ -15,18 +15,23 @@ formatSource(
const content = formHash.elements["content"].value; const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value; const created = formHash.elements["created"].value;
const version = parseInt(formHash.elements["version"].value, 10);
const url = formHash.elements["url"].value; const url = formHash.elements["url"].value;
const hash = hashTwt({ const hash = hashTwt(
content, {
created, content,
url, created,
}); url,
},
version,
);
const result = [ const result = [
\`content: \${content}\`, \`content: \${content}\`,
\`created: \${created}\`, \`created: \${created}\`,
\`url: \${url}\`, \`url: \${url}\`,
\`version: \${version}\`,
\`hash: \${hash}\`, \`hash: \${hash}\`,
].join("\\n"); ].join("\\n");

View File

@@ -76,6 +76,18 @@ code {
white-space: pre-wrap; white-space: pre-wrap;
} }
details figure {
margin: 1rem .5rem;
}
details figure pre {
overflow: auto;
white-space: pre;
}
details[open] summary ~ * {
animation: riseInDetails .5s ease-in-out;
}
label { label {
display: inline-block; display: inline-block;
@@ -110,6 +122,15 @@ pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
select {
background-color: var(--fg-light);
font-size: 1rem;
}
select:open {
background-color: var(--link-active);
}
textarea { textarea {
background-color: var(--fg-light); background-color: var(--fg-light);
font-size: 1rem; font-size: 1rem;
@@ -117,21 +138,6 @@ textarea {
width: 100%; width: 100%;
} }
details figure {
margin: 1rem .5rem;
}
details figure pre {
overflow: auto;
white-space: pre;
}
details[open] summary ~ * {
animation: riseInDetails .5s ease-in-out;
}
summary { summary {
cursor: pointer; cursor: pointer;
} }

View File

@@ -1 +1 @@
export declare const __dirname: string; export declare const HASH_V2_EPOCH = "2026-07-01T00:00:00Z";

View File

@@ -1,2 +1,3 @@
import { Twt } from './types.ts'; import { Twt } from './types.ts';
export default function hashTwt(twt: Twt): string; export type HashableTwt = Pick<Twt, "content" | "created" | "url"> & Partial<Omit<Twt, "content" | "created" | "url">>;
export default function hashTwt(twt: HashableTwt, version?: number): string;

View File

@@ -2900,6 +2900,7 @@ const base32Encode = (payload) => {
return encoder.write(payload).finalize(); return encoder.write(payload).finalize();
}; };
const getValueOrFirstEntry = (value) => Array.isArray(value) && value.length ? value[0] : value; const getValueOrFirstEntry = (value) => Array.isArray(value) && value.length ? value[0] : value;
const HASH_V2_EPOCH = `2026-07-01T00:00:00Z`;
const dateRegex = /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/; const dateRegex = /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/;
const formatRFC3339 = (date) => { const formatRFC3339 = (date) => {
const pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`; const pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`;
@@ -2928,10 +2929,14 @@ const formatRFC3339 = (date) => {
offset offset
].join(""); ].join("");
}; };
function hashTwt(twt) { function hashTwt(twt, version = 0) {
const created = formatRFC3339(twt.created); const created = formatRFC3339(twt.created);
const payload = [twt.url, created, twt.content].join("\n"); const payload = [twt.url, created, twt.content].join("\n");
return base32Encode(blakejsExports.blake2b(payload, void 0, 32)).toLowerCase().slice(-7); const encoded = base32Encode(blakejsExports.blake2b(payload, void 0, 32)).toLowerCase();
const createdDate = new Date(twt.created), epochDate = new Date(HASH_V2_EPOCH);
if (version === 1 || !version && createdDate < epochDate)
return encoded.slice(-7);
return encoded.slice(0, 12);
} }
var dayjs_min$1 = { exports: {} }; var dayjs_min$1 = { exports: {} };
var dayjs_min = dayjs_min$1.exports; var dayjs_min = dayjs_min$1.exports;

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -14,8 +14,10 @@
<ul class="tabs"> <ul class="tabs">
<li class="tab tabOverview" id="overview"> <li class="tab tabOverview" id="overview">
<a class="tab-link" href="#overview" id="tabOverview-link">Overview</a> <a class="tab-link" href="#overview" id="tabOverview-link">Overview</a>
<div class="tab-panel" id="tabOverview-panel"> <div class="tab-panel" id="tabOverview-panel">
<h1>twtxt-lib</h2> <h1>twtxt-lib</h2>
<p> <p>
An isomorphic TypeScript library of An isomorphic TypeScript library of
utility functions for parsing and interacting with utility functions for parsing and interacting with
@@ -47,17 +49,28 @@
<ul> <ul>
<li>Isomorphic, available as an <li>Isomorphic, available as an
(<a href="/dist-browser/twtxt-lib.min.js">optionally minified</a>) (<a href="/dist-browser/twtxt-lib.min.js">optionally minified</a>)
<a href="/dist-browser/twtxt-lib.js">ES6+ library for the browser</a>, <a href="/dist-browser/twtxt-lib.js">ES6+ library for the browser</a>
with NPM and JSR versions coming soon. or as a package (from
<li>Fully typed and source-mapped.</li> <a href="https://www.npmjs.com/package/twtxt-lib">NPM</a> or
<li>Built as an <a href="https://caniuse.com/es6-module">ES6 module</a> (and <a href="https://antfu.me/posts/move-on-to-esm-only">ESM only</a>)</li> <a href="https://jsr.io/@itsericwoodward/twtxt-lib">JSR</a>).
<li>Includes an interactive demo <em>(you&apos;re looking at it)</em>.</li> </li>
<li>Fully typed and source-mapped (I hope).</li>
<li>Built as an <a href="https://caniuse.com/es6-module">ES6 module</a>
(and <a href="https://antfu.me/posts/move-on-to-esm-only">ESM only</a>)
</li>
<li>
Includes sample files and an interactive demo <em>(you&apos;re looking
at it)</em>.
</li>
</ul> </ul>
<h2>Installation</h2> <h2>Installation</h2>
<p>This library can be installed in several different ways:</p> <p>This library can be installed several different ways:</p>
<h3>For the Browser</h3> <h3>For the Browser</h3>
@@ -70,6 +83,7 @@
<a href="/dist-browser/twtxt-lib.js">website</a>, or doing a <a href="/dist-browser/twtxt-lib.js">website</a>, or doing a
<code>git clone https://git.itsericwoodward.com/eric/twtxt-lib.git</code> <code>git clone https://git.itsericwoodward.com/eric/twtxt-lib.git</code>
and pulling it out of the <code>dist-browser</code> folder. and pulling it out of the <code>dist-browser</code> folder.
<ul> <ul>
<li> <li>
Alternatively, you can grab the minified version from the Alternatively, you can grab the minified version from the
@@ -78,10 +92,12 @@
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
Add the newly acquired file to your static site / progressive web app / Add the newly acquired file to your static site / progressive web app /
over-engineered blog. over-engineered blog.
</li> </li>
<li> <li>
Import the desired function(s) via ESM: Import the desired function(s) via ESM:
<code>import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js";</code> <code>import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js";</code>
@@ -93,19 +109,22 @@
<ol> <ol>
<li> <li>
Add the package to your project. Add the package to your project.
<ul> <ul>
<li> <li>
Via <a href="https://www.npmjs.com/package/twtxt-lib">NPM</a>: Via <a href="https://www.npmjs.com/package/twtxt-lib">NPM</a>:
<code>yarn add twtxt-lib</code> <code>yarn add twtxt-lib</code>
</li> </li>
<li> <li>
Via <a href="https://jsr.io/@itsericwoodward/twtxt-lib">JSR</a>: Via <a href="https://jsr.io/@itsericwoodward/twtxt-lib">JSR</a>:
<code>yarn add jsr:@itsericwoodward/twtxt-lib</code> <code>yarn add jsr:@itsericwoodward/twtxt-lib</code>
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
Import the desired function(s) into your code: Import the desired function(s) via ESM:
<code>import { hashTwt, loadAndParseTwtxtFile } from "twtxt-lib";</code> <code>import { hashTwt, loadAndParseTwtxtFile } from "twtxt-lib";</code>
</li> </li>
</ol> </ol>
@@ -121,16 +140,27 @@
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> </a <code>/twtxt-demos/demo-hipster-twtxt.txt</code> </a
>: >:
</p> </p>
<span class="dotLoader"></span> <span class="dotLoader"></span>
</div> </div>
<p class="copyright">
Copyright &copy; 2026 Eric Woodward, released under the <a href="https://www.itsericwoodward.com/licenses/mit/">MIT License</a>. <p>
</p> Example files generated with
<a href="https://hipsum.co/">Hipster Ipsum</a>,
<a href="https://pirateipsum.me/">Pirate Ipsum</a>,
a different <a href="https://lorem-ipsumm.com/pirate-ipsum/">Pirate Ipsum</a>, and
<a href="https://saganipsum.com/">Sagan Ipsum</a>.
</p>
<p class="copyright">
Copyright &copy; 2026 Eric Woodward, released under the <a href="https://www.itsericwoodward.com/licenses/mit/">MIT License</a>.
</p>
</div> </div>
</li> </li>
<li class="tab tabHashTwt" id="hashTwt"> <li class="tab tabHashTwt" id="hashTwt">
<a class="tab-link" href="#hashTwt">hashTwt</a> <a class="tab-link" href="#hashTwt">hashTwt</a>
<div class="tab-panel" id="tabHashTwt-panel"> <div class="tab-panel" id="tabHashTwt-panel">
<p> <p>
A function that takes the constituent parts of a &ldquo;twt&rdquo; and A function that takes the constituent parts of a &ldquo;twt&rdquo; and
@@ -138,15 +168,43 @@
<a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible hash</a> <a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible hash</a>
for it. for it.
</p> </p>
<p>
Version 0.10.0 and above includes support for
<a href="https://git.mills.io/yarnsocial/twtxt.dev/pulls/28">V2 of the
Hashing Spec</a>:
<ul>
<li>A specific hashing version can be provided as an optional argument.</li>
<li>
When no version argument is provided, it defaults to using version
1 for all twts with a created date before the epoch date
(<code>2026-07-01T00:00:00Z</code>), and version 2 for all twts
created on or after the epoch.
</li>
</ul>
</p>
<form id="formHash" name="formHash" method="post"> <form id="formHash" name="formHash" method="post">
<div class="flexRow"> <div class="flexRow">
<div class="flexCol"> <div class="flexCol">
<label for="version">Version
<select id="version" name="version">
<option value="0">Default</option>
<option value="1">v1</option>
<option value="2">v2</option>
</select>
</label>
<label for="content">Content</label> <label for="content">Content</label>
<textarea <textarea
id="content" id="content"
name="content" name="content"
>Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.</textarea> >Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.</textarea>
</div> </div>
<div class="flexCol"> <div class="flexCol">
<label> <label>
Created Created
@@ -156,6 +214,7 @@
value="2026-02-01T01:23:45Z" value="2026-02-01T01:23:45Z"
/> />
</label> </label>
<label> <label>
URL URL
<input <input
@@ -164,6 +223,7 @@
value="https://example.org/~pirate/twtxt.txt" value="https://example.org/~pirate/twtxt.txt"
/> />
</label> </label>
<input type="submit" value="Go" /> <input type="submit" value="Go" />
</div> </div>
</div> </div>
@@ -173,12 +233,12 @@
<li class="tab tabParse" id="parseTwtxt"> <li class="tab tabParse" id="parseTwtxt">
<a class="tab-link" href="#parseTwtxt">parseTwtxt</a> <a class="tab-link" href="#parseTwtxt">parseTwtxt</a>
<div class="tab-panel" id="tabParse-panel"> <div class="tab-panel" id="tabParse-panel">
<p> <p>
A function that parses a twtxt file string, A function that parses a twtxt file string,
returning an object with information about the file and its owner returning an object with information about the file and its owner
(including <a href="https://twtxt.dev/exts/twt-hash.html">hashes</a> (including <a href="#hashTwt">hashes</a> for each twt and any
for each twt and any
<a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the <a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the
file). file).
</p> </p>
@@ -187,6 +247,7 @@
<ul> <ul>
<li> <li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> <code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-hipster-twtxt.txt" data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="parseHipsterButton" id="parseHipsterButton"
@@ -195,8 +256,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code> <code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-pirate-twtxt.txt" data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="parsePirateButton" id="parsePirateButton"
@@ -205,8 +268,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code> <code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-sagan-twtxt.txt" data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="parseSaganButton" id="parseSaganButton"
@@ -216,13 +281,6 @@
</button> </button>
</li> </li>
</ul> </ul>
<p>
<em
>Note that CORS restrictions may limit the
effectiveness of using this function from another
domain.</em
>
</p>
<form id="formParse" name="formParse" method="post"> <form id="formParse" name="formParse" method="post">
<div class="flexRow"> <div class="flexRow">
@@ -236,7 +294,9 @@
value="/twtxt-demos/demo-hipster-twtxt.txt" value="/twtxt-demos/demo-hipster-twtxt.txt"
/> />
</label> </label>
<input type="submit" value="Go" /> <input type="submit" value="Go" />
<span class="dotLoader"></span> <span class="dotLoader"></span>
</div> </div>
</div> </div>
@@ -246,15 +306,19 @@
<li class="tab tabLoadAndParse" id="loadAndParseTwtxtFile"> <li class="tab tabLoadAndParse" id="loadAndParseTwtxtFile">
<a class="tab-link" href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a> <a class="tab-link" href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a>
<div class="tab-panel" id="tabLoadAndParse-panel"> <div class="tab-panel" id="tabLoadAndParse-panel">
<p> <p>
An async function that fetches a twtxt.txt-compatible file An async function that fetches a <code>twtxt.txt</code>-compatible file
from a URL and parses it into an object from a URL and parses it, returning the extracted data as an object.
</p> </p>
<p>Pre-included examples:</p> <p>Pre-included examples:</p>
<ul> <ul>
<li> <li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> <code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-hipster-twtxt.txt" data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="loadAndParseHipsterButton" id="loadAndParseHipsterButton"
@@ -263,8 +327,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code> <code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-pirate-twtxt.txt" data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="loadAndParsePirateButton" id="loadAndParsePirateButton"
@@ -273,8 +339,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code> <code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-sagan-twtxt.txt" data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="loadAndParseSaganButton" id="loadAndParseSaganButton"
@@ -284,12 +352,14 @@
</button> </button>
</li> </li>
</ul> </ul>
<p> <p>
<em <em>
>Note that CORS restrictions may limit the Note that
effectiveness of using this function from another <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">Cross-Origin
domain.</em Resource Sharing (CORS)</a> restrictions may limit the
> effectiveness of using this function across domains.
</em>
</p> </p>
<form <form
@@ -308,7 +378,9 @@
value="/twtxt-demos/demo-hipster-twtxt.txt" value="/twtxt-demos/demo-hipster-twtxt.txt"
/> />
</label> </label>
<input type="submit" value="Go" /> <input type="submit" value="Go" />
<span class="dotLoader"></span> <span class="dotLoader"></span>
</div> </div>
</div> </div>

View File

@@ -1 +1 @@
export declare const __dirname: string; export declare const HASH_V2_EPOCH = "2026-07-01T00:00:00Z";

5
dist-node/constants.js Normal file
View File

@@ -0,0 +1,5 @@
const HASH_V2_EPOCH = `2026-07-01T00:00:00Z`;
export {
HASH_V2_EPOCH
};
//# sourceMappingURL=constants.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"constants.js","sources":["../src/constants.ts"],"sourcesContent":["export const HASH_V2_EPOCH = `2026-07-01T00:00:00Z`;\n"],"names":[],"mappings":"AAAO,MAAM,gBAAgB;"}

View File

@@ -1,2 +1,3 @@
import { Twt } from './types.ts'; import { Twt } from './types.ts';
export default function hashTwt(twt: Twt): string; export type HashableTwt = Pick<Twt, "content" | "created" | "url"> & Partial<Omit<Twt, "content" | "created" | "url">>;
export default function hashTwt(twt: HashableTwt, version?: number): string;

View File

@@ -1,5 +1,6 @@
import { blake2b } from "@exodus/blakejs"; import { blake2b } from "@exodus/blakejs";
import { base32Encode } from "./utils.js"; import { base32Encode } from "./utils.js";
import { HASH_V2_EPOCH } from "./constants.js";
const dateRegex = /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/; const dateRegex = /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/;
const formatRFC3339 = (date) => { const formatRFC3339 = (date) => {
const pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`; const pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`;
@@ -28,10 +29,14 @@ const formatRFC3339 = (date) => {
offset offset
].join(""); ].join("");
}; };
function hashTwt(twt) { function hashTwt(twt, version = 0) {
const created = formatRFC3339(twt.created); const created = formatRFC3339(twt.created);
const payload = [twt.url, created, twt.content].join("\n"); const payload = [twt.url, created, twt.content].join("\n");
return base32Encode(blake2b(payload, void 0, 32)).toLowerCase().slice(-7); const encoded = base32Encode(blake2b(payload, void 0, 32)).toLowerCase();
const createdDate = new Date(twt.created), epochDate = new Date(HASH_V2_EPOCH);
if (version === 1 || !version && createdDate < epochDate)
return encoded.slice(-7);
return encoded.slice(0, 12);
} }
export { export {
hashTwt as default hashTwt as default

View File

@@ -1 +1 @@
{"version":3,"file":"hashTwt.js","sources":["../src/hashTwt.ts"],"sourcesContent":["import type { Twt } from \"./types.ts\";\n\nimport { blake2b } from \"@exodus/blakejs\";\n\nimport { base32Encode } from \"./utils.ts\";\n\nconst dateRegex =\n\t/^(\\d{4})-(\\d{2})-(\\d{2})([tT ])(\\d{2}):(\\d{2}):(\\d{2})\\.?(\\d{3})?(?:(?:([+-]\\d{2}):?(\\d{2}))|Z)?$/;\n\nconst formatRFC3339 = (date: string) => {\n\tconst pad = (num: number | string = 0) => `${+num < 10 ? 0 : \"\"}${+num}`;\n\tconst padYear = (num: number | string = 0) =>\n\t\t`${+num < 1000 ? 0 : \"\"}${+num < 100 ? 0 : \"\"}${\n\t\t\t+num < 10 ? 0 : \"\"\n\t\t}${+num}`;\n\n\tlet m = dateRegex.exec(date);\n\n\t//if timezone is undefined, it must be Z or nothing (otherwise the group would have captured).\n\tif (m && m?.[9] === undefined) {\n\t\t//Use UTC.\n\t\tm[9] = \"+00\";\n\t}\n\tif (m && m?.[10] === undefined) {\n\t\tm[10] = \"00\";\n\t}\n\n\tconst offset = `${m?.[9]}:${m?.[10]}`.replace(/[+-]?00:00$/, \"Z\");\n\n\treturn [\n\t\tpadYear(m?.[1]),\n\t\t\"-\",\n\t\tpad(m?.[2]),\n\t\t\"-\",\n\t\tpad(m?.[3]),\n\t\tm?.[4],\n\t\tpad(m?.[5]),\n\t\t\":\",\n\t\tpad(m?.[6]),\n\t\t\":\",\n\t\tpad(m?.[7]),\n\t\t//ignore milliseconds (m[8])\n\t\toffset,\n\t].join(\"\");\n};\n\nexport default function hashTwt(twt: Twt): string {\n\tconst created = formatRFC3339(twt.created);\n\tconst payload = [twt.url, created, twt.content].join(\"\\n\");\n\n\treturn base32Encode(blake2b(payload, undefined, 32))\n\t\t.toLowerCase()\n\t\t.slice(-7);\n}\n"],"names":[],"mappings":";;AAMA,MAAM,YACL;AAED,MAAM,gBAAgB,CAAC,SAAiB;AACvC,QAAM,MAAM,CAAC,MAAuB,MAAM,GAAG,CAAC,MAAM,KAAK,IAAI,EAAE,GAAG,CAAC,GAAG;AACtE,QAAM,UAAU,CAAC,MAAuB,MACvC,GAAG,CAAC,MAAM,MAAO,IAAI,EAAE,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,GAC5C,CAAC,MAAM,KAAK,IAAI,EACjB,GAAG,CAAC,GAAG;AAER,MAAI,IAAI,UAAU,KAAK,IAAI;AAG3B,MAAI,KAAK,IAAI,CAAC,MAAM,QAAW;AAE9B,MAAE,CAAC,IAAI;AAAA,EACR;AACA,MAAI,KAAK,IAAI,EAAE,MAAM,QAAW;AAC/B,MAAE,EAAE,IAAI;AAAA,EACT;AAEA,QAAM,SAAS,GAAG,IAAI,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,GAAG,QAAQ,eAAe,GAAG;AAEhE,SAAO;AAAA,IACN,QAAQ,IAAI,CAAC,CAAC;AAAA,IACd;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA,IACV;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA,IACV,IAAI,CAAC;AAAA,IACL,IAAI,IAAI,CAAC,CAAC;AAAA,IACV;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA,IACV;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA;AAAA,IAEV;AAAA,EAAA,EACC,KAAK,EAAE;AACV;AAEA,SAAwB,QAAQ,KAAkB;AACjD,QAAM,UAAU,cAAc,IAAI,OAAO;AACzC,QAAM,UAAU,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,EAAE,KAAK,IAAI;AAEzD,SAAO,aAAa,QAAQ,SAAS,QAAW,EAAE,CAAC,EACjD,YAAA,EACA,MAAM,EAAE;AACX;"} {"version":3,"file":"hashTwt.js","sources":["../src/hashTwt.ts"],"sourcesContent":["import type { Twt } from \"./types.ts\";\n\nimport { blake2b } from \"@exodus/blakejs\";\n\nimport { base32Encode } from \"./utils.ts\";\nimport { HASH_V2_EPOCH } from \"./constants.ts\";\n\nconst dateRegex =\n\t/^(\\d{4})-(\\d{2})-(\\d{2})([tT ])(\\d{2}):(\\d{2}):(\\d{2})\\.?(\\d{3})?(?:(?:([+-]\\d{2}):?(\\d{2}))|Z)?$/;\n\nconst formatRFC3339 = (date: string) => {\n\tconst pad = (num: number | string = 0) => `${+num < 10 ? 0 : \"\"}${+num}`;\n\tconst padYear = (num: number | string = 0) =>\n\t\t`${+num < 1000 ? 0 : \"\"}${+num < 100 ? 0 : \"\"}${\n\t\t\t+num < 10 ? 0 : \"\"\n\t\t}${+num}`;\n\n\tlet m = dateRegex.exec(date);\n\n\t//if timezone is undefined, it must be Z or nothing (otherwise the group would have captured).\n\tif (m && m?.[9] === undefined) {\n\t\t//Use UTC.\n\t\tm[9] = \"+00\";\n\t}\n\tif (m && m?.[10] === undefined) {\n\t\tm[10] = \"00\";\n\t}\n\n\tconst offset = `${m?.[9]}:${m?.[10]}`.replace(/[+-]?00:00$/, \"Z\");\n\n\treturn [\n\t\tpadYear(m?.[1]),\n\t\t\"-\",\n\t\tpad(m?.[2]),\n\t\t\"-\",\n\t\tpad(m?.[3]),\n\t\tm?.[4],\n\t\tpad(m?.[5]),\n\t\t\":\",\n\t\tpad(m?.[6]),\n\t\t\":\",\n\t\tpad(m?.[7]),\n\t\t//ignore milliseconds (m[8])\n\t\toffset,\n\t].join(\"\");\n};\n\nexport type HashableTwt = Pick<Twt, \"content\" | \"created\" | \"url\"> &\n\tPartial<Omit<Twt, \"content\" | \"created\" | \"url\">>;\n\nexport default function hashTwt(twt: HashableTwt, version = 0): string {\n\tconst created = formatRFC3339(twt.created);\n\tconst payload = [twt.url, created, twt.content].join(\"\\n\");\n\n\tconst encoded = base32Encode(blake2b(payload, undefined, 32)).toLowerCase();\n\n\tconst createdDate = new Date(twt.created),\n\t\tepochDate = new Date(HASH_V2_EPOCH);\n\n\tif (version === 1 || (!version && createdDate < epochDate))\n\t\treturn encoded.slice(-7);\n\treturn encoded.slice(0, 12);\n}\n"],"names":[],"mappings":";;;AAOA,MAAM,YACL;AAED,MAAM,gBAAgB,CAAC,SAAiB;AACvC,QAAM,MAAM,CAAC,MAAuB,MAAM,GAAG,CAAC,MAAM,KAAK,IAAI,EAAE,GAAG,CAAC,GAAG;AACtE,QAAM,UAAU,CAAC,MAAuB,MACvC,GAAG,CAAC,MAAM,MAAO,IAAI,EAAE,GAAG,CAAC,MAAM,MAAM,IAAI,EAAE,GAC5C,CAAC,MAAM,KAAK,IAAI,EACjB,GAAG,CAAC,GAAG;AAER,MAAI,IAAI,UAAU,KAAK,IAAI;AAG3B,MAAI,KAAK,IAAI,CAAC,MAAM,QAAW;AAE9B,MAAE,CAAC,IAAI;AAAA,EACR;AACA,MAAI,KAAK,IAAI,EAAE,MAAM,QAAW;AAC/B,MAAE,EAAE,IAAI;AAAA,EACT;AAEA,QAAM,SAAS,GAAG,IAAI,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,GAAG,QAAQ,eAAe,GAAG;AAEhE,SAAO;AAAA,IACN,QAAQ,IAAI,CAAC,CAAC;AAAA,IACd;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA,IACV;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA,IACV,IAAI,CAAC;AAAA,IACL,IAAI,IAAI,CAAC,CAAC;AAAA,IACV;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA,IACV;AAAA,IACA,IAAI,IAAI,CAAC,CAAC;AAAA;AAAA,IAEV;AAAA,EAAA,EACC,KAAK,EAAE;AACV;AAKA,SAAwB,QAAQ,KAAkB,UAAU,GAAW;AACtE,QAAM,UAAU,cAAc,IAAI,OAAO;AACzC,QAAM,UAAU,CAAC,IAAI,KAAK,SAAS,IAAI,OAAO,EAAE,KAAK,IAAI;AAEzD,QAAM,UAAU,aAAa,QAAQ,SAAS,QAAW,EAAE,CAAC,EAAE,YAAA;AAE9D,QAAM,cAAc,IAAI,KAAK,IAAI,OAAO,GACvC,YAAY,IAAI,KAAK,aAAa;AAEnC,MAAI,YAAY,KAAM,CAAC,WAAW,cAAc;AAC/C,WAAO,QAAQ,MAAM,EAAE;AACxB,SAAO,QAAQ,MAAM,GAAG,EAAE;AAC3B;"}

View File

@@ -14,8 +14,10 @@
<ul class="tabs"> <ul class="tabs">
<li class="tab tabOverview" id="overview"> <li class="tab tabOverview" id="overview">
<a class="tab-link" href="#overview" id="tabOverview-link">Overview</a> <a class="tab-link" href="#overview" id="tabOverview-link">Overview</a>
<div class="tab-panel" id="tabOverview-panel"> <div class="tab-panel" id="tabOverview-panel">
<h1>twtxt-lib</h2> <h1>twtxt-lib</h2>
<p> <p>
An isomorphic TypeScript library of An isomorphic TypeScript library of
utility functions for parsing and interacting with utility functions for parsing and interacting with
@@ -47,17 +49,28 @@
<ul> <ul>
<li>Isomorphic, available as an <li>Isomorphic, available as an
(<a href="/dist-browser/twtxt-lib.min.js">optionally minified</a>) (<a href="/dist-browser/twtxt-lib.min.js">optionally minified</a>)
<a href="/dist-browser/twtxt-lib.js">ES6+ library for the browser</a>, <a href="/dist-browser/twtxt-lib.js">ES6+ library for the browser</a>
with NPM and JSR versions coming soon. or as a package (from
<li>Fully typed and source-mapped.</li> <a href="https://www.npmjs.com/package/twtxt-lib">NPM</a> or
<li>Built as an <a href="https://caniuse.com/es6-module">ES6 module</a> (and <a href="https://antfu.me/posts/move-on-to-esm-only">ESM only</a>)</li> <a href="https://jsr.io/@itsericwoodward/twtxt-lib">JSR</a>).
<li>Includes an interactive demo <em>(you&apos;re looking at it)</em>.</li> </li>
<li>Fully typed and source-mapped (I hope).</li>
<li>Built as an <a href="https://caniuse.com/es6-module">ES6 module</a>
(and <a href="https://antfu.me/posts/move-on-to-esm-only">ESM only</a>)
</li>
<li>
Includes sample files and an interactive demo <em>(you&apos;re looking
at it)</em>.
</li>
</ul> </ul>
<h2>Installation</h2> <h2>Installation</h2>
<p>This library can be installed in several different ways:</p> <p>This library can be installed several different ways:</p>
<h3>For the Browser</h3> <h3>For the Browser</h3>
@@ -70,6 +83,7 @@
<a href="/dist-browser/twtxt-lib.js">website</a>, or doing a <a href="/dist-browser/twtxt-lib.js">website</a>, or doing a
<code>git clone https://git.itsericwoodward.com/eric/twtxt-lib.git</code> <code>git clone https://git.itsericwoodward.com/eric/twtxt-lib.git</code>
and pulling it out of the <code>dist-browser</code> folder. and pulling it out of the <code>dist-browser</code> folder.
<ul> <ul>
<li> <li>
Alternatively, you can grab the minified version from the Alternatively, you can grab the minified version from the
@@ -78,10 +92,12 @@
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
Add the newly acquired file to your static site / progressive web app / Add the newly acquired file to your static site / progressive web app /
over-engineered blog. over-engineered blog.
</li> </li>
<li> <li>
Import the desired function(s) via ESM: Import the desired function(s) via ESM:
<code>import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js";</code> <code>import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js";</code>
@@ -93,19 +109,22 @@
<ol> <ol>
<li> <li>
Add the package to your project. Add the package to your project.
<ul> <ul>
<li> <li>
Via <a href="https://www.npmjs.com/package/twtxt-lib">NPM</a>: Via <a href="https://www.npmjs.com/package/twtxt-lib">NPM</a>:
<code>yarn add twtxt-lib</code> <code>yarn add twtxt-lib</code>
</li> </li>
<li> <li>
Via <a href="https://jsr.io/@itsericwoodward/twtxt-lib">JSR</a>: Via <a href="https://jsr.io/@itsericwoodward/twtxt-lib">JSR</a>:
<code>yarn add jsr:@itsericwoodward/twtxt-lib</code> <code>yarn add jsr:@itsericwoodward/twtxt-lib</code>
</li> </li>
</ul> </ul>
</li> </li>
<li> <li>
Import the desired function(s) into your code: Import the desired function(s) via ESM:
<code>import { hashTwt, loadAndParseTwtxtFile } from "twtxt-lib";</code> <code>import { hashTwt, loadAndParseTwtxtFile } from "twtxt-lib";</code>
</li> </li>
</ol> </ol>
@@ -121,16 +140,27 @@
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> </a <code>/twtxt-demos/demo-hipster-twtxt.txt</code> </a
>: >:
</p> </p>
<span class="dotLoader"></span> <span class="dotLoader"></span>
</div> </div>
<p class="copyright">
Copyright &copy; 2026 Eric Woodward, released under the <a href="https://www.itsericwoodward.com/licenses/mit/">MIT License</a>. <p>
</p> Example files generated with
<a href="https://hipsum.co/">Hipster Ipsum</a>,
<a href="https://pirateipsum.me/">Pirate Ipsum</a>,
a different <a href="https://lorem-ipsumm.com/pirate-ipsum/">Pirate Ipsum</a>, and
<a href="https://saganipsum.com/">Sagan Ipsum</a>.
</p>
<p class="copyright">
Copyright &copy; 2026 Eric Woodward, released under the <a href="https://www.itsericwoodward.com/licenses/mit/">MIT License</a>.
</p>
</div> </div>
</li> </li>
<li class="tab tabHashTwt" id="hashTwt"> <li class="tab tabHashTwt" id="hashTwt">
<a class="tab-link" href="#hashTwt">hashTwt</a> <a class="tab-link" href="#hashTwt">hashTwt</a>
<div class="tab-panel" id="tabHashTwt-panel"> <div class="tab-panel" id="tabHashTwt-panel">
<p> <p>
A function that takes the constituent parts of a &ldquo;twt&rdquo; and A function that takes the constituent parts of a &ldquo;twt&rdquo; and
@@ -138,15 +168,43 @@
<a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible hash</a> <a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible hash</a>
for it. for it.
</p> </p>
<p>
Version 0.10.0 and above includes support for
<a href="https://git.mills.io/yarnsocial/twtxt.dev/pulls/28">V2 of the
Hashing Spec</a>:
<ul>
<li>A specific hashing version can be provided as an optional argument.</li>
<li>
When no version argument is provided, it defaults to using version
1 for all twts with a created date before the epoch date
(<code>2026-07-01T00:00:00Z</code>), and version 2 for all twts
created on or after the epoch.
</li>
</ul>
</p>
<form id="formHash" name="formHash" method="post"> <form id="formHash" name="formHash" method="post">
<div class="flexRow"> <div class="flexRow">
<div class="flexCol"> <div class="flexCol">
<label for="version">Version
<select id="version" name="version">
<option value="0">Default</option>
<option value="1">v1</option>
<option value="2">v2</option>
</select>
</label>
<label for="content">Content</label> <label for="content">Content</label>
<textarea <textarea
id="content" id="content"
name="content" name="content"
>Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.</textarea> >Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.</textarea>
</div> </div>
<div class="flexCol"> <div class="flexCol">
<label> <label>
Created Created
@@ -156,6 +214,7 @@
value="2026-02-01T01:23:45Z" value="2026-02-01T01:23:45Z"
/> />
</label> </label>
<label> <label>
URL URL
<input <input
@@ -164,6 +223,7 @@
value="https://example.org/~pirate/twtxt.txt" value="https://example.org/~pirate/twtxt.txt"
/> />
</label> </label>
<input type="submit" value="Go" /> <input type="submit" value="Go" />
</div> </div>
</div> </div>
@@ -173,12 +233,12 @@
<li class="tab tabParse" id="parseTwtxt"> <li class="tab tabParse" id="parseTwtxt">
<a class="tab-link" href="#parseTwtxt">parseTwtxt</a> <a class="tab-link" href="#parseTwtxt">parseTwtxt</a>
<div class="tab-panel" id="tabParse-panel"> <div class="tab-panel" id="tabParse-panel">
<p> <p>
A function that parses a twtxt file string, A function that parses a twtxt file string,
returning an object with information about the file and its owner returning an object with information about the file and its owner
(including <a href="https://twtxt.dev/exts/twt-hash.html">hashes</a> (including <a href="#hashTwt">hashes</a> for each twt and any
for each twt and any
<a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the <a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the
file). file).
</p> </p>
@@ -187,6 +247,7 @@
<ul> <ul>
<li> <li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> <code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-hipster-twtxt.txt" data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="parseHipsterButton" id="parseHipsterButton"
@@ -195,8 +256,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code> <code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-pirate-twtxt.txt" data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="parsePirateButton" id="parsePirateButton"
@@ -205,8 +268,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code> <code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-sagan-twtxt.txt" data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="parseSaganButton" id="parseSaganButton"
@@ -216,13 +281,6 @@
</button> </button>
</li> </li>
</ul> </ul>
<p>
<em
>Note that CORS restrictions may limit the
effectiveness of using this function from another
domain.</em
>
</p>
<form id="formParse" name="formParse" method="post"> <form id="formParse" name="formParse" method="post">
<div class="flexRow"> <div class="flexRow">
@@ -236,7 +294,9 @@
value="/twtxt-demos/demo-hipster-twtxt.txt" value="/twtxt-demos/demo-hipster-twtxt.txt"
/> />
</label> </label>
<input type="submit" value="Go" /> <input type="submit" value="Go" />
<span class="dotLoader"></span> <span class="dotLoader"></span>
</div> </div>
</div> </div>
@@ -246,15 +306,19 @@
<li class="tab tabLoadAndParse" id="loadAndParseTwtxtFile"> <li class="tab tabLoadAndParse" id="loadAndParseTwtxtFile">
<a class="tab-link" href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a> <a class="tab-link" href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a>
<div class="tab-panel" id="tabLoadAndParse-panel"> <div class="tab-panel" id="tabLoadAndParse-panel">
<p> <p>
An async function that fetches a twtxt.txt-compatible file An async function that fetches a <code>twtxt.txt</code>-compatible file
from a URL and parses it into an object from a URL and parses it, returning the extracted data as an object.
</p> </p>
<p>Pre-included examples:</p> <p>Pre-included examples:</p>
<ul> <ul>
<li> <li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> <code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-hipster-twtxt.txt" data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="loadAndParseHipsterButton" id="loadAndParseHipsterButton"
@@ -263,8 +327,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code> <code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-pirate-twtxt.txt" data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="loadAndParsePirateButton" id="loadAndParsePirateButton"
@@ -273,8 +339,10 @@
Load Load
</button> </button>
</li> </li>
<li> <li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code> <code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button <button
data-url="/twtxt-demos/demo-sagan-twtxt.txt" data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="loadAndParseSaganButton" id="loadAndParseSaganButton"
@@ -284,12 +352,14 @@
</button> </button>
</li> </li>
</ul> </ul>
<p> <p>
<em <em>
>Note that CORS restrictions may limit the Note that
effectiveness of using this function from another <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS">Cross-Origin
domain.</em Resource Sharing (CORS)</a> restrictions may limit the
> effectiveness of using this function across domains.
</em>
</p> </p>
<form <form
@@ -308,7 +378,9 @@
value="/twtxt-demos/demo-hipster-twtxt.txt" value="/twtxt-demos/demo-hipster-twtxt.txt"
/> />
</label> </label>
<input type="submit" value="Go" /> <input type="submit" value="Go" />
<span class="dotLoader"></span> <span class="dotLoader"></span>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@itsericwoodward/twtxt-lib", "name": "@itsericwoodward/twtxt-lib",
"version": "0.9.4", "version": "0.10.0",
"license": "MIT", "license": "MIT",
"exports": "./src/index.ts" "exports": "./src/index.ts"
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "twtxt-lib", "name": "twtxt-lib",
"version": "0.9.4", "version": "0.10.0",
"description": "An isomorphic TypeScript library of utility functions for parsing and interacting with twtxt.txt files.", "description": "An isomorphic TypeScript library of utility functions for parsing and interacting with twtxt.txt files.",
"license": "MIT", "license": "MIT",
"author": { "author": {
@@ -33,7 +33,6 @@
"preview": "vite preview", "preview": "vite preview",
"prepublishOnly": "yarn build", "prepublishOnly": "yarn build",
"postpublish": "git push && git push --tags", "postpublish": "git push && git push --tags",
"publish": "npm publish",
"publish:jsr": "npx jsr publish", "publish:jsr": "npx jsr publish",
"publish:all": "npm publish && npx jsr publish", "publish:all": "npm publish && npx jsr publish",
"test": "vitest" "test": "vitest"
@@ -41,17 +40,17 @@
"dependencies": { "dependencies": {
"@exodus/blakejs": "^1.1.1-exodus.0", "@exodus/blakejs": "^1.1.1-exodus.0",
"base32.js": "^0.1.0", "base32.js": "^0.1.0",
"dayjs": "^1.11.19" "dayjs": "^1.11.20"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.5", "@types/node": "^25.5.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"unplugin-dts": "1.0.0-beta.6", "unplugin-dts": "1.0.0-beta.6",
"vite": "^7.3.1", "vite": "^7.3.1",
"vite-plugin-no-bundle": "^4.0.0", "vite-plugin-no-bundle": "^4.0.0",
"vite-plugin-node-polyfills": "^0.25.0", "vite-plugin-node-polyfills": "^0.26.0",
"vitest": "^4.0.18" "vitest": "^4.1.2"
}, },
"packageManager": "yarn@4.13.0" "packageManager": "yarn@4.13.0"
} }

View File

@@ -9,17 +9,22 @@ formHash.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
const content = formHash.elements["content"].value; const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value; const created = formHash.elements["created"].value;
const version = parseInt(formHash.elements["version"].value, 10);
const url = formHash.elements["url"].value; const url = formHash.elements["url"].value;
const hash = hashTwt({ const hash = hashTwt(
content, {
created, content,
url, created,
}); url,
},
version,
);
const result = [ const result = [
`content: ${content}`, `content: ${content}`,
`created: ${created}`, `created: ${created}`,
`url: ${url}`, `url: ${url}`,
`version: ${version}`,
`hash: ${hash}`, `hash: ${hash}`,
].join("\n"); ].join("\n");

View File

@@ -15,18 +15,23 @@ formatSource(
const content = formHash.elements["content"].value; const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value; const created = formHash.elements["created"].value;
const version = parseInt(formHash.elements["version"].value, 10);
const url = formHash.elements["url"].value; const url = formHash.elements["url"].value;
const hash = hashTwt({ const hash = hashTwt(
content, {
created, content,
url, created,
}); url,
},
version,
);
const result = [ const result = [
\`content: \${content}\`, \`content: \${content}\`,
\`created: \${created}\`, \`created: \${created}\`,
\`url: \${url}\`, \`url: \${url}\`,
\`version: \${version}\`,
\`hash: \${hash}\`, \`hash: \${hash}\`,
].join("\\n"); ].join("\\n");

View File

@@ -76,6 +76,18 @@ code {
white-space: pre-wrap; white-space: pre-wrap;
} }
details figure {
margin: 1rem .5rem;
}
details figure pre {
overflow: auto;
white-space: pre;
}
details[open] summary ~ * {
animation: riseInDetails .5s ease-in-out;
}
label { label {
display: inline-block; display: inline-block;
@@ -110,6 +122,15 @@ pre {
white-space: pre-wrap; white-space: pre-wrap;
} }
select {
background-color: var(--fg-light);
font-size: 1rem;
}
select:open {
background-color: var(--link-active);
}
textarea { textarea {
background-color: var(--fg-light); background-color: var(--fg-light);
font-size: 1rem; font-size: 1rem;
@@ -117,21 +138,6 @@ textarea {
width: 100%; width: 100%;
} }
details figure {
margin: 1rem .5rem;
}
details figure pre {
overflow: auto;
white-space: pre;
}
details[open] summary ~ * {
animation: riseInDetails .5s ease-in-out;
}
summary { summary {
cursor: pointer; cursor: pointer;
} }

View File

@@ -3,30 +3,195 @@ import { describe, expect, it } from "vitest";
import hashTwt from "../hashTwt.ts"; import hashTwt from "../hashTwt.ts";
describe("hashtwt", () => { describe("hashtwt", () => {
it("should correctly provide hash for known good twt", () => { it("should correctly provide v1 hash for twt before epoch", () => {
// https://twtxt.net/twt/g524g5q // https://twtxt.net/twt/g524g5q
const created = "2025-08-09T21:33:43-04:00";
const twt = { const twt = {
content: "Is this thing on?", content: "Is this thing on?",
created, created: "2025-08-09T21:33:43-04:00",
createdUTC: "",
url: "http://itsericwoodward.com/twtxt.txt", url: "http://itsericwoodward.com/twtxt.txt",
}; };
const result = hashTwt(twt); const result = hashTwt(twt);
expect(result).toEqual("g524g5q"); expect(result).toEqual("g524g5q");
}); });
it("should correctly provide hash for twt", () => { it("should correctly provide v1 hash for twt before epoch", () => {
const created = "2025-03-02T18:47:01+01:00";
// dayjs.utc(created).toISOString();
const twt = { const twt = {
content: content:
"Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.", "Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.",
created, created: "2025-03-02T18:47:01+01:00",
createdUTC: "",
url: "https://example.com/demo-hipster-twtxt.txt", url: "https://example.com/demo-hipster-twtxt.txt",
}; };
const result = hashTwt(twt); const result = hashTwt(twt);
expect(result).toEqual("tvjursa"); expect(result).toEqual("tvjursa");
}); });
it("should correctly provide v1 hash for twt before epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-06-24T00:00:00Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("y34mvza");
});
it("should correctly provide v1 hash for twt before epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:57Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("5fket4q");
});
it("should correctly provide v1 hash for twt before epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:58Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("acwz4tq");
});
it("should correctly provide v1 hash for twt before epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:59Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("74qi5kq");
});
it("should correctly provide v2 hash for twt after epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-07-01T00:00:00Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("myzxbwxktuvs");
});
it("should correctly provide v2 hash for twt after epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-07-01T00:00:01Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("b3ropa74yjxx");
});
it("should correctly provide v2 hash for twt after epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-07-01T00:00:02Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("4ymrlnngtolc");
});
it("should correctly provide v2 hash for twt after epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-07-01T00:00:03Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("sdcslqv5epkb");
});
it("should correctly provide v2 hash for twt after epoch", () => {
const twt = {
content: "Hello World!",
created: "2026-07-08T00:00:00Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("heim3egy5qik");
});
it("should correctly provide v1 hash for twt before epoch when version is 0", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:58Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 0);
expect(result).toEqual("acwz4tq");
});
it("should correctly provide v1 hash for twt before epoch when version is 0", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:57Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 0);
expect(result).toEqual("5fket4q");
});
it("should correctly provide v2 hash for twt after epoch when version is 0", () => {
const twt = {
content: "Hello World!",
created: "2026-07-01T00:00:03Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 0);
expect(result).toEqual("sdcslqv5epkb");
});
it("should correctly provide v2 hash for twt after epoch when version is 0", () => {
const twt = {
content: "Hello World!",
created: "2026-07-08T00:00:00Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 0);
expect(result).toEqual("heim3egy5qik");
});
it("should correctly provide v1 hash for twt before epoch when version is 1", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:58Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 1);
expect(result).toEqual("acwz4tq");
});
it("should correctly provide v2 hash for twt before epoch when version is 2", () => {
const twt = {
content: "Hello World!",
created: "2026-06-30T23:59:57Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 2);
expect(result).toEqual("pn6ju5op6nwn");
});
it("should correctly provide v1 hash for twt after epoch when version is 1", () => {
const twt = {
content: "Hello World!",
created: "2026-07-01T00:00:03Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt, 1);
expect(result).toEqual("ufitwvq");
});
it("should correctly provide v2 hash for twt after epoch when version is 2", () => {
const twt = {
content: "Hello World!",
created: "2026-07-08T00:00:00Z",
url: "https://example.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("heim3egy5qik");
});
}); });

View File

@@ -1,4 +1 @@
import { dirname } from "node:path"; export const HASH_V2_EPOCH = `2026-07-01T00:00:00Z`;
import { fileURLToPath } from "node:url";
export const __dirname = dirname(fileURLToPath(import.meta.url));

View File

@@ -3,6 +3,7 @@ import type { Twt } from "./types.ts";
import { blake2b } from "@exodus/blakejs"; import { blake2b } from "@exodus/blakejs";
import { base32Encode } from "./utils.ts"; import { base32Encode } from "./utils.ts";
import { HASH_V2_EPOCH } from "./constants.ts";
const dateRegex = const dateRegex =
/^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/; /^(\d{4})-(\d{2})-(\d{2})([tT ])(\d{2}):(\d{2}):(\d{2})\.?(\d{3})?(?:(?:([+-]\d{2}):?(\d{2}))|Z)?$/;
@@ -44,11 +45,19 @@ const formatRFC3339 = (date: string) => {
].join(""); ].join("");
}; };
export default function hashTwt(twt: Twt): string { export type HashableTwt = Pick<Twt, "content" | "created" | "url"> &
Partial<Omit<Twt, "content" | "created" | "url">>;
export default function hashTwt(twt: HashableTwt, version = 0): string {
const created = formatRFC3339(twt.created); const created = formatRFC3339(twt.created);
const payload = [twt.url, created, twt.content].join("\n"); const payload = [twt.url, created, twt.content].join("\n");
return base32Encode(blake2b(payload, undefined, 32)) const encoded = base32Encode(blake2b(payload, undefined, 32)).toLowerCase();
.toLowerCase()
.slice(-7); const createdDate = new Date(twt.created),
epochDate = new Date(HASH_V2_EPOCH);
if (version === 1 || (!version && createdDate < epochDate))
return encoded.slice(-7);
return encoded.slice(0, 12);
} }

1332
yarn.lock

File diff suppressed because it is too large Load Diff