initial public commit

This commit is contained in:
2026-02-22 21:26:15 -05:00
commit 9dbf7ae796
100 changed files with 18823 additions and 0 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# EditorConfig is awesome: https://editorconfig.org
[*]
charset = utf-8
end_of_line = lf
indent_size = tab
indent_style = tab
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
indent_style = tab
trim_trailing_whitespace = false
[*.json]
indent_size = 2
indent_style = spaces

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
/.yarn/** linguist-vendored
/.yarn/releases/* binary
/.yarn/plugins/**/* binary
/.pnp.* binary linguist-generated

19
.gitignore vendored Normal file
View File

@@ -0,0 +1,19 @@
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Whether you use PnP or not, the node_modules folder is often used to store
# build artifacts that should be gitignored
node_modules
# Swap the comments on the following lines if you wish to use zero-installs
# In that case, don't forget to run `yarn config set enableGlobalCache false`!
# Documentation here: https://yarnpkg.com/features/caching#zero-installs
#!.yarn/cache
.pnp.*
NOTES.md
TODO.md

View File

@@ -0,0 +1 @@
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt

View File

@@ -0,0 +1 @@
// @license-end

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"tabWidth": 4,
"useTabs": true
}

10
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"cSpell.words": [
"blakejs",
"btih",
"twts",
"Twttr",
"Twtxt",
"unplugin"
]
}

