init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
import { eq, gt, gte, lt, lte } from '../version.js';
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _object_spread(target) {
for(var i = 1; i < arguments.length; i++){
var source = arguments[i] != null ? arguments[i] : {};
var ownKeys = Object.keys(source);
if (typeof Object.getOwnPropertySymbols === "function") {
ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {
return Object.getOwnPropertyDescriptor(source, sym).enumerable;
}));
}
ownKeys.forEach(function(key) {
_define_property(target, key, source[key]);
});
}
return target;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) {
symbols = symbols.filter(function(sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
}
keys.push.apply(keys, symbols);
}
return keys;
}
function _object_spread_props(target, source) {
source = source != null ? source : {};
if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function(key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
return target;
}
function compareExtension(descriptor) {
function makeComparable(data) {
var _data_major, _data_minor, _data_patch;
const version = {
major: (_data_major = data.major) !== null && _data_major !== void 0 ? _data_major : 0,
minor: (_data_minor = data.minor) !== null && _data_minor !== void 0 ? _data_minor : 0,
patch: (_data_patch = data.patch) !== null && _data_patch !== void 0 ? _data_patch : 0
};
return _object_spread_props(_object_spread({}, data), {
eq: (value)=>eq(version, value),
gt: (value)=>gt(version, value),
gte: (value)=>gte(version, value),
lt: (value)=>lt(version, value),
lte: (value)=>lte(version, value),
is: (value)=>data.name === value || data.variant === value
});
}
return _object_spread_props(_object_spread({}, descriptor), {
extensions: [
...descriptor.extensions,
'compare'
],
browser: makeComparable(descriptor.browser),
engine: makeComparable(descriptor.engine),
os: makeComparable(descriptor.os)
});
}
export { compareExtension };

View File

@@ -0,0 +1,105 @@
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _object_spread(target) {
for(var i = 1; i < arguments.length; i++){
var source = arguments[i] != null ? arguments[i] : {};
var ownKeys = Object.keys(source);
if (typeof Object.getOwnPropertySymbols === "function") {
ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {
return Object.getOwnPropertyDescriptor(source, sym).enumerable;
}));
}
ownKeys.forEach(function(key) {
_define_property(target, key, source[key]);
});
}
return target;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) {
symbols = symbols.filter(function(sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
}
keys.push.apply(keys, symbols);
}
return keys;
}
function _object_spread_props(target, source) {
source = source != null ? source : {};
if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function(key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
return target;
}
function flagsExtension(descriptor) {
let osName = descriptor.os.name;
const isAndroid = descriptor.os.name === 'android';
let isMacOS = descriptor.os.name === 'macos';
let isIOS = descriptor.os.name === 'ios';
var _descriptor_navigator_maxTouchPoints;
/**
* Newer iPads will identify as macOS in the UserAgent string but can still be differentiated by
* inspecting `maxTouchPoints`. The macOS and iOS values need to be reset when detected.
*/ const isIPadOS = osName === 'ipados' || isIOS && /ipad/i.test(descriptor.ua) || isMacOS && ((_descriptor_navigator_maxTouchPoints = descriptor.navigator.maxTouchPoints) !== null && _descriptor_navigator_maxTouchPoints !== void 0 ? _descriptor_navigator_maxTouchPoints : 0) >= 2;
if (isIPadOS) {
osName = 'ipados';
isIOS = false;
isMacOS = false;
}
const browser = _object_spread_props(_object_spread({}, descriptor.browser), {
isUnknown: descriptor.browser.name === 'unknown',
isSafari: descriptor.browser.name === 'safari',
isChrome: descriptor.browser.name === 'chrome',
isFirefox: descriptor.browser.name === 'firefox',
isEdge: descriptor.browser.name === 'edge',
isWebView: descriptor.browser.name === 'webview',
isOther: descriptor.browser.name === 'other',
isMobile: descriptor.browser.mobile || isIOS || isIPadOS || isAndroid || false
});
const engine = _object_spread_props(_object_spread({}, descriptor.engine), {
isUnknown: descriptor.engine.name === 'unknown',
isWebKit: descriptor.engine.name === 'webkit',
isBlink: descriptor.engine.name === 'blink',
isGecko: descriptor.engine.name === 'gecko'
});
const os = _object_spread_props(_object_spread({}, descriptor.os), {
name: osName,
isUnknown: descriptor.os.name === 'unknown',
isLinux: descriptor.os.name === 'linux',
isWindows: descriptor.os.name === 'windows',
isMacOS,
isAndroid,
isIOS,
isIPadOS
});
return _object_spread_props(_object_spread({}, descriptor), {
extensions: [
...descriptor.extensions,
'flags'
],
browser,
os,
engine
});
}
export { flagsExtension };

View File

@@ -0,0 +1,22 @@
function applyRules(rules, navigator, data) {
const { userAgent } = navigator !== null && navigator !== void 0 ? navigator : {};
if (typeof userAgent !== 'string' || userAgent.trim() === '') {
return data;
}
for (const rule of rules){
const patterns = rule.slice(0, -1);
const parser = rule[rule.length - 1];
let match = null;
for (const pattern of patterns){
match = userAgent.match(pattern);
if (match !== null) {
Object.assign(data, parser(match, navigator, data));
break;
}
}
if (match !== null) break;
}
return data;
}
export { applyRules };

View File

@@ -0,0 +1,392 @@
import { parseVersion } from './version.js';
import { applyRules } from './rules.js';
function _define_property(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _object_spread(target) {
for(var i = 1; i < arguments.length; i++){
var source = arguments[i] != null ? arguments[i] : {};
var ownKeys = Object.keys(source);
if (typeof Object.getOwnPropertySymbols === "function") {
ownKeys = ownKeys.concat(Object.getOwnPropertySymbols(source).filter(function(sym) {
return Object.getOwnPropertyDescriptor(source, sym).enumerable;
}));
}
ownKeys.forEach(function(key) {
_define_property(target, key, source[key]);
});
}
return target;
}
function ownKeys(object, enumerableOnly) {
var keys = Object.keys(object);
if (Object.getOwnPropertySymbols) {
var symbols = Object.getOwnPropertySymbols(object);
if (enumerableOnly) {
symbols = symbols.filter(function(sym) {
return Object.getOwnPropertyDescriptor(object, sym).enumerable;
});
}
keys.push.apply(keys, symbols);
}
return keys;
}
function _object_spread_props(target, source) {
source = source != null ? source : {};
if (Object.getOwnPropertyDescriptors) {
Object.defineProperties(target, Object.getOwnPropertyDescriptors(source));
} else {
ownKeys(Object(source)).forEach(function(key) {
Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key));
});
}
return target;
}
var _match_, _match_1;
const RULES = {
// BROWSER ==========================================================================
browser: [
// WEBVIEW ------------------------------------------------------------------------
// iTunes/Music.app/TV.app
[
/^(itunes|music|tv)\/([\w.]+)\s/i,
(match)=>_object_spread_props(_object_spread({
name: 'webview',
variant: match[1].trim().toLowerCase().replace(/(music|tv)/i, '$1.app')
}, parseVersion(match[2])), {
mobile: false
})
],
// Facebook
[
/(?:(?:fban\/fbios|fb_iab\/fb4a)(?!.+fbav)|;fbav\/([\w.]+);)/i,
(match)=>_object_spread({
name: 'webview',
variant: 'facebook',
mobile: true
}, parseVersion(match[1]))
],
// Instagram / SnapChat
[
/(instagram|snapchat)[/ ]([-\w.]+)/i,
(match)=>_object_spread({
name: 'webview',
variant: match[1].trim().toLowerCase(),
mobile: true
}, parseVersion(match[2]))
],
// TikTok
[
/musical_ly(?:.+app_?version\/|_)([\w.]+)/i,
(match)=>_object_spread({
name: 'webview',
variant: 'tiktok',
mobile: true
}, parseVersion(match[1]))
],
// Twitter
[
/twitter/i,
()=>({
name: 'webview',
variant: 'twitter',
mobile: true
})
],
// Chrome WebView
[
/ wv\).+?(?:version|chrome)\/([\w.]+)/i,
(match)=>_object_spread({
name: 'webview',
mobile: true
}, parseVersion(match[1]))
],
// ELECTRON -----------------------------------------------------------------------
[
/electron\/([\w.]+) safari/i,
(match)=>_object_spread({
name: 'electron',
mobile: false
}, parseVersion(match[1]))
],
// OTHER --------------------------------------------------------------------------
[
/tesla\/(.*?(20\d\d\.([-\w.])+))/i,
(match)=>_object_spread_props(_object_spread({
name: 'other',
variant: 'tesla',
mobile: false
}, parseVersion(match[2])), {
version: match[1]
})
],
[
/(samsung|huawei)browser\/([-\w.]+)/i,
(match)=>_object_spread({
name: 'other',
variant: match[1].trim().toLowerCase().replace(/browser/i, ''),
mobile: true
}, parseVersion(match[2]))
],
[
/yabrowser\/([-\w.]+)/i,
(match)=>_object_spread({
name: 'other',
variant: 'yandex',
mobile: false
}, parseVersion(match[1]))
],
[
/(brave|flock|rockmelt|midori|epiphany|silk|skyfire|ovibrowser|bolt|iron|vivaldi|iridium|phantomjs|bowser|quark|qupzilla|falkon|rekonq|puffin|whale(?!.+naver)|qqbrowserlite|qq|duckduckgo)\/([-\w.]+)/i,
(match, { userAgent })=>_object_spread({
name: 'other',
variant: match[1].trim().toLowerCase(),
mobile: /mobile/i.test(userAgent)
}, parseVersion(match[2].replace(/-/g, '.')))
],
// EDGE / IE ----------------------------------------------------------------------
[
/edg(e|ios|a)?\/([\w.]+)/i,
(match)=>_object_spread({
name: 'edge',
mobile: /(edgios|edga)/i.test((_match_ = match[1]) !== null && _match_ !== void 0 ? _match_ : '')
}, parseVersion(match[2]))
],
[
/trident.+rv[: ]([\w.]{1,9})\b.+like gecko/i,
(match)=>_object_spread({
name: 'ie',
mobile: false
}, parseVersion(match[1]))
],
// OPERA --------------------------------------------------------------------------
[
/opr\/([\w.]+)/i,
/opera mini\/([-\w.]+)/i,
/opera [mobiletab]{3,6}\b.+version\/([-\w.]+)/i,
/opera(?:.+version\/|[/ ]+)([\w.]+)/i,
(match)=>_object_spread({
name: 'opera',
mobile: /mobile/i.test(match[0])
}, parseVersion(match[1]))
],
// CHROME -------------------------------------------------------------------------
// Headless
[
/headlesschrome(?:\/([\w.]+)| )/i,
(match)=>_object_spread({
name: 'chrome',
variant: 'headless',
mobile: false
}, parseVersion(match[1]))
],
// Chrome for iOS
[
/\b(?:crmo|crios)\/([\w.]+)/i,
(match)=>_object_spread({
name: 'chrome',
mobile: true
}, parseVersion(match[1]))
],
// Chrome
[
/chrome(?: browser)?\/v?([\w.]+)( mobile)?/i,
(match)=>_object_spread({
name: 'chrome',
mobile: /mobile/i.test((_match_1 = match[2]) !== null && _match_1 !== void 0 ? _match_1 : '')
}, parseVersion(match[1]))
],
// FIREFOX ------------------------------------------------------------------------
// Focus
[
/\bfocus\/([\w.]+)/i,
(match)=>_object_spread({
name: 'firefox',
variant: 'focus',
mobile: true
}, parseVersion(match[1]))
],
// Firefox for iOS
[
/fxios\/([\w.-]+)/i,
/(?:mobile|tablet);.*(?:firefox)\/([\w.-]+)/i,
(match)=>_object_spread({
name: 'firefox',
mobile: true
}, parseVersion(match[1]))
],
// Firefox OSS versions
[
/(icedragon|iceweasel|camino|chimera|fennec|maemo browser|minimo|conkeror|klar)[/ ]?([\w.+]+)/i,
(match)=>_object_spread({
name: 'firefox',
variant: match[1].trim().toLowerCase(),
mobile: false
}, parseVersion(match[2]))
],
// Firefox
[
/(?:firefox)\/([\w.]+)/i,
/(?:mozilla)\/([\w.]+) .+rv:.+gecko\/\d+/i,
(match)=>_object_spread({
name: 'firefox',
mobile: false
}, parseVersion(match[1]))
],
// SAFARI -------------------------------------------------------------------------
[
/version\/([\w.,]+) .*mobile(?:\/\w+ | ?)safari/i,
/version\/([\w.,]+) .*(safari)/i,
/webkit.+?(?:mobile ?safari|safari)(?:\/([\w.]+))/i,
(match)=>_object_spread({
name: 'safari',
mobile: /mobile/i.test(match[0])
}, parseVersion(match[1]))
]
],
// ENGINE ---------------------------------------------------------------------------
engine: [
[
/webkit\/(?:537\.36).+chrome\/(?!27)([\w.]+)/i,
(match)=>_object_spread({
name: 'blink'
}, parseVersion(match[1]))
],
[
/windows.+ edge\/([\w.]+)/i,
(match)=>_object_spread({
name: 'blink'
}, parseVersion(match[1]))
],
[
/presto\/([\w.]+)/i,
(match)=>_object_spread({
name: 'presto'
}, parseVersion(match[2]))
],
[
/trident\/([\w.]+)/i,
(match)=>_object_spread({
name: 'trident'
}, parseVersion(match[1]))
],
[
/gecko\/([\w.]+)/i,
(match)=>_object_spread({
name: 'gecko'
}, parseVersion(match[1]))
],
[
/(khtml|netfront|netsurf|amaya|lynx|w3m|goanna)\/([\w.]+)/i,
(match)=>_object_spread({
name: 'other'
}, parseVersion(match[2]))
],
[
/webkit\/([\w.]+)/i,
(match)=>_object_spread({
name: 'webkit'
}, parseVersion(match[1]))
]
],
// OS -------------------------------------------------------------------------------
os: [
// Windows
[
/microsoft windows (vista|xp)/i,
/windows nt 6\.2; (arm)/i,
/windows (?:phone(?: os)?|mobile)[/ ]?([\d.\w ]*)/i,
/windows[/ ]?([ntce\d. ]+\w)(?!.+xbox)/i,
/(?:win(?=3|9|n)|win 9x )([nt\d.]+)/i,
(match)=>_object_spread({
name: 'windows'
}, parseVersion(match[1]))
],
// iOS (iPhone/iPad)
[
/ip[honead]{2,4}\b(?:.*os ([\w]+) like mac|; opera)/i,
/(?:ios;fbsv\/|iphone.+ios[/ ])([\d.]+)/i,
(match)=>_object_spread({
name: 'ios'
}, parseVersion(match[1].replace(/_/g, '.')))
],
// macOS
[
/mac(?:intosh;?)? os x ?([\d._]+)/i,
(match)=>_object_spread({
name: 'macos'
}, parseVersion(match[1].replace(/_/g, '.')))
],
// ChromeOS
[
/cros [\w]+(?:\)| ([\w.]+)\b)/i,
(match)=>_object_spread({
name: 'chromeos'
}, parseVersion(match[1]))
],
// Android
[
/(?:android|webos|qnx|bada|rim tablet os|maemo|meego|sailfish)[-/ ]?([\w.]*)/i,
/droid ([\w.]+|[\d+])\b.+(android[- ]x86|harmonyos)/i,
(match)=>_object_spread({
name: 'android'
}, parseVersion(match[1]))
],
// Linux
[
/linux/i,
()=>({
name: 'linux'
})
]
]
};
/**
* Extend a data structure by running a list of functions over it.
*/ function applyExtensions(data, extensions) {
let result = data;
for (const extension of extensions){
result = extension(result);
}
return result;
}
/**
* Parse the user agent string from the navigator object into a descriptor.
*
* @param navigator The Navigator object to parse
* @param options Parse options
* @returns The descriptor with optional extensions applied
*/ function parseUserAgent(navigator, options) {
var _navigator, _options;
var _navigator_userAgent;
const descriptor = {
navigator: navigator,
ua: (_navigator_userAgent = (_navigator = navigator) === null || _navigator === void 0 ? void 0 : _navigator.userAgent) !== null && _navigator_userAgent !== void 0 ? _navigator_userAgent : '',
extensions: [],
browser: applyRules(RULES.browser, navigator, {
name: 'unknown',
mobile: false
}),
engine: applyRules(RULES.engine, navigator, {
name: 'unknown'
}),
os: applyRules(RULES.os, navigator, {
name: 'unknown'
})
};
var _options_extensions;
return applyExtensions(descriptor, (_options_extensions = (_options = options) === null || _options === void 0 ? void 0 : _options.extensions) !== null && _options_extensions !== void 0 ? _options_extensions : []);
}
export { parseUserAgent };

