4 Commits

19 changed files with 1787 additions and 64 deletions

View File

@@ -1,7 +1,9 @@
import js from "@eslint/js"; import js from "@eslint/js";
import tseslint from "typescript-eslint";
export default [ export default [
js.configs.recommended, js.configs.recommended,
...tseslint.configs.recommended,
{ {
files: ["**/*.ts", "**/*.tsx"], files: ["**/*.ts", "**/*.tsx"],
languageOptions: { languageOptions: {
@@ -9,7 +11,8 @@ export default [
sourceType: "module", sourceType: "module",
}, },
rules: { rules: {
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], "no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
}, },
}, },
{ {

View File

@@ -21,6 +21,7 @@
"prettier": "^3.6.2", "prettier": "^3.6.2",
"storybook": "8.6.14", "storybook": "8.6.14",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }

View File

@@ -77,12 +77,40 @@ By default, tags are rendered as plain styled text (not links).
## Angular usage ## Angular usage
Tokenization helper:
```ts ```ts
import { createAngularContentTokens } from "@hublib-web/content-suggestions/angular"; import { createAngularContentTokens } from "@hublib-web/content-suggestions/angular";
const tokens = createAngularContentTokens(text); const tokens = createAngularContentTokens(text);
``` ```
Ready-to-use UI renderer (React-like props API):
```ts
import { AngularContentTextWithSuggestionsRenderer } from "@hublib-web/content-suggestions/angular";
const renderer = new AngularContentTextWithSuggestionsRenderer();
renderer.attach(containerElement, {
text,
weight: "normal",
ellipsis: { count: 180, expandable: true },
blur: false,
onView: () => {
// analytics
},
});
```
Title variant with defaults (`weight: "bold"`, `ellipsis: { rows: 2 }`):
```ts
import { AngularContentTitleWithSuggestionsRenderer } from "@hublib-web/content-suggestions/angular";
const titleRenderer = new AngularContentTitleWithSuggestionsRenderer();
titleRenderer.attach(titleContainer, { text: title });
```
## Storybook (dev/design system) ## Storybook (dev/design system)
Run from repository root: Run from repository root:

View File

@@ -76,6 +76,41 @@ var findAllEntities = (content) => {
}; };
// src/angular/index.ts // src/angular/index.ts
var LINK_COLOR = "#1677ff";
var READ_MORE_TEXT = "\u0427\u0438\u0442\u0430\u0442\u044C \u043F\u043E\u043B\u043D\u043E\u0441\u0442\u044C\u044E";
var toKebabCase = (value) => value.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
var applyStyleObject = (element, styles) => {
if (!styles) {
return;
}
for (const [key, rawValue] of Object.entries(styles)) {
if (rawValue === null || rawValue === void 0) {
continue;
}
const styleValue = typeof rawValue === "number" ? `${rawValue}px` : String(rawValue);
if (key.startsWith("--")) {
element.style.setProperty(key, styleValue);
continue;
}
const cssKey = key.includes("-") ? key : toKebabCase(key);
element.style.setProperty(cssKey, styleValue);
}
};
var toNode = (value) => {
if (value === null || value === void 0) {
return null;
}
if (value instanceof Node) {
return value;
}
return document.createTextNode(String(value));
};
var resolveEllipsisSymbol = (symbol, expanded) => {
if (typeof symbol === "function") {
return symbol(expanded);
}
return symbol ?? READ_MORE_TEXT;
};
var buildAngularTagHref = (entity) => { var buildAngularTagHref = (entity) => {
return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`; return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`;
}; };
@@ -133,9 +168,325 @@ var AngularContentSuggestionsAdapter = class {
}; };
} }
}; };
var AngularContentTextRenderer = class {
host = null;
props = {};
expanded = false;
viewed = false;
hostInsideView = false;
observer = null;
attach(host, props = {}) {
this.destroy();
this.host = host;
this.props = { ...props };
this.expanded = false;
this.viewed = false;
this.hostInsideView = false;
this.initObserver();
this.render();
return this.getState();
}
update(nextProps) {
this.props = {
...this.props,
...nextProps
};
this.render();
return this.getState();
}
destroy() {
if (this.observer && this.host) {
this.observer.unobserve(this.host);
this.observer.disconnect();
}
this.observer = null;
if (this.host) {
this.host.innerHTML = "";
}
this.host = null;
this.props = {};
this.expanded = false;
this.viewed = false;
this.hostInsideView = false;
}
getState() {
return {
props: { ...this.props },
snapshot: this.createSnapshot(),
expanded: this.getMergedExpanded(this.resolveEllipsisConfig(), this.getText())
};
}
getText() {
return this.props.text ?? "";
}
createSnapshot() {
const text = this.getText();
const entities = findAllEntities(text);
const tokens = createAngularContentTokens(text);
return {
text,
entities,
tokens
};
}
resolveEllipsisConfig() {
const ellipsis = this.props.ellipsis;
if (!ellipsis || typeof ellipsis !== "object") {
return null;
}
return ellipsis;
}
getMergedExpanded(ellipsisConfig, text) {
const controlledExpanded = ellipsisConfig && typeof ellipsisConfig.expanded === "boolean" ? ellipsisConfig.expanded : void 0;
if (controlledExpanded !== void 0) {
return controlledExpanded;
}
if (ellipsisConfig && "count" in ellipsisConfig) {
const count = ellipsisConfig.count ?? 0;
if (count <= 0 || text.length <= count) {
return true;
}
}
return this.expanded;
}
createDefaultMentionNode(entity) {
const span = document.createElement("span");
span.style.color = LINK_COLOR;
span.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
span.textContent = entity.displayText;
return span;
}
createDefaultTagNode(entity) {
const span = document.createElement("span");
span.style.color = LINK_COLOR;
span.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
span.textContent = entity.text;
return span;
}
createDefaultLinkNode(entity) {
const anchor = document.createElement("a");
anchor.href = entity.url;
anchor.target = "_blank";
anchor.referrerPolicy = "no-referrer";
anchor.style.color = LINK_COLOR;
anchor.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
anchor.textContent = entity.text;
return anchor;
}
renderEntity(entity, index) {
if (entity.type === "mention") {
const customNode2 = this.props.renderMention?.(entity, index);
return toNode(customNode2) ?? this.createDefaultMentionNode(entity);
}
if (entity.type === "tag") {
const customNode2 = this.props.renderTag?.(entity, index);
return toNode(customNode2) ?? this.createDefaultTagNode(entity);
}
const customNode = this.props.renderLink?.(entity, index);
return toNode(customNode) ?? this.createDefaultLinkNode(entity);
}
buildTextNodes(text, entities, upto = null) {
let lastIndex = 0;
const nodes = [];
for (const [index, entity] of entities.entries()) {
if (upto !== null && entity.start >= upto) {
break;
}
const textEnd = upto !== null ? Math.min(entity.start, upto) : entity.start;
if (entity.start > lastIndex && lastIndex < textEnd) {
nodes.push(document.createTextNode(text.slice(lastIndex, textEnd)));
}
if (upto === null || entity.end <= upto) {
const entityNode = this.renderEntity(entity, index);
if (entityNode) {
nodes.push(entityNode);
}
}
lastIndex = entity.end;
}
if (upto === null) {
if (lastIndex < text.length) {
nodes.push(document.createTextNode(text.slice(lastIndex)));
}
} else if (lastIndex < upto) {
nodes.push(document.createTextNode(text.slice(lastIndex, upto)));
}
return nodes;
}
applyBaseParagraphStyle(element) {
element.style.whiteSpace = "pre-wrap";
element.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
element.style.setProperty("-webkit-touch-callout", "default");
element.style.setProperty("-webkit-user-select", "text");
element.style.setProperty("-khtml-user-select", "text");
element.style.setProperty("-moz-user-select", "text");
element.style.setProperty("-ms-user-select", "text");
element.style.setProperty("user-select", "text");
if (this.props.blur) {
element.style.filter = "blur(3px)";
element.style.setProperty("-webkit-user-select", "none");
element.style.setProperty("-khtml-user-select", "none");
element.style.setProperty("-moz-user-select", "none");
element.style.setProperty("-ms-user-select", "none");
element.style.setProperty("user-select", "none");
element.style.pointerEvents = "none";
}
applyStyleObject(element, this.props.style);
}
buildReadMoreButton(symbol) {
const button = document.createElement("button");
button.type = "button";
button.textContent = resolveEllipsisSymbol(symbol, this.expanded);
button.style.border = "0";
button.style.background = "none";
button.style.padding = "0";
button.style.marginLeft = "6px";
button.style.cursor = "pointer";
button.style.color = LINK_COLOR;
button.style.fontWeight = "700";
button.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
this.handleExpand();
};
return button;
}
handleExpand() {
const ellipsisConfig = this.resolveEllipsisConfig();
if (!ellipsisConfig) {
return;
}
this.expanded = true;
ellipsisConfig.onExpand?.(true);
this.render();
}
computeEntitySafeCutoff(count, entities) {
let cutoff = count;
let extended = true;
while (extended) {
extended = false;
for (const entity of entities) {
if (entity.start < cutoff && entity.end > cutoff) {
cutoff = entity.end;
extended = true;
}
}
}
return cutoff;
}
render() {
if (!this.host) {
return;
}
const text = this.getText();
const entities = findAllEntities(text);
const ellipsisConfig = this.resolveEllipsisConfig();
const mergedExpanded = this.getMergedExpanded(ellipsisConfig, text);
const wrapper = document.createElement("div");
const paragraph = document.createElement("div");
if (this.props.className) {
paragraph.className = this.props.className;
}
this.applyBaseParagraphStyle(paragraph);
if (ellipsisConfig && "count" in ellipsisConfig) {
const count = ellipsisConfig.count ?? 0;
const shouldTruncate = !mergedExpanded && count > 0 && text.length > count;
if (shouldTruncate) {
const cutoff = this.computeEntitySafeCutoff(count, entities);
const nodes = this.buildTextNodes(text, entities, cutoff);
paragraph.append(...nodes);
paragraph.append(document.createTextNode("\u2026"));
if (ellipsisConfig.expandable) {
paragraph.append(this.buildReadMoreButton(ellipsisConfig.symbol));
}
} else {
paragraph.append(...this.buildTextNodes(text, entities));
}
} else {
paragraph.append(...this.buildTextNodes(text, entities));
if (ellipsisConfig && "rows" in ellipsisConfig && !mergedExpanded) {
paragraph.style.display = "-webkit-box";
paragraph.style.setProperty("-webkit-box-orient", "vertical");
paragraph.style.setProperty("-webkit-line-clamp", String(ellipsisConfig.rows));
paragraph.style.overflow = "hidden";
if (ellipsisConfig.expandable) {
wrapper.append(paragraph);
wrapper.append(this.buildReadMoreButton(ellipsisConfig.symbol));
this.host.replaceChildren(wrapper);
this.emitOnViewIfNeeded(mergedExpanded);
return;
}
}
}
wrapper.append(paragraph);
this.host.replaceChildren(wrapper);
this.emitOnViewIfNeeded(mergedExpanded);
}
initObserver() {
if (!this.host) {
return;
}
if (typeof IntersectionObserver === "undefined") {
this.hostInsideView = true;
return;
}
this.observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) {
return;
}
if (entry.isIntersecting && !this.hostInsideView) {
this.hostInsideView = true;
const ellipsisConfig = this.resolveEllipsisConfig();
const mergedExpanded = this.getMergedExpanded(ellipsisConfig, this.getText());
this.emitOnViewIfNeeded(mergedExpanded);
}
},
{ threshold: 0.5 }
);
this.observer.observe(this.host);
}
emitOnViewIfNeeded(mergedExpanded) {
if (!mergedExpanded || this.viewed || !this.hostInsideView) {
return;
}
this.viewed = true;
this.props.onView?.();
}
};
var AngularContentTextWithSuggestionsRenderer = class extends AngularContentTextRenderer {
};
var AngularContentTitleWithSuggestionsRenderer = class {
renderer = new AngularContentTextWithSuggestionsRenderer();
attach(host, props = {}) {
return this.renderer.attach(host, this.normalizeProps(props));
}
update(props) {
return this.renderer.update(this.normalizeProps(props));
}
destroy() {
this.renderer.destroy();
}
getState() {
return this.renderer.getState();
}
normalizeProps(props) {
return {
...props,
weight: "bold",
blur: props.blur ?? false,
ellipsis: props.ellipsis === void 0 ? { rows: 2 } : props.ellipsis
};
}
};
exports.AngularContentSuggestionsAdapter = AngularContentSuggestionsAdapter; exports.AngularContentSuggestionsAdapter = AngularContentSuggestionsAdapter;
exports.AngularContentTextRenderer = AngularContentTextRenderer;
exports.AngularContentTextWithSuggestionsRenderer = AngularContentTextWithSuggestionsRenderer;
exports.AngularContentTitleWithSuggestionsRenderer = AngularContentTitleWithSuggestionsRenderer;
exports.buildAngularTagHref = buildAngularTagHref; exports.buildAngularTagHref = buildAngularTagHref;
exports.createAngularContentTokens = createAngularContentTokens; exports.createAngularContentTokens = createAngularContentTokens;
exports.toKebabCase = toKebabCase;
//# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map
//# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map