1
.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Eric Woodward [https://www.itsericwoodward.com]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

217
README.md Normal file
View File

@@ -0,0 +1,217 @@
# twtxt-lib
An isomorphic TypeScript library of utility functions for parsing and interacting with
[`twtxt.txt` files](https://twtxt.dev/).
These functions include:
- `hashTwt`: takes the constituent parts of a "twt" and generates an
[extension-compatible hash](https://twtxt.dev/exts/twt-hash.html) for it.
- `parseTwtxt`: parses a twtxt file string, returning an object with information about the file and
its owner (including [hashes](https://twtxt.dev/exts/twt-hash.html) for each twt and any
[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).
## Installation
Browser
- Download [library](/dist-browser/twtxt-lib.js) (or [minified version](/dist-browser/twtxt-lib.min.js)).
- Import desired function(s) into your project: `import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js"`
NPM
- Coming soon
JSR
- Coming soon
## 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.
- Fully typed and source-mapped.
- [ESM](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
### loadAndParseText
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
```
import { hashTwt } from "/web/dist/twtxt-lib.js";
const hash = hashTwt({
content: "Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.",
created: "2026-02-01T01:23:45Z",
url: "https://example.org/~pirate/twtxt.txt",
});
console.log(`Hash: ${hash}`);
```
Result
```
Hash: 7uftieq
```
### parseTwtxt
```
import { base32Encode, hashTwt, loadAndParseTwtxt, parseTwtxt } from "twtxt-lib";
const fileText = `
# nick = demo_sagan
# url = https://example.net/~saganos/twtxt.txt
# avatar = https://i.pravatar.cc/150?img=69
# description = Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit?
# finger = sagaan@example.net
# link = Web https://example.net/~saganos/
#
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
#
# following = 3
#
2026-02-05T23:17:47Z The ash of stellar alchemy permanence of the stars extraordinary claims require extraordinary evidence rings of Uranus vanquish the impossible encyclopaedia galactica?
2026-01-22T12:09:44Z Something incredible is waiting to be known another world hearts of the stars tendrils of gossamer clouds a still more glorious dawn awaits venture.
2026-01-10T13:44:37Z Dream of the mind's eye citizens of distant epochs a still more glorious dawn awaits preserve and cherish that pale blue dot hearts of the stars preserve and cherish that pale blue dot and billions upon billions upon billions upon billions upon billions upon billions upon billions.
2026-01-01T02:05:04Z With pretty stories for which there's little good evidence muse about a billion trillion globular star cluster inconspicuous motes of rock and gas dream of the mind's eye.
`.trim();
console.log(parseTwtxt(fileText));
```
Result:
```
{
"following": [
{
"nick": "demo_sagan",
"url": "https://example.net/~saganos/twtxt.txt"
},
{
"nick": "demo_pirate",
"url": "https://example.org/~pirate/twtxt.txt"
},
{
"nick": "demo_hipster",
"url": "https://example.com/demo-hipster-twtxt.txt"
}
],
"metadata": {
"nick": "demo_sagan",
"url": "https://example.net/~saganos/twtxt.txt",
"avatar": "https://i.pravatar.cc/150?img=69",
"description": "Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit?",
"finger": "sagaan@example.net",
"link": "Web https://example.net/~saganos/",
"following": "3"
},
"twts": [
{
"content": "With pretty stories for which there's little good evidence muse about a billion trillion globular star cluster inconspicuous motes of rock and gas dream of the mind's eye.",
"created": "2026-01-01T02:05:04Z",
"createdUTC": "2026-01-01T02:05:04.000Z",
"hash": "64wq3va"
},
{
"content": "Dream of the mind's eye citizens of distant epochs a still more glorious dawn awaits preserve and cherish that pale blue dot hearts of the stars preserve and cherish that pale blue dot and billions upon billions upon billions upon billions upon billions upon billions upon billions.",
"created": "2026-01-10T13:44:37Z",
"createdUTC": "2026-01-10T13:44:37.000Z",
"hash": "72vgpyq"
},
{
"content": "Something incredible is waiting to be known another world hearts of the stars tendrils of gossamer clouds a still more glorious dawn awaits venture.",
"created": "2026-01-22T12:09:44Z",
"createdUTC": "2026-01-22T12:09:44.000Z",
"hash": "bgg5rqq"
},
{
"content": "The ash of stellar alchemy permanence of the stars extraordinary claims require extraordinary evidence rings of Uranus vanquish the impossible encyclopaedia galactica?",
"created": "2026-02-05T23:17:47Z",
"createdUTC": "2026-02-05T23:17:47.000Z",
"hash": "qa4xrla"
}
]
}
```
See the included tests and demo file for more information on how to use it.
## License
Copyright (c) 2026 Eric Woodward, released under the [MIT License](https://www.itsericwoodward.com/licenses/mit/).

184
build.js Normal file
View File

@@ -0,0 +1,184 @@
import dts from "unplugin-dts/vite";
import {
copyFile,
cp,
mkdir,
readdir,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, build as viteBuild } from "vite";
import noBundlePlugin from "vite-plugin-no-bundle";
const __dirname = dirname(fileURLToPath(import.meta.url));
const outputPathPrefix =
process.env.BUILD_ENV !== "production" ? "dist-temp" : "dist";
const browserOutPath = `${outputPathPrefix}-browser`;
const browserOutDir = resolve(__dirname, browserOutPath);
const demoOutDir = resolve(__dirname, `${outputPathPrefix}-demo`);
const entry = "src/index.ts";
const globalConfig = {
libraryName: "twtxt-lib",
basePath: resolve(__dirname),
outDir: resolve(__dirname, outputPathPrefix),
builds: [
{
entry,
outDir: browserOutDir,
outfile: "twtxt-lib.js",
target: "browser",
},
{
entry,
outDir: browserOutDir,
outfile: "twtxt-lib.min.js",
target: "browser",
},
{
entry,
outDir: resolve(__dirname, `${outputPathPrefix}-node`),
target: "node",
},
],
licenseBegin: "", // assigned in init()
licenseEnd: "", // assigned in init()
licensePath: resolve(__dirname, ".license-fragments"),
/** @type Record<string, any>|null */
pkg: null, // assigned in init()
async init() {
this.pkg = JSON.parse(
await readFile(resolve(this.basePath, "package.json")),
);
this.licenseBegin = await readFile(
resolve(this.licensePath, "license-begin.txt"),
);
this.licenseEnd = await readFile(
resolve(this.licensePath, "license-end.txt"),
);
this.builds = this.builds.map((config) => {
config.entry = resolve(globalConfig.basePath, config.entry);
config.minify = config?.outfile?.includes(".min.") ?? false;
config.standalone = config?.target?.includes("browser") ?? false;
config.format = "es";
return config;
});
},
};
const purgeOutDirs = async (config) => {
await rm(config.outDir, { recursive: true, force: true });
};
const buildWithVite = async (config) => {
const plugins = [
dts({ exclude: ["./vitest.setup.ts", "**/__tests__/*.*"] }),
];
if (config.target !== "browser") plugins.push(noBundlePlugin());
await viteBuild(
defineConfig({
define: {
"globalThis.__BUILD_VERSION__": JSON.stringify(
globalConfig.pkg.version,
),
},
build: {
lib: {
entry: config.entry,
name: globalConfig.libraryName,
formats: [config.format],
fileName: () => config?.outfile ?? "[name].js",
},
emptyOutDir: false,
outDir: config.outDir,
minify: config.minify || false,
sourcemap: true,
rollupOptions: {
external: [],
preserveEntrySignatures: "strict",
shimMissingExports: true,
treeshake: false,
},
target: config.target === "browser" ? "es6" : "node22",
},
plugins,
publicDir: false,
resolve: {
extensions: [".ts", ".js"],
alias: {
"@": `${globalConfig.basePath}/src`,
},
},
}),
);
};
const wrapWithLicense = async (config) => {
const fileText = await readFile(
resolve(config.outDir, config.outfile ?? "index.js"),
);
await writeFile(
resolve(config.outDir, config.outfile ?? "index.js"),
[globalConfig.licenseBegin, fileText, globalConfig.licenseEnd].join(""),
{ encoding: "utf8", flag: "w" },
);
};
const copyDir = async (source, destination) => {
const entries = await readdir(source, { withFileTypes: true });
await mkdir(destination, { recursive: true });
return Promise.all(
entries.map(async (entry) => {
const sourcePath = resolve(source, entry.name);
const destinationPath = resolve(destination, entry.name);
return entry.isDirectory()
? copyDir(sourcePath, destinationPath)
: copyFile(sourcePath, destinationPath);
}),
);
};
const createDemoSite = async () => {
await rm(demoOutDir, { recursive: true, force: true });
await copyDir(resolve(__dirname, "public"), demoOutDir);
await copyFile(
resolve(__dirname, "index.html"),
resolve(demoOutDir, "index.html"),
);
await copyDir(browserOutDir, resolve(demoOutDir, browserOutPath));
};
async function main() {
await globalConfig.init();
await rm(globalConfig.outDir, { recursive: true, force: true });
await Promise.all([
...globalConfig.builds.map((config) => purgeOutDirs(config)),
]);
await Promise.all([
...globalConfig.builds.map((config) => buildWithVite(config)),
]);
await Promise.all([
...globalConfig.builds.map((config) => wrapWithLicense(config)),
]);
await createDemoSite();
console.log("All library file builds complete.");
}
main();

1
dist-browser/constants.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export declare const __dirname: string;

2
dist-browser/hashTwt.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
import { Twt } from './types.ts';
export default function hashTwt(twt: Twt): string;

5
dist-browser/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export type * from './types.ts';
export { default as hashTwt } from './hashTwt.ts';
export { default as loadAndParseTwtxtFile } from './loadAndParseTwtxt.ts';
export { default as parseTwtxt } from './parseTwtxt.ts';
export { base32Encode } from './utils.ts';

14
dist-browser/loadAndParseTwtxt.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
export default function loadAndParseTwtxtFile(url?: string): Promise<{
lastModified: string;
following: import('./types.ts').Twttr[];
metadata: import('./types.ts').Metadata;
twts: {
content: string;
created: string;
createdUTC: string;
hash: string;
replyHash: string | undefined;
replyNick: string | undefined;
replyUrl: string | undefined;
}[];
}>;

18
dist-browser/parseTwtxt.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { Metadata, Twttr } from './types.ts';
/**
* @param twtxt
* @returns object containing: following, metadata, twts
*/
export default function parseTwtxt(twtxt: string): {
following: Twttr[];
metadata: Metadata;
twts: {
content: string;
created: string;
createdUTC: string;
hash: string;
replyHash: string | undefined;
replyNick: string | undefined;
replyUrl: string | undefined;
}[];
};

3401
dist-browser/twtxt-lib.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

2318
dist-browser/twtxt-lib.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

31
dist-browser/types.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
export interface LoadAndParseTwtxtWithCacheConfig {
cacheKeyPrefix: string;
onLoad?: (data: Twtxt) => void;
user?: Twttr;
}
export interface Metadata {
[key: string]: string | string[];
}
export interface Twt {
avatar?: string;
content: string;
created: string;
createdUTC: string;
hash?: string;
nick?: string;
noDom?: boolean;
replyHash?: string;
replyNick?: string;
replyUrl?: string;
url?: string;
}
export interface Twttr {
avatar?: string;
nick: string;
url: string;
}
export interface Twtxt {
following: Twttr[];
metadata: Metadata;
twts: Twt[];
}

2
dist-browser/utils.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export declare const base32Encode: (payload: string | Uint8Array<ArrayBufferLike>) => any;
export declare const getValueOrFirstEntry: (value: unknown | unknown[]) => any;

View File

@@ -0,0 +1,6 @@
document.addEventListener("DOMContentLoaded", () => {
// add default #overview route
if (!window.location.hash) {
window.location.hash = "overview";
}
});

View File

@@ -0,0 +1,12 @@
document.addEventListener("DOMContentLoaded", () => {
const currentHost = window.location.hostname;
const links = document.querySelectorAll("a");
links.forEach((link) => {
if (link.hostname && link.hostname !== currentHost) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
});

View File

@@ -0,0 +1,44 @@
export default function formatSource(source, panelId) {
source = (source ?? "").trim();
if (!source || !panelId) return;
const sourceSplit = source.split(/\r?\n/);
const spaceCount = sourceSplit[sourceSplit.length - 1].search(/\S/);
if (spaceCount <= 0) return; // empty string
const trimRE = new RegExp(`^\\s{${spaceCount}}`);
const preDom = document.createElement("pre");
preDom.append(sourceSplit.map((val) => val.replace(trimRE, "")).join("\n"));
preDom.innerHTML = preDom.innerHTML
// strRegEx must be applied first to prevent false positives
.replace(/\"[^"]+\"/g, (val) =>
val !== '"module"' ? `<span class="code-str">${val}</span>` : val,
)
.replace(/\/\/.*/g, (val) => `<span class="code-cmnt">${val}</span>`)
.replace(/\`[^`]+\`/g, (val) => `<span class="code-str">${val}</span>`)
.replace(/\.\w+/g, (val) =>
val !== ".js" ? `<span class="code-func">${val}</span>` : val,
)
.replace(
/&lt;\/?script[^&]*&gt;/g,
(val) => `<span class="code-cmd">${val}</span>`,
);
const sourceHTML = `
<details class='js-sourceDetails' open="true">
<summary>Source</summary>
<div>
<figure>
${preDom.outerHTML}
</figure>
</div>
</details>
`.trim();
document
.getElementById(panelId)
.insertAdjacentHTML("beforeend", sourceHTML);
}

View File

@@ -0,0 +1,63 @@
import { hashTwt } from "/dist-browser/twtxt-lib.js";
let wasHashTwtResultAppended = false;
const formHash = document.forms["formHash"];
formHash.addEventListener("submit", (e) => {
e.preventDefault();
const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value;
const url = formHash.elements["url"].value;
const hash = hashTwt({
content,
created,
url,
});
const result = [
`content: ${content}`,
`created: ${created}`,
`url: ${url}`,
`hash: ${hash}`,
].join("\n");
console.log((wasHashTwtResultAppended ? "\n" : "") + result);
const resultHTML = result
.split("\n")
.map((line) =>
line.replace(
// to color properties
/^\w+:/,
(val) => `<span class="code-str">${val}</span>`,
),
)
.join("\n");
if (wasHashTwtResultAppended) {
document
.getElementById("preHashTwtResult")
.insertAdjacentHTML("afterbegin", resultHTML + "<br />\n");
return;
}
const resultsHTML = `
<details open="">
<summary>Results</summary>
<figure>
<pre id="preHashTwtResult">${resultHTML}</pre>
</figure>
</details>
`.trim();
document
.getElementById("tabHashTwt-panel")
.insertAdjacentHTML("beforeend", resultsHTML);
document
.querySelector("#tabHashTwt-panel .js-sourceDetails")
?.removeAttribute("open");
document.body.classList.add("isLoaded");
wasHashTwtResultAppended = true;
});

View File

@@ -0,0 +1,40 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
import { hashTwt } from "/web/dist/twtxt-lib.js";
let wasHashTwtResultAppended = false;
const formHash = document.forms["formHash"];
formHash.addEventListener("submit", (e) => {
e.preventDefault();
const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value;
const url = formHash.elements["url"].value;
const hash = hashTwt({
content,
created,
url,
});
const result = [
\`content: \${content}\`,
\`created: \${created}\`,
\`url: \${url}\`,
\`hash: \${hash}\`,
].join("\\n");
console.log(
(wasHashTwtResultAppended ? "\\n" : "") + result,
);
wasHashTwtResultAppended = true;
});
<\/script>
`,
"tabHashTwt-panel",
);

View File

@@ -0,0 +1,79 @@
import { loadAndParseTwtxtFile } from "/dist-browser/twtxt-lib.js";
const tabLoadAndParsePanel = document.getElementById("tabLoadAndParse-panel");
let wasLoadAndParseResultAppended = false;
document
.getElementById("formLoadAndParse")
.addEventListener("submit", async (ev) => {
ev?.preventDefault();
tabLoadAndParsePanel.classList.add("isLoading");
const loadAndParseURL = document.getElementById("loadAndParseURL");
const url =
loadAndParseURL?.value || "/twtxt-demos/demo-hipster-twtxt.txt";
const parsedFile = await loadAndParseTwtxtFile(url);
console.log(parsedFile);
tabLoadAndParsePanel.classList.remove("isLoading");
if (wasLoadAndParseResultAppended) {
document.getElementById("preLoadAndParseResult").outerHTML = `
<pre id="preLoadAndParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
`;
return;
}
const resultsHTML = `
<details open="true">
<summary>Results</summary>
<figure>
<pre id="preLoadAndParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
</figure>
</details>
`;
tabLoadAndParsePanel.insertAdjacentHTML("beforeend", resultsHTML);
document
.querySelector("#tabLoadAndParse-panel .js-sourceDetails")
?.removeAttribute("open");
document.body.classList.add("isLoaded");
wasLoadAndParseResultAppended = true;
});
const loadAndParseClickHandler = (ev) => {
ev?.preventDefault();
loadAndParseURL.value = ev.target.dataset.url;
};
[
"loadAndParseHipsterButton",
"loadAndParsePirateButton",
"loadAndParseSaganButton",
].forEach((curr) => {
document
.getElementById(curr)
.addEventListener("click", loadAndParseClickHandler);
});

View File

@@ -0,0 +1,25 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
import { loadAndParseTwtxtFile } from "/web/dist/twtxt-lib.js";
document
.getElementById("formLoadAndParse")
.addEventListener("submit", async (ev) => {
ev?.preventDefault();
const url =
document.getElementById("loadAndParseURL")?.value ??
"/twtxt-demos/demo-hipster-twtxt.txt";
const parsedFile = await loadAndParseTwtxtFile(url);
console.log(parsedFile);
});
<\/script>
`,
"tabLoadAndParse-panel",
);

View File

@@ -0,0 +1,36 @@
import { loadAndParseTwtxtFile } from "/dist-browser/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);
document.getElementById("tabOverview-example")?.insertAdjacentHTML(
"beforeend",
`
<details open="true">
<summary>Result</summary>
<figure>
<pre id="preResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
</figure>
</details>
`,
);
document.body.classList.add("isLoaded");
} catch (err) {
console.error(err);
}
})();

View File

@@ -0,0 +1,24 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
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);
}
})();
<\/script>
`,
"tabOverview-example",
);

View File

@@ -0,0 +1,76 @@
import { parseTwtxt } from "/dist-browser/twtxt-lib.js";
const tabParsePanel = document.getElementById("tabParse-panel");
let wasParseResultAppended = false;
document.getElementById("formParse").addEventListener("submit", async (ev) => {
ev?.preventDefault();
tabParsePanel.classList.add("isLoading");
const parseURL = document.getElementById("parseURL");
const url = parseURL?.value ?? "/twtxt-demos/demo-hipster-twtxt.txt";
const response = await fetch(url);
const twtxtFile = await response.text();
const parsedFile = parseTwtxt(twtxtFile);
console.log(parsedFile);
tabParsePanel.classList.remove("isLoading");
if (wasParseResultAppended) {
document.getElementById("preParseResult").outerHTML = `
<pre id="preParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
`;
return;
}
const resultsHTML = `
<details open="true">
<summary>Results</summary>
<figure>
<pre id="preParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
</figure>
</details>
`;
tabParsePanel.insertAdjacentHTML("beforeend", resultsHTML);
document
.querySelector("#tabParse-panel .js-sourceDetails")
?.removeAttribute("open");
document.body.classList.add("isLoaded");
wasParseResultAppended = true;
});
const parseClickHandler = (ev) => {
ev?.preventDefault();
parseURL.value = ev.target.dataset.url;
};
["parseHipsterButton", "parsePirateButton", "parseSaganButton"].forEach(
(curr) => {
document
.getElementById(curr)
.addEventListener("click", parseClickHandler);
},
);

View File

@@ -0,0 +1,27 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
import { parseTwtxt } from "/web/dist/twtxt-lib.js";
document
.getElementById("formParse")
.addEventListener("submit", async (ev) => {
ev?.preventDefault();
const url =
document.getElementById("parseURL")?.value ??
"/twtxt-demos/demo-hipster-twtxt.txt";
const response = await fetch(url);
const twtxtFile = await response.text();
const parsedFile = parseTwtxt(twtxtFile);
console.log(parsedFile);
});
<\/script>
`,
"tabParse-panel",
);

471
dist-demo/demo/styles.css Normal file
View File

@@ -0,0 +1,471 @@
:root {
--fg-main: #DBDFAC;
--bg-main: #3B1F2B;
--fg-light: #edefd5;
--bg-dark: rgba(10, 10, 10, .5);
--bg-light: rgba(245, 245, 245, .6);
--main-link: #8598AD;
--link-active: #ccc;
--link-active: lch(from var(--main-link) calc(l + 20) c h);
--gray-light: #5F758E;
}
@keyframes riseInDetails {
0% {opacity: 0; margin-top: 2rem}
100% {opacity: 1; margin-top: 0rem}
}
@keyframes riseInTab {
0% {opacity: 0; margin-top: -2rem}
100% {opacity: 1; margin-top: -4rem}
}
* {
box-sizing: border-box;
scrollbar-width: thin;
}
html {
font-size: 18px;
}
body {
background-color: var(--bg-main);
color: var(--fg-main);
margin: 0;
transition:
background-color .5s,
border-color .5s,
color .5s;
}
a {
border: 1px solid transparent;
border-bottom-color: var(--main-link);
border-radius: 0;
color: var(--main-link);
padding: 0 .5rem ;
text-decoration: none;
transition: all .5s;
}
a:active {
background-color: var(--fg-main);
border-color: var(--bg-main);
color: var(--link-active);
}
a:hover {
border-color: var(--link-active);
border-radius: .5rem;
color: var(--link-active);
}
button {
background-color: var(--link-active);
}
label {
display: inline-block;
border: none;
max-width: 25rem;
padding: 1rem;
text-align: center;
width: 100%;
}
input {
background-color: var(--fg-main);
}
input[type="reset"], input[type="submit"] {
background-color: var(--link-active);
}
input[type="text"], input[type="url"] {
background-color: var(--fg-light);
font-size: 1rem;
max-width: 25rem;
width: 100%;
}
pre {
background-color: var(--bg-dark);
border: 1px solid var(--fg-main);
color: var(--fg-main);
font-size: smaller;
padding: 1rem;
white-space: pre-wrap;
}
textarea {
background-color: var(--fg-light);
font-size: 1rem;
max-width: 30rem;
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 {
cursor: pointer;
}
.code-cmd {
color: var(--gray-light);
}
.code-cmnt {
color: #ccc;
}
.code-str {
color: var(--gray-light);
}
.copyright {
font-style: italic;
}
.flexCol {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 25rem;
width: 100%;
}
.flexRow {
align-items: center;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: .5rem 1rem;
}
.tab {
border: 2px solid transparent;
border-top: 2px solid var(--fg-main);
border-radius: .5rem .5rem 0 0;
display: block;
}
.tab-link {
background-color: var(--bg-main);
border-radius: .5rem .5rem 0 0;
border-bottom: 0;
color: var(--main-link);
display: block;
padding: 1rem 2rem;
position: relative;
text-decoration: none;
}
.tab-panel {
bottom: 0;
display: none;
left: 0;
overflow: auto;
padding: 0 1rem 1rem;
right: 0;
top: 4.25rem;
width: 100%;
z-index: -2;
}
.tabs {
border: 2px solid var(--fg-main);
border-top-color: transparent;
border-radius: .5rem .5rem 0 0;
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
min-height: 100vh;
padding: 0;
}
.tab:target .tab-panel {
display: block;
}
body:not(:has(:target)) #tabOverview-panel {
display: block;
}
.tab:target .tab-link {
background: linear-gradient(var(--bg-dark), var(--bg-main));
}
.tab:target + .tab {
margin-top: auto;
}
body:not(:has(:target)) #tabOverview-link {
background-color: var(--bg-dark);
color: var(--fg-main);
}
.themeToggle-button {
background-color: var(--fg-main);
border: 1px solid var(--gray-light);
border-radius: 1rem;
bottom: .5rem;
color: var(--gray-light);
display: flex;
font-size: 1.5rem;
padding: .5rem;
position: fixed;
right: .5rem;
z-index: 100;
}
.themeToggle-svgIndicator {
rotate: 180deg;
transition: rotate .5s;
}
/** Loader from https://www.cssportal.com/css-loader-generator/ */
.dotLoader {
animation: dotLoaderFrames 1s infinite steps(6);
background:
linear-gradient(var(--bg-main) 0 0) left -25% top 0 /20% 100% no-repeat var(--fg-main);
display: block;
height: 20px;
margin-top: 1rem;
mask: linear-gradient(90deg,var(--bg-main) 70%,#0000 0) left/20% 100%;
-webkit-mask: linear-gradient(90deg,var(--bg-main) 70%,#0000 0) left/20% 100%;
transition: opacity .5s;
width: 120px;
}
@keyframes dotLoaderFrames {
100% {background-position: right -25% top 0}
}
/** ID Overrides */
#formHash .flexCol {
min-width: 15rem;
width: 50%;
}
#formHash input[type="submit"] {
margin-top: 1rem;
min-width: 5rem;
}
#formHash textarea {
min-height: 6rem;
width: 100%
}
#formLoadAndParse .flexCol {
width: 100%;
max-width: 25rem;
}
#formLoadAndParse label {
width: 100%;
max-width: 25rem;
}
/** Media-Query Overrides */
@media (min-width: 900px) {
.flexRow {
flex-direction: row;
}
.tab {
border-color: transparent;
display: inline-block;
position: static;
}
.tab-link {
background-color: transparent;;
border: 2px solid var(--fg-main);
border-bottom-color: transparent;
margin-top: -4.5rem;
}
.tab-link:hover {
background-color: var(--link-active);
border-radius: .5rem .5rem 0 0;
color: var(--bg-main);
font-size: 1.2rem;
margin-top: -4.65rem;
}
.tab-panel {
animation: riseInTab .5s ease-in-out;
margin-top: -4rem;
position: relative;
padding: 0 2rem;
}
.tabs {
border-top: 2px solid var(--fg-main);
bottom: 0;
flex-direction: row;
left: 0;
margin-top: 4.5rem;
min-height: auto;
padding: 1rem;
position: absolute;
right: 0;
top: 0;
}
.tab:target + .tab {
margin-top: 0;
}
.tab:target .tab-link {
border-bottom: 0;
font-size: 1.2rem;
margin-top: -4.55rem;
}
.tab:target .tab-link:hover {
border-radius: .5rem .5rem 0 0;
background-color: var(--bg-dark);
color: var(--main-link);
font-size: 1.2rem;
margin-top: -4.55rem;
}
.tab:target .tab-panel {
position: absolute;
z-index: 1;
}
body:not(:has(:target)) #tabOverview-panel {
display: block;
position: absolute;
z-index: 1;
}
.themeToggle-button {
bottom: auto;
position: absolute;
right: 1rem;
top: 1rem;
}
}
/** State-Based Overrides */
body.invertedTheme {
background-color: var(--fg-main);
color: var(--bg-main);
}
body.invertedTheme a:hover {
background-color: var(--main-link);
border-color: var(--bg-dark);
color: var(--fg-main);
}
body.invertedTheme figcaption {
border-color: var(--bg-main);
}
body.invertedTheme figure {
background-color: var(--gray-light);
border-color: var(--bg-main);
}
body.invertedTheme input,
body.invertedTheme textarea {
background-color: var(--fg-light);
}
body.invertedTheme pre {
background-color: var(--bg-light);
border-color: var(--bg-main);
color: var(--bg-main);
}
body.invertedTheme .code-cmd {
color: var(--bg-dark);
}
body.invertedTheme .code-str {
color: var(--bg-dark);
}
body.invertedTheme .tab-link {
color: var(--bg-dark);
background-color: var(--fg-main);
border-color: var(--bg-main);
}
body.invertedTheme .tab:target .tab-link {
background: linear-gradient(var(--link-active), var(--fg-main));
}
body.invertedTheme .tab-link:hover {
background-color: var(--main-link);
border-color: var(--bg-dark);
}
body.invertedTheme .tab:target .tab-link {
border-color: var(--bg-main);
}
body.invertedTheme .tab-panel {
border-color: var(--bg-main);
}
body.invertedTheme .tabs {
border: 2px solid var(--bg-main);
}
body.invertedTheme .themeToggle-button {
background-color: var(--bg-main);
color: var(--main-link);
}
body.invertedTheme .themeToggle-svgIndicator {
rotate: 0deg;
}
.isLoaded .dotLoader {
margin: 0;
max-height: 0;
opacity: 0;
}
.isLoading .dotLoader {
opacity: 1;
}
a[href^='http']::after {
content: '\2197'; /* Code for ↗ */
display: inline-block;
margin-left: 5px;
font-size: 0.9em;
}

View File

@@ -0,0 +1,29 @@
document.addEventListener("DOMContentLoaded", () => {
const toggle = document.createElement("button");
toggle.classList.add("themeToggle-button");
toggle.setAttribute("id", "themeToggle-button");
toggle.addEventListener("click", () => {
document.body.classList.toggle("invertedTheme");
});
toggle.innerHTML = `
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
class="themeToggle-svgIndicator"
fill="currentColor"
viewBox="0 0 32 32"
>
<path
d="M16 .5C7.4.5.5 7.4.5 16S7.4 31.5 16 31.5 31.5 24.6 31.5 16 24.6.5
16 .5zm0 28.1V3.4C23 3.4 28.6 9 28.6 16S23 28.6 16 28.6z"
/>
</svg>
`.trim();
document.body.appendChild(toggle);
});

1
dist-demo/dist-browser/constants.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export declare const __dirname: string;

2
dist-demo/dist-browser/hashTwt.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
import { Twt } from './types.ts';
export default function hashTwt(twt: Twt): string;

5
dist-demo/dist-browser/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export type * from './types.ts';
export { default as hashTwt } from './hashTwt.ts';
export { default as loadAndParseTwtxtFile } from './loadAndParseTwtxt.ts';
export { default as parseTwtxt } from './parseTwtxt.ts';
export { base32Encode } from './utils.ts';

View File

@@ -0,0 +1,14 @@
export default function loadAndParseTwtxtFile(url?: string): Promise<{
lastModified: string;
following: import('./types.ts').Twttr[];
metadata: import('./types.ts').Metadata;
twts: {
content: string;
created: string;
createdUTC: string;
hash: string;
replyHash: string | undefined;
replyNick: string | undefined;
replyUrl: string | undefined;
}[];
}>;

18
dist-demo/dist-browser/parseTwtxt.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { Metadata, Twttr } from './types.ts';
/**
* @param twtxt
* @returns object containing: following, metadata, twts
*/
export default function parseTwtxt(twtxt: string): {
following: Twttr[];
metadata: Metadata;
twts: {
content: string;
created: string;
createdUTC: string;
hash: string;
replyHash: string | undefined;
replyNick: string | undefined;
replyUrl: string | undefined;
}[];
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

2318
dist-demo/dist-browser/twtxt-lib.min.js vendored Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

31
dist-demo/dist-browser/types.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
export interface LoadAndParseTwtxtWithCacheConfig {
cacheKeyPrefix: string;
onLoad?: (data: Twtxt) => void;
user?: Twttr;
}
export interface Metadata {
[key: string]: string | string[];
}
export interface Twt {
avatar?: string;
content: string;
created: string;
createdUTC: string;
hash?: string;
nick?: string;
noDom?: boolean;
replyHash?: string;
replyNick?: string;
replyUrl?: string;
url?: string;
}
export interface Twttr {
avatar?: string;
nick: string;
url: string;
}
export interface Twtxt {
following: Twttr[];
metadata: Metadata;
twts: Twt[];
}

2
dist-demo/dist-browser/utils.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export declare const base32Encode: (payload: string | Uint8Array<ArrayBufferLike>) => any;
export declare const getValueOrFirstEntry: (value: unknown | unknown[]) => any;

297
dist-demo/index.html Normal file
View File

@@ -0,0 +1,297 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>twtxt-lib Demo</title>
<link rel="stylesheet" href="/demo/styles.css" />
</head>
<body>
<ul class="tabs">
<li class="tab tabOverview" id="overview">
<a class="tab-link" href="#overview" id="tabOverview-link">Overview</a>
<div class="tab-panel" id="tabOverview-panel">
<h1>twtxt-lib</h2>
<p>
An isomorphic TypeScript library of
utility functions for parsing and interacting with
<a href="https://twtxt.dev/"
><code>twtxt.txt</code> files</a
>.
</p>
<p>These functions include:</p>
<ul>
<li><a href="#hashTwt">hashTwt</a>: takes the constituent parts of a
&ldquo;twt&rdquo; and generates an
<a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible
hash</a> for it.</li>
<li><a href="#parseTwtxt">parseTwtxt</a>: parses a twtxt file string,
returning an object with information about the file and its owner
(including <a href="https://twtxt.dev/exts/twt-hash.html">hashes</a>
for each twt and any
<a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the
file).</li>
<li><a href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a>: fetches a
twtxt file from <em>the internet</em> and parses it into an object (as
above).</li>
</ul>
<h2>Features</h2>
<ul>
<li>Isomorphic, available as an
(<a href="/dist-browser/twtxt-lib.min.js">optionally minified</a>)
<a href="/dist-browser/twtxt-lib.js">ES6+ library for the browser</a>,
with NPM and JSR versions coming soon.
<li>Fully typed and source-mapped.</li>
<li><a href="https://caniuse.com/es6-module">ESM</a> (and <a href="https://antfu.me/posts/move-on-to-esm-only">ESM only</a>)</li>
<li>Includes an interactive demo <em>(you&apos;re looking at it)</em>.</li>
</ul>
<h2>Installation</h2>
<p>
Browser
<ul>
<li>Download <a href="/dist-browser/twtxt-lib.js">library</a> (or
<a href="/dist-browser/twtxt-lib.min.js">minified version</a>).</li>
<li>Import desired function(s) into your project: `import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js"`</li>
</ul>
</p>
<p>
JSR
<ul>
<li>Coming soon</li>
</ul>
</p>
<p>
NPM
<ul>
<li>Coming soon</li>
</ul>
</p>
<p>Use the tabs to learn more.</p>
<hr />
<div id="tabOverview-example">
<p>
Example parsed file from
<a href="/twtxt-demos/demo-hipster-twtxt.txt">
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> </a
>:
</p>
<span class="dotLoader"></span>
</div>
<p class="copyright">
Copyright &copy; 2026 Eric Woodward, released under the <a href="https://www.itsericwoodward.com/licenses/mit/">MIT License</a>.
</p>
</div>
</li>
<li class="tab tabHashTwt" id="hashTwt">
<a class="tab-link" href="#hashTwt">hashTwt</a>
<div class="tab-panel" id="tabHashTwt-panel">
<p>
A function that takes the constituent parts of a &ldquo;twt&rdquo; and
generates an
<a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible hash</a>
for it.
</p>
<form id="formHash" name="formHash" method="post">
<div class="flexRow">
<div class="flexCol">
<label for="content">Content</label>
<textarea
id="content"
name="content"
>Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.</textarea>
</div>
<div class="flexCol">
<label>
Created
<input
type="text"
name="created"
value="2026-02-01T01:23:45Z"
/>
</label>
<label>
URL
<input
type="text"
name="url"
value="https://example.org/~pirate/twtxt.txt"
/>
</label>
<input type="submit" value="Go" />
</div>
</div>
</form>
</div>
</li>
<li class="tab tabParse" id="parseTwtxt">
<a class="tab-link" href="#parseTwtxt">parseTwtxt</a>
<div class="tab-panel" id="tabParse-panel">
<p>
A function that parses a twtxt file string,
returning an object with information about the file and its owner
(including <a href="https://twtxt.dev/exts/twt-hash.html">hashes</a>
for each twt and any
<a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the
file).
</p>
<p>Pre-included examples:</p>
<ul>
<li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="parseHipsterButton"
name="parseHipsterButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="parsePirateButton"
name="parsePirateButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="parseSaganButton"
name="parseSaganButton"
/>
Load
</button>
</li>
</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">
<div class="flexRow">
<div class="flexCol">
<label>
URL
<input
id="parseURL"
name="url"
type="text"
value="/twtxt-demos/demo-hipster-twtxt.txt"
/>
</label>
<input type="submit" value="Go" />
<span class="dotLoader"></span>
</div>
</div>
</form>
</div>
</li>
<li class="tab tabLoadAndParse" id="loadAndParseTwtxtFile">
<a class="tab-link" href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a>
<div class="tab-panel" id="tabLoadAndParse-panel">
<p>
An async function that fetches a twtxt.txt-compatible file
from a URL and parses it into an object
</p>
<p>Pre-included examples:</p>
<ul>
<li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="loadAndParseHipsterButton"
name="loadAndParseHipsterButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="loadAndParsePirateButton"
name="loadAndParsePirateButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="loadAndParseSaganButton"
name="loadAndParseSaganButton"
>
Load
</button>
</li>
</ul>
<p>
<em
>Note that CORS restrictions may limit the
effectiveness of using this function from another
domain.</em
>
</p>
<form
id="formLoadAndParse"
name="formLoadAndParse"
method="post"
>
<div class="flexRow">
<div class="flexCol">
<label>
URL
<input
id="loadAndParseURL"
name="url"
type="text"
value="/twtxt-demos/demo-hipster-twtxt.txt"
/>
</label>
<input type="submit" value="Go" />
<span class="dotLoader"></span>
</div>
</div>
</form>
</div>
</li>
</ul>
<script src="/dist-browser/twtxt-lib.js" type="module"></script>
<script src="/demo/theme-toggle.js" type="module"></script>
<script src="/demo/external-links.js" type="module"></script>
<script src="/demo/add-default-route.js" type="module"></script>
<script src="/demo/overview-example-source.js" type="module"></script>
<script src="/demo/overview-example-result.js" type="module"></script>
<script src="/demo/hashTwt-example-source.js" type="module"></script>
<script src="/demo/hashTwt-example-result.js" type="module"></script>
<script src="/demo/parseTwtxt-example-source.js" type="module"></script>
<script src="/demo/parseTwtxt-example-result.js" type="module"></script>
<script src="/demo/loadAndParseTwtxtFile-example-source.js" type="module"></script>
<script src="/demo/loadAndParseTwtxtFile-example-result.js" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,20 @@
# 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?
#
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
2025-06-02T18:47:01+01:00 Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.
2025-07-05T00:17:46+02:00 Cardigan jean shorts eu 90's. Kitsch knausgaard culpa, marfa mumblecore portland raclette banjo retro exercitation pariatur snackwave williamsburg.
2025-07-05T19:35:33+03:00 Literally deep v snackwave nostrud pug YOLO yes plz anim. JOMO crucifix bespoke chambray lomo keytar, labore ipsum.
2025-08-05T22:46:11+04:00 Ut letterpress synth hoodie, wayfarers kitsch air plant eu selvage tilde taiyaki grailed cliche ex. Skateboard pariatur non leggings.
2025-08-08T13:49:20+05:00 Ad pug ex hashtag live-edge distillery affogato. Succulents hammock taiyaki biodiesel chartreuse, nulla you probably haven't heard of them four dollar toast quinoa keytar cornhole.
2025-09-09T12:48:04+05:00 Cardigan JOMO blackbird spyplane, whatever commodo pop-up normcore ad yr in eiusmod forage echo park exercitation +1.
2025-10-09T20:33:15+04:00 Culpa snackwave williamsburg, asymmetrical wolf microdosing literally. La croix coloring book jean shorts poutine, 3 wolf moon chicharrones hashtag chillwave affogato green juice.
2025-11-11T12:54:43+03:00 Kickstarter kale chips williamsburg swag sunt disrupt chartreuse jianbing banh mi craft beer anim vaporware readymade.
2025-12-05T17:15:28+02:00 Pinterest Brooklyn direct trade freegan. Health goth consequat bespoke ad hoodie in est ugh. IPhone typewriter lomo venmo. Hashtag chillwave hella lumbersexual in blackbird spyplane yr tbh. Yr waistcoat kogi est neutra hammock mollit. Drinking vinegar godard hell of occaecat direct trade. In 3 wolf moon jianbing bitters, roof party mixtape yuccie.
2026-01-01T11:32:39+01:00 Ethical twee swag, farm-to-table irure semiotics bodega boys umami sriracha stumptown cred four dollar toast tofu photo booth tbh.
2026-02-01T13:13:13+02:00 (#4f5dlsa) <@demo_pirate https://example.org/~pirate/twtxt.txt> Asymmetrical kombucha trust fund jawn gentrify sartorial cloud bread artisan live-edge.

View File

@@ -0,0 +1,30 @@
# nick = demo_pirate
# url = https://example.org/~pirate/twtxt.txt
# avatar = https://i.pravatar.cc/150?img=22
# description = Drink up me hearties, yo ho!
# finger = demo_pirate@example.org
# link = Web https://example.org/~pirate/
# link = Ship https://example.org/~pirate/vessel/
# link = Profile https://example.org/~pirate/profile/
#
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
#
# following = 3
#
2026-02-01T01:23:45Z Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.
2026-02-02T02:24:36Z Swab barque interloper chantey doubloon starboard grog black jack gangway rutters.
2026-02-03T10:48:44Z Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl.
2026-02-04T11:54:12Z Belay yo-ho-ho keelhaul squiffy black spot yardarm spyglass sheet transom heave to.
2026-02-05T13:56:12Z Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors.
2026-02-06T14:57:58Z Bring a spring upon her cable holystone blow the man down spanker Shiver me timbers to go on account lookout wherry doubloon chase.
2026-02-07T13:58:56Z Trysail Sail ho Corsair red ensign hulk smartly boom jib rum gangway.
2026-02-08T14:26:54Z Case shot Shiver me timbers gangplank crack Jennys tea cup ballast Blimey lee snow crow's nest rutters.
2026-02-09T15:01:53Z Fluke jib scourge of the seven seas boatswain schooner gaff booty Jack Tar transom spirits.
2026-02-10T19:20:15Z Spyglass snow quarter wench cutlass coxswain scurvy landlubber or just lubber parley walk the plank.
2026-02-11T19:34:20Z Barbary Coast transom broadside clap of thunder dead men tell no tales gangplank barque bilge rat brigantine list.
2026-02-12T13:17:54Z Prow swab tender case shot aye capstan brigantine loot bucko take a caulk.

View File

@@ -0,0 +1,30 @@
# nick = demo_sagan
# url = https://example.net/~saganos/twtxt.txt
# avatar = https://i.pravatar.cc/150?img=69
# description = Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit?
# finger = sagaan@example.net
# link = Web https://example.net/~saganos/
#
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
#
# following = 3
#
2026-02-05T23:17:47Z The ash of stellar alchemy permanence of the stars extraordinary claims require extraordinary evidence rings of Uranus vanquish the impossible encyclopaedia galactica?
2026-01-22T12:09:44Z Something incredible is waiting to be known another world hearts of the stars tendrils of gossamer clouds a still more glorious dawn awaits venture.
2026-01-10T13:44:37Z Dream of the mind's eye citizens of distant epochs a still more glorious dawn awaits preserve and cherish that pale blue dot hearts of the stars preserve and cherish that pale blue dot and billions upon billions upon billions upon billions upon billions upon billions upon billions.
2026-01-01T02:05:04Z With pretty stories for which there's little good evidence muse about a billion trillion globular star cluster inconspicuous motes of rock and gas dream of the mind's eye.
2025-12-19T20:34:01Z Emerged into consciousness the carbon in our apple pies a very small stage in a vast cosmic arena extraordinary claims require extraordinary evidence from which we spring a mote of dust suspended in a sunbeam.
2025-12-15T20:18:08Z Finite but unbounded kindling the energy hidden in matter from which we spring vastness is bearable only through love vastness is bearable only through love billions upon billions.
2025-12-11T18:54:56Z Venture emerged into consciousness Vangelis how far away rings of Uranus radio telescope.
2025-12-10T17:53:00Z A mote of dust suspended in a sunbeam concept of the number one made in the interiors of collapsing stars vastness is bearable only through love across the centuries inconspicuous motes of rock and gas.
2025-11-08T15:23:30Z The ash of stellar alchemy another world at the edge of forever kindling the energy hidden in matter bits of moving fluff star stuff harvesting star light.
2025-11-07T14:49:29Z Network of wormholes citizens of distant epochs a mote of dust suspended in a sunbeam hearts of the stars are creatures of the cosmos network of wormholes.
2025-11-03T14:06:39Z Ship of the imagination the carbon in our apple pies dream of the mind's eye concept of the number one science as a patch of light.
2025-11-02T14:25:45Z The sky calls to us of brilliant syntheses tingling of the spine a very small stage in a vast cosmic arena venture the only home we've ever known.
2025-11-01T13:46:45Z Across the centuries take root and flourish preserve and cherish that pale blue dot from which we spring great turbulent clouds muse about.
2025-10-25T12:58:20Z White dwarf a mote of dust suspended in a sunbeam muse about globular star cluster cosmic ocean circumnavigated.
2025-10-24T12:29:20Z Are creatures of the cosmos another world citizens of distant epochs courage of our questions are creatures of the cosmos venture.
2025-10-23T12:49:02Z Something incredible is waiting to be known the only home we've ever known courage of our questions hydrogen atoms vastness is bearable only through love a very small stage in a vast cosmic arena.

1
dist-node/constants.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
export declare const __dirname: string;

2
dist-node/hashTwt.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
import { Twt } from './types.ts';
export default function hashTwt(twt: Twt): string;

41
dist-node/hashTwt.js Normal file
View File

@@ -0,0 +1,41 @@
import { Buffer } from "vite-plugin-node-polyfills/shims/buffer";
import { blake2b } from "@exodus/blakejs";
import { base32Encode } from "./utils.js";
globalThis.Buffer = Buffer;
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 pad = (num = 0) => `${+num < 10 ? 0 : ""}${+num}`;
const padYear = (num = 0) => `${+num < 1e3 ? 0 : ""}${+num < 100 ? 0 : ""}${+num < 10 ? 0 : ""}${+num}`;
let m = dateRegex.exec(date);
if (m && m?.[9] === void 0) {
m[9] = "+00";
}
if (m && m?.[10] === void 0) {
m[10] = "00";
}
const offset = `${m?.[9]}:${m?.[10]}`.replace(/[+-]?00:00$/, "Z");
return [
padYear(m?.[1]),
"-",
pad(m?.[2]),
"-",
pad(m?.[3]),
m?.[4],
pad(m?.[5]),
":",
pad(m?.[6]),
":",
pad(m?.[7]),
//ignore milliseconds (m[8])
offset
].join("");
};
function hashTwt(twt) {
const created = formatRFC3339(twt.created);
const payload = [twt.url, created, twt.content].join("\n");
return base32Encode(blake2b(payload, void 0, 32)).toLowerCase().slice(-7);
}
export {
hashTwt as default
};
//# sourceMappingURL=hashTwt.js.map

1
dist-node/hashTwt.js.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"hashTwt.js","sources":["../src/hashTwt.ts"],"sourcesContent":["import { Buffer } from \"buffer\";\nglobalThis.Buffer = Buffer;\n\nimport 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":";;;AACA,WAAW,SAAS;AAQpB,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;"}

5
dist-node/index.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
export type * from './types.ts';
export { default as hashTwt } from './hashTwt.ts';
export { default as loadAndParseTwtxtFile } from './loadAndParseTwtxt.ts';
export { default as parseTwtxt } from './parseTwtxt.ts';
export { base32Encode } from './utils.ts';

13
dist-node/index.js Normal file
View File

@@ -0,0 +1,13 @@
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt
import { default as default2 } from "./hashTwt.js";
import { default as default3 } from "./loadAndParseTwtxt.js";
import { default as default4 } from "./parseTwtxt.js";
import { base32Encode } from "./utils.js";
export {
base32Encode,
default2 as hashTwt,
default3 as loadAndParseTwtxtFile,
default4 as parseTwtxt
};
//# sourceMappingURL=index.js.map
// @license-end

1
dist-node/index.js.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;"}

14
dist-node/loadAndParseTwtxt.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
export default function loadAndParseTwtxtFile(url?: string): Promise<{
lastModified: string;
following: import('./types.ts').Twttr[];
metadata: import('./types.ts').Metadata;
twts: {
content: string;
created: string;
createdUTC: string;
hash: string;
replyHash: string | undefined;
replyNick: string | undefined;
replyUrl: string | undefined;
}[];
}>;

View File

@@ -0,0 +1,23 @@
import dayjs from "dayjs";
import parseTwtxt from "./parseTwtxt.js";
async function loadAndParseTwtxtFile(url = "") {
if (!url) throw new Error("URL is required");
try {
const response = await fetch(url);
const twtxtFile = await response.text();
const lastModified = dayjs(
response.headers.get("Last-Modified")
).toISOString();
return {
...parseTwtxt(twtxtFile),
lastModified
};
} catch (err) {
console.error(err);
throw err;
}
}
export {
loadAndParseTwtxtFile as default
};
//# sourceMappingURL=loadAndParseTwtxt.js.map

View File

@@ -0,0 +1 @@
{"version":3,"file":"loadAndParseTwtxt.js","sources":["../src/loadAndParseTwtxt.ts"],"sourcesContent":["import dayjs from \"dayjs\";\n\nimport parseTwtxt from \"./parseTwtxt.js\";\n\nexport default async function loadAndParseTwtxtFile(url = \"\") {\n\tif (!url) throw new Error(\"URL is required\");\n\n\ttry {\n\t\tconst response = await fetch(url);\n\t\tconst twtxtFile = await response.text();\n\t\tconst lastModified = dayjs(\n\t\t\tresponse.headers.get(\"Last-Modified\"),\n\t\t).toISOString();\n\n\t\treturn {\n\t\t\t...parseTwtxt(twtxtFile),\n\t\t\tlastModified,\n\t\t};\n\t} catch (err) {\n\t\tconsole.error(err);\n\t\tthrow err;\n\t}\n}\n"],"names":[],"mappings":";;AAIA,eAA8B,sBAAsB,MAAM,IAAI;AAC7D,MAAI,CAAC,IAAK,OAAM,IAAI,MAAM,iBAAiB;AAE3C,MAAI;AACH,UAAM,WAAW,MAAM,MAAM,GAAG;AAChC,UAAM,YAAY,MAAM,SAAS,KAAA;AACjC,UAAM,eAAe;AAAA,MACpB,SAAS,QAAQ,IAAI,eAAe;AAAA,IAAA,EACnC,YAAA;AAEF,WAAO;AAAA,MACN,GAAG,WAAW,SAAS;AAAA,MACvB;AAAA,IAAA;AAAA,EAEF,SAAS,KAAK;AACb,YAAQ,MAAM,GAAG;AACjB,UAAM;AAAA,EACP;AACD;"}

18
dist-node/parseTwtxt.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { Metadata, Twttr } from './types.ts';
/**
* @param twtxt
* @returns object containing: following, metadata, twts
*/
export default function parseTwtxt(twtxt: string): {
following: Twttr[];
metadata: Metadata;
twts: {
content: string;
created: string;
createdUTC: string;
hash: string;
replyHash: string | undefined;
replyNick: string | undefined;
replyUrl: string | undefined;
}[];
};

83
dist-node/parseTwtxt.js Normal file
View File

@@ -0,0 +1,83 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
import hashTwt from "./hashTwt.js";
import { getValueOrFirstEntry } from "./utils.js";
dayjs.extend(utc);
function parseTwtxt(twtxt) {
const allLines = twtxt.split("\n");
const { commentLines = [], contentLines = [] } = allLines.reduce(
(acc, originalLine) => {
const line = originalLine.trim();
if (line === "") return acc;
if (line.startsWith("#")) acc.commentLines.push(line);
else acc.contentLines.push(line);
return acc;
},
{
commentLines: [],
contentLines: []
}
);
const { following = [], metadata = {} } = commentLines.filter((line) => line.includes("=")).reduce(
(acc, line) => {
const [key, ...vals] = line.substring(1).split("=").map((field) => field.trim());
const val = vals.join("=");
if (key === "follow") {
const [nick, url] = val.trim().split(/\s+/);
acc.following.push({ nick, url });
} else {
if (acc.metadata[key]) {
if (!Array.isArray(acc.metadata[key]))
acc.metadata[key] = [acc.metadata[key], val];
else acc.metadata[key].push(val);
} else acc.metadata[key] = val;
}
return acc;
},
{
following: [],
metadata: {}
}
);
const replyRegEx = /\(#([\w]+)\) (\<\@(\S+) ([^>]+)>)*/;
const twts = contentLines.map((line) => {
const [created, content] = line.split(/\t/).map((val) => val.trim());
if (typeof content === "undefined")
throw new Error(`Content is undefined: ${line}`);
const createdDayjs = dayjs.utc(created);
if (!createdDayjs.isValid())
throw new Error(`Date is invalid: ${line}`);
const createdUTC = createdDayjs.toISOString();
const replyMatches = replyRegEx.exec(content);
let replyHash, replyNick, replyUrl;
if (replyMatches?.length) {
replyHash = replyMatches?.[1];
replyNick = replyMatches?.[3];
replyUrl = replyMatches?.[4];
}
const hash = hashTwt({
content,
created,
createdUTC,
url: getValueOrFirstEntry(metadata?.url ?? "")
});
return {
content,
created,
createdUTC,
hash,
replyHash,
replyNick,
replyUrl
};
}).sort((a, b) => dayjs(a.created).diff(dayjs(b.created)));
return {
following,
metadata,
twts
};
}
export {
parseTwtxt as default
};
//# sourceMappingURL=parseTwtxt.js.map

File diff suppressed because one or more lines are too long

31
dist-node/types.d.ts vendored Normal file
View File

@@ -0,0 +1,31 @@
export interface LoadAndParseTwtxtWithCacheConfig {
cacheKeyPrefix: string;
onLoad?: (data: Twtxt) => void;
user?: Twttr;
}
export interface Metadata {
[key: string]: string | string[];
}
export interface Twt {
avatar?: string;
content: string;
created: string;
createdUTC: string;
hash?: string;
nick?: string;
noDom?: boolean;
replyHash?: string;
replyNick?: string;
replyUrl?: string;
url?: string;
}
export interface Twttr {
avatar?: string;
nick: string;
url: string;
}
export interface Twtxt {
following: Twttr[];
metadata: Metadata;
twts: Twt[];
}

2
dist-node/utils.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
export declare const base32Encode: (payload: string | Uint8Array<ArrayBufferLike>) => any;
export declare const getValueOrFirstEntry: (value: unknown | unknown[]) => any;

11
dist-node/utils.js Normal file
View File

@@ -0,0 +1,11 @@
import base32 from "base32.js";
const base32Encode = (payload) => {
const encoder = new base32.Encoder({ type: "rfc4648" });
return encoder.write(payload).finalize();
};
const getValueOrFirstEntry = (value) => Array.isArray(value) && value.length ? value[0] : value;
export {
base32Encode,
getValueOrFirstEntry
};
//# sourceMappingURL=utils.js.map

1
dist-node/utils.js.map Normal file
View File

@@ -0,0 +1 @@
{"version":3,"file":"utils.js","sources":["../src/utils.ts"],"sourcesContent":["import base32 from \"base32.js\";\n\nexport const base32Encode = (payload: string | Uint8Array<ArrayBufferLike>) => {\n\tconst encoder = new base32.Encoder({ type: \"rfc4648\" });\n\treturn encoder.write(payload).finalize();\n};\n\nexport const getValueOrFirstEntry = (value: unknown | unknown[]) =>\n\tArray.isArray(value) && value.length ? value[0] : value;\n"],"names":[],"mappings":";AAEO,MAAM,eAAe,CAAC,YAAkD;AAC9E,QAAM,UAAU,IAAI,OAAO,QAAQ,EAAE,MAAM,WAAW;AACtD,SAAO,QAAQ,MAAM,OAAO,EAAE,SAAA;AAC/B;AAEO,MAAM,uBAAuB,CAAC,UACpC,MAAM,QAAQ,KAAK,KAAK,MAAM,SAAS,MAAM,CAAC,IAAI;"}

297
index.html Normal file
View File

@@ -0,0 +1,297 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>twtxt-lib Demo</title>
<link rel="stylesheet" href="/demo/styles.css" />
</head>
<body>
<ul class="tabs">
<li class="tab tabOverview" id="overview">
<a class="tab-link" href="#overview" id="tabOverview-link">Overview</a>
<div class="tab-panel" id="tabOverview-panel">
<h1>twtxt-lib</h2>
<p>
An isomorphic TypeScript library of
utility functions for parsing and interacting with
<a href="https://twtxt.dev/"
><code>twtxt.txt</code> files</a
>.
</p>
<p>These functions include:</p>
<ul>
<li><a href="#hashTwt">hashTwt</a>: takes the constituent parts of a
&ldquo;twt&rdquo; and generates an
<a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible
hash</a> for it.</li>
<li><a href="#parseTwtxt">parseTwtxt</a>: parses a twtxt file string,
returning an object with information about the file and its owner
(including <a href="https://twtxt.dev/exts/twt-hash.html">hashes</a>
for each twt and any
<a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the
file).</li>
<li><a href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a>: fetches a
twtxt file from <em>the internet</em> and parses it into an object (as
above).</li>
</ul>
<h2>Features</h2>
<ul>
<li>Isomorphic, available as an
(<a href="/dist-browser/twtxt-lib.min.js">optionally minified</a>)
<a href="/dist-browser/twtxt-lib.js">ES6+ library for the browser</a>,
with NPM and JSR versions coming soon.
<li>Fully typed and source-mapped.</li>
<li><a href="https://caniuse.com/es6-module">ESM</a> (and <a href="https://antfu.me/posts/move-on-to-esm-only">ESM only</a>)</li>
<li>Includes an interactive demo <em>(you&apos;re looking at it)</em>.</li>
</ul>
<h2>Installation</h2>
<p>
Browser
<ul>
<li>Download <a href="/dist-browser/twtxt-lib.js">library</a> (or
<a href="/dist-browser/twtxt-lib.min.js">minified version</a>).</li>
<li>Import desired function(s) into your project: `import { hashTwt, loadAndParseTwtxtFile } from "./twtxt-lib.js"`</li>
</ul>
</p>
<p>
JSR
<ul>
<li>Coming soon</li>
</ul>
</p>
<p>
NPM
<ul>
<li>Coming soon</li>
</ul>
</p>
<p>Use the tabs to learn more.</p>
<hr />
<div id="tabOverview-example">
<p>
Example parsed file from
<a href="/twtxt-demos/demo-hipster-twtxt.txt">
<code>/twtxt-demos/demo-hipster-twtxt.txt</code> </a
>:
</p>
<span class="dotLoader"></span>
</div>
<p class="copyright">
Copyright &copy; 2026 Eric Woodward, released under the <a href="https://www.itsericwoodward.com/licenses/mit/">MIT License</a>.
</p>
</div>
</li>
<li class="tab tabHashTwt" id="hashTwt">
<a class="tab-link" href="#hashTwt">hashTwt</a>
<div class="tab-panel" id="tabHashTwt-panel">
<p>
A function that takes the constituent parts of a &ldquo;twt&rdquo; and
generates an
<a href="https://twtxt.dev/exts/twt-hash.html">extension-compatible hash</a>
for it.
</p>
<form id="formHash" name="formHash" method="post">
<div class="flexRow">
<div class="flexCol">
<label for="content">Content</label>
<textarea
id="content"
name="content"
>Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.</textarea>
</div>
<div class="flexCol">
<label>
Created
<input
type="text"
name="created"
value="2026-02-01T01:23:45Z"
/>
</label>
<label>
URL
<input
type="text"
name="url"
value="https://example.org/~pirate/twtxt.txt"
/>
</label>
<input type="submit" value="Go" />
</div>
</div>
</form>
</div>
</li>
<li class="tab tabParse" id="parseTwtxt">
<a class="tab-link" href="#parseTwtxt">parseTwtxt</a>
<div class="tab-panel" id="tabParse-panel">
<p>
A function that parses a twtxt file string,
returning an object with information about the file and its owner
(including <a href="https://twtxt.dev/exts/twt-hash.html">hashes</a>
for each twt and any
<a href="https://twtxt.dev/exts/metadata.html">metadata</a> in the
file).
</p>
<p>Pre-included examples:</p>
<ul>
<li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="parseHipsterButton"
name="parseHipsterButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="parsePirateButton"
name="parsePirateButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="parseSaganButton"
name="parseSaganButton"
/>
Load
</button>
</li>
</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">
<div class="flexRow">
<div class="flexCol">
<label>
URL
<input
id="parseURL"
name="url"
type="text"
value="/twtxt-demos/demo-hipster-twtxt.txt"
/>
</label>
<input type="submit" value="Go" />
<span class="dotLoader"></span>
</div>
</div>
</form>
</div>
</li>
<li class="tab tabLoadAndParse" id="loadAndParseTwtxtFile">
<a class="tab-link" href="#loadAndParseTwtxtFile">loadAndParseTwtxtFile</a>
<div class="tab-panel" id="tabLoadAndParse-panel">
<p>
An async function that fetches a twtxt.txt-compatible file
from a URL and parses it into an object
</p>
<p>Pre-included examples:</p>
<ul>
<li>
<code>/twtxt-demos/demo-hipster-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-hipster-twtxt.txt"
id="loadAndParseHipsterButton"
name="loadAndParseHipsterButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-pirate-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-pirate-twtxt.txt"
id="loadAndParsePirateButton"
name="loadAndParsePirateButton"
>
Load
</button>
</li>
<li>
<code>/twtxt-demos/demo-sagan-twtxt.txt</code>
<button
data-url="/twtxt-demos/demo-sagan-twtxt.txt"
id="loadAndParseSaganButton"
name="loadAndParseSaganButton"
>
Load
</button>
</li>
</ul>
<p>
<em
>Note that CORS restrictions may limit the
effectiveness of using this function from another
domain.</em
>
</p>
<form
id="formLoadAndParse"
name="formLoadAndParse"
method="post"
>
<div class="flexRow">
<div class="flexCol">
<label>
URL
<input
id="loadAndParseURL"
name="url"
type="text"
value="/twtxt-demos/demo-hipster-twtxt.txt"
/>
</label>
<input type="submit" value="Go" />
<span class="dotLoader"></span>
</div>
</div>
</form>
</div>
</li>
</ul>
<script src="/dist-browser/twtxt-lib.js" type="module"></script>
<script src="/demo/theme-toggle.js" type="module"></script>
<script src="/demo/external-links.js" type="module"></script>
<script src="/demo/add-default-route.js" type="module"></script>
<script src="/demo/overview-example-source.js" type="module"></script>
<script src="/demo/overview-example-result.js" type="module"></script>
<script src="/demo/hashTwt-example-source.js" type="module"></script>
<script src="/demo/hashTwt-example-result.js" type="module"></script>
<script src="/demo/parseTwtxt-example-source.js" type="module"></script>
<script src="/demo/parseTwtxt-example-result.js" type="module"></script>
<script src="/demo/loadAndParseTwtxtFile-example-source.js" type="module"></script>
<script src="/demo/loadAndParseTwtxtFile-example-result.js" type="module"></script>
</body>
</html>

44
package.json Normal file
View File

@@ -0,0 +1,44 @@
{
"name": "twtxt-lib",
"version": "0.9.0",
"description": "",
"license": "MIT",
"exports": {
".": {
"import": "./dist-node/index.js"
}
},
"type": "module",
"files": [
"dist-node"
],
"module": "./dist-node/index.js",
"types": "./dist-node/index.d.ts",
"scripts": {
"build:dev": "BUILD_ENV=development node ./build.js",
"build": "BUILD_ENV=production node ./build.js",
"build-tsc": "tsc",
"ci": "yarn build:release",
"dev": "vite",
"preview": "vite preview",
"prepublishOnly": "yarn build",
"postpublish": "git push && git push --tags",
"test": "vitest"
},
"devDependencies": {
"@types/node": "^25.3.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"unplugin-dts": "1.0.0-beta.6",
"vite": "^7.3.1",
"vite-plugin-no-bundle": "^4.0.0",
"vite-plugin-node-polyfills": "^0.25.0",
"vitest": "^4.0.18"
},
"packageManager": "yarn@4.12.0",
"dependencies": {
"@exodus/blakejs": "^1.1.1-exodus.0",
"base32.js": "^0.1.0",
"dayjs": "^1.11.19"
}
}

View File

@@ -0,0 +1,6 @@
document.addEventListener("DOMContentLoaded", () => {
// add default #overview route
if (!window.location.hash) {
window.location.hash = "overview";
}
});

View File

@@ -0,0 +1,12 @@
document.addEventListener("DOMContentLoaded", () => {
const currentHost = window.location.hostname;
const links = document.querySelectorAll("a");
links.forEach((link) => {
if (link.hostname && link.hostname !== currentHost) {
link.setAttribute("target", "_blank");
link.setAttribute("rel", "noopener noreferrer");
}
});
});

View File

@@ -0,0 +1,44 @@
export default function formatSource(source, panelId) {
source = (source ?? "").trim();
if (!source || !panelId) return;
const sourceSplit = source.split(/\r?\n/);
const spaceCount = sourceSplit[sourceSplit.length - 1].search(/\S/);
if (spaceCount <= 0) return; // empty string
const trimRE = new RegExp(`^\\s{${spaceCount}}`);
const preDom = document.createElement("pre");
preDom.append(sourceSplit.map((val) => val.replace(trimRE, "")).join("\n"));
preDom.innerHTML = preDom.innerHTML
// strRegEx must be applied first to prevent false positives
.replace(/\"[^"]+\"/g, (val) =>
val !== '"module"' ? `<span class="code-str">${val}</span>` : val,
)
.replace(/\/\/.*/g, (val) => `<span class="code-cmnt">${val}</span>`)
.replace(/\`[^`]+\`/g, (val) => `<span class="code-str">${val}</span>`)
.replace(/\.\w+/g, (val) =>
val !== ".js" ? `<span class="code-func">${val}</span>` : val,
)
.replace(
/&lt;\/?script[^&]*&gt;/g,
(val) => `<span class="code-cmd">${val}</span>`,
);
const sourceHTML = `
<details class='js-sourceDetails' open="true">
<summary>Source</summary>
<div>
<figure>
${preDom.outerHTML}
</figure>
</div>
</details>
`.trim();
document
.getElementById(panelId)
.insertAdjacentHTML("beforeend", sourceHTML);
}

View File

@@ -0,0 +1,63 @@
import { hashTwt } from "/dist-browser/twtxt-lib.js";
let wasHashTwtResultAppended = false;
const formHash = document.forms["formHash"];
formHash.addEventListener("submit", (e) => {
e.preventDefault();
const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value;
const url = formHash.elements["url"].value;
const hash = hashTwt({
content,
created,
url,
});
const result = [
`content: ${content}`,
`created: ${created}`,
`url: ${url}`,
`hash: ${hash}`,
].join("\n");
console.log((wasHashTwtResultAppended ? "\n" : "") + result);
const resultHTML = result
.split("\n")
.map((line) =>
line.replace(
// to color properties
/^\w+:/,
(val) => `<span class="code-str">${val}</span>`,
),
)
.join("\n");
if (wasHashTwtResultAppended) {
document
.getElementById("preHashTwtResult")
.insertAdjacentHTML("afterbegin", resultHTML + "<br />\n");
return;
}
const resultsHTML = `
<details open="">
<summary>Results</summary>
<figure>
<pre id="preHashTwtResult">${resultHTML}</pre>
</figure>
</details>
`.trim();
document
.getElementById("tabHashTwt-panel")
.insertAdjacentHTML("beforeend", resultsHTML);
document
.querySelector("#tabHashTwt-panel .js-sourceDetails")
?.removeAttribute("open");
document.body.classList.add("isLoaded");
wasHashTwtResultAppended = true;
});

View File

@@ -0,0 +1,40 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
import { hashTwt } from "/web/dist/twtxt-lib.js";
let wasHashTwtResultAppended = false;
const formHash = document.forms["formHash"];
formHash.addEventListener("submit", (e) => {
e.preventDefault();
const content = formHash.elements["content"].value;
const created = formHash.elements["created"].value;
const url = formHash.elements["url"].value;
const hash = hashTwt({
content,
created,
url,
});
const result = [
\`content: \${content}\`,
\`created: \${created}\`,
\`url: \${url}\`,
\`hash: \${hash}\`,
].join("\\n");
console.log(
(wasHashTwtResultAppended ? "\\n" : "") + result,
);
wasHashTwtResultAppended = true;
});
<\/script>
`,
"tabHashTwt-panel",
);

View File

@@ -0,0 +1,79 @@
import { loadAndParseTwtxtFile } from "/dist-browser/twtxt-lib.js";
const tabLoadAndParsePanel = document.getElementById("tabLoadAndParse-panel");
let wasLoadAndParseResultAppended = false;
document
.getElementById("formLoadAndParse")
.addEventListener("submit", async (ev) => {
ev?.preventDefault();
tabLoadAndParsePanel.classList.add("isLoading");
const loadAndParseURL = document.getElementById("loadAndParseURL");
const url =
loadAndParseURL?.value || "/twtxt-demos/demo-hipster-twtxt.txt";
const parsedFile = await loadAndParseTwtxtFile(url);
console.log(parsedFile);
tabLoadAndParsePanel.classList.remove("isLoading");
if (wasLoadAndParseResultAppended) {
document.getElementById("preLoadAndParseResult").outerHTML = `
<pre id="preLoadAndParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
`;
return;
}
const resultsHTML = `
<details open="true">
<summary>Results</summary>
<figure>
<pre id="preLoadAndParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
</figure>
</details>
`;
tabLoadAndParsePanel.insertAdjacentHTML("beforeend", resultsHTML);
document
.querySelector("#tabLoadAndParse-panel .js-sourceDetails")
?.removeAttribute("open");
document.body.classList.add("isLoaded");
wasLoadAndParseResultAppended = true;
});
const loadAndParseClickHandler = (ev) => {
ev?.preventDefault();
loadAndParseURL.value = ev.target.dataset.url;
};
[
"loadAndParseHipsterButton",
"loadAndParsePirateButton",
"loadAndParseSaganButton",
].forEach((curr) => {
document
.getElementById(curr)
.addEventListener("click", loadAndParseClickHandler);
});

View File

@@ -0,0 +1,25 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
import { loadAndParseTwtxtFile } from "/web/dist/twtxt-lib.js";
document
.getElementById("formLoadAndParse")
.addEventListener("submit", async (ev) => {
ev?.preventDefault();
const url =
document.getElementById("loadAndParseURL")?.value ??
"/twtxt-demos/demo-hipster-twtxt.txt";
const parsedFile = await loadAndParseTwtxtFile(url);
console.log(parsedFile);
});
<\/script>
`,
"tabLoadAndParse-panel",
);

View File

@@ -0,0 +1,36 @@
import { loadAndParseTwtxtFile } from "/dist-browser/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);
document.getElementById("tabOverview-example")?.insertAdjacentHTML(
"beforeend",
`
<details open="true">
<summary>Result</summary>
<figure>
<pre id="preResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
</figure>
</details>
`,
);
document.body.classList.add("isLoaded");
} catch (err) {
console.error(err);
}
})();

View File

@@ -0,0 +1,24 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
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);
}
})();
<\/script>
`,
"tabOverview-example",
);

View File

@@ -0,0 +1,76 @@
import { parseTwtxt } from "/dist-browser/twtxt-lib.js";
const tabParsePanel = document.getElementById("tabParse-panel");
let wasParseResultAppended = false;
document.getElementById("formParse").addEventListener("submit", async (ev) => {
ev?.preventDefault();
tabParsePanel.classList.add("isLoading");
const parseURL = document.getElementById("parseURL");
const url = parseURL?.value ?? "/twtxt-demos/demo-hipster-twtxt.txt";
const response = await fetch(url);
const twtxtFile = await response.text();
const parsedFile = parseTwtxt(twtxtFile);
console.log(parsedFile);
tabParsePanel.classList.remove("isLoading");
if (wasParseResultAppended) {
document.getElementById("preParseResult").outerHTML = `
<pre id="preParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
`;
return;
}
const resultsHTML = `
<details open="true">
<summary>Results</summary>
<figure>
<pre id="preParseResult">${JSON.stringify(
parsedFile,
null,
2,
).replace(
// to color properties
/"\w+":/g,
(val) => `<span class="code-str">${val}</span>`,
)}</pre>
</figure>
</details>
`;
tabParsePanel.insertAdjacentHTML("beforeend", resultsHTML);
document
.querySelector("#tabParse-panel .js-sourceDetails")
?.removeAttribute("open");
document.body.classList.add("isLoaded");
wasParseResultAppended = true;
});
const parseClickHandler = (ev) => {
ev?.preventDefault();
parseURL.value = ev.target.dataset.url;
};
["parseHipsterButton", "parsePirateButton", "parseSaganButton"].forEach(
(curr) => {
document
.getElementById(curr)
.addEventListener("click", parseClickHandler);
},
);

View File

@@ -0,0 +1,27 @@
import formatSource from "./format-source.js";
formatSource(
`
\<script type="module">
import { parseTwtxt } from "/web/dist/twtxt-lib.js";
document
.getElementById("formParse")
.addEventListener("submit", async (ev) => {
ev?.preventDefault();
const url =
document.getElementById("parseURL")?.value ??
"/twtxt-demos/demo-hipster-twtxt.txt";
const response = await fetch(url);
const twtxtFile = await response.text();
const parsedFile = parseTwtxt(twtxtFile);
console.log(parsedFile);
});
<\/script>
`,
"tabParse-panel",
);

471
public/demo/styles.css Normal file
View File

@@ -0,0 +1,471 @@
:root {
--fg-main: #DBDFAC;
--bg-main: #3B1F2B;
--fg-light: #edefd5;
--bg-dark: rgba(10, 10, 10, .5);
--bg-light: rgba(245, 245, 245, .6);
--main-link: #8598AD;
--link-active: #ccc;
--link-active: lch(from var(--main-link) calc(l + 20) c h);
--gray-light: #5F758E;
}
@keyframes riseInDetails {
0% {opacity: 0; margin-top: 2rem}
100% {opacity: 1; margin-top: 0rem}
}
@keyframes riseInTab {
0% {opacity: 0; margin-top: -2rem}
100% {opacity: 1; margin-top: -4rem}
}
* {
box-sizing: border-box;
scrollbar-width: thin;
}
html {
font-size: 18px;
}
body {
background-color: var(--bg-main);
color: var(--fg-main);
margin: 0;
transition:
background-color .5s,
border-color .5s,
color .5s;
}
a {
border: 1px solid transparent;
border-bottom-color: var(--main-link);
border-radius: 0;
color: var(--main-link);
padding: 0 .5rem ;
text-decoration: none;
transition: all .5s;
}
a:active {
background-color: var(--fg-main);
border-color: var(--bg-main);
color: var(--link-active);
}
a:hover {
border-color: var(--link-active);
border-radius: .5rem;
color: var(--link-active);
}
button {
background-color: var(--link-active);
}
label {
display: inline-block;
border: none;
max-width: 25rem;
padding: 1rem;
text-align: center;
width: 100%;
}
input {
background-color: var(--fg-main);
}
input[type="reset"], input[type="submit"] {
background-color: var(--link-active);
}
input[type="text"], input[type="url"] {
background-color: var(--fg-light);
font-size: 1rem;
max-width: 25rem;
width: 100%;
}
pre {
background-color: var(--bg-dark);
border: 1px solid var(--fg-main);
color: var(--fg-main);
font-size: smaller;
padding: 1rem;
white-space: pre-wrap;
}
textarea {
background-color: var(--fg-light);
font-size: 1rem;
max-width: 30rem;
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 {
cursor: pointer;
}
.code-cmd {
color: var(--gray-light);
}
.code-cmnt {
color: #ccc;
}
.code-str {
color: var(--gray-light);
}
.copyright {
font-style: italic;
}
.flexCol {
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 25rem;
width: 100%;
}
.flexRow {
align-items: center;
display: flex;
flex-direction: column;
justify-content: space-around;
padding: .5rem 1rem;
}
.tab {
border: 2px solid transparent;
border-top: 2px solid var(--fg-main);
border-radius: .5rem .5rem 0 0;
display: block;
}
.tab-link {
background-color: var(--bg-main);
border-radius: .5rem .5rem 0 0;
border-bottom: 0;
color: var(--main-link);
display: block;
padding: 1rem 2rem;
position: relative;
text-decoration: none;
}
.tab-panel {
bottom: 0;
display: none;
left: 0;
overflow: auto;
padding: 0 1rem 1rem;
right: 0;
top: 4.25rem;
width: 100%;
z-index: -2;
}
.tabs {
border: 2px solid var(--fg-main);
border-top-color: transparent;
border-radius: .5rem .5rem 0 0;
display: flex;
flex-direction: column;
list-style: none;
margin: 0;
min-height: 100vh;
padding: 0;
}
.tab:target .tab-panel {
display: block;
}
body:not(:has(:target)) #tabOverview-panel {
display: block;
}
.tab:target .tab-link {
background: linear-gradient(var(--bg-dark), var(--bg-main));
}
.tab:target + .tab {
margin-top: auto;
}
body:not(:has(:target)) #tabOverview-link {
background-color: var(--bg-dark);
color: var(--fg-main);
}
.themeToggle-button {
background-color: var(--fg-main);
border: 1px solid var(--gray-light);
border-radius: 1rem;
bottom: .5rem;
color: var(--gray-light);
display: flex;
font-size: 1.5rem;
padding: .5rem;
position: fixed;
right: .5rem;
z-index: 100;
}
.themeToggle-svgIndicator {
rotate: 180deg;
transition: rotate .5s;
}
/** Loader from https://www.cssportal.com/css-loader-generator/ */
.dotLoader {
animation: dotLoaderFrames 1s infinite steps(6);
background:
linear-gradient(var(--bg-main) 0 0) left -25% top 0 /20% 100% no-repeat var(--fg-main);
display: block;
height: 20px;
margin-top: 1rem;
mask: linear-gradient(90deg,var(--bg-main) 70%,#0000 0) left/20% 100%;
-webkit-mask: linear-gradient(90deg,var(--bg-main) 70%,#0000 0) left/20% 100%;
transition: opacity .5s;
width: 120px;
}
@keyframes dotLoaderFrames {
100% {background-position: right -25% top 0}
}
/** ID Overrides */
#formHash .flexCol {
min-width: 15rem;
width: 50%;
}
#formHash input[type="submit"] {
margin-top: 1rem;
min-width: 5rem;
}
#formHash textarea {
min-height: 6rem;
width: 100%
}
#formLoadAndParse .flexCol {
width: 100%;
max-width: 25rem;
}
#formLoadAndParse label {
width: 100%;
max-width: 25rem;
}
/** Media-Query Overrides */
@media (min-width: 900px) {
.flexRow {
flex-direction: row;
}
.tab {
border-color: transparent;
display: inline-block;
position: static;
}
.tab-link {
background-color: transparent;;
border: 2px solid var(--fg-main);
border-bottom-color: transparent;
margin-top: -4.5rem;
}
.tab-link:hover {
background-color: var(--link-active);
border-radius: .5rem .5rem 0 0;
color: var(--bg-main);
font-size: 1.2rem;
margin-top: -4.65rem;
}
.tab-panel {
animation: riseInTab .5s ease-in-out;
margin-top: -4rem;
position: relative;
padding: 0 2rem;
}
.tabs {
border-top: 2px solid var(--fg-main);
bottom: 0;
flex-direction: row;
left: 0;
margin-top: 4.5rem;
min-height: auto;
padding: 1rem;
position: absolute;
right: 0;
top: 0;
}
.tab:target + .tab {
margin-top: 0;
}
.tab:target .tab-link {
border-bottom: 0;
font-size: 1.2rem;
margin-top: -4.55rem;
}
.tab:target .tab-link:hover {
border-radius: .5rem .5rem 0 0;
background-color: var(--bg-dark);
color: var(--main-link);
font-size: 1.2rem;
margin-top: -4.55rem;
}
.tab:target .tab-panel {
position: absolute;
z-index: 1;
}
body:not(:has(:target)) #tabOverview-panel {
display: block;
position: absolute;
z-index: 1;
}
.themeToggle-button {
bottom: auto;
position: absolute;
right: 1rem;
top: 1rem;
}
}
/** State-Based Overrides */
body.invertedTheme {
background-color: var(--fg-main);
color: var(--bg-main);
}
body.invertedTheme a:hover {
background-color: var(--main-link);
border-color: var(--bg-dark);
color: var(--fg-main);
}
body.invertedTheme figcaption {
border-color: var(--bg-main);
}
body.invertedTheme figure {
background-color: var(--gray-light);
border-color: var(--bg-main);
}
body.invertedTheme input,
body.invertedTheme textarea {
background-color: var(--fg-light);
}
body.invertedTheme pre {
background-color: var(--bg-light);
border-color: var(--bg-main);
color: var(--bg-main);
}
body.invertedTheme .code-cmd {
color: var(--bg-dark);
}
body.invertedTheme .code-str {
color: var(--bg-dark);
}
body.invertedTheme .tab-link {
color: var(--bg-dark);
background-color: var(--fg-main);
border-color: var(--bg-main);
}
body.invertedTheme .tab:target .tab-link {
background: linear-gradient(var(--link-active), var(--fg-main));
}
body.invertedTheme .tab-link:hover {
background-color: var(--main-link);
border-color: var(--bg-dark);
}
body.invertedTheme .tab:target .tab-link {
border-color: var(--bg-main);
}
body.invertedTheme .tab-panel {
border-color: var(--bg-main);
}
body.invertedTheme .tabs {
border: 2px solid var(--bg-main);
}
body.invertedTheme .themeToggle-button {
background-color: var(--bg-main);
color: var(--main-link);
}
body.invertedTheme .themeToggle-svgIndicator {
rotate: 0deg;
}
.isLoaded .dotLoader {
margin: 0;
max-height: 0;
opacity: 0;
}
.isLoading .dotLoader {
opacity: 1;
}
a[href^='http']::after {
content: '\2197'; /* Code for ↗ */
display: inline-block;
margin-left: 5px;
font-size: 0.9em;
}

View File

@@ -0,0 +1,29 @@
document.addEventListener("DOMContentLoaded", () => {
const toggle = document.createElement("button");
toggle.classList.add("themeToggle-button");
toggle.setAttribute("id", "themeToggle-button");
toggle.addEventListener("click", () => {
document.body.classList.toggle("invertedTheme");
});
toggle.innerHTML = `
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
width="1em"
height="1em"
class="themeToggle-svgIndicator"
fill="currentColor"
viewBox="0 0 32 32"
>
<path
d="M16 .5C7.4.5.5 7.4.5 16S7.4 31.5 16 31.5 31.5 24.6 31.5 16 24.6.5
16 .5zm0 28.1V3.4C23 3.4 28.6 9 28.6 16S23 28.6 16 28.6z"
/>
</svg>
`.trim();
document.body.appendChild(toggle);
});

View File

@@ -0,0 +1,20 @@
# 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?
#
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
2025-06-02T18:47:01+01:00 Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.
2025-07-05T00:17:46+02:00 Cardigan jean shorts eu 90's. Kitsch knausgaard culpa, marfa mumblecore portland raclette banjo retro exercitation pariatur snackwave williamsburg.
2025-07-05T19:35:33+03:00 Literally deep v snackwave nostrud pug YOLO yes plz anim. JOMO crucifix bespoke chambray lomo keytar, labore ipsum.
2025-08-05T22:46:11+04:00 Ut letterpress synth hoodie, wayfarers kitsch air plant eu selvage tilde taiyaki grailed cliche ex. Skateboard pariatur non leggings.
2025-08-08T13:49:20+05:00 Ad pug ex hashtag live-edge distillery affogato. Succulents hammock taiyaki biodiesel chartreuse, nulla you probably haven't heard of them four dollar toast quinoa keytar cornhole.
2025-09-09T12:48:04+05:00 Cardigan JOMO blackbird spyplane, whatever commodo pop-up normcore ad yr in eiusmod forage echo park exercitation +1.
2025-10-09T20:33:15+04:00 Culpa snackwave williamsburg, asymmetrical wolf microdosing literally. La croix coloring book jean shorts poutine, 3 wolf moon chicharrones hashtag chillwave affogato green juice.
2025-11-11T12:54:43+03:00 Kickstarter kale chips williamsburg swag sunt disrupt chartreuse jianbing banh mi craft beer anim vaporware readymade.
2025-12-05T17:15:28+02:00 Pinterest Brooklyn direct trade freegan. Health goth consequat bespoke ad hoodie in est ugh. IPhone typewriter lomo venmo. Hashtag chillwave hella lumbersexual in blackbird spyplane yr tbh. Yr waistcoat kogi est neutra hammock mollit. Drinking vinegar godard hell of occaecat direct trade. In 3 wolf moon jianbing bitters, roof party mixtape yuccie.
2026-01-01T11:32:39+01:00 Ethical twee swag, farm-to-table irure semiotics bodega boys umami sriracha stumptown cred four dollar toast tofu photo booth tbh.
2026-02-01T13:13:13+02:00 (#4f5dlsa) <@demo_pirate https://example.org/~pirate/twtxt.txt> Asymmetrical kombucha trust fund jawn gentrify sartorial cloud bread artisan live-edge.

View File

@@ -0,0 +1,30 @@
# nick = demo_pirate
# url = https://example.org/~pirate/twtxt.txt
# avatar = https://i.pravatar.cc/150?img=22
# description = Drink up me hearties, yo ho!
# finger = demo_pirate@example.org
# link = Web https://example.org/~pirate/
# link = Ship https://example.org/~pirate/vessel/
# link = Profile https://example.org/~pirate/profile/
#
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
#
# following = 3
#
2026-02-01T01:23:45Z Prow scuttle parley provost Sail ho shrouds spirits boom mizzenmast yardarm.
2026-02-02T02:24:36Z Swab barque interloper chantey doubloon starboard grog black jack gangway rutters.
2026-02-03T10:48:44Z Pinnace holystone mizzenmast quarter crow's nest nipperkin grog yardarm hempen halter furl.
2026-02-04T11:54:12Z Belay yo-ho-ho keelhaul squiffy black spot yardarm spyglass sheet transom heave to.
2026-02-05T13:56:12Z Deadlights jack lad schooner scallywag dance the hempen jig carouser broadside cable strike colors.
2026-02-06T14:57:58Z Bring a spring upon her cable holystone blow the man down spanker Shiver me timbers to go on account lookout wherry doubloon chase.
2026-02-07T13:58:56Z Trysail Sail ho Corsair red ensign hulk smartly boom jib rum gangway.
2026-02-08T14:26:54Z Case shot Shiver me timbers gangplank crack Jennys tea cup ballast Blimey lee snow crow's nest rutters.
2026-02-09T15:01:53Z Fluke jib scourge of the seven seas boatswain schooner gaff booty Jack Tar transom spirits.
2026-02-10T19:20:15Z Spyglass snow quarter wench cutlass coxswain scurvy landlubber or just lubber parley walk the plank.
2026-02-11T19:34:20Z Barbary Coast transom broadside clap of thunder dead men tell no tales gangplank barque bilge rat brigantine list.
2026-02-12T13:17:54Z Prow swab tender case shot aye capstan brigantine loot bucko take a caulk.

View File

@@ -0,0 +1,30 @@
# nick = demo_sagan
# url = https://example.net/~saganos/twtxt.txt
# avatar = https://i.pravatar.cc/150?img=69
# description = Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit?
# finger = sagaan@example.net
# link = Web https://example.net/~saganos/
#
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
#
# following = 3
#
2026-02-05T23:17:47Z The ash of stellar alchemy permanence of the stars extraordinary claims require extraordinary evidence rings of Uranus vanquish the impossible encyclopaedia galactica?
2026-01-22T12:09:44Z Something incredible is waiting to be known another world hearts of the stars tendrils of gossamer clouds a still more glorious dawn awaits venture.
2026-01-10T13:44:37Z Dream of the mind's eye citizens of distant epochs a still more glorious dawn awaits preserve and cherish that pale blue dot hearts of the stars preserve and cherish that pale blue dot and billions upon billions upon billions upon billions upon billions upon billions upon billions.
2026-01-01T02:05:04Z With pretty stories for which there's little good evidence muse about a billion trillion globular star cluster inconspicuous motes of rock and gas dream of the mind's eye.
2025-12-19T20:34:01Z Emerged into consciousness the carbon in our apple pies a very small stage in a vast cosmic arena extraordinary claims require extraordinary evidence from which we spring a mote of dust suspended in a sunbeam.
2025-12-15T20:18:08Z Finite but unbounded kindling the energy hidden in matter from which we spring vastness is bearable only through love vastness is bearable only through love billions upon billions.
2025-12-11T18:54:56Z Venture emerged into consciousness Vangelis how far away rings of Uranus radio telescope.
2025-12-10T17:53:00Z A mote of dust suspended in a sunbeam concept of the number one made in the interiors of collapsing stars vastness is bearable only through love across the centuries inconspicuous motes of rock and gas.
2025-11-08T15:23:30Z The ash of stellar alchemy another world at the edge of forever kindling the energy hidden in matter bits of moving fluff star stuff harvesting star light.
2025-11-07T14:49:29Z Network of wormholes citizens of distant epochs a mote of dust suspended in a sunbeam hearts of the stars are creatures of the cosmos network of wormholes.
2025-11-03T14:06:39Z Ship of the imagination the carbon in our apple pies dream of the mind's eye concept of the number one science as a patch of light.
2025-11-02T14:25:45Z The sky calls to us of brilliant syntheses tingling of the spine a very small stage in a vast cosmic arena venture the only home we've ever known.
2025-11-01T13:46:45Z Across the centuries take root and flourish preserve and cherish that pale blue dot from which we spring great turbulent clouds muse about.
2025-10-25T12:58:20Z White dwarf a mote of dust suspended in a sunbeam muse about globular star cluster cosmic ocean circumnavigated.
2025-10-24T12:29:20Z Are creatures of the cosmos another world citizens of distant epochs courage of our questions are creatures of the cosmos venture.
2025-10-23T12:49:02Z Something incredible is waiting to be known the only home we've ever known courage of our questions hydrogen atoms vastness is bearable only through love a very small stage in a vast cosmic arena.

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import hashTwt from "../hashTwt.ts";
describe("hashtwt", () => {
it("should correctly provide hash for known good twt", () => {
// https://twtxt.net/twt/g524g5q
const created = "2025-08-09T21:33:43-04:00";
const twt = {
content: "Is this thing on?",
created,
createdUTC: "",
url: "http://itsericwoodward.com/twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("g524g5q");
});
it("should correctly provide hash for twt", () => {
const created = "2025-03-02T18:47:01+01:00";
// dayjs.utc(created).toISOString();
const twt = {
content:
"Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.",
created,
createdUTC: "",
url: "https://example.com/demo-hipster-twtxt.txt",
};
const result = hashTwt(twt);
expect(result).toEqual("tvjursa");
});
});

View File

@@ -0,0 +1,105 @@
export const hipsterMockData = {
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",
},
{
content:
"Literally deep v snackwave nostrud pug YOLO yes plz anim. JOMO crucifix bespoke chambray lomo keytar, labore ipsum.",
created: "2025-07-05T19:35:33+03:00",
createdUTC: "2025-07-05T16:35:33.000Z",
hash: "5yjtq4a",
},
{
content:
"Ut letterpress synth hoodie, wayfarers kitsch air plant eu selvage tilde taiyaki grailed cliche ex. Skateboard pariatur non leggings.",
created: "2025-08-05T22:46:11+04:00",
createdUTC: "2025-08-05T18:46:11.000Z",
hash: "gtotdwq",
},
{
content:
"Ad pug ex hashtag live-edge distillery affogato. Succulents hammock taiyaki biodiesel chartreuse, nulla you probably haven't heard of them four dollar toast quinoa keytar cornhole.",
created: "2025-08-08T13:49:20+05:00",
createdUTC: "2025-08-08T08:49:20.000Z",
hash: "2chryga",
},
{
content:
"Cardigan JOMO blackbird spyplane, whatever commodo pop-up normcore ad yr in eiusmod forage echo park exercitation +1.",
created: "2025-09-09T12:48:04+05:00",
createdUTC: "2025-09-09T07:48:04.000Z",
hash: "oqby2ja",
},
{
content:
"Culpa snackwave williamsburg, asymmetrical wolf microdosing literally. La croix coloring book jean shorts poutine, 3 wolf moon chicharrones hashtag chillwave affogato green juice.",
created: "2025-10-09T20:33:15+04:00",
createdUTC: "2025-10-09T16:33:15.000Z",
hash: "e4ylk3a",
},
{
content:
"Kickstarter kale chips williamsburg swag sunt disrupt chartreuse jianbing banh mi craft beer anim vaporware readymade.",
created: "2025-11-11T12:54:43+03:00",
createdUTC: "2025-11-11T09:54:43.000Z",
hash: "bdsl7tq",
},
{
content:
"Pinterest Brooklyn direct trade freegan. Health goth consequat bespoke ad hoodie in est ugh. IPhone typewriter lomo venmo. Hashtag chillwave hella lumbersexual in blackbird spyplane yr tbh. Yr waistcoat kogi est neutra hammock mollit. Drinking vinegar godard hell of occaecat direct trade. In 3 wolf moon jianbing bitters, roof party mixtape yuccie.",
created: "2025-12-05T17:15:28+02:00",
createdUTC: "2025-12-05T15:15:28.000Z",
hash: "qa45s7a",
},
{
content:
"Ethical twee swag, farm-to-table irure semiotics bodega boys umami sriracha stumptown cred four dollar toast tofu photo booth tbh.",
created: "2026-01-01T11:32:39+01:00",
createdUTC: "2026-01-01T10:32:39.000Z",
hash: "rbl3dxq",
},
{
content:
"(#4f5dlsa) <@demo_pirate https://example.org/~pirate/twtxt.txt> Asymmetrical kombucha trust fund jawn gentrify sartorial cloud bread artisan live-edge.",
created: "2026-02-01T13:13:13+02:00",
createdUTC: "2026-02-01T11:13:13.000Z",
hash: "drsaq7q",
replyHash: "4f5dlsa",
replyNick: "demo_pirate",
replyUrl: "https://example.org/~pirate/twtxt.txt",
},
],
};

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import parseTwtxt from "../parseTwtxt.ts";
import { hipsterMockData } from "./parseTwtxt.mocks.ts";
export const loadFileFromSrc = async (...filePathFromSrc: string[]) => {
try {
return readFile(resolve(__dirname, "..", ...filePathFromSrc), "utf8");
} catch (error) {
console.error("Failed to read file: ", error);
}
};
describe("parseTwtxt", () => {
it("parses hipster file", async () => {
const fileText = await loadFileFromSrc(
"twtxt-demos",
"demo-hipster-twtxt.txt",
);
const fileData = parseTwtxt(fileText ?? "");
expect(fileData).toEqual(hipsterMockData);
});
});

1
src/base32.js.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "base32.js";

4
src/constants.ts Normal file
View File

@@ -0,0 +1,4 @@
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
export const __dirname = dirname(fileURLToPath(import.meta.url));

57
src/hashTwt.ts Normal file
View File

@@ -0,0 +1,57 @@
import { Buffer } from "buffer";
globalThis.Buffer = Buffer;
import type { Twt } from "./types.ts";
import { blake2b } from "@exodus/blakejs";
import { base32Encode } from "./utils.ts";
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: string) => {
const pad = (num: number | string = 0) => `${+num < 10 ? 0 : ""}${+num}`;
const padYear = (num: number | string = 0) =>
`${+num < 1000 ? 0 : ""}${+num < 100 ? 0 : ""}${
+num < 10 ? 0 : ""
}${+num}`;
let m = dateRegex.exec(date);
//if timezone is undefined, it must be Z or nothing (otherwise the group would have captured).
if (m && m?.[9] === undefined) {
//Use UTC.
m[9] = "+00";
}
if (m && m?.[10] === undefined) {
m[10] = "00";
}
const offset = `${m?.[9]}:${m?.[10]}`.replace(/[+-]?00:00$/, "Z");
return [
padYear(m?.[1]),
"-",
pad(m?.[2]),
"-",
pad(m?.[3]),
m?.[4],
pad(m?.[5]),
":",
pad(m?.[6]),
":",
pad(m?.[7]),
//ignore milliseconds (m[8])
offset,
].join("");
};
export default function hashTwt(twt: Twt): string {
const created = formatRFC3339(twt.created);
const payload = [twt.url, created, twt.content].join("\n");
return base32Encode(blake2b(payload, undefined, 32))
.toLowerCase()
.slice(-7);
}