View File

@@ -0,0 +1,99 @@
function parseVersion(input) {
const data = {
version: input.toLowerCase()
};
const parts = input.toLowerCase().split('.').filter((p)=>!!p);
// Only parse single parts that are actual numbers.
// This check will prevent `parseInt` from pasing the leading chars if they are
// valid numbers.
if (parts.length <= 1 && !/^\d+$/.test(parts[0])) {
return data;
}
const major = parseInt(parts[0], 10);
const minor = parseInt(parts[1], 10);
const patch = parseInt(parts[2], 10);
// Only add converted versions if `major` part was valid
if (!Number.isNaN(major)) {
data.major = major;
if (!Number.isNaN(minor)) data.minor = minor;
if (!Number.isNaN(patch)) data.patch = patch;
}
return data;
}
/**
* Check if the given value is a complete Version struct.
*/ // eslint-disable-next-line @typescript-eslint/no-explicit-any
function isVersion(value) {
var _value, _value1;
return typeof ((_value = value) === null || _value === void 0 ? void 0 : _value.major) === 'number' && typeof ((_value1 = value) === null || _value1 === void 0 ? void 0 : _value1.minor) === 'number';
}
/**
* Compare two version numbers together.
*
* NOTE: This only supports the first 3 segments (major, minor, patch) and does not
* do a full SemVer compare.
*
* @example
* ```javascript
* compareVersion('1.2.3', '1.2.4');
* // => -1
* ```
*/ function compareVersion(base, comp) {
let baseList = toNumbers(base);
let compList = toNumbers(comp);
// Right pad versions with zeros to make them equal length
const versionLength = Math.max(baseList.length, compList.length);
baseList = baseList.concat(Array(versionLength).fill(0)).slice(0, versionLength);
compList = compList.concat(Array(versionLength).fill(0)).slice(0, versionLength);
/** Constrain the given value to the output range of this function. */ const constrain = (value)=>{
if (value <= -1) return -1;
else if (value >= 1) return 1;
else return 0;
};
for(let index = 0; index < versionLength; index++){
const aValue = baseList[index];
const bValue = compList[index];
if (aValue !== bValue) {
return constrain(aValue - bValue);
}
}
return 0;
}
function eq(base, comp) {
return compareVersion(base, comp) === 0;
}
function gt(base, comp) {
return compareVersion(base, comp) > 0;
}
function gte(base, comp) {
const result = compareVersion(base, comp);
return result > 0 || result === 0;
}
function lt(base, comp) {
return compareVersion(base, comp) < 0;
}
function lte(base, comp) {
const result = compareVersion(base, comp);
return result < 0 || result === 0;
}
function toNumbers(value) {
if (Array.isArray(value)) {
return value;
} else if (typeof value === 'number') {
return [
value
];
} else if (typeof value === 'string') {
return toNumbers(parseVersion(value));
} else {
const values = [
value.major,
value.minor,
value.patch
];
const uidx = values.indexOf(undefined);
return uidx === -1 ? values : values.slice(0, uidx);
}
}
export { compareVersion, eq, gt, gte, isVersion, lt, lte, parseVersion };