File diff suppressed because one or more lines are too long

View File

@@ -24,10 +24,94 @@ interface AngularContentSnapshot {
entities: ContentEntity[]; entities: ContentEntity[];
tokens: AngularContentToken[]; tokens: AngularContentToken[];
} }
type AngularEllipsisSymbol = string | ((expanded: boolean) => string);
type AngularCountEllipsisConfig = {
count: number;
rows?: never;
expandable?: boolean;
expanded?: boolean;
symbol?: AngularEllipsisSymbol;
onExpand?: (expanded: boolean) => void;
};
type AngularRowsEllipsisConfig = {
rows: number;
count?: never;
expandable?: boolean;
expanded?: boolean;
symbol?: AngularEllipsisSymbol;
onExpand?: (expanded: boolean) => void;
};
type AngularContentEllipsisConfig = AngularCountEllipsisConfig | AngularRowsEllipsisConfig | false;
type AngularRenderResult = Node | string | number | null | undefined;
type AngularMentionRenderer = (entity: MentionEntity, index: number) => AngularRenderResult;
type AngularTagRenderer = (entity: TagEntity, index: number) => AngularRenderResult;
type AngularLinkRenderer = (entity: LinkEntity, index: number) => AngularRenderResult;
interface AngularContentTextProps {
className?: string;
weight?: "normal" | "bold";
text?: string | null;
ellipsis?: AngularContentEllipsisConfig;
blur?: boolean;
style?: Record<string, string | number> | null;
onView?: () => void;
renderMention?: AngularMentionRenderer;
renderTag?: AngularTagRenderer;
renderLink?: AngularLinkRenderer;
}
type AngularContentTextWithSuggestionsProps = Omit<AngularContentTextProps, "renderMention" | "renderTag"> & {
renderMention?: AngularMentionRenderer;
renderTag?: AngularTagRenderer;
};
type AngularContentTitleWithSuggestionsProps = Omit<AngularContentTextWithSuggestionsProps, "weight">;
interface AngularContentTextRendererState {
props: AngularContentTextProps;
snapshot: AngularContentSnapshot;
expanded: boolean;
}
declare const toKebabCase: (value: string) => string;
declare const buildAngularTagHref: (entity: TagEntity) => string; declare const buildAngularTagHref: (entity: TagEntity) => string;
declare const createAngularContentTokens: (inputText: string | null | undefined) => AngularContentToken[]; declare const createAngularContentTokens: (inputText: string | null | undefined) => AngularContentToken[];
declare class AngularContentSuggestionsAdapter { declare class AngularContentSuggestionsAdapter {
snapshot(inputText: string | null | undefined): AngularContentSnapshot; snapshot(inputText: string | null | undefined): AngularContentSnapshot;
} }
declare class AngularContentTextRenderer {
private host;
private props;
private expanded;
private viewed;
private hostInsideView;
private observer;
attach(host: HTMLElement, props?: AngularContentTextProps): AngularContentTextRendererState;
update(nextProps: AngularContentTextProps): AngularContentTextRendererState;
destroy(): void;
getState(): AngularContentTextRendererState;
private getText;
private createSnapshot;
private resolveEllipsisConfig;
private getMergedExpanded;
private createDefaultMentionNode;
private createDefaultTagNode;
private createDefaultLinkNode;
private renderEntity;
private buildTextNodes;
private applyBaseParagraphStyle;
private buildReadMoreButton;
private handleExpand;
private computeEntitySafeCutoff;
private render;
private initObserver;
private emitOnViewIfNeeded;
}
declare class AngularContentTextWithSuggestionsRenderer extends AngularContentTextRenderer {
}
declare class AngularContentTitleWithSuggestionsRenderer {
private readonly renderer;
attach(host: HTMLElement, props?: AngularContentTitleWithSuggestionsProps): AngularContentTextRendererState;
update(props: AngularContentTitleWithSuggestionsProps): AngularContentTextRendererState;
destroy(): void;
getState(): AngularContentTextRendererState;
private normalizeProps;
}
export { type AngularContentSnapshot, AngularContentSuggestionsAdapter, type AngularContentToken, type AngularLinkToken, type AngularMentionToken, type AngularTagToken, type AngularTextToken, buildAngularTagHref, createAngularContentTokens }; export { type AngularContentEllipsisConfig, type AngularContentSnapshot, AngularContentSuggestionsAdapter, type AngularContentTextProps, AngularContentTextRenderer, type AngularContentTextRendererState, type AngularContentTextWithSuggestionsProps, AngularContentTextWithSuggestionsRenderer, type AngularContentTitleWithSuggestionsProps, AngularContentTitleWithSuggestionsRenderer, type AngularContentToken, type AngularCountEllipsisConfig, type AngularEllipsisSymbol, type AngularLinkRenderer, type AngularLinkToken, type AngularMentionRenderer, type AngularMentionToken, type AngularRenderResult, type AngularRowsEllipsisConfig, type AngularTagRenderer, type AngularTagToken, type AngularTextToken, buildAngularTagHref, createAngularContentTokens, toKebabCase };

