This commit is contained in:
tamaina 2025-08-30 17:28:36 +09:00 committed by GitHub
commit cc1e69ea64
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 222 additions and 6 deletions

View File

@ -23,9 +23,13 @@ export type Acct = {
declare namespace acct { declare namespace acct {
export { export {
correctAcct,
parse, parse,
parseUrl,
parseAcctOrUrl,
toString_2 as toString, toString_2 as toString,
Acct Acct,
UrlIsNotAcctLikeError
} }
} }
export { acct } export { acct }
@ -1229,6 +1233,9 @@ type ClipsUpdateRequest = operations['clips___update']['requestBody']['content']
// @public (undocumented) // @public (undocumented)
type ClipsUpdateResponse = operations['clips___update']['responses']['200']['content']['application/json']; type ClipsUpdateResponse = operations['clips___update']['responses']['200']['content']['application/json'];
// @public (undocumented)
function correctAcct(acct: Acct, localHostname?: string): Acct;
// @public (undocumented) // @public (undocumented)
type DateString = string; type DateString = string;
@ -3261,7 +3268,13 @@ type PagesUnlikeRequest = operations['pages___unlike']['requestBody']['content']
type PagesUpdateRequest = operations['pages___update']['requestBody']['content']['application/json']; type PagesUpdateRequest = operations['pages___update']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
function parse(_acct: string): Acct; function parse(acct: string): Acct;
// @public (undocumented)
function parseAcctOrUrl(acct: string, localHostname?: string): Acct;
// @public (undocumented)
function parseUrl(str: string): Acct;
// Warning: (ae-forgotten-export) The symbol "Values" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "Values" needs to be exported by the entry point index.d.ts
// //
@ -3628,6 +3641,11 @@ type TestResponse = operations['test']['responses']['200']['content']['applicati
// @public (undocumented) // @public (undocumented)
function toString_2(acct: Acct): string; function toString_2(acct: Acct): string;
// @public (undocumented)
class UrlIsNotAcctLikeError extends Error {
constructor();
}
// @public (undocumented) // @public (undocumented)
type User = components['schemas']['User']; type User = components['schemas']['User'];

View File

@ -3,13 +3,77 @@ export type Acct = {
host: string | null; host: string | null;
}; };
export function parse(_acct: string): Acct { export function correctAcct(acct: Acct, localHostname?: string): Acct {
let acct = _acct; const result = { ...acct };
if (acct.startsWith('@')) acct = acct.substring(1); if (!acct.host) {
const split = acct.split('@', 2); result.host = null;
} else if (localHostname && acct.host === localHostname) {
result.host = null;
}
return result;
}
export function parse(acct: string): Acct {
let acctWithNoPrefix = acct;
if (acct.startsWith('@')) {
acctWithNoPrefix = acct.substring(1);
} else if (acct.startsWith('acct:')) {
acctWithNoPrefix = acct.substring(5);
}
const split = acctWithNoPrefix.split('@', 2);
return { username: split[0], host: split[1] || null }; return { username: split[0], host: split[1] || null };
} }
export class UrlIsNotAcctLikeError extends Error {
constructor() {
super('This url is not acct like.');
}
}
/**
* Only supports `https?://example.com/@username` or `https?://example.com/@username@other.example.com`
*/
export function parseUrl(str: string): Acct {
const url = new URL(str);
if (url.hash.length > 0) throw new UrlIsNotAcctLikeError();
if (url.search.length > 0) throw new UrlIsNotAcctLikeError();
const splited = url.pathname.split('/');
const path = splited[1];
if (!path) throw new UrlIsNotAcctLikeError();
if (!path.startsWith('@')) throw new UrlIsNotAcctLikeError();
if (path.length <= 1) throw new UrlIsNotAcctLikeError();
const split = path.split('@', 3); // ['', 'username', 'other.example.com']
return { username: split[1], host: split[2] || url.hostname };
}
/**
* Parses a string and returns an Acct object.
* @param acct String to parse
* The string should be in one of the following formats:
* * At-mark style: `@username@example.com`
* * Starts with `acct:`: `acct:username@example.com`
* * URL style: `https://example.com/@username`, `https://self.example.com/@username@other.example.com`
* @param localHostname If provided and matches the host found in acct, the returned `host` will be set to `null`.
* @returns Acct object
*/
export function parseAcctOrUrl(acct: string, localHostname?: string): Acct {
if (acct.startsWith('https://') || acct.startsWith('http://')) {
// url style
return correctAcct(parseUrl(acct), localHostname);
}
// acct style
return correctAcct(parse(acct), localHostname);
}
export function toString(acct: Acct): string { export function toString(acct: Acct): string {
return acct.host == null ? acct.username : `${acct.username}@${acct.host}`; return acct.host == null ? acct.username : `${acct.username}@${acct.host}`;
} }

View File

@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import * as acct from '../src/acct.js';
function testParseAcct(fn: (acct: string) => acct.Acct) {
it('parses plain username', () => {
const res = fn('alice');
expect(res).toEqual({ username: 'alice', host: null });
});
it('parses at-mark style without host', () => {
const res = fn('@alice');
expect(res).toEqual({ username: 'alice', host: null });
});
it('parses at-mark style with host', () => {
const res = fn('@alice@example.com');
expect(res).toEqual({ username: 'alice', host: 'example.com' });
});
it('parses acct: style', () => {
const res = fn('acct:alice@example.com');
expect(res).toEqual({ username: 'alice', host: 'example.com' });
});
it('parse Mr.http', () => {
const res = fn('http');
expect(res).toEqual({ username: 'http', host: null });
});
}
function testParseUrl(fn: (acct: string) => acct.Acct) {
it('parses url style https with same host -> host kept when localHostname not provided', () => {
const res = fn('https://example.com/@alice');
expect(res).toEqual({ username: 'alice', host: 'example.com' });
});
it('parses url style with remote host contained in path', () => {
const res = fn('https://self.example.com/@alice@other.example.com');
expect(res).toEqual({ username: 'alice', host: 'other.example.com' });
});
it('throws on non-acct-like url path (root)', () => {
expect(() => fn('https://example.com')).toThrowError();
});
it('throws on non-acct-like url path (users/alice)', () => {
expect(() => fn('https://example.com/users/alice')).toThrowError();
});
it('throws ended with @', () => {
expect(() => fn('https://example.com/@')).toThrowError();
});
it('parses url ended with /', () => {
const res = fn('https://example.com/@alice/');
expect(res).toEqual({ username: 'alice', host: 'example.com' });
});
it('throws url have @username path but ended with sub directory', () => {
expect(() => fn('https://example.com/@alice/subdir')).toThrowError();
});
it('throws url with search params', () => {
expect(() => fn('https://example.com/@alice?foo=bar')).toThrowError();
});
it('throws url with hash', () => {
expect(() => fn('https://example.com/@alice#fragment')).toThrowError();
});
it('throws not root path', () => {
expect(() => fn('https://example.com/users/@alice')).toThrowError();
});
}
describe('acct.parse', () => {
testParseAcct(acct.parse);
});
describe('acct.parseUrl', () => {
testParseUrl(acct.parseUrl);
});
describe('acct.parseAcctOrUrl', () => {
testParseAcct(acct.parseAcctOrUrl);
testParseUrl(acct.parseAcctOrUrl);
it('parse url with localHostname', () => {
const res = acct.parseAcctOrUrl('https://example.com/@alice', 'example.com');
expect(res).toEqual({ username: 'alice', host: null });
});
it('parse @username with localHostname', () => {
const res = acct.parseAcctOrUrl('@alice', 'example.com');
expect(res).toEqual({ username: 'alice', host: null });
});
});
describe('acct.correctAcct', () => {
it('returns host=null when acct.host is null', () => {
const input: acct.Acct = { username: 'alice', host: null };
const out = acct.correctAcct(input);
expect(out).toEqual({ username: 'alice', host: null });
expect(out).not.toBe(input); // immutability
});
it('keeps host when localHostname not provided', () => {
const input: acct.Acct = { username: 'bob', host: 'example.com' };
const out = acct.correctAcct(input);
expect(out).toEqual({ username: 'bob', host: 'example.com' });
});
it('nulls host when it matches localHostname', () => {
const input: acct.Acct = { username: 'carol', host: 'example.com' };
const out = acct.correctAcct(input, 'example.com');
expect(out).toEqual({ username: 'carol', host: null });
});
it('keeps host when it differs from localHostname', () => {
const input: acct.Acct = { username: 'dave', host: 'other.example.com' };
const out = acct.correctAcct(input, 'example.com');
expect(out).toEqual({ username: 'dave', host: 'other.example.com' });
});
});
describe('acct.toString', () => {
it('returns username when host is null', () => {
expect(acct.toString({ username: 'alice', host: null })).toBe('alice');
});
it('returns username@host when host exists', () => {
expect(acct.toString({ username: 'alice', host: 'example.com' })).toBe('alice@example.com');
});
});