View File

@@ -0,0 +1,39 @@
export enum PWADisplayMode {
TWA = 'twa',
BROWSER = 'browser',
STANDALONE = 'standalone',
MINIMAL = 'minimal-ui',
FULLSCREEN = 'fullscreen',
OVERLAY = 'window-controls-overlay',
UNKNOWN = 'unknown',
}
/**
* For PWA, reads the "display" value from the manifest.json and returns the proper value if it matches.
* Inspired by the sample snippet here: https://web.dev/learn/pwa/detection
*/
export const getPWADisplayMode = (): PWADisplayMode => {
switch (true) {
case document.referrer.startsWith('android-app://'):
return PWADisplayMode.TWA;
case window.matchMedia('(display-mode: browser)').matches:
return PWADisplayMode.BROWSER;
case window.matchMedia('(display-mode: standalone)').matches:
return PWADisplayMode.STANDALONE;
case window.matchMedia('(display-mode: minimal-ui)').matches:
return PWADisplayMode.MINIMAL;
case window.matchMedia('(display-mode: fullscreen)').matches:
return PWADisplayMode.FULLSCREEN;
case window.matchMedia('(display-mode: window-controls-overlay)')
.matches:
return PWADisplayMode.OVERLAY;
default:
return PWADisplayMode.UNKNOWN;
}
};