View File

@@ -24,10 +24,94 @@ interface AngularContentSnapshot {
entities: ContentEntity[]; entities: ContentEntity[];
tokens: AngularContentToken[]; tokens: AngularContentToken[];
} }
type AngularEllipsisSymbol = string | ((expanded: boolean) => string);
type AngularCountEllipsisConfig = {
count: number;
rows?: never;
expandable?: boolean;
expanded?: boolean;
symbol?: AngularEllipsisSymbol;
onExpand?: (expanded: boolean) => void;
};
type AngularRowsEllipsisConfig = {
rows: number;
count?: never;
expandable?: boolean;
expanded?: boolean;
symbol?: AngularEllipsisSymbol;
onExpand?: (expanded: boolean) => void;
};
type AngularContentEllipsisConfig = AngularCountEllipsisConfig | AngularRowsEllipsisConfig | false;
type AngularRenderResult = Node | string | number | null | undefined;
type AngularMentionRenderer = (entity: MentionEntity, index: number) => AngularRenderResult;
type AngularTagRenderer = (entity: TagEntity, index: number) => AngularRenderResult;
type AngularLinkRenderer = (entity: LinkEntity, index: number) => AngularRenderResult;
interface AngularContentTextProps {
className?: string;
weight?: "normal" | "bold";
text?: string | null;
ellipsis?: AngularContentEllipsisConfig;
blur?: boolean;
style?: Record<string, string | number> | null;
onView?: () => void;
renderMention?: AngularMentionRenderer;
renderTag?: AngularTagRenderer;
renderLink?: AngularLinkRenderer;
}
type AngularContentTextWithSuggestionsProps = Omit<AngularContentTextProps, "renderMention" | "renderTag"> & {
renderMention?: AngularMentionRenderer;
renderTag?: AngularTagRenderer;
};
type AngularContentTitleWithSuggestionsProps = Omit<AngularContentTextWithSuggestionsProps, "weight">;
interface AngularContentTextRendererState {
props: AngularContentTextProps;
snapshot: AngularContentSnapshot;
expanded: boolean;
}
declare const toKebabCase: (value: string) => string;
declare const buildAngularTagHref: (entity: TagEntity) => string; declare const buildAngularTagHref: (entity: TagEntity) => string;
declare const createAngularContentTokens: (inputText: string | null | undefined) => AngularContentToken[]; declare const createAngularContentTokens: (inputText: string | null | undefined) => AngularContentToken[];
declare class AngularContentSuggestionsAdapter { declare class AngularContentSuggestionsAdapter {
snapshot(inputText: string | null | undefined): AngularContentSnapshot; snapshot(inputText: string | null | undefined): AngularContentSnapshot;
} }
declare class AngularContentTextRenderer {
private host;
private props;
private expanded;
private viewed;
private hostInsideView;
private observer;
attach(host: HTMLElement, props?: AngularContentTextProps): AngularContentTextRendererState;
update(nextProps: AngularContentTextProps): AngularContentTextRendererState;
destroy(): void;
getState(): AngularContentTextRendererState;
private getText;
private createSnapshot;
private resolveEllipsisConfig;
private getMergedExpanded;
private createDefaultMentionNode;
private createDefaultTagNode;
private createDefaultLinkNode;
private renderEntity;
private buildTextNodes;
private applyBaseParagraphStyle;
private buildReadMoreButton;
private handleExpand;
private computeEntitySafeCutoff;
private render;
private initObserver;
private emitOnViewIfNeeded;
}
declare class AngularContentTextWithSuggestionsRenderer extends AngularContentTextRenderer {
}
declare class AngularContentTitleWithSuggestionsRenderer {
private readonly renderer;
attach(host: HTMLElement, props?: AngularContentTitleWithSuggestionsProps): AngularContentTextRendererState;
update(props: AngularContentTitleWithSuggestionsProps): AngularContentTextRendererState;
destroy(): void;
getState(): AngularContentTextRendererState;
private normalizeProps;
}
export { type AngularContentSnapshot, AngularContentSuggestionsAdapter, type AngularContentToken, type AngularLinkToken, type AngularMentionToken, type AngularTagToken, type AngularTextToken, buildAngularTagHref, createAngularContentTokens }; export { type AngularContentEllipsisConfig, type AngularContentSnapshot, AngularContentSuggestionsAdapter, type AngularContentTextProps, AngularContentTextRenderer, type AngularContentTextRendererState, type AngularContentTextWithSuggestionsProps, AngularContentTextWithSuggestionsRenderer, type AngularContentTitleWithSuggestionsProps, AngularContentTitleWithSuggestionsRenderer, type AngularContentToken, type AngularCountEllipsisConfig, type AngularEllipsisSymbol, type AngularLinkRenderer, type AngularLinkToken, type AngularMentionRenderer, type AngularMentionToken, type AngularRenderResult, type AngularRowsEllipsisConfig, type AngularTagRenderer, type AngularTagToken, type AngularTextToken, buildAngularTagHref, createAngularContentTokens, toKebabCase };

View File