6
src/index.ts Normal file
View File

@@ -0,0 +1,6 @@
export type * from "./types.ts";
export { default as hashTwt } from "./hashTwt.ts";
export { default as loadAndParseTwtxtFile } from "./loadAndParseTwtxt.ts";
export { default as parseTwtxt } from "./parseTwtxt.ts";
export { base32Encode } from "./utils.ts";

23
src/loadAndParseTwtxt.ts Normal file
View File

@@ -0,0 +1,23 @@
import dayjs from "dayjs";
import parseTwtxt from "./parseTwtxt.js";
export default async function loadAndParseTwtxtFile(url = "") {
if (!url) throw new Error("URL is required");
try {
const response = await fetch(url);
const twtxtFile = await response.text();
const lastModified = dayjs(
response.headers.get("Last-Modified"),
).toISOString();
return {
...parseTwtxt(twtxtFile),
lastModified,
};
} catch (err) {
console.error(err);
throw err;
}
}

123
src/parseTwtxt.ts Normal file
View File

@@ -0,0 +1,123 @@
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc.js";
import type { Metadata, Twttr } from "./types.ts";
import hashTwt from "./hashTwt.js";
import { getValueOrFirstEntry } from "./utils.ts";
dayjs.extend(utc);
/**
* @param twtxt
* @returns object containing: following, metadata, twts
*/
export default function parseTwtxt(twtxt: string) {
const allLines = twtxt.split("\n");
const { commentLines = [], contentLines = [] } = allLines.reduce(
(
acc: {
commentLines: string[];
contentLines: string[];
},
originalLine,
) => {
const line = originalLine.trim();
if (line === "") return acc;
if (line.startsWith("#")) acc.commentLines.push(line);
else acc.contentLines.push(line);
return acc;
},
{
commentLines: [],
contentLines: [],
},
);
const { following = [], metadata = {} } = commentLines
.filter((line) => line.includes("="))
.reduce(
(
acc: {
following: Twttr[];
metadata: Metadata;
},
line,
) => {
const [key, ...vals] = line
// remove #
.substring(1)
.split("=")
.map((field) => field.trim());
const val = vals.join("=");
if (key === "follow") {
const [nick, url] = val.trim().split(/\s+/);
acc.following.push({ nick, url });
} else {
if (acc.metadata[key]) {
if (!Array.isArray(acc.metadata[key]))
acc.metadata[key] = [acc.metadata[key], val];
else acc.metadata[key].push(val);
} else acc.metadata[key] = val;
}
return acc;
},
{
following: [],
metadata: {},
},
);
const replyRegEx = /\(#([\w]+)\) (\<\@(\S+) ([^>]+)>)*/;
const twts = contentLines
.map((line) => {
const [created, content] = line
.split(/\t/)
.map((val) => val.trim());
if (typeof content === "undefined")
throw new Error(`Content is undefined: ${line}`);
const createdDayjs = dayjs.utc(created);
if (!createdDayjs.isValid())
throw new Error(`Date is invalid: ${line}`);
const createdUTC = createdDayjs.toISOString();
const replyMatches = replyRegEx.exec(content);
let replyHash, replyNick, replyUrl;
if (replyMatches?.length) {
replyHash = replyMatches?.[1];
replyNick = replyMatches?.[3];
replyUrl = replyMatches?.[4];
}
const hash = hashTwt({
content,
created,
createdUTC,
url: getValueOrFirstEntry(metadata?.url ?? ""),
});
return {
content,
created,
createdUTC,
hash,
replyHash,
replyNick,
replyUrl,
};
})
.sort((a, b) => dayjs(a.created).diff(dayjs(b.created)));
return {
following,
metadata,
twts,
};
}

View File

@@ -0,0 +1,20 @@
# 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?
#
# follow = demo_hipster https://example.com/demo-hipster-twtxt.txt
# follow = demo_pirate https://example.org/~pirate/twtxt.txt
# follow = demo_sagan https://example.net/~saganos/twtxt.txt
2025-06-02T18:47:01+01:00 Normcore tilde ad selfies, culpa cupping nostrud gatekeep aesthetic PBR&B 3 wolf moon mustache twee.
2025-07-05T00:17:46+02:00 Cardigan jean shorts eu 90's. Kitsch knausgaard culpa, marfa mumblecore portland raclette banjo retro exercitation pariatur snackwave williamsburg.
2025-07-05T19:35:33+03:00 Literally deep v snackwave nostrud pug YOLO yes plz anim. JOMO crucifix bespoke chambray lomo keytar, labore ipsum.
2025-08-05T22:46:11+04:00 Ut letterpress synth hoodie, wayfarers kitsch air plant eu selvage tilde taiyaki grailed cliche ex. Skateboard pariatur non leggings.
2025-08-08T13:49:20+05:00 Ad pug ex hashtag live-edge distillery affogato. Succulents hammock taiyaki biodiesel chartreuse, nulla you probably haven't heard of them four dollar toast quinoa keytar cornhole.
2025-09-09T12:48:04+05:00 Cardigan JOMO blackbird spyplane, whatever commodo pop-up normcore ad yr in eiusmod forage echo park exercitation +1.
2025-10-09T20:33:15+04:00 Culpa snackwave williamsburg, asymmetrical wolf microdosing literally. La croix coloring book jean shorts poutine, 3 wolf moon chicharrones hashtag chillwave affogato green juice.
2025-11-11T12:54:43+03:00 Kickstarter kale chips williamsburg swag sunt disrupt chartreuse jianbing banh mi craft beer anim vaporware readymade.
2025-12-05T17:15:28+02:00 Pinterest Brooklyn direct trade freegan. Health goth consequat bespoke ad hoodie in est ugh. IPhone typewriter lomo venmo. Hashtag chillwave hella lumbersexual in blackbird spyplane yr tbh. Yr waistcoat kogi est neutra hammock mollit. Drinking vinegar godard hell of occaecat direct trade. In 3 wolf moon jianbing bitters, roof party mixtape yuccie.
2026-01-01T11:32:39+01:00 Ethical twee swag, farm-to-table irure semiotics bodega boys umami sriracha stumptown cred four dollar toast tofu photo booth tbh.
2026-02-01T13:13:13+02:00 (#4f5dlsa) <@demo_pirate https://example.org/~pirate/twtxt.txt> Asymmetrical kombucha trust fund jawn gentrify sartorial cloud bread artisan live-edge.

35
src/types.ts Normal file
View File

@@ -0,0 +1,35 @@
export interface LoadAndParseTwtxtWithCacheConfig {
cacheKeyPrefix: string;
onLoad?: (data: Twtxt) => void;
user?: Twttr;
}
export interface Metadata {
[key: string]: string | string[];
}
export interface Twt {
avatar?: string;
content: string;
created: string;
createdUTC: string;
hash?: string;
nick?: string;
noDom?: boolean;
replyHash?: string;
replyNick?: string;
replyUrl?: string;
url?: string;
}
export interface Twttr {
avatar?: string;
nick: string;
url: string;
}
export interface Twtxt {
following: Twttr[];
metadata: Metadata;
twts: Twt[];
}

9
src/utils.ts Normal file
View File

@@ -0,0 +1,9 @@
import base32 from "base32.js";
export const base32Encode = (payload: string | Uint8Array<ArrayBufferLike>) => {
const encoder = new base32.Encoder({ type: "rfc4648" });
return encoder.write(payload).finalize();
};
export const getValueOrFirstEntry = (value: unknown | unknown[]) =>
Array.isArray(value) && value.length ? value[0] : value;

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "es6",
"useDefineForClassFields": true,
"module": "nodenext",
"skipLibCheck": true,
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"typeRoots": ["./node_modules/@types/"]
},
}

32
vite.config.js Normal file
View File

@@ -0,0 +1,32 @@
import dts from "unplugin-dts/vite";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import { nodePolyfills } from "vite-plugin-node-polyfills";
const __dirname = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
dts({ exclude: ["**/__tests__/*.*"] }),
nodePolyfills({
// Specify which polyfills to include (optional, but recommended for bundle size)
include: ["buffer"], // Only polyfill 'buffer' (add others like 'process' if needed)
// Configure global variables (e.g., expose Buffer to window)
globals: {
Buffer: true, // Expose Buffer as a global variable (optional but useful for some cases)
},
}),
],
build: {
lib: {
entry: resolve(__dirname, "src/index.ts"),
name: "twtxt-lib",
fileName: "index",
formats: ["es"],
outDir: "./dist",
},
minify: false,
},
});

3349
yarn.lock Normal file

File diff suppressed because it is too large Load Diff