168
shared/utils/src/history.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
import { LruMap } from './lru-map';
import type { ScrollableElement } from './try-scroll';
import { tryScroll } from './try-scroll';
import { removeHost } from './url';
import { generateUuid } from './uuid';
export interface Options {
getScrollablePageElement(): ScrollableElement | null;
}
type Id = string;
const HISTORY_SIZE_LIMIT = 10;
interface WithScrollPosition<State> {
scrollY: number;
state: State;
}
/**
* We are using a currentStateId on this class to always store the state id instead of saving
* it on the window.history.state because there seems to be a bug in Safari where it is mutating
* the window.history.state to null after our Sign In flow which includes multiple iframes
* and multiple internal state changes inside the iframes. We can move back to window.history.state storing the id
* if the Safari Issue is fixed in future.
*/
export class History<State> {
private readonly log: Logger;
private readonly states: LruMap<Id, WithScrollPosition<State>>;
private readonly getScrollablePageElement: () => ScrollableElement | null;
private currentStateId: string | undefined;
constructor(
loggerFactory: LoggerFactory,
options: Options,
sizeLimit: number = HISTORY_SIZE_LIMIT,
) {
this.log = loggerFactory.loggerFor('History');
this.states = new LruMap(sizeLimit);
this.getScrollablePageElement = options.getScrollablePageElement;
}
// Update page data but keep scroll position
updateState(update: (state?: State) => State): void {
if (!this.currentStateId) {
this.log.warn(
'failed: encountered a null currentStateId inside updateState',
);
return;
}
const currentState = this.states.get(this.currentStateId);
const newState = update(currentState?.state);
this.log.info('updateState', newState, this.currentStateId);
this.states.set(this.currentStateId, {
...(currentState as WithScrollPosition<State>),
state: newState,
});
}
replaceState(state: State, url: string | null): void {
const id = generateId();
this.log.info('replaceState', state, url, id);
window.history.replaceState({ id }, '', this.removeHost(url));
this.currentStateId = id;
this.states.set(id, { state, scrollY: 0 });
this.scrollTop = 0;
}
pushState(state: State, url: string | null): void {
const id = generateId();
this.log.info('pushState', state, url, id);
window.history.pushState({ id }, '', this.removeHost(url));
this.currentStateId = id;
this.states.set(id, { state, scrollY: 0 });
this.scrollTop = 0;
}
beforeTransition(): void {
const { state } = window.history;
if (!state) {
return;
}
const oldState = this.states.get(state.id);
if (!oldState) {
this.log.info(
'current history state evicted from LRU, not saving scroll position',
);
return;
}
const { scrollTop } = this;
this.states.set(state.id, {
...oldState,
scrollY: scrollTop,
});
this.log.info('saving scroll position', scrollTop);
}
private removeHost(url: string | null): string | undefined {
if (!url) {
this.log.warn('received null URL');
return;
}
// TODO: rdar://77982655 (Investigate router improvements): host mismatch?
return removeHost(url);
}
onPopState(
listener: (url: string, state: State | undefined) => void,
): void {
window.addEventListener('popstate', (event: PopStateEvent): void => {
this.currentStateId = event.state?.id;
if (!this.currentStateId) {
this.log.warn(
'encountered a null event.state.id in onPopState event: ',
window.location.href,
);
}
this.log.info('popstate', this.states, this.currentStateId);
const state = this.currentStateId
? this.states.get(this.currentStateId)
: undefined;
listener(window.location.href, state?.state);
if (!state) {
return;
}
const { scrollY } = state;
this.log.info('restoring scroll to', scrollY);
tryScroll(this.log, () => this.getScrollablePageElement(), scrollY);
});
}
private get scrollTop(): number {
return this.getScrollablePageElement()?.scrollTop || 0;
}
private set scrollTop(scrollTop: number) {
const element = this.getScrollablePageElement();
if (element) {
element.scrollTop = scrollTop;
}
}
// TODO: rdar://77982655 (Investigate router improvements): offPopState?
}
/**
* Generate a (unique) id for storing in window.history.state.
*
* @return the generated ID
*/
function generateId(): Id {
// The use of something random (and not say, an incrementing counter) is important
// here. These states can survive refreshes so the IDs used must be globally unique
// (and not just unique to the current page load).
return generateUuid();
}