@@ -74,6 +74,41 @@ var findAllEntities = (content) => {
}; };
// src/angular/index.ts // src/angular/index.ts
var LINK_COLOR = "#1677ff";
var READ_MORE_TEXT = "\u0427\u0438\u0442\u0430\u0442\u044C \u043F\u043E\u043B\u043D\u043E\u0441\u0442\u044C\u044E";
var toKebabCase = (value) => value.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`);
var applyStyleObject = (element, styles) => {
if (!styles) {
return;
}
for (const [key, rawValue] of Object.entries(styles)) {
if (rawValue === null || rawValue === void 0) {
continue;
}
const styleValue = typeof rawValue === "number" ? `${rawValue}px` : String(rawValue);
if (key.startsWith("--")) {
element.style.setProperty(key, styleValue);
continue;
}
const cssKey = key.includes("-") ? key : toKebabCase(key);
element.style.setProperty(cssKey, styleValue);
}
};
var toNode = (value) => {
if (value === null || value === void 0) {
return null;
}
if (value instanceof Node) {
return value;
}
return document.createTextNode(String(value));
};
var resolveEllipsisSymbol = (symbol, expanded) => {
if (typeof symbol === "function") {
return symbol(expanded);
}
return symbol ?? READ_MORE_TEXT;
};
var buildAngularTagHref = (entity) => { var buildAngularTagHref = (entity) => {
return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`; return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`;
}; };
@@ -131,7 +166,319 @@ var AngularContentSuggestionsAdapter = class {
}; };
} }
}; };
var AngularContentTextRenderer = class {
host = null;
props = {};
expanded = false;
viewed = false;
hostInsideView = false;
observer = null;
attach(host, props = {}) {
this.destroy();
this.host = host;
this.props = { ...props };
this.expanded = false;
this.viewed = false;
this.hostInsideView = false;
this.initObserver();
this.render();
return this.getState();
}
update(nextProps) {
this.props = {
...this.props,
...nextProps
};
this.render();
return this.getState();
}
destroy() {
if (this.observer && this.host) {
this.observer.unobserve(this.host);
this.observer.disconnect();
}
this.observer = null;
if (this.host) {
this.host.innerHTML = "";
}
this.host = null;
this.props = {};
this.expanded = false;
this.viewed = false;
this.hostInsideView = false;
}
getState() {
return {
props: { ...this.props },
snapshot: this.createSnapshot(),
expanded: this.getMergedExpanded(this.resolveEllipsisConfig(), this.getText())
};
}
getText() {
return this.props.text ?? "";
}
createSnapshot() {
const text = this.getText();
const entities = findAllEntities(text);
const tokens = createAngularContentTokens(text);
return {
text,
entities,
tokens
};
}
resolveEllipsisConfig() {
const ellipsis = this.props.ellipsis;
if (!ellipsis || typeof ellipsis !== "object") {
return null;
}
return ellipsis;
}
getMergedExpanded(ellipsisConfig, text) {
const controlledExpanded = ellipsisConfig && typeof ellipsisConfig.expanded === "boolean" ? ellipsisConfig.expanded : void 0;
if (controlledExpanded !== void 0) {
return controlledExpanded;
}
if (ellipsisConfig && "count" in ellipsisConfig) {
const count = ellipsisConfig.count ?? 0;
if (count <= 0 || text.length <= count) {
return true;
}
}
return this.expanded;
}
createDefaultMentionNode(entity) {
const span = document.createElement("span");
span.style.color = LINK_COLOR;
span.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
span.textContent = entity.displayText;
return span;
}
createDefaultTagNode(entity) {
const span = document.createElement("span");
span.style.color = LINK_COLOR;
span.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
span.textContent = entity.text;
return span;
}
createDefaultLinkNode(entity) {
const anchor = document.createElement("a");
anchor.href = entity.url;
anchor.target = "_blank";
anchor.referrerPolicy = "no-referrer";
anchor.style.color = LINK_COLOR;
anchor.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
anchor.textContent = entity.text;
return anchor;
}
renderEntity(entity, index) {
if (entity.type === "mention") {
const customNode2 = this.props.renderMention?.(entity, index);
return toNode(customNode2) ?? this.createDefaultMentionNode(entity);
}
if (entity.type === "tag") {
const customNode2 = this.props.renderTag?.(entity, index);
return toNode(customNode2) ?? this.createDefaultTagNode(entity);
}
const customNode = this.props.renderLink?.(entity, index);
return toNode(customNode) ?? this.createDefaultLinkNode(entity);
}
buildTextNodes(text, entities, upto = null) {
let lastIndex = 0;
const nodes = [];
for (const [index, entity] of entities.entries()) {
if (upto !== null && entity.start >= upto) {
break;
}
const textEnd = upto !== null ? Math.min(entity.start, upto) : entity.start;
if (entity.start > lastIndex && lastIndex < textEnd) {
nodes.push(document.createTextNode(text.slice(lastIndex, textEnd)));
}
if (upto === null || entity.end <= upto) {
const entityNode = this.renderEntity(entity, index);
if (entityNode) {
nodes.push(entityNode);
}
}
lastIndex = entity.end;
}
if (upto === null) {
if (lastIndex < text.length) {
nodes.push(document.createTextNode(text.slice(lastIndex)));
}
} else if (lastIndex < upto) {
nodes.push(document.createTextNode(text.slice(lastIndex, upto)));
}
return nodes;
}
applyBaseParagraphStyle(element) {
element.style.whiteSpace = "pre-wrap";
element.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
element.style.setProperty("-webkit-touch-callout", "default");
element.style.setProperty("-webkit-user-select", "text");
element.style.setProperty("-khtml-user-select", "text");
element.style.setProperty("-moz-user-select", "text");
element.style.setProperty("-ms-user-select", "text");
element.style.setProperty("user-select", "text");
if (this.props.blur) {
element.style.filter = "blur(3px)";
element.style.setProperty("-webkit-user-select", "none");
element.style.setProperty("-khtml-user-select", "none");
element.style.setProperty("-moz-user-select", "none");
element.style.setProperty("-ms-user-select", "none");
element.style.setProperty("user-select", "none");
element.style.pointerEvents = "none";
}
applyStyleObject(element, this.props.style);
}
buildReadMoreButton(symbol) {
const button = document.createElement("button");
button.type = "button";
button.textContent = resolveEllipsisSymbol(symbol, this.expanded);
button.style.border = "0";
button.style.background = "none";
button.style.padding = "0";
button.style.marginLeft = "6px";
button.style.cursor = "pointer";
button.style.color = LINK_COLOR;
button.style.fontWeight = "700";
button.onclick = (event) => {
event.preventDefault();
event.stopPropagation();
this.handleExpand();
};
return button;
}
handleExpand() {
const ellipsisConfig = this.resolveEllipsisConfig();
if (!ellipsisConfig) {
return;
}
this.expanded = true;
ellipsisConfig.onExpand?.(true);
this.render();
}
computeEntitySafeCutoff(count, entities) {
let cutoff = count;
let extended = true;
while (extended) {
extended = false;
for (const entity of entities) {
if (entity.start < cutoff && entity.end > cutoff) {
cutoff = entity.end;
extended = true;
}
}
}
return cutoff;
}
render() {
if (!this.host) {
return;
}
const text = this.getText();
const entities = findAllEntities(text);
const ellipsisConfig = this.resolveEllipsisConfig();
const mergedExpanded = this.getMergedExpanded(ellipsisConfig, text);
const wrapper = document.createElement("div");
const paragraph = document.createElement("div");
if (this.props.className) {
paragraph.className = this.props.className;
}
this.applyBaseParagraphStyle(paragraph);
if (ellipsisConfig && "count" in ellipsisConfig) {
const count = ellipsisConfig.count ?? 0;
const shouldTruncate = !mergedExpanded && count > 0 && text.length > count;
if (shouldTruncate) {
const cutoff = this.computeEntitySafeCutoff(count, entities);
const nodes = this.buildTextNodes(text, entities, cutoff);
paragraph.append(...nodes);
paragraph.append(document.createTextNode("\u2026"));
if (ellipsisConfig.expandable) {
paragraph.append(this.buildReadMoreButton(ellipsisConfig.symbol));
}
} else {
paragraph.append(...this.buildTextNodes(text, entities));
}
} else {
paragraph.append(...this.buildTextNodes(text, entities));
if (ellipsisConfig && "rows" in ellipsisConfig && !mergedExpanded) {
paragraph.style.display = "-webkit-box";
paragraph.style.setProperty("-webkit-box-orient", "vertical");
paragraph.style.setProperty("-webkit-line-clamp", String(ellipsisConfig.rows));
paragraph.style.overflow = "hidden";
if (ellipsisConfig.expandable) {
wrapper.append(paragraph);
wrapper.append(this.buildReadMoreButton(ellipsisConfig.symbol));
this.host.replaceChildren(wrapper);
this.emitOnViewIfNeeded(mergedExpanded);
return;
}
}
}
wrapper.append(paragraph);
this.host.replaceChildren(wrapper);
this.emitOnViewIfNeeded(mergedExpanded);
}
initObserver() {
if (!this.host) {
return;
}
if (typeof IntersectionObserver === "undefined") {
this.hostInsideView = true;
return;
}
this.observer = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (!entry) {
return;
}
if (entry.isIntersecting && !this.hostInsideView) {
this.hostInsideView = true;
const ellipsisConfig = this.resolveEllipsisConfig();
const mergedExpanded = this.getMergedExpanded(ellipsisConfig, this.getText());
this.emitOnViewIfNeeded(mergedExpanded);
}
},
{ threshold: 0.5 }
);
this.observer.observe(this.host);
}
emitOnViewIfNeeded(mergedExpanded) {
if (!mergedExpanded || this.viewed || !this.hostInsideView) {
return;
}
this.viewed = true;
this.props.onView?.();
}
};
var AngularContentTextWithSuggestionsRenderer = class extends AngularContentTextRenderer {
};
var AngularContentTitleWithSuggestionsRenderer = class {
renderer = new AngularContentTextWithSuggestionsRenderer();
attach(host, props = {}) {
return this.renderer.attach(host, this.normalizeProps(props));
}
update(props) {
return this.renderer.update(this.normalizeProps(props));
}
destroy() {
this.renderer.destroy();
}
getState() {
return this.renderer.getState();
}
normalizeProps(props) {
return {
...props,
weight: "bold",
blur: props.blur ?? false,
ellipsis: props.ellipsis === void 0 ? { rows: 2 } : props.ellipsis
};
}
};
export { AngularContentSuggestionsAdapter, buildAngularTagHref, createAngularContentTokens }; export { AngularContentSuggestionsAdapter, AngularContentTextRenderer, AngularContentTextWithSuggestionsRenderer, AngularContentTitleWithSuggestionsRenderer, buildAngularTagHref, createAngularContentTokens, toKebabCase };
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map //# sourceMappingURL=index.js.map

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"name": "@hublib-web/content-suggestions", "name": "@hublib-web/content-suggestions",
"version": "0.1.0", "version": "0.1.1",
"description": "Content text/title with mentions, tags and links for React and Angular", "description": "Content text/title with mentions, tags and links for React and Angular",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",

View File

@@ -36,10 +36,140 @@ export interface AngularContentSnapshot {
tokens: AngularContentToken[]; tokens: AngularContentToken[];
} }
export const buildAngularTagHref = (entity: TagEntity): string => { export type AngularEllipsisSymbol = string | ((expanded: boolean) => string);
export type AngularCountEllipsisConfig = {
count: number;
rows?: never;
expandable?: boolean;
expanded?: boolean;
symbol?: AngularEllipsisSymbol;
onExpand?: (expanded: boolean) => void;
};
export type AngularRowsEllipsisConfig = {
rows: number;
count?: never;
expandable?: boolean;
expanded?: boolean;
symbol?: AngularEllipsisSymbol;
onExpand?: (expanded: boolean) => void;
};
export type AngularContentEllipsisConfig =
| AngularCountEllipsisConfig
| AngularRowsEllipsisConfig
| false;
export type AngularRenderResult = Node | string | number | null | undefined;
export type AngularMentionRenderer = (
entity: MentionEntity,
index: number,
) => AngularRenderResult;
export type AngularTagRenderer = (
entity: TagEntity,
index: number,
) => AngularRenderResult;
export type AngularLinkRenderer = (
entity: LinkEntity,
index: number,
) => AngularRenderResult;
export interface AngularContentTextProps {
className?: string;
weight?: "normal" | "bold";
text?: string | null;
ellipsis?: AngularContentEllipsisConfig;
blur?: boolean;
style?: Record<string, string | number> | null;
onView?: () => void;
renderMention?: AngularMentionRenderer;
renderTag?: AngularTagRenderer;
renderLink?: AngularLinkRenderer;
}
export type AngularContentTextWithSuggestionsProps = Omit<
AngularContentTextProps,
"renderMention" | "renderTag"
> & {
renderMention?: AngularMentionRenderer;
renderTag?: AngularTagRenderer;
};
export type AngularContentTitleWithSuggestionsProps = Omit<
AngularContentTextWithSuggestionsProps,
"weight"
>;
export interface AngularContentTextRendererState {
props: AngularContentTextProps;
snapshot: AngularContentSnapshot;
expanded: boolean;
}
const LINK_COLOR = "#1677ff";
const READ_MORE_TEXT = "Читать полностью";
const toKebabCase = (value: string) => value.replace(/[A-Z]/g, char => `-${char.toLowerCase()}`);
const applyStyleObject = (
element: HTMLElement,
styles?: Record<string, string | number> | null,
) => {
if (!styles) {
return;
}
for (const [key, rawValue] of Object.entries(styles)) {
if (rawValue === null || rawValue === undefined) {
continue;
}
const styleValue =
typeof rawValue === "number" ? `${rawValue}px` : String(rawValue);
if (key.startsWith("--")) {
element.style.setProperty(key, styleValue);
continue;
}
const cssKey = key.includes("-") ? key : toKebabCase(key);
element.style.setProperty(cssKey, styleValue);
}
};
const toNode = (value: AngularRenderResult): Node | null => {
if (value === null || value === undefined) {
return null;
}
if (value instanceof Node) {
return value;
}
return document.createTextNode(String(value));
};
const resolveEllipsisSymbol = (
symbol: AngularEllipsisSymbol | undefined,
expanded: boolean,
): string => {
if (typeof symbol === "function") {
return symbol(expanded);
}
return symbol ?? READ_MORE_TEXT;
};
const buildAngularTagHref = (entity: TagEntity): string => {
return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`; return `/search/?query=${encodeURIComponent(entity.tag.toLowerCase())}`;
}; };
export { buildAngularTagHref };
export const createAngularContentTokens = ( export const createAngularContentTokens = (
inputText: string | null | undefined, inputText: string | null | undefined,
): AngularContentToken[] => { ): AngularContentToken[] => {
@@ -104,3 +234,410 @@ export class AngularContentSuggestionsAdapter {
}; };
} }
} }
export class AngularContentTextRenderer {
private host: HTMLElement | null = null;
private props: AngularContentTextProps = {};
private expanded = false;
private viewed = false;
private hostInsideView = false;
private observer: IntersectionObserver | null = null;
attach(host: HTMLElement, props: AngularContentTextProps = {}): AngularContentTextRendererState {
this.destroy();
this.host = host;
this.props = { ...props };
this.expanded = false;
this.viewed = false;
this.hostInsideView = false;
this.initObserver();
this.render();
return this.getState();
}
update(nextProps: AngularContentTextProps): AngularContentTextRendererState {
this.props = {
...this.props,
...nextProps,
};
this.render();
return this.getState();
}
destroy(): void {
if (this.observer && this.host) {
this.observer.unobserve(this.host);
this.observer.disconnect();
}
this.observer = null;
if (this.host) {
this.host.innerHTML = "";
}
this.host = null;
this.props = {};
this.expanded = false;
this.viewed = false;
this.hostInsideView = false;
}
getState(): AngularContentTextRendererState {
return {
props: { ...this.props },
snapshot: this.createSnapshot(),
expanded: this.getMergedExpanded(this.resolveEllipsisConfig(), this.getText()),
};
}
private getText(): string {
return this.props.text ?? "";
}
private createSnapshot(): AngularContentSnapshot {
const text = this.getText();
const entities = findAllEntities(text);
const tokens = createAngularContentTokens(text);
return {
text,
entities,
tokens,
};
}
private resolveEllipsisConfig():
| AngularCountEllipsisConfig
| AngularRowsEllipsisConfig
| null {
const ellipsis = this.props.ellipsis;
if (!ellipsis || typeof ellipsis !== "object") {
return null;
}
return ellipsis;
}
private getMergedExpanded(
ellipsisConfig: AngularCountEllipsisConfig | AngularRowsEllipsisConfig | null,
text: string,
): boolean {
const controlledExpanded =
ellipsisConfig && typeof ellipsisConfig.expanded === "boolean"
? ellipsisConfig.expanded
: undefined;
if (controlledExpanded !== undefined) {
return controlledExpanded;
}
if (ellipsisConfig && "count" in ellipsisConfig) {
const count = ellipsisConfig.count ?? 0;
if (count <= 0 || text.length <= count) {
return true;
}
}
return this.expanded;
}
private createDefaultMentionNode(entity: MentionEntity): Node {
const span = document.createElement("span");
span.style.color = LINK_COLOR;
span.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
span.textContent = entity.displayText;
return span;
}
private createDefaultTagNode(entity: TagEntity): Node {
const span = document.createElement("span");
span.style.color = LINK_COLOR;
span.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
span.textContent = entity.text;
return span;
}
private createDefaultLinkNode(entity: LinkEntity): Node {
const anchor = document.createElement("a");
anchor.href = entity.url;
anchor.target = "_blank";
anchor.referrerPolicy = "no-referrer";
anchor.style.color = LINK_COLOR;
anchor.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
anchor.textContent = entity.text;
return anchor;
}
private renderEntity(entity: ContentEntity, index: number): Node | null {
if (entity.type === "mention") {
const customNode = this.props.renderMention?.(entity, index);
return toNode(customNode) ?? this.createDefaultMentionNode(entity);
}
if (entity.type === "tag") {
const customNode = this.props.renderTag?.(entity, index);
return toNode(customNode) ?? this.createDefaultTagNode(entity);
}
const customNode = this.props.renderLink?.(entity, index);
return toNode(customNode) ?? this.createDefaultLinkNode(entity);
}
private buildTextNodes(
text: string,
entities: ContentEntity[],
upto: number | null = null,
): Node[] {
let lastIndex = 0;
const nodes: Node[] = [];
for (const [index, entity] of entities.entries()) {
if (upto !== null && entity.start >= upto) {
break;
}
const textEnd = upto !== null ? Math.min(entity.start, upto) : entity.start;
if (entity.start > lastIndex && lastIndex < textEnd) {
nodes.push(document.createTextNode(text.slice(lastIndex, textEnd)));
}
if (upto === null || entity.end <= upto) {
const entityNode = this.renderEntity(entity, index);
if (entityNode) {
nodes.push(entityNode);
}
}
lastIndex = entity.end;
}
if (upto === null) {
if (lastIndex < text.length) {
nodes.push(document.createTextNode(text.slice(lastIndex)));
}
} else if (lastIndex < upto) {
nodes.push(document.createTextNode(text.slice(lastIndex, upto)));
}
return nodes;
}
private applyBaseParagraphStyle(element: HTMLElement): void {
element.style.whiteSpace = "pre-wrap";
element.style.fontWeight = this.props.weight === "bold" ? "700" : "400";
element.style.setProperty("-webkit-touch-callout", "default");
element.style.setProperty("-webkit-user-select", "text");
element.style.setProperty("-khtml-user-select", "text");
element.style.setProperty("-moz-user-select", "text");
element.style.setProperty("-ms-user-select", "text");
element.style.setProperty("user-select", "text");
if (this.props.blur) {
element.style.filter = "blur(3px)";
element.style.setProperty("-webkit-user-select", "none");
element.style.setProperty("-khtml-user-select", "none");
element.style.setProperty("-moz-user-select", "none");
element.style.setProperty("-ms-user-select", "none");
element.style.setProperty("user-select", "none");
element.style.pointerEvents = "none";
}
applyStyleObject(element, this.props.style);
}
private buildReadMoreButton(symbol: AngularEllipsisSymbol | undefined): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.textContent = resolveEllipsisSymbol(symbol, this.expanded);
button.style.border = "0";
button.style.background = "none";
button.style.padding = "0";
button.style.marginLeft = "6px";
button.style.cursor = "pointer";
button.style.color = LINK_COLOR;
button.style.fontWeight = "700";
button.onclick = event => {
event.preventDefault();
event.stopPropagation();
this.handleExpand();
};
return button;
}
private handleExpand(): void {
const ellipsisConfig = this.resolveEllipsisConfig();
if (!ellipsisConfig) {
return;
}
this.expanded = true;
ellipsisConfig.onExpand?.(true);
this.render();
}
private computeEntitySafeCutoff(
count: number,
entities: ContentEntity[],
): number {
let cutoff = count;
let extended = true;
while (extended) {
extended = false;
for (const entity of entities) {
if (entity.start < cutoff && entity.end > cutoff) {
cutoff = entity.end;
extended = true;
}
}
}
return cutoff;
}
private render(): void {
if (!this.host) {
return;
}
const text = this.getText();
const entities = findAllEntities(text);
const ellipsisConfig = this.resolveEllipsisConfig();
const mergedExpanded = this.getMergedExpanded(ellipsisConfig, text);
const wrapper = document.createElement("div");
const paragraph = document.createElement("div");
if (this.props.className) {
paragraph.className = this.props.className;
}
this.applyBaseParagraphStyle(paragraph);
if (ellipsisConfig && "count" in ellipsisConfig) {
const count = ellipsisConfig.count ?? 0;
const shouldTruncate = !mergedExpanded && count > 0 && text.length > count;
if (shouldTruncate) {
const cutoff = this.computeEntitySafeCutoff(count, entities);
const nodes = this.buildTextNodes(text, entities, cutoff);
paragraph.append(...nodes);
paragraph.append(document.createTextNode("…"));
if (ellipsisConfig.expandable) {
paragraph.append(this.buildReadMoreButton(ellipsisConfig.symbol));
}
} else {
paragraph.append(...this.buildTextNodes(text, entities));
}
} else {
paragraph.append(...this.buildTextNodes(text, entities));
if (ellipsisConfig && "rows" in ellipsisConfig && !mergedExpanded) {
paragraph.style.display = "-webkit-box";
paragraph.style.setProperty("-webkit-box-orient", "vertical");
paragraph.style.setProperty("-webkit-line-clamp", String(ellipsisConfig.rows));
paragraph.style.overflow = "hidden";
if (ellipsisConfig.expandable) {
wrapper.append(paragraph);
wrapper.append(this.buildReadMoreButton(ellipsisConfig.symbol));
this.host.replaceChildren(wrapper);
this.emitOnViewIfNeeded(mergedExpanded);
return;
}
}
}
wrapper.append(paragraph);
this.host.replaceChildren(wrapper);
this.emitOnViewIfNeeded(mergedExpanded);
}
private initObserver(): void {
if (!this.host) {
return;
}
if (typeof IntersectionObserver === "undefined") {
this.hostInsideView = true;
return;
}
this.observer = new IntersectionObserver(
entries => {
const entry = entries[0];
if (!entry) {
return;
}
if (entry.isIntersecting && !this.hostInsideView) {
this.hostInsideView = true;
const ellipsisConfig = this.resolveEllipsisConfig();
const mergedExpanded = this.getMergedExpanded(ellipsisConfig, this.getText());
this.emitOnViewIfNeeded(mergedExpanded);
}
},
{ threshold: 0.5 },
);
this.observer.observe(this.host);
}
private emitOnViewIfNeeded(mergedExpanded: boolean): void {
if (!mergedExpanded || this.viewed || !this.hostInsideView) {
return;
}
this.viewed = true;
this.props.onView?.();
}
}
export class AngularContentTextWithSuggestionsRenderer extends AngularContentTextRenderer {}
export class AngularContentTitleWithSuggestionsRenderer {
private readonly renderer = new AngularContentTextWithSuggestionsRenderer();
attach(
host: HTMLElement,
props: AngularContentTitleWithSuggestionsProps = {},
): AngularContentTextRendererState {
return this.renderer.attach(host, this.normalizeProps(props));
}
update(props: AngularContentTitleWithSuggestionsProps): AngularContentTextRendererState {
return this.renderer.update(this.normalizeProps(props));
}
destroy(): void {
this.renderer.destroy();
}
getState(): AngularContentTextRendererState {
return this.renderer.getState();
}
private normalizeProps(
props: AngularContentTitleWithSuggestionsProps,
): AngularContentTextWithSuggestionsProps {
return {
...props,
weight: "bold",
blur: props.blur ?? false,
ellipsis: props.ellipsis === undefined ? { rows: 2 } : props.ellipsis,
};
}
}
export { toKebabCase };

