initial public commit
This commit is contained in:
17
.editorconfig
Normal file
17
.editorconfig
Normal 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
4
.gitattributes
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/.yarn/** linguist-vendored
|
||||
/.yarn/releases/* binary
|
||||
/.yarn/plugins/**/* binary
|
||||
/.pnp.* binary linguist-generated
|
||||
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
|
||||
1
.license-fragments/license-begin.txt
Normal file
1
.license-fragments/license-begin.txt
Normal file
@@ -0,0 +1 @@
|
||||
// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt
|
||||
1
.license-fragments/license-end.txt
Normal file
1
.license-fragments/license-end.txt
Normal file
@@ -0,0 +1 @@
|
||||
// @license-end
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"useTabs": true
|
||||
}
|
||||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"blakejs",
|
||||
"btih",
|
||||
"twts",
|
||||
"Twttr",
|
||||
"Twtxt",
|
||||
"unplugin"
|
||||
]
|
||||
}
|
||||
1
.yarnrc.yml
Normal file
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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
217
README.md
Normal 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
184
build.js
Normal 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
1
dist-browser/constants.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare const __dirname: string;
|
||||
2
dist-browser/hashTwt.d.ts
vendored
Normal file
2
dist-browser/hashTwt.d.ts
vendored
Normal 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
5
dist-browser/index.d.ts
vendored
Normal 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
14
dist-browser/loadAndParseTwtxt.d.ts
vendored
Normal 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
18
dist-browser/parseTwtxt.d.ts
vendored
Normal 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
3401
dist-browser/twtxt-lib.js
Normal file
File diff suppressed because it is too large
Load Diff
1
dist-browser/twtxt-lib.js.map
Normal file
1
dist-browser/twtxt-lib.js.map
Normal file
File diff suppressed because one or more lines are too long
2318
dist-browser/twtxt-lib.min.js
vendored
Normal file
2318
dist-browser/twtxt-lib.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
dist-browser/twtxt-lib.min.js.map
Normal file
1
dist-browser/twtxt-lib.min.js.map
Normal file
File diff suppressed because one or more lines are too long
31
dist-browser/types.d.ts
vendored
Normal file
31
dist-browser/types.d.ts
vendored
Normal 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
2
dist-browser/utils.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export declare const base32Encode: (payload: string | Uint8Array<ArrayBufferLike>) => any;
|
||||
export declare const getValueOrFirstEntry: (value: unknown | unknown[]) => any;
|
||||
6
dist-demo/demo/add-default-route.js
Normal file
6
dist-demo/demo/add-default-route.js
Normal file
@@ -0,0 +1,6 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// add default #overview route
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = "overview";
|
||||
}
|
||||
});
|
||||
12
dist-demo/demo/external-links.js
Normal file
12
dist-demo/demo/external-links.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
44
dist-demo/demo/format-source.js
Normal file
44
dist-demo/demo/format-source.js
Normal 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(
|
||||
/<\/?script[^&]*>/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);
|
||||
}
|
||||
63
dist-demo/demo/hashTwt-example-result.js
Normal file
63
dist-demo/demo/hashTwt-example-result.js
Normal 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;
|
||||
});
|
||||
40
dist-demo/demo/hashTwt-example-source.js
Normal file
40
dist-demo/demo/hashTwt-example-source.js
Normal 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",
|
||||
);
|
||||
79
dist-demo/demo/loadAndParseTwtxtFile-example-result.js
Normal file
79
dist-demo/demo/loadAndParseTwtxtFile-example-result.js
Normal 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);
|
||||
});
|
||||
25
dist-demo/demo/loadAndParseTwtxtFile-example-source.js
Normal file
25
dist-demo/demo/loadAndParseTwtxtFile-example-source.js
Normal 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",
|
||||
);
|
||||
36
dist-demo/demo/overview-example-result.js
Normal file
36
dist-demo/demo/overview-example-result.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
24
dist-demo/demo/overview-example-source.js
Normal file
24
dist-demo/demo/overview-example-source.js
Normal 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",
|
||||
);
|
||||
76
dist-demo/demo/parseTwtxt-example-result.js
Normal file
76
dist-demo/demo/parseTwtxt-example-result.js
Normal 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);
|
||||
},
|
||||
);
|
||||
27
dist-demo/demo/parseTwtxt-example-source.js
Normal file
27
dist-demo/demo/parseTwtxt-example-source.js
Normal 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
471
dist-demo/demo/styles.css
Normal 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;
|
||||
}
|
||||
29
dist-demo/demo/theme-toggle.js
Normal file
29
dist-demo/demo/theme-toggle.js
Normal 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
1
dist-demo/dist-browser/constants.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare const __dirname: string;
|
||||
2
dist-demo/dist-browser/hashTwt.d.ts
vendored
Normal file
2
dist-demo/dist-browser/hashTwt.d.ts
vendored
Normal 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
5
dist-demo/dist-browser/index.d.ts
vendored
Normal 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-demo/dist-browser/loadAndParseTwtxt.d.ts
vendored
Normal file
14
dist-demo/dist-browser/loadAndParseTwtxt.d.ts
vendored
Normal 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
18
dist-demo/dist-browser/parseTwtxt.d.ts
vendored
Normal 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-demo/dist-browser/twtxt-lib.js
Normal file
3401
dist-demo/dist-browser/twtxt-lib.js
Normal file
File diff suppressed because it is too large
Load Diff
1
dist-demo/dist-browser/twtxt-lib.js.map
Normal file
1
dist-demo/dist-browser/twtxt-lib.js.map
Normal file
File diff suppressed because one or more lines are too long
2318
dist-demo/dist-browser/twtxt-lib.min.js
vendored
Normal file
2318
dist-demo/dist-browser/twtxt-lib.min.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
1
dist-demo/dist-browser/twtxt-lib.min.js.map
Normal file
1
dist-demo/dist-browser/twtxt-lib.min.js.map
Normal file
File diff suppressed because one or more lines are too long
31
dist-demo/dist-browser/types.d.ts
vendored
Normal file
31
dist-demo/dist-browser/types.d.ts
vendored
Normal 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
2
dist-demo/dist-browser/utils.d.ts
vendored
Normal 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
297
dist-demo/index.html
Normal 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
|
||||
“twt” 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'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 © 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 “twt” 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>
|
||||
20
dist-demo/twtxt-demos/demo-hipster-twtxt.txt
Normal file
20
dist-demo/twtxt-demos/demo-hipster-twtxt.txt
Normal 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.
|
||||
30
dist-demo/twtxt-demos/demo-pirate-twtxt.txt
Normal file
30
dist-demo/twtxt-demos/demo-pirate-twtxt.txt
Normal 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.
|
||||
30
dist-demo/twtxt-demos/demo-sagan-twtxt.txt
Normal file
30
dist-demo/twtxt-demos/demo-sagan-twtxt.txt
Normal 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
1
dist-node/constants.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
export declare const __dirname: string;
|
||||
2
dist-node/hashTwt.d.ts
vendored
Normal file
2
dist-node/hashTwt.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
import { Twt } from './types.ts';
|
||||
export default function hashTwt(twt: Twt): string;
|
||||
41
dist-node/hashTwt.js
Normal file
41
dist-node/hashTwt.js
Normal 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
1
dist-node/hashTwt.js.map
Normal 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
5
dist-node/index.d.ts
vendored
Normal 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
13
dist-node/index.js
Normal 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
1
dist-node/index.js.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;"}
|
||||
14
dist-node/loadAndParseTwtxt.d.ts
vendored
Normal file
14
dist-node/loadAndParseTwtxt.d.ts
vendored
Normal 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;
|
||||
}[];
|
||||
}>;
|
||||
23
dist-node/loadAndParseTwtxt.js
Normal file
23
dist-node/loadAndParseTwtxt.js
Normal 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
|
||||
1
dist-node/loadAndParseTwtxt.js.map
Normal file
1
dist-node/loadAndParseTwtxt.js.map
Normal 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
18
dist-node/parseTwtxt.d.ts
vendored
Normal 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
83
dist-node/parseTwtxt.js
Normal 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
|
||||
1
dist-node/parseTwtxt.js.map
Normal file
1
dist-node/parseTwtxt.js.map
Normal file
File diff suppressed because one or more lines are too long
31
dist-node/types.d.ts
vendored
Normal file
31
dist-node/types.d.ts
vendored
Normal 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
2
dist-node/utils.d.ts
vendored
Normal 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
11
dist-node/utils.js
Normal 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
1
dist-node/utils.js.map
Normal 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
297
index.html
Normal 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
|
||||
“twt” 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'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 © 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 “twt” 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
44
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
public/demo/add-default-route.js
Normal file
6
public/demo/add-default-route.js
Normal file
@@ -0,0 +1,6 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// add default #overview route
|
||||
if (!window.location.hash) {
|
||||
window.location.hash = "overview";
|
||||
}
|
||||
});
|
||||
12
public/demo/external-links.js
Normal file
12
public/demo/external-links.js
Normal 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
44
public/demo/format-source.js
Normal file
44
public/demo/format-source.js
Normal 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(
|
||||
/<\/?script[^&]*>/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);
|
||||
}
|
||||
63
public/demo/hashTwt-example-result.js
Normal file
63
public/demo/hashTwt-example-result.js
Normal 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;
|
||||
});
|
||||
40
public/demo/hashTwt-example-source.js
Normal file
40
public/demo/hashTwt-example-source.js
Normal 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",
|
||||
);
|
||||
79
public/demo/loadAndParseTwtxtFile-example-result.js
Normal file
79
public/demo/loadAndParseTwtxtFile-example-result.js
Normal 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);
|
||||
});
|
||||
25
public/demo/loadAndParseTwtxtFile-example-source.js
Normal file
25
public/demo/loadAndParseTwtxtFile-example-source.js
Normal 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",
|
||||
);
|
||||
36
public/demo/overview-example-result.js
Normal file
36
public/demo/overview-example-result.js
Normal 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);
|
||||
}
|
||||
})();
|
||||
24
public/demo/overview-example-source.js
Normal file
24
public/demo/overview-example-source.js
Normal 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",
|
||||
);
|
||||
76
public/demo/parseTwtxt-example-result.js
Normal file
76
public/demo/parseTwtxt-example-result.js
Normal 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);
|
||||
},
|
||||
);
|
||||
27
public/demo/parseTwtxt-example-source.js
Normal file
27
public/demo/parseTwtxt-example-source.js
Normal 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
471
public/demo/styles.css
Normal 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;
|
||||
}
|
||||
29
public/demo/theme-toggle.js
Normal file
29
public/demo/theme-toggle.js
Normal 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);
|
||||
});
|
||||
20
public/twtxt-demos/demo-hipster-twtxt.txt
Normal file
20
public/twtxt-demos/demo-hipster-twtxt.txt
Normal 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.
|
||||
30
public/twtxt-demos/demo-pirate-twtxt.txt
Normal file
30
public/twtxt-demos/demo-pirate-twtxt.txt
Normal 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.
|
||||
30
public/twtxt-demos/demo-sagan-twtxt.txt
Normal file
30
public/twtxt-demos/demo-sagan-twtxt.txt
Normal 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.
|
||||
32
src/__tests__/hashTwt.test.ts
Normal file
32
src/__tests__/hashTwt.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
105
src/__tests__/parseTwtxt.mocks.ts
Normal file
105
src/__tests__/parseTwtxt.mocks.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
25
src/__tests__/parseTwtxt.test.ts
Normal file
25
src/__tests__/parseTwtxt.test.ts
Normal 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
1
src/base32.js.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "base32.js";
|
||||
4
src/constants.ts
Normal file
4
src/constants.ts
Normal 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
57
src/hashTwt.ts
Normal 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
6
src/index.ts
Normal 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
23
src/loadAndParseTwtxt.ts
Normal 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
123
src/parseTwtxt.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
20
src/twtxt-demos/demo-hipster-twtxt.txt
Normal file
20
src/twtxt-demos/demo-hipster-twtxt.txt
Normal 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
35
src/types.ts
Normal 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
9
src/utils.ts
Normal 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
24
tsconfig.json
Normal 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
32
vite.config.js
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user