View File

@@ -0,0 +1,20 @@
/**
* Determine if {@linkcode arg} is a Plain Old JavaScript Object.
*
* @see https://masteringjs.io/tutorials/fundamentals/pojo
*
* @param arg to test
* @returns true if {@linkcode arg} is a POJO
*/
export function isPOJO(arg: unknown): arg is Record<string, unknown> {
if (!arg || typeof arg !== 'object') {
return false;
}
const proto = Object.getPrototypeOf(arg);
if (!proto) {
return true; // `Object.create(null)`
}
return proto === Object.prototype;
}

View File

@@ -0,0 +1,109 @@
import { createClientLink } from './scheme';
import type { Platform } from '../platform';
/**
* Navigator for older Microsoft (MS) browsers like Internet Explorer.
*/
type MSNavigator = Navigator & {
msLaunchUri: (
href: string | URL,
successCallback: () => void,
failureCallback: () => void,
) => void;
};
/**
* Check if the given value is an MSNavigator.
*/
function isMSNavigator(value: Partial<MSNavigator>): value is MSNavigator {
return typeof value?.msLaunchUri === 'function';
}
/**
* Callback for client launches.
*/
export type LaunchCallback = (result: {
link: URL;
success: boolean;
}) => void | Promise<void>;
/**
* Attempt to launch the native client for the given Web URL.
*/
export function launchClient(
url: string | URL,
platform: Platform,
callback: LaunchCallback = () => {},
): void {
const { window, browser, os } = platform;
/** URL for opening the native application */
const link = createClientLink(url, { platform });
// macOS Safari
if (os.isMacOS && browser.isSafari) {
launchOnMacOS(link, platform, callback);
}
// Proprietary msLaunchUri method (IE 10+ on Windows 8+)
else if (isMSNavigator(platform.navigator)) {
platform.navigator.msLaunchUri(
String(link),
() => callback({ link, success: true }),
() => callback({ link, success: false }),
);
}
// Other platforms
else {
try {
// on iOS, Windows and Android simply opening the href works
window!.top!.window.location.href = String(link);
callback({ link, success: true });
} catch (e) {
// we know this is NOT installed
callback({ link, success: false });
}
}
}
function launchOnMacOS(
link: URL,
platform: Platform,
callback: LaunchCallback,
): void {
const { window } = platform;
if (typeof window === 'undefined') {
callback({ link, success: false });
return;
}
/** Timer for blur fallback */
let timer: number;
/** IFrame reference for opening the client link */
let iframe: HTMLIFrameElement | undefined;
/** Cleanup function run after the client launch has been initiated */
function finalize() {
clearTimeout(timer);
window!.removeEventListener('blur', finalize);
if (iframe !== undefined) {
window!.document.body.removeChild(iframe);
}
callback({ link, success: true });
}
// Add an iFrame window to the current document to open the URL
iframe = window.document.createElement('iframe');
iframe.id = 'launch-client-opener';
iframe.style.display = 'none';
window.document.body.appendChild(iframe);
// Redirect the iFrame to the client link to trigger it to open
iframe.contentWindow!.location.href = String(link);
// Wait a tiny amount of time for the client launch to appear
window.addEventListener('blur', finalize);
timer = setTimeout(finalize, 50) as unknown as number;
}

View File