View File

@@ -105,6 +105,8 @@ export declare class VideoPlayerRuntime {
private currentEngine; private currentEngine;
private currentSource; private currentSource;
private vhsAuthTokenRef; private vhsAuthTokenRef;
private hlsAuthTokenRef;
private hlsTokenResolvePromise;
private vhsRequestCleanupRef; private vhsRequestCleanupRef;
private visibilityObserverRef; private visibilityObserverRef;
private originalPlayRef; private originalPlayRef;
@@ -116,6 +118,8 @@ export declare class VideoPlayerRuntime {
dispose(): void; dispose(): void;
getState(): VideoPlayerRuntimeState; getState(): VideoPlayerRuntimeState;
getPlayer(): VideoPlayerRuntimePlayer | null; getPlayer(): VideoPlayerRuntimePlayer | null;
private ensureHlsAuthToken;
private refreshHlsAuthTokenInBackground;
private emit; private emit;
private tryPlay; private tryPlay;
private resolveEngine; private resolveEngine;

View File

@@ -1 +1 @@
{"version":3,"file":"player-runtime.d.ts","sourceRoot":"","sources":["../../src/core/player-runtime.ts"],"names":[],"mappings":"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"} {"version":3,"file":"player-runtime.d.ts","sourceRoot":"","sources":["../../src/core/player-runtime.ts"],"names":[],"mappings":"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"}

View File

@@ -12,37 +12,30 @@ const detectIOS = () => {
const userAgent = navigator.userAgent || ""; const userAgent = navigator.userAgent || "";
return /iPad|iPhone|iPod/.test(userAgent); return /iPad|iPhone|iPod/.test(userAgent);
}; };
const createAuthPlaylistLoader = ({ debug, }) => { const createAuthPlaylistLoader = ({ debug, getToken, refreshToken, }) => {
const BaseLoader = Hls.DefaultConfig.loader; const BaseLoader = Hls.DefaultConfig.loader;
return class AuthPlaylistLoader extends BaseLoader { return class AuthPlaylistLoader extends BaseLoader {
constructor(config) { constructor(config) {
super({ ...config, debug: debug ?? false }); super({ ...config, debug: debug ?? false });
} }
load(context, config, callbacks) { load(context, config, callbacks) {
const start = async () => { try {
try { const token = getToken();
const token = await resolveVideoPlayerToken(); if (token) {
if (token) { context.headers = {
context.headers = { ...(context.headers ?? {}),
...(context.headers ?? {}), Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`, };
};
}
} }
catch (error) { }
if (debug) { catch (error) {
console.warn("[VideoRuntime:HLS] Failed to append auth header to playlist request", error);
}
}
finally {
super.load(context, config, callbacks);
}
};
void start().catch(error => {
if (debug) { if (debug) {
console.error("[VideoRuntime:HLS] Playlist loader start failed", error); console.warn("[VideoRuntime:HLS] Failed to append auth header to playlist request", error);
} }
}); }
// Critical path must stay sync for hls.js loader lifecycle.
super.load(context, config, callbacks);
refreshToken();
} }
}; };
}; };
@@ -82,6 +75,8 @@ export class VideoPlayerRuntime {
this.currentEngine = null; this.currentEngine = null;
this.currentSource = null; this.currentSource = null;
this.vhsAuthTokenRef = null; this.vhsAuthTokenRef = null;
this.hlsAuthTokenRef = null;
this.hlsTokenResolvePromise = null;
this.vhsRequestCleanupRef = null; this.vhsRequestCleanupRef = null;
this.visibilityObserverRef = null; this.visibilityObserverRef = null;
this.originalPlayRef = null; this.originalPlayRef = null;
@@ -147,6 +142,8 @@ export class VideoPlayerRuntime {
this.vhsRequestCleanupRef?.(); this.vhsRequestCleanupRef?.();
this.vhsRequestCleanupRef = null; this.vhsRequestCleanupRef = null;
this.vhsAuthTokenRef = null; this.vhsAuthTokenRef = null;
this.hlsAuthTokenRef = null;
this.hlsTokenResolvePromise = null;
if (this.playerRef) { if (this.playerRef) {
this.playerRef.dispose(); this.playerRef.dispose();
} }
@@ -171,6 +168,29 @@ export class VideoPlayerRuntime {
getPlayer() { getPlayer() {
return this.playerRef; return this.playerRef;
} }
async ensureHlsAuthToken() {
if (this.hlsTokenResolvePromise !== null) {
await this.hlsTokenResolvePromise;
return;
}
this.hlsTokenResolvePromise = resolveVideoPlayerToken()
.then(token => {
this.hlsAuthTokenRef = token ?? null;
})
.catch(() => {
this.hlsAuthTokenRef = null;
})
.finally(() => {
this.hlsTokenResolvePromise = null;
});
await this.hlsTokenResolvePromise;
}
refreshHlsAuthTokenInBackground() {
if (this.hlsTokenResolvePromise !== null) {
return;
}
void this.ensureHlsAuthToken();
}
emit(event, payload) { emit(event, payload) {
const listeners = this.eventListeners.get(event); const listeners = this.eventListeners.get(event);
if (!listeners?.size) { if (!listeners?.size) {
@@ -417,7 +437,11 @@ export class VideoPlayerRuntime {
const preferHqSettings = options.preferHQ const preferHqSettings = options.preferHQ
? { abrEwmaDefaultEstimate: 10690560 * 1.2 } ? { abrEwmaDefaultEstimate: 10690560 * 1.2 }
: {}; : {};
const playlistLoader = createAuthPlaylistLoader({ debug: options.debug }); const playlistLoader = createAuthPlaylistLoader({
debug: options.debug,
getToken: () => this.hlsAuthTokenRef,
refreshToken: () => this.refreshHlsAuthTokenInBackground(),
});
return { return {
debug: options.debug, debug: options.debug,
enableWorker: true, enableWorker: true,
@@ -456,6 +480,8 @@ export class VideoPlayerRuntime {
await this.loadVideoJsSource(); await this.loadVideoJsSource();
return; return;
} }
// Resolve async token before starting HLS manifest load.
await this.ensureHlsAuthToken();
const setupHls = () => { const setupHls = () => {
if (this.hlsLoaded) { if (this.hlsLoaded) {
return; return;

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"name": "@hublib-web/video-player", "name": "@hublib-web/video-player",
"version": "0.1.0", "version": "0.1.2",
"description": "Cross-framework video player package for React and Angular", "description": "Cross-framework video player package for React and Angular",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -10,7 +10,8 @@
"sideEffects": true, "sideEffects": true,
"files": [ "files": [
"dist", "dist",
"README.md" "README.md",
"scripts"
], ],
"typesVersions": { "typesVersions": {
"*": { "*": {
@@ -48,6 +49,7 @@
} }
}, },
"scripts": { "scripts": {
"postinstall": "node ./scripts/apply-videojs-patch.mjs",
"build": "yarn clean && node ./scripts/build.mjs", "build": "yarn clean && node ./scripts/build.mjs",
"clean": "rm -rf dist storybook-static", "clean": "rm -rf dist storybook-static",
"typecheck": "tsc -p ./tsconfig.json --noEmit", "typecheck": "tsc -p ./tsconfig.json --noEmit",

View File

@@ -0,0 +1,45 @@
import { readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const originalSnippet = ` const originalAbort = request.abort;
request.abort = function () {
request.aborted = true;
return originalAbort.apply(request, arguments);
};`;
const patchedSnippet = ` // const originalAbort = request.abort;
// request.abort = function () {
// request.aborted = true;
// return originalAbort.apply(request, arguments);
// };`;
function run() {
const packageJsonPath = require.resolve("video.js/package.json");
const videoEsPath = join(dirname(packageJsonPath), "dist", "video.es.js");
const source = readFileSync(videoEsPath, "utf8");
if (source.includes(patchedSnippet)) {
console.log("[video-player] video.js patch already applied");
return;
}
if (!source.includes(originalSnippet)) {
throw new Error(
"[video-player] Cannot apply video.js patch: target snippet not found. Expected video.js 8.23.4 source."
);
}
const patched = source.replace(originalSnippet, patchedSnippet);
writeFileSync(videoEsPath, patched, "utf8");
console.log("[video-player] Applied video.js patch");
}
try {
run();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}

View File

@@ -143,8 +143,12 @@ const detectIOS = () => {
const createAuthPlaylistLoader = ({ const createAuthPlaylistLoader = ({
debug, debug,
getToken,
refreshToken,
}: { }: {
debug?: boolean; debug?: boolean;
getToken: () => string | null;
refreshToken: () => void;
}): PlaylistLoaderConstructor => { }): PlaylistLoaderConstructor => {
const BaseLoader = Hls.DefaultConfig.loader as unknown as new ( const BaseLoader = Hls.DefaultConfig.loader as unknown as new (
config: HlsConfig, config: HlsConfig,
@@ -155,37 +159,31 @@ const createAuthPlaylistLoader = ({
super({ ...config, debug: debug ?? false }); super({ ...config, debug: debug ?? false });
} }
load( override load(
context: PlaylistLoaderContext, context: PlaylistLoaderContext,
config: LoaderConfiguration, config: LoaderConfiguration,
callbacks: LoaderCallbacks<PlaylistLoaderContext>, callbacks: LoaderCallbacks<PlaylistLoaderContext>,
): void { ): void {
const start = async () => { try {
try { const token = getToken();
const token = await resolveVideoPlayerToken(); if (token) {
if (token) { context.headers = {
context.headers = { ...(context.headers ?? {}),
...(context.headers ?? {}), Authorization: `Bearer ${token}`,
Authorization: `Bearer ${token}`, };
};
}
} catch (error) {
if (debug) {
console.warn(
"[VideoRuntime:HLS] Failed to append auth header to playlist request",
error,
);
}
} finally {
super.load(context, config, callbacks);
} }
}; } catch (error) {
void start().catch(error => {
if (debug) { if (debug) {
console.error("[VideoRuntime:HLS] Playlist loader start failed", error); console.warn(
"[VideoRuntime:HLS] Failed to append auth header to playlist request",
error,
);
} }
}); }
// Critical path must stay sync for hls.js loader lifecycle.
super.load(context, config, callbacks);
refreshToken();
} }
}; };
}; };
@@ -232,6 +230,8 @@ export class VideoPlayerRuntime {
private currentEngine: PlaybackEngine | null = null; private currentEngine: PlaybackEngine | null = null;
private currentSource: VideoPlayerRuntimeSource | null = null; private currentSource: VideoPlayerRuntimeSource | null = null;
private vhsAuthTokenRef: string | null | undefined = null; private vhsAuthTokenRef: string | null | undefined = null;
private hlsAuthTokenRef: string | null = null;
private hlsTokenResolvePromise: Promise<void> | null = null;
private vhsRequestCleanupRef: (() => void) | null = null; private vhsRequestCleanupRef: (() => void) | null = null;
private visibilityObserverRef: IntersectionObserver | null = null; private visibilityObserverRef: IntersectionObserver | null = null;
private originalPlayRef: VideoPlayerRuntimePlayer["play"] | null = null; private originalPlayRef: VideoPlayerRuntimePlayer["play"] | null = null;
@@ -328,6 +328,8 @@ export class VideoPlayerRuntime {
this.vhsRequestCleanupRef?.(); this.vhsRequestCleanupRef?.();
this.vhsRequestCleanupRef = null; this.vhsRequestCleanupRef = null;
this.vhsAuthTokenRef = null; this.vhsAuthTokenRef = null;
this.hlsAuthTokenRef = null;
this.hlsTokenResolvePromise = null;
if (this.playerRef) { if (this.playerRef) {
this.playerRef.dispose(); this.playerRef.dispose();
@@ -358,6 +360,34 @@ export class VideoPlayerRuntime {
return this.playerRef; return this.playerRef;
} }
private async ensureHlsAuthToken(): Promise<void> {
if (this.hlsTokenResolvePromise !== null) {
await this.hlsTokenResolvePromise;
return;
}
this.hlsTokenResolvePromise = resolveVideoPlayerToken()
.then(token => {
this.hlsAuthTokenRef = token ?? null;
})
.catch(() => {
this.hlsAuthTokenRef = null;
})
.finally(() => {
this.hlsTokenResolvePromise = null;
});
await this.hlsTokenResolvePromise;
}
private refreshHlsAuthTokenInBackground(): void {
if (this.hlsTokenResolvePromise !== null) {
return;
}
void this.ensureHlsAuthToken();
}
private emit<K extends keyof VideoPlayerRuntimeEventMap>( private emit<K extends keyof VideoPlayerRuntimeEventMap>(
event: K, event: K,
payload: VideoPlayerRuntimeEventMap[K], payload: VideoPlayerRuntimeEventMap[K],
@@ -658,7 +688,11 @@ export class VideoPlayerRuntime {
? { abrEwmaDefaultEstimate: 10690560 * 1.2 } ? { abrEwmaDefaultEstimate: 10690560 * 1.2 }
: {}; : {};
const playlistLoader = createAuthPlaylistLoader({ debug: options.debug }); const playlistLoader = createAuthPlaylistLoader({
debug: options.debug,
getToken: () => this.hlsAuthTokenRef,
refreshToken: () => this.refreshHlsAuthTokenInBackground(),
});
return { return {
debug: options.debug, debug: options.debug,
@@ -702,6 +736,9 @@ export class VideoPlayerRuntime {
return; return;
} }
// Resolve async token before starting HLS manifest load.
await this.ensureHlsAuthToken();
const setupHls = () => { const setupHls = () => {
if (this.hlsLoaded) { if (this.hlsLoaded) {
return; return;

182
yarn.lock
View File

@@ -797,7 +797,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@eslint-community/eslint-utils@npm:^4.8.0": "@eslint-community/eslint-utils@npm:^4.8.0, @eslint-community/eslint-utils@npm:^4.9.1":
version: 4.9.1 version: 4.9.1
resolution: "@eslint-community/eslint-utils@npm:4.9.1" resolution: "@eslint-community/eslint-utils@npm:4.9.1"
dependencies: dependencies:
@@ -808,7 +808,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@eslint-community/regexpp@npm:^4.12.1": "@eslint-community/regexpp@npm:^4.12.1, @eslint-community/regexpp@npm:^4.12.2":
version: 4.12.2 version: 4.12.2
resolution: "@eslint-community/regexpp@npm:4.12.2" resolution: "@eslint-community/regexpp@npm:4.12.2"
checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d checksum: 10c0/fddcbc66851b308478d04e302a4d771d6917a0b3740dc351513c0da9ca2eab8a1adf99f5e0aa7ab8b13fa0df005c81adeee7e63a92f3effd7d367a163b721c2d
@@ -949,6 +949,7 @@ __metadata:
prettier: "npm:^3.6.2" prettier: "npm:^3.6.2"
storybook: "npm:8.6.14" storybook: "npm:8.6.14"
typescript: "npm:^5.9.3" typescript: "npm:^5.9.3"
typescript-eslint: "npm:^8.57.0"
vitest: "npm:^3.2.4" vitest: "npm:^3.2.4"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -2299,6 +2300,141 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.57.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.57.0"
"@typescript-eslint/type-utils": "npm:8.57.0"
"@typescript-eslint/utils": "npm:8.57.0"
"@typescript-eslint/visitor-keys": "npm:8.57.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.4.0"
peerDependencies:
"@typescript-eslint/parser": ^8.57.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/600033b98dd96e11bb0e22ff77dcaa0f9e9135b60046267059296ce8c8870dfabcddf40d5c8b62415eb3e2133e77a1fb1ac08dca42b859533dd85fbba1f220f7
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/parser@npm:8.57.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.57.0"
"@typescript-eslint/types": "npm:8.57.0"
"@typescript-eslint/typescript-estree": "npm:8.57.0"
"@typescript-eslint/visitor-keys": "npm:8.57.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/c224e0802cdc42ad7c79553018d6572370eff6539b3cb92220e44da3931dfe7e94a11fcea7d30d9c9366e76d50488c8c9d59002ba52dd6818fdc598280f7990c
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/project-service@npm:8.57.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.57.0"
"@typescript-eslint/types": "npm:^8.57.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/f97c25ad9c39957fc58fba21dbc8ce928d3889f95b0ecc93b477da3ce9bb6057bf866cac8114c0c93c455f68d0fb5b0042dc4771e436f07cd9c975bc61f3221f
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/scope-manager@npm:8.57.0"
dependencies:
"@typescript-eslint/types": "npm:8.57.0"
"@typescript-eslint/visitor-keys": "npm:8.57.0"
checksum: 10c0/a3e1243044f4634a36308f0d027db97ef686ed88cb93183feee1ba0a6de4eaa8824bb63b79075241c0a275d989d5f2641a6341cc785a6c688ee6f0d05c07d723
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.57.0, @typescript-eslint/tsconfig-utils@npm:^8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.57.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/d63f4de1a9d39c208b05a93df838318ff48af0a6ae561395d1860a8fd1fc552d47cc08065c445e084fb67bfac1c5a477183213477ed2bca688b9409cbeda3965
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/type-utils@npm:8.57.0"
dependencies:
"@typescript-eslint/types": "npm:8.57.0"
"@typescript-eslint/typescript-estree": "npm:8.57.0"
"@typescript-eslint/utils": "npm:8.57.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.4.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/55fd3b6b71d76602cead51fe3ea246eb908e2614bbe092fae26d9320f73c2f107e82d28e2cf509b61ea5f29d5b1fa32046bef0823cea63105bc35c15319e95ec
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.57.0, @typescript-eslint/types@npm:^8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/types@npm:8.57.0"
checksum: 10c0/69eb21a9a550f17ce9445b7bfab9099d6a43fa33f79506df966793077d73423dad7612f33a7efb1e09f4403a889ba6b7a44987cf3e6fea0e63a373022226bc68
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/typescript-estree@npm:8.57.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.57.0"
"@typescript-eslint/tsconfig-utils": "npm:8.57.0"
"@typescript-eslint/types": "npm:8.57.0"
"@typescript-eslint/visitor-keys": "npm:8.57.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
tinyglobby: "npm:^0.2.15"
ts-api-utils: "npm:^2.4.0"
peerDependencies:
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/2b72ff255b6711d529496bcae38869e3809b15761252809743d80d01e3efa5a62ebaafc24b96b16a245a8d0bd307958a3e9ab31434d03a87acedbdd5e01c18be
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/utils@npm:8.57.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.57.0"
"@typescript-eslint/types": "npm:8.57.0"
"@typescript-eslint/typescript-estree": "npm:8.57.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/d2c5803a7eaae71ce4cf1435fdc0ab0243e8924647b39bc823e42bc7604f6e01cdcb101eaf9c0eec91fe1bd272e5533041b8a40017679b164be11f32242f292b
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.57.0":
version: 8.57.0
resolution: "@typescript-eslint/visitor-keys@npm:8.57.0"
dependencies:
"@typescript-eslint/types": "npm:8.57.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10c0/4e585126b7b10f04c8d52166a473b715038793c87c7b7a1dbd0f577b017896db8545d6ea13bd191c12cf951dfdac23884b3e9bf0bb6f44afea38ae9eae5d7a6a
languageName: node
linkType: hard
"@videojs/http-streaming@npm:^3.17.2, @videojs/http-streaming@npm:^3.17.4": "@videojs/http-streaming@npm:^3.17.2, @videojs/http-streaming@npm:^3.17.4":
version: 3.17.4 version: 3.17.4
resolution: "@videojs/http-streaming@npm:3.17.4" resolution: "@videojs/http-streaming@npm:3.17.4"
@@ -3107,7 +3243,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.1": "debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.4, debug@npm:^4.4.0, debug@npm:^4.4.1, debug@npm:^4.4.3":
version: 4.4.3 version: 4.4.3
resolution: "debug@npm:4.4.3" resolution: "debug@npm:4.4.3"
dependencies: dependencies:
@@ -3540,6 +3676,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eslint-visitor-keys@npm:^5.0.0":
version: 5.0.1
resolution: "eslint-visitor-keys@npm:5.0.1"
checksum: 10c0/16190bdf2cbae40a1109384c94450c526a79b0b9c3cb21e544256ed85ac48a4b84db66b74a6561d20fe6ab77447f150d711c2ad5ad74df4fcc133736bce99678
languageName: node
linkType: hard
"eslint@npm:^9.37.0": "eslint@npm:^9.37.0":
version: 9.39.3 version: 9.39.3
resolution: "eslint@npm:9.39.3" resolution: "eslint@npm:9.39.3"
@@ -4061,6 +4204,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ignore@npm:^7.0.5":
version: 7.0.5
resolution: "ignore@npm:7.0.5"
checksum: 10c0/ae00db89fe873064a093b8999fe4cc284b13ef2a178636211842cceb650b9c3e390d3339191acb145d81ed5379d2074840cf0c33a20bdbd6f32821f79eb4ad5d
languageName: node
linkType: hard
"immutable@npm:^5.0.2": "immutable@npm:^5.0.2":
version: 5.1.5 version: 5.1.5
resolution: "immutable@npm:5.1.5" resolution: "immutable@npm:5.1.5"
@@ -6045,7 +6195,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"semver@npm:^7.3.5, semver@npm:^7.6.2": "semver@npm:^7.3.5, semver@npm:^7.6.2, semver@npm:^7.7.3":
version: 7.7.4 version: 7.7.4
resolution: "semver@npm:7.7.4" resolution: "semver@npm:7.7.4"
bin: bin:
@@ -6463,6 +6613,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"ts-api-utils@npm:^2.4.0":
version: 2.4.0
resolution: "ts-api-utils@npm:2.4.0"
peerDependencies:
typescript: ">=4.8.4"
checksum: 10c0/ed185861aef4e7124366a3f6561113557a57504267d4d452a51e0ba516a9b6e713b56b4aeaab9fa13de9db9ab755c65c8c13a777dba9133c214632cb7b65c083
languageName: node
linkType: hard
"ts-dedent@npm:^2.0.0, ts-dedent@npm:^2.2.0": "ts-dedent@npm:^2.0.0, ts-dedent@npm:^2.2.0":
version: 2.2.0 version: 2.2.0
resolution: "ts-dedent@npm:2.2.0" resolution: "ts-dedent@npm:2.2.0"
@@ -6546,6 +6705,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"typescript-eslint@npm:^8.57.0":
version: 8.57.0
resolution: "typescript-eslint@npm:8.57.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.57.0"
"@typescript-eslint/parser": "npm:8.57.0"
"@typescript-eslint/typescript-estree": "npm:8.57.0"
"@typescript-eslint/utils": "npm:8.57.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.0.0"
checksum: 10c0/5491c6dff2bc3f2914d60326490316b3f92e022756017da8b36cbb9d4d94fc781b642a3a033ca3add2ff26ee7a95798baedc5f55598cd21ce706bae5b7731632
languageName: node
linkType: hard
"typescript@npm:5.9.3, typescript@npm:^5.9.3": "typescript@npm:5.9.3, typescript@npm:^5.9.3":
version: 5.9.3 version: 5.9.3
resolution: "typescript@npm:5.9.3" resolution: "typescript@npm:5.9.3"