@@ -0,0 +1,339 @@
import { removeScheme } from '..';
import { Platform } from '../platform';
/**
* Check if the URL hostname matches the given value.
*/
const matchesHostName = (url: URL, hostName: string) =>
url.hostname === hostName;
/**
* Check if the URL `?app=xyz` search param matches the given value.
*/
const matchesAppName = (url: URL, appName: string) =>
url.searchParams.get('app') === appName;
/**
* Check if the URL `?mt=n` search param matches any of the given values.
*/
const matchesMediaType = (url: URL, mediaTypes: string[]) => {
const mt = url.searchParams.get('mt');
return mt ? mediaTypes.includes(mt) : false;
};
/**
* Check if the URL pathname matches the given pattern.
*/
const matchesPathName = (url: URL, pattern: RegExp | string) =>
new RegExp(pattern).test(url.pathname);
/**
* Check if the URL is for Audiobooks
*/
const isAudiobookURL = (url: URL): boolean =>
matchesAppName(url, 'audiobook') ||
matchesMediaType(url, ['3']) ||
matchesPathName(url, /\/(audiobook\/|viewAudiobook)/i);
/**
* Check if the URL is for Books.
*/
const isBooksURL = (url: URL): boolean =>
!isAudiobookURL(url) &&
(matchesHostName(url, 'books.apple.com') ||
matchesAppName(url, 'books') ||
matchesMediaType(url, ['11', '13']) ||
matchesPathName(url, '/book/'));
/**
* Check if the URL is for Commerce.
*/
const isCommerceURL = (url: URL): boolean =>
matchesHostName(url, 'finance-app.itunes.apple.com') ||
matchesPathName(url, '/account/');
/**
* Check if the URL is for a macOS App.
*/
const isMacAppURL = (url: URL): boolean =>
matchesAppName(url, 'mac-app') ||
matchesMediaType(url, ['12']) ||
matchesPathName(url, '/mac-app/');
/**
* Check if the URL is an AppStore Story.
*/
const isStoryURL = (url: URL): boolean =>
matchesAppName(url, 'story') || matchesPathName(url, '/story/');
/**
* Check if the URL is for Messages.
*/
const isMessagesURL = (url: URL): boolean => matchesAppName(url, 'messages');
/**
* Check if the URL is for Music.
*/
const isMusicURL = (url: URL): boolean =>
matchesHostName(url, 'music.apple.com') ||
matchesAppName(url, 'music') ||
matchesPathName(
url,
/\/(album|artist|playlist|station|curator|music-video)\//i,
);
/**
* Check if the URL is for Podcasts.
*/
const isPodcastsURL = (url: URL): boolean =>
matchesHostName(url, 'podcasts.apple.com') ||
matchesAppName(url, 'podcasts') ||
matchesMediaType(url, ['2']) ||
matchesPathName(url, '/podcast/');
/**
* Check if the URL is for TV.
*/
const isTVURL = (url: URL): boolean =>
matchesHostName(url, 'tv.apple.com') ||
matchesPathName(
url,
/\/(episode|movie|movie-collection|show|season|sporting-event|person)\//i,
);
/**
* Check if the URL is for the Watch.
*/
const isWatchURL = (url: URL): boolean => matchesAppName(url, 'watch');
/**
* Check if the URL is developer.apple.com related.
*/
const isDeveloperURL = (url: URL): boolean =>
matchesAppName(url, 'developer') || matchesPathName(url, '/developer/');
/**
* Check if the URL is for an app.
*/
const isAppsURL = (url: URL): boolean =>
matchesMediaType(url, ['8']) && !isMessagesURL(url) && !isWatchURL(url);
/**
* Function for identifying application schemes from web URLs.
*/
type SchemeIdentifier = (url: URL, platform: Platform) => boolean;
/**
* List of schemes and functions to identify them based on a URL and Platform details.
*
* These schemes are derived from [Jingle Properties](https://github.pie.apple.com/amp-dev/Jingle/blob/6392929afb8540ac488315647992c3f46a9cc82f/MZConfig/Properties/apps/MZInit2/common.properties#L993).
*
* ```java
* // <rdar://problem/66551318> iOS Bag: Move mobile-url-handlers to a property defined list
* MZInit.iOS.acceptedUrlHandlers=("applenews", "applenewss", "applestore", "applestore-sec", "bridge", "com.apple.tv", "disneymoviesanywhere",\
* "http", "https", "itms", "itmss", "itms-apps", "itms-appss", "itms-books", "itms-bookss", "itms-gc", "itms-gcs", "itms-itunesu",\
* "itms-itunesus", "itms-podcast", "itms-podcasts", "itms-ui", "its-music", "its-musics", "its-news", "its-newss", "its-videos",\
* "its-videoss", "itsradio", "livenation", "mailto", "message", "moviesanywhere", "music", "musics", "prefs", "shoebox")
* ```
*/
const identifiers: [string, SchemeIdentifier, ...SchemeIdentifier[]][] = [
[
'itms-apps',
(url, platform) =>
platform.os.isIOS &&
(isCommerceURL(url) ||
isAppsURL(url) ||
isStoryURL(url) ||
isDeveloperURL(url)),
],
// Watch app on mobile
[
'itms-watch',
(url, platform) => platform.browser.isMobile && isWatchURL(url),
],
// Messages app on mobile
[
'itms-messages',
function (url: URL, platform: Platform) {
return platform.browser.isMobile && isMessagesURL(url);
},
],
[
'itms-books',
(url, platform) =>
platform.os.isMacOS &&
platform.os.gte('10.15') &&
isAudiobookURL(url),
(url, _platform) => isBooksURL(url),
],
// Music on Android
[
'apple-music',
(url, platform) => platform.os.isAndroid && isMusicURL(url),
],
// Music on iOS/macOS
[
'music',
(url, platform) => platform.os.isIOS && isMusicURL(url),
(url, platform) => {
return (
platform.os.isMacOS &&
platform.os.gte('10.15') &&
isMusicURL(url)
);
},
],
// Podcasts on iOS
[
'itms-podcasts',
(url, platform) => platform.os.isIOS && isPodcastsURL(url),
],
// Podcasts on macOS
[
'podcasts',
(url, platform) =>
platform.os.isMacOS &&
platform.os.gte('10.15') &&
isPodcastsURL(url),
],
// TV on iOS
[
'com.apple.tv',
(url, platform) =>
platform.os.isIOS && platform.os.gte('10.2') && isTVURL(url),
],
// TV on macOS
[
'videos',
(url: URL, platform: Platform) =>
platform.os.isMacOS && platform.os.gte('10.15') && isTVURL(url),
],
[
'macappstore',
(url, _platform) => isMacAppURL(url),
(url, platform) =>
platform.os.isMacOS &&
platform.os.gte('10.15') &&
isCommerceURL(url),
// Story and developer pages should launch Mac App Store on Mojave(10.14)+
// <rdar://problem/46461633> Story page with ls=1 QP should attempt to open Mac App Store on Mojave +
// rdar://81291713 (Star: https://apps.apple.com/developer/id463855590?ls=1 launches Music App)
(url, platform) =>
platform.os.isMacOS &&
platform.os.gte('10.14') &&
(isStoryURL(url) || isDeveloperURL(url)),
],
// Catch All
['itms', (_url, _platform) => true],
];
/**
* Get the Scheme for attempting to open a platform native application.
*
* @see {@link https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax}
*/
export function detectClientScheme(
url: string | URL,
options?: { platform?: Platform },
): string {
url = new URL(url);
// Assume that any URLs that don't have the http(s) scheme already have the
// correct scheme assigned.
if (/https?/i.test(url.protocol)) {
const platform = options?.platform ?? Platform.detect();
for (const [scheme, ...fns] of identifiers) {
for (const fn of fns) {
if (fn(url, platform)) {
return scheme;
}
}
}
}
// At this point something should have matched. If not just return the original
// scheme and have the browser or system handle it.
return normalizeScheme(url.protocol);
}
/**
* Check if the given URL has an Apple specific Scheme.
*
* @example
* ```javascript
* hasAppleClientScheme('music://music.apple.com/browse') // => true
* hasAppleClientScheme('https://music.apple.com/browse') // => false
* ```
*/
export function hasAppleClientScheme(
url: URL | string,
_options?: { platform?: Platform },
) {
const pattern =
/^(?:itms(?:-.*)?|macappstore|podcast|video|(?:apple-)?music)s?(:|$)/im;
return pattern.test(new URL(url).protocol);
}
/**
* Create a link for attempting to open a platform native application based on a web URL.
*
* @example
* ```javascript
* createClientLink('https://music.apple.com/browse');
* // => 'music://music.apple.com/browse'
* ```
*/
export function createClientLink(
url: string | URL,
options?: { platform?: Platform },
): URL {
const link = new URL(url);
// Removes any development prefixes in order to correctly identify the scheme
link.host = link.host.replace(
/^(?:[^-]+[-.])?([^.]+)\.apple\.com/,
'$1.apple.com',
);
// Remove any port designation, this should not be present in application links
link.port = '';
const scheme = detectClientScheme(link, {
platform: options?.platform,
});
// If the identified scheme is already assigned we want to leave the URL unmodified
if (scheme === normalizeScheme(link.protocol)) {
return new URL(url);
}
return new URL(scheme + '://' + removeScheme(link));
}
/**
* Normalize a scheme value by removing any separators from it.
*
* @example
* ```javascript
* normalizeScheme('music') // => 'music'
* normalizeScheme('TV') // => 'tv'
* normalizeScheme('https:') // => 'https'
* normalizeScheme('https://') // => 'https'
* ```
*/
function normalizeScheme(value: string): string {
return value.replace(/[:]+$/, '').toLowerCase();
}

View File

@@ -0,0 +1,60 @@
/**
* LRU Map implementation storing key/values up to a provided size limit. Beyond that
* size limit, the least recently used entry is evicted.
*
* @see https://github.pie.apple.com/isao/lru-map
*/
export class LruMap<K, V> extends Map<K, V> {
private sizeLimit: number;
constructor(sizeLimit: number) {
super();
this.setSizeLimit(sizeLimit);
// Needed to convince TS that this is set (it's actually handled by setSizeLimit)
this.sizeLimit = sizeLimit;
}
/**
* Retrieve a value from the map with a given key.
* @param key The key for the entry
* @return value The entry's value (or undefined if non existent)
*/
get(key: K): V | undefined {
let value: V | undefined;
if (this.has(key)) {
value = super.get(key);
// Map entries are always in insertion order. So
// readding, pushes this entry to the top of the LRU.
this.delete(key);
super.set(key, value!);
}
return value;
}
set(key: K, value: V): this {
super.set(key, value);
this.prune();
return this;
}
setSizeLimit(newSizeLimit: number): void {
if (newSizeLimit < 0 || !isFinite(newSizeLimit)) {
throw new Error(
`setSizeLimit expects finite positive number, got: ${newSizeLimit}`,
);
}
this.sizeLimit = newSizeLimit;
this.prune();
}
private prune(): void {
while (this.size > this.sizeLimit) {
const leastRecentlyUsedKey = this.keys().next().value;
this.delete(leastRecentlyUsedKey);
}
}
}

View File

@@ -0,0 +1,18 @@
// TODO: rdar://78109780 (Update to Node 16)
/**
* Create an object from an iterable of key/value pairs.
*
* @param entries The key value pairs (ex. [['a', 1], ['b', 2]])
* @return The created object
*/
export function fromEntries<V>(entries: Iterable<readonly [PropertyKey, V]>): {
[k: string]: V;
} {
const result: Record<PropertyKey, V> = {};
for (const [key, value] of entries) {
result[key] = value;
}
return result;
}

View File

@@ -0,0 +1,22 @@
export type Optional<T> = T | None;
export type None = null | undefined;
/**
* Determine if an optional value is present.
*
* @param optional value
* @return true if present, false otherwise
*/
export function isSome<T>(optional: Optional<T>): optional is T {
return optional !== null && optional !== undefined;
}
/**
* Determine if an optional value is not present.
*
* @param optional value
* @return true if not present, false otherwise
*/
export function isNone<T>(optional: Optional<T>): optional is None {
return optional === null || optional === undefined;
}

View File

@@ -0,0 +1,249 @@
import {
parseUserAgent,
flagsExtension,
compareExtension,
} from '@amp/runtime-detect';
import { launchClient, type LaunchCallback } from './launch/launch-client';
type NavigatorLike = {
userAgent: string;
maxTouchPoints?: number;
};
/**
* Detect a Platform descriptor from the browsers user agent.
*/
function detectDescriptor(options?: {
window?: Window;
navigator?: NavigatorLike;
}) {
const defaultNavigator: NavigatorLike =
typeof options?.window?.navigator !== 'undefined'
? options.window.navigator
: {
userAgent: '',
maxTouchPoints: 0,
};
return parseUserAgent(options?.navigator ?? defaultNavigator, {
extensions: [flagsExtension, compareExtension],
});
}
export type PlatformDescriptor = ReturnType<typeof detectDescriptor>;
export class Platform {
static detect(
this: typeof Platform,
options?: { window?: Window; navigator?: NavigatorLike },
) {
const window = options?.window ?? globalThis?.window;
return new this({
window: window,
descriptor: detectDescriptor({
window: window,
navigator: options?.navigator,
}),
});
}
/**
* Descriptor from detecting platform data.
*/
readonly descriptor: PlatformDescriptor;
/**
* Navigator value used to create the platform descriptor.
*/
readonly navigator: NavigatorLike;
/**
* Reference to the platform Window object. This might be `undefined` in some
* environments.
*/
readonly window: Window | undefined;
/**
* User Agent string the platform descriptor was parsed from.
*/
readonly ua: string;
/**
* Browser descriptor for the Platform.
*/
readonly browser: PlatformDescriptor['browser'];
/**
* Browser Engine descriptor for the Platform.
*/
readonly engine: PlatformDescriptor['engine'];
/**
* Operating System descriptor for the Platform.
*/
readonly os: PlatformDescriptor['os'];
constructor(config: {
descriptor: PlatformDescriptor;
window?: Window;
navigator?: NavigatorLike;
}) {
const { descriptor } = config;
this.descriptor = descriptor;
this.navigator = config.navigator ?? descriptor.navigator;
this.window = config.window;
this.ua = descriptor.ua;
this.browser = descriptor.browser;
this.engine = descriptor.engine;
this.os = descriptor.os;
}
/**
* Check if Apple native applications can be opened on the Platform.
*/
canOpenNative(): boolean {
return this.ismacOS() || this.isiOS();
}
/**
* Check if the Platform is running a mobile browser.
*/
isMobile(): boolean {
return this.browser.isMobile;
}
/**
* Check if the Platform registers as running the Android operating system.
*/
isAndroid(): boolean {
return this.os.isAndroid;
}
/**
* Check if the Platform registers as running the iOS operating system.
*/
isiOS(): boolean {
return this.os.isIOS;
}
/**
* Check if the Platform registers as running the iPadOS operating system.
*/
isiPadOS(): boolean {
return this.os.isIPadOS;
}
/**
* Check if the Platform registers as running the macOS operating system.
*/
ismacOS(): boolean {
return this.os.isMacOS;
}
/**
* Check if the Platform registers as running the Windows operating system.
*/
isWindows(): boolean {
return this.os.isWindows;
}
/**
* Check if the Platform registers as running a Linux operating system.
*/
isLinux(): boolean {
return this.os.isLinux;
}
/**
* Check if the Platform is running the Apple Safari browser.
*/
isSafari(): boolean {
return this.browser.isSafari;
}
/**
* Check if the Platform is running the Google Chrome browser.
*/
isChrome(): boolean {
return this.browser.isChrome;
}
/**
* Check if the Platform is running the Mozilla Firefox browser.
*/
isFirefox(): boolean {
return this.browser.isFirefox;
}
/**
* Check if the Platform is running the Microsoft Edge browser.
*/
isEdge(): boolean {
return this.browser.isEdge;
}
/**
* Get name for the Platform browser.
* @deprecated Use `platform.browser.name` directly
*/
clientName(): string {
return this.browser.name[0].toUpperCase() + this.browser.name.slice(1);
}
/**
* Get the Platform browser major version number.
* @deprecated Use `platform.browser.major` directly
*/
majorVersion(): number {
return this.browser.major ?? 0;
}
/**
* Get the Platform browser minor version number.
* @deprecated Use `platform.browser.minor` directly
*/
minorVersion(): number {
return this.browser.minor ?? 0;
}
/**
* Get the name for the Platform operating system.
* @deprecated Use `platform.os.name` directly
*/
osName(): string {
return this.os.name;
}
/**
* Attempt to launch a native client for the given web URL.
*
* The callback is called with a report if the attempt was successful.
*
* @example
* ```javascript
* platform.launchClient(
* 'https://music.apple.com/browse',
* function ({ link, success }) {
* if (success) {
* console.log(`Opened client with ${link}`);
* } else {
* console.log(`Failed to open client with ${link}`);
* }
* }
* );
* ```
*/
launchClient(url: string, callback?: LaunchCallback): void {
launchClient(url, this, callback);
}
/**
* Check if the platform has full support for playing encrypted HLS content.
*/
hasEncryptedPlaybackSupport(): boolean {
return !this.os.isIOS || this.os.gte('17.5');
}
}
export const platform = Platform.detect();

View File

@@ -0,0 +1,65 @@
import type { Logger } from '@amp/web-apps-logger';
export interface ScrollableElement {
scrollTop: number;
scrollHeight: number;
offsetHeight: number;
}
// Global is okay here as this only runs in the browser
let nextTry: number | null = null;
export function tryScroll(
log: Logger,
getScrollablePageElement: Function,
scrollY: number,
): void {
let tries = 0;
if (nextTry !== null) {
window.cancelAnimationFrame(nextTry);
}
nextTry = window.requestAnimationFrame(function doNextTry() {
// At 16ms per frame, this is 1600ms
// See: https://github.com/DockYard/ember-router-scroll/blob/2f17728f/addon/services/router-scroll.js#L56
if (++tries >= 100) {
log.warn("wasn't able to restore scroll within 100 frames");
nextTry = null;
return;
}
let element = getScrollablePageElement();
if (!element) {
log.warn(
'could not restore scroll: the scrollable element is missing',
);
return;
}
const { scrollHeight, offsetHeight } = element;
// Only scroll once we're able to get a full screen of content when
// scrollTop is set to scrollY
//
// +16 is a bit of a fudge factor to count for imperfections in
// features like lazy loading. If the scroll position to restore is
// the very bottom of the page, then scrollY + offsetHeight must be
// exactly scrollHeight. But if lazy loading components (for example)
// cause the page to grow by a few pixels, then this will never hold.
// Thus, we fudge by a few pixels to be more forgiving in this scenario.
const canScroll = scrollY + offsetHeight <= scrollHeight + 16;
if (!canScroll) {
log.info('page is not tall enough for scroll yet', {
scrollHeight,
offsetHeight,
});
nextTry = window.requestAnimationFrame(doNextTry);
return;
}
element.scrollTop = scrollY;
log.info('scroll restored to', scrollY);
nextTry = null;
});
}

90
shared/utils/src/url.ts Normal file
View File

@@ -0,0 +1,90 @@
/**
* Remove the scheme and separators from the given URL.
*
* @example
* ```javascript
* removeScheme('https://music.apple.com/browse') // => 'music.apple.com/browse'
* removeScheme('apple-music://music.apple.com/browse') // => 'music.apple.com/browse'
* removeScheme('music.apple.com/browse') // => 'music.apple.com/browse'
* ```
*/
export function removeScheme(
url: string | URL | null | undefined,
): string | undefined {
if (url === null || url === undefined) {
return undefined;
}
return String(url).replace(/^((?:[^:]*:[/]{0,2})|(?::?\/\/))/i, '');
}
/**
* Strip scheme and host (hostname + port) from a URL, leaving just the path, query
* params, and hash.
*
* @param {string} url The URL possibly containing a host
* @returns {string} hostlessUrl The url without its host
*/
export function removeHost(
url: string | URL | null | undefined,
): string | undefined {
return removeScheme(url)?.replace(/^([^/]*)/i, '');
}
/**
* Strip query params and fragment from a URL.
*/
export function removeQueryParams(
url: string | URL | undefined,
): string | undefined {
if (url === undefined) {
return undefined;
}
const value = String(url);
const splitIndex = value.indexOf('?');
return splitIndex >= 0 ? value.slice(0, splitIndex) : value;
}
export function getBaseUrl(): string {
const currentUrl = new URL(window.location.href);
return `${currentUrl.protocol}//${currentUrl.host}`;
}
export function buildUrl(props: {
protocol?: string;
hostname: string;
pathname?: string | string[];
queryParams?: string | Record<string, string>;
hash?: string;
}): URL {
const {
hostname,
pathname = '/',
queryParams = {},
protocol = 'https',
hash = '',
} = props;
// Base URL with domain
const url = new URL(protocol + '://' + removeScheme(hostname));
// URL path
url.pathname = Array.isArray(pathname)
? '/' + pathname.map(encodeURIComponent).join('/').replace(/[/]+/, '/')
: pathname;
// URL search (a.k.a. queryParams)
if (typeof queryParams === 'string') {
url.search = queryParams;
} else {
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value);
}
}
// URL hash
url.hash = hash;
return url;
}

22
shared/utils/src/uuid.ts Normal file
View File

@@ -0,0 +1,22 @@
/**
* Generate a variant 1 UUIDv4.
*
* @return the UUID
*/
export function generateUuid(): string {
return 'xxxxxxxx-xxxx-4xxx-Vxxx-xxxxxxxxxxxx'.replace(
/[xV]/g,
(placeholder) => {
let nibble = (Math.random() * 16) | 0;
if (placeholder === 'V') {
// Per RFC, the two MSB of byte 8 must be 0b10 (0x8).
// 0x3 (0b11) masks out the bottom two bits.
// See: https://tools.ietf.org/html/rfc4122.html#section-4.1.1
nibble = (nibble & 0x3) | 0x8;
}
return nibble.toString(16);
},
);
}