chore: resolve no-explicit-any lint warnings in plugins (#12237)

* chore: resolve no-explicit-any lint warnings in plugins

Replaces uses of `any` in the plugins directory with concrete types,
`unknown`, or structured type assertions, addressing the remaining
typescript-eslint(no-explicit-any) warnings flagged by oxlint.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* chore: address review feedback in GitLabIssueProvider

Drop trailing semicolon from log string and add early return in
`destroyNamespace` when neither `user_id` nor `full_path` is present
to avoid an unnecessary full-scan transaction.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Tom Moor
2026-05-01 08:29:58 -04:00
committed by GitHub
parent e2c28f4b9f
commit 1f097b0fdd
21 changed files with 202 additions and 98 deletions
+8 -3
View File
@@ -1,6 +1,8 @@
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { getCookie, removeCookie, setCookie } from "tiny-cookie"; import { getCookie, removeCookie, setCookie } from "tiny-cookie";
import usePersistedState, { setPersistedState } from "~/hooks/usePersistedState"; import usePersistedState, {
setPersistedState,
} from "~/hooks/usePersistedState";
import Logger from "~/utils/Logger"; import Logger from "~/utils/Logger";
import history from "~/utils/history"; import history from "~/utils/history";
import { isAllowedLoginRedirect } from "~/utils/urls"; import { isAllowedLoginRedirect } from "~/utils/urls";
@@ -39,9 +41,12 @@ export function useLastVisitedPath(): [string, (path: string) => void] {
*/ */
export function useTrackLastVisitedPath(currentPath: string): void { export function useTrackLastVisitedPath(currentPath: string): void {
const prevPathRef = useRef<string>(); const prevPathRef = useRef<string>();
// Update localStorage directly if path has changed // Update localStorage directly if path has changed
if (prevPathRef.current !== currentPath && isAllowedLoginRedirect(currentPath)) { if (
prevPathRef.current !== currentPath &&
isAllowedLoginRedirect(currentPath)
) {
prevPathRef.current = currentPath; prevPathRef.current = currentPath;
setPersistedState("lastVisitedPath", currentPath); setPersistedState("lastVisitedPath", currentPath);
} }
+1 -1
View File
@@ -11,7 +11,7 @@ import { useMenuAction } from "~/hooks/useMenuAction";
/** /**
* Hook that constructs the action menu for share management operations. * Hook that constructs the action menu for share management operations.
* *
* @param targetShare - the share to build actions for, or null to skip. * @param targetShare - the share to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if share is null. * @returns action with children for use in menus, or undefined if share is null.
*/ */
+1 -1
View File
@@ -24,7 +24,7 @@ import {
/** /**
* Hook that constructs the action menu for user management operations. * Hook that constructs the action menu for user management operations.
* *
* @param targetUser - the user to build actions for, or null to skip. * @param targetUser - the user to build actions for, or null to skip.
* @returns action with children for use in menus, or undefined if user is null. * @returns action with children for use in menus, or undefined if user is null.
*/ */
+72 -25
View File
@@ -5,7 +5,26 @@ import { Integration, IntegrationAuthentication } from "@server/models";
import { BaseIssueProvider } from "@server/utils/BaseIssueProvider"; import { BaseIssueProvider } from "@server/utils/BaseIssueProvider";
import { GitLab } from "./gitlab"; import { GitLab } from "./gitlab";
import { sequelize } from "@server/storage/database"; import { sequelize } from "@server/storage/database";
import { Op } from "sequelize"; import { Op, type WhereOptions } from "sequelize";
interface GitLabWebhookPayload {
event_name?: string;
old_full_path?: string;
old_username?: string;
full_path?: string;
username?: string;
group_id?: string;
user_id?: string;
project_id?: string | number;
project_namespace_id?: string | number;
name?: string;
path_with_namespace?: string;
changes?: { before: string }[];
project?: {
name: string;
path_with_namespace: string;
};
}
export class GitLabIssueProvider extends BaseIssueProvider { export class GitLabIssueProvider extends BaseIssueProvider {
constructor() { constructor() {
@@ -64,7 +83,8 @@ export class GitLabIssueProvider extends BaseIssueProvider {
headers: Record<string, unknown>; headers: Record<string, unknown>;
}) { }) {
const hookId = headers["x-gitlab-webhook-uuid"] as string; const hookId = headers["x-gitlab-webhook-uuid"] as string;
const eventName = payload.event_name as string; const typedPayload = payload as GitLabWebhookPayload;
const eventName = typedPayload.event_name;
if (!eventName) { if (!eventName) {
Logger.warn( Logger.warn(
@@ -77,28 +97,28 @@ export class GitLabIssueProvider extends BaseIssueProvider {
case "project_update": case "project_update":
case "project_transfer": case "project_transfer":
case "project_rename": case "project_rename":
await this.updateProject(payload); await this.updateProject(typedPayload);
break; break;
case "repository_update": case "repository_update":
await this.createProject(payload); await this.createProject(typedPayload);
break; break;
case "project_destroy": case "project_destroy":
await this.destroyProject(payload); await this.destroyProject(typedPayload);
break; break;
case "group_rename": case "group_rename":
case "user_rename": case "user_rename":
await this.updateNamespace(payload); await this.updateNamespace(typedPayload);
break; break;
case "user_destroy": case "user_destroy":
case "group_destroy": case "group_destroy":
await this.destroyNamespace(payload); await this.destroyNamespace(typedPayload);
break; break;
default: default:
break; break;
} }
} }
private async updateNamespace(payload: Record<string, any>) { private async updateNamespace(payload: GitLabWebhookPayload) {
const name = payload.old_full_path ?? payload.old_username; const name = payload.old_full_path ?? payload.old_username;
const where = { const where = {
service: IntegrationService.GitLab, service: IntegrationService.GitLab,
@@ -119,6 +139,12 @@ export class GitLabIssueProvider extends BaseIssueProvider {
return; return;
} }
const newName = payload.full_path ?? payload.username;
if (!newName) {
Logger.warn(`GitLab namespace_update event without new name`);
return;
}
const sources = integration.issueSources ?? []; const sources = integration.issueSources ?? [];
const updatedSources = sources.map((source) => { const updatedSources = sources.map((source) => {
if (source.owner.name === name) { if (source.owner.name === name) {
@@ -126,7 +152,7 @@ export class GitLabIssueProvider extends BaseIssueProvider {
...source, ...source,
owner: { owner: {
id: payload.group_id || source.owner.id, id: payload.group_id || source.owner.id,
name: payload.full_path ?? payload.username, name: newName,
}, },
}; };
} }
@@ -139,19 +165,29 @@ export class GitLabIssueProvider extends BaseIssueProvider {
}); });
} }
private async destroyNamespace(payload: Record<string, any>) { private async destroyNamespace(payload: GitLabWebhookPayload) {
if (!payload.user_id && !payload.full_path) {
Logger.warn(
`GitLab namespace_destroy event without user_id or full_path`
);
return;
}
let replacements = {}; let replacements = {};
const whereCondition: any = { const whereCondition: WhereOptions = {
service: IntegrationService.GitLab, service: IntegrationService.GitLab,
...(payload.user_id && {
"settings.gitlab.installation.account.id": payload.user_id,
}),
...(!payload.user_id &&
payload.full_path && {
[Op.and]: sequelize.literal(
`"issueSources"::jsonb @> :jsonCondition`
),
}),
}; };
if (payload.user_id) { if (!payload.user_id && payload.full_path) {
whereCondition["settings.gitlab.installation.account.id"] =
payload.user_id;
} else if (payload.full_path) {
whereCondition[Op.and] = sequelize.literal(
`"issueSources"::jsonb @> :jsonCondition`
);
replacements = { replacements = {
jsonCondition: JSON.stringify([{ owner: { name: payload.full_path } }]), jsonCondition: JSON.stringify([{ owner: { name: payload.full_path } }]),
}; };
@@ -187,7 +223,7 @@ export class GitLabIssueProvider extends BaseIssueProvider {
}); });
} }
private async destroyProject(payload: Record<string, any>) { private async destroyProject(payload: GitLabWebhookPayload) {
await sequelize.transaction(async (transaction) => { await sequelize.transaction(async (transaction) => {
const integrations = await Integration.findAll({ const integrations = await Integration.findAll({
where: { where: {
@@ -223,14 +259,20 @@ export class GitLabIssueProvider extends BaseIssueProvider {
}); });
} }
private async createProject(payload: Record<string, any>) { private async createProject(payload: GitLabWebhookPayload) {
const createEvent = payload.changes.some((p: { before: string }) => const createEvent = payload.changes?.some((p: { before: string }) =>
/^0{40}$/.test(p.before) /^0{40}$/.test(p.before)
); );
if (!createEvent) { if (!createEvent) {
return; return;
} }
const project = payload.project;
if (!project || !payload.project_id) {
return;
}
await sequelize.transaction(async (transaction) => { await sequelize.transaction(async (transaction) => {
const integration = (await Integration.findOne({ const integration = (await Integration.findOne({
where: { where: {
@@ -245,7 +287,6 @@ export class GitLabIssueProvider extends BaseIssueProvider {
return; return;
} }
const project = payload.project;
const owner = { const owner = {
id: "", // namespace.id is not provided in this webhook payload id: "", // namespace.id is not provided in this webhook payload
name: project.path_with_namespace.split("/").slice(0, -1).join("/"), name: project.path_with_namespace.split("/").slice(0, -1).join("/"),
@@ -264,7 +305,13 @@ export class GitLabIssueProvider extends BaseIssueProvider {
}); });
} }
private async updateProject(payload: Record<string, any>) { private async updateProject(payload: GitLabWebhookPayload) {
if (!payload.name || !payload.path_with_namespace) {
return;
}
const newName = payload.name;
const pathWithNamespace = payload.path_with_namespace;
await sequelize.transaction(async (transaction) => { await sequelize.transaction(async (transaction) => {
const integrations = await Integration.findAll({ const integrations = await Integration.findAll({
where: { where: {
@@ -293,8 +340,8 @@ export class GitLabIssueProvider extends BaseIssueProvider {
); );
if (source) { if (source) {
source.name = payload.name; source.name = newName;
source.owner.name = payload.path_with_namespace source.owner.name = pathWithNamespace
.split("/") .split("/")
.slice(0, -1) .slice(0, -1)
.join("/"); .join("/");
+8 -1
View File
@@ -46,7 +46,14 @@ class Iframely {
return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body. return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body.
} }
const parsedData = data as Record<string, any>; const parsedData = data as {
url: string;
meta: { title: string; description: string; site: string };
links: {
thumbnail?: { href: string }[];
icon?: { href: string }[];
};
};
return { return {
type: UnfurlResourceType.URL, type: UnfurlResourceType.URL,
+8 -2
View File
@@ -1,7 +1,13 @@
import type { Request } from "express";
import { HttpsProxyAgent } from "https-proxy-agent"; import { HttpsProxyAgent } from "https-proxy-agent";
import type OAuth2Strategy from "passport-oauth2"; import type OAuth2Strategy from "passport-oauth2";
import { Strategy } from "passport-oauth2"; import { Strategy } from "passport-oauth2";
interface AuthenticateOptions {
originalQuery?: Request["query"];
[key: string]: unknown;
}
export class OIDCStrategy extends Strategy { export class OIDCStrategy extends Strategy {
constructor( constructor(
options: OAuth2Strategy.StrategyOptionsWithRequest, options: OAuth2Strategy.StrategyOptionsWithRequest,
@@ -15,12 +21,12 @@ export class OIDCStrategy extends Strategy {
} }
} }
authenticate(req: any, options: any) { authenticate(req: Request, options: AuthenticateOptions) {
options.originalQuery = req.query; options.originalQuery = req.query;
super.authenticate(req, options); super.authenticate(req, options);
} }
authorizationParams(options: any) { authorizationParams(options: AuthenticateOptions) {
return { return {
...options.originalQuery, ...options.originalQuery,
...super.authorizationParams?.(options), ...super.authorizationParams?.(options),
+8 -3
View File
@@ -1,4 +1,5 @@
import { startRegistration } from "@simplewebauthn/browser"; import { startRegistration } from "@simplewebauthn/browser";
import type { JSONObject } from "@shared/types";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { KeyIcon, PlusIcon } from "outline-icons"; import { KeyIcon, PlusIcon } from "outline-icons";
import * as React from "react"; import * as React from "react";
@@ -66,9 +67,13 @@ function PasskeysSettings() {
} }
); );
const attResp = await startRegistration(resp.data); const attResp = await startRegistration(resp.data);
await client.post("/passkeys.verifyRegistration", attResp as any, { await client.post(
baseUrl: "/auth", "/passkeys.verifyRegistration",
}); attResp as unknown as JSONObject,
{
baseUrl: "/auth",
}
);
toast.success(t("Passkey added successfully")); toast.success(t("Passkey added successfully"));
await loadPasskeys(); await loadPasskeys();
} catch (err) { } catch (err) {
+14 -13
View File
@@ -8,19 +8,20 @@ describe("getExpectedOrigin", () => {
hostname: string; hostname: string;
host: string; host: string;
forwardedPort?: string; forwardedPort?: string;
}): APIContext => ({ }): APIContext =>
protocol: options.protocol, ({
request: { protocol: options.protocol,
hostname: options.hostname, request: {
host: options.host, hostname: options.hostname,
get: (header: string) => { host: options.host,
if (header === "X-Forwarded-Port" && options.forwardedPort) { get: (header: string) => {
return options.forwardedPort; if (header === "X-Forwarded-Port" && options.forwardedPort) {
} return options.forwardedPort;
return undefined; }
}, return undefined;
} as unknown, },
}) as unknown as APIContext; } as unknown,
}) as unknown as APIContext;
it("should construct origin with non-standard HTTPS port from X-Forwarded-Port", () => { it("should construct origin with non-standard HTTPS port from X-Forwarded-Port", () => {
const ctx = createMockContext({ const ctx = createMockContext({
@@ -236,13 +236,13 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
where, where,
limit, limit,
offset, offset,
}) as any as Promise<RankedDocument[]>; }) as unknown as Promise<RankedDocument[]>;
const countQuery = Document.unscoped().count({ const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count // @ts-expect-error Types are incorrect for count
replacements: findOptions.replacements, replacements: findOptions.replacements,
where, where,
}) as any as Promise<number>; }) as unknown as Promise<number>;
const [results, count] = await Promise.all([resultsQuery, countQuery]); const [results, count] = await Promise.all([resultsQuery, countQuery]);
// Final query to get associated document data // Final query to get associated document data
@@ -428,7 +428,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
where, where,
limit, limit,
offset, offset,
})) as any as RankedDocument[]; })) as unknown as RankedDocument[];
const countQuery = Document.unscoped().count({ const countQuery = Document.unscoped().count({
// @ts-expect-error Types are incorrect for count // @ts-expect-error Types are incorrect for count
@@ -436,7 +436,7 @@ export default class PostgresSearchProvider extends BaseSearchProvider {
include, include,
replacements: findOptions.replacements, replacements: findOptions.replacements,
where, where,
}) as any as Promise<number>; }) as unknown as Promise<number>;
// Final query to get associated document data // Final query to get associated document data
const [documents, count] = await Promise.all([ const [documents, count] = await Promise.all([
+2 -1
View File
@@ -238,7 +238,8 @@ router.post(
return; return;
} }
const { results, total } = await SearchProviderManager.getProvider().searchForUser(user, options); const { results, total } =
await SearchProviderManager.getProvider().searchForUser(user, options);
await SearchQuery.create({ await SearchQuery.create({
userId: user ? user.id : null, userId: user ? user.id : null,
+9 -3
View File
@@ -1,4 +1,4 @@
import querystring from "node:querystring"; import querystring, { type ParsedUrlQueryInput } from "node:querystring";
import { InvalidRequestError } from "@server/errors"; import { InvalidRequestError } from "@server/errors";
import fetch from "@server/utils/fetch"; import fetch from "@server/utils/fetch";
import { SlackUtils } from "../shared/SlackUtils"; import { SlackUtils } from "../shared/SlackUtils";
@@ -13,7 +13,10 @@ const SLACK_API_URL = "https://slack.com/api";
* @param body - the request body containing token and other parameters. * @param body - the request body containing token and other parameters.
* @returns the parsed JSON response from Slack. * @returns the parsed JSON response from Slack.
*/ */
export async function post(endpoint: string, body: Record<string, any>) { export async function post(
endpoint: string,
body: { token: string } & Record<string, unknown>
) {
let data; let data;
const { token, ...bodyWithoutToken } = body; const { token, ...bodyWithoutToken } = body;
@@ -44,7 +47,10 @@ export async function post(endpoint: string, body: Record<string, any>) {
* @param body - the request parameters. * @param body - the request parameters.
* @returns the parsed JSON response from Slack. * @returns the parsed JSON response from Slack.
*/ */
export async function request(endpoint: string, body: Record<string, any>) { export async function request(
endpoint: string,
body: { client_id?: string; client_secret?: string } & ParsedUrlQueryInput
) {
let data; let data;
const { client_id, client_secret, ...params } = body; const { client_id, client_secret, ...params } = body;
+1 -1
View File
@@ -16,7 +16,7 @@ export class SlackUtils {
static createState( static createState(
teamId: string, teamId: string,
type: IntegrationType, type: IntegrationType,
data?: Record<string, any> data?: Record<string, unknown>
) { ) {
return JSON.stringify({ type, teamId, ...data }); return JSON.stringify({ type, teamId, ...data });
} }
+23 -6
View File
@@ -16,7 +16,10 @@ describe("errors", () => {
describe("User input errors", () => { describe("User input errors", () => {
const userInputErrors = [ const userInputErrors = [
{ name: "AuthenticationError", fn: errors.AuthenticationError }, { name: "AuthenticationError", fn: errors.AuthenticationError },
{ name: "InvalidAuthenticationError", fn: errors.InvalidAuthenticationError }, {
name: "InvalidAuthenticationError",
fn: errors.InvalidAuthenticationError,
},
{ name: "AuthorizationError", fn: errors.AuthorizationError }, { name: "AuthorizationError", fn: errors.AuthorizationError },
{ name: "CSRFError", fn: errors.CSRFError }, { name: "CSRFError", fn: errors.CSRFError },
{ name: "RateLimitExceededError", fn: errors.RateLimitExceededError }, { name: "RateLimitExceededError", fn: errors.RateLimitExceededError },
@@ -33,12 +36,24 @@ describe("errors", () => {
{ name: "FileImportError", fn: errors.FileImportError }, { name: "FileImportError", fn: errors.FileImportError },
{ name: "OAuthStateMismatchError", fn: errors.OAuthStateMismatchError }, { name: "OAuthStateMismatchError", fn: errors.OAuthStateMismatchError },
{ name: "TeamPendingDeletionError", fn: errors.TeamPendingDeletionError }, { name: "TeamPendingDeletionError", fn: errors.TeamPendingDeletionError },
{ name: "EmailAuthenticationRequiredError", fn: errors.EmailAuthenticationRequiredError }, {
name: "EmailAuthenticationRequiredError",
fn: errors.EmailAuthenticationRequiredError,
},
{ name: "MicrosoftGraphError", fn: errors.MicrosoftGraphError }, { name: "MicrosoftGraphError", fn: errors.MicrosoftGraphError },
{ name: "TeamDomainRequiredError", fn: errors.TeamDomainRequiredError }, { name: "TeamDomainRequiredError", fn: errors.TeamDomainRequiredError },
{ name: "GmailAccountCreationError", fn: errors.GmailAccountCreationError }, {
{ name: "OIDCMalformedUserInfoError", fn: errors.OIDCMalformedUserInfoError }, name: "GmailAccountCreationError",
{ name: "AuthenticationProviderDisabledError", fn: errors.AuthenticationProviderDisabledError }, fn: errors.GmailAccountCreationError,
},
{
name: "OIDCMalformedUserInfoError",
fn: errors.OIDCMalformedUserInfoError,
},
{
name: "AuthenticationProviderDisabledError",
fn: errors.AuthenticationProviderDisabledError,
},
{ name: "UnprocessableEntityError", fn: errors.UnprocessableEntityError }, { name: "UnprocessableEntityError", fn: errors.UnprocessableEntityError },
{ name: "ClientClosedRequestError", fn: errors.ClientClosedRequestError }, { name: "ClientClosedRequestError", fn: errors.ClientClosedRequestError },
]; ];
@@ -53,7 +68,9 @@ describe("errors", () => {
describe("UserSuspendedError", () => { describe("UserSuspendedError", () => {
it("should not be marked for Sentry reporting", () => { it("should not be marked for Sentry reporting", () => {
const error = errors.UserSuspendedError({ adminEmail: "test@example.com" }); const error = errors.UserSuspendedError({
adminEmail: "test@example.com",
});
expect(error.isReportable).toBe(false); expect(error.isReportable).toBe(false);
}); });
}); });
@@ -26,6 +26,8 @@ export default class IntegrationCreatedProcessor extends BaseProcessor {
}); });
// Clear the cache of unfurled data for the team as it may be stale now. // Clear the cache of unfurled data for the team as it may be stale now.
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId)); await CacheHelper.clearData(
RedisPrefixHelper.getUnfurlKey(integration.teamId)
);
} }
} }
@@ -27,7 +27,9 @@ export default class IntegrationDeletedProcessor extends BaseProcessor {
// Clear the cache of unfurled data for the team as it may be stale now. // Clear the cache of unfurled data for the team as it may be stale now.
if (integration.type === IntegrationType.Embed) { if (integration.type === IntegrationType.Embed) {
await CacheHelper.clearData(RedisPrefixHelper.getUnfurlKey(integration.teamId)); await CacheHelper.clearData(
RedisPrefixHelper.getUnfurlKey(integration.teamId)
);
} }
await integration.destroy({ force: true }); await integration.destroy({ force: true });
+5 -1
View File
@@ -74,7 +74,11 @@ router.post(
offset, offset,
limit, limit,
}), }),
SearchProviderManager.getProvider().searchCollectionsForUser(actor, { query, offset, limit }), SearchProviderManager.getProvider().searchCollectionsForUser(actor, {
query,
offset,
limit,
}),
]); ]);
ctx.body = { ctx.body = {
+1 -1
View File
@@ -341,7 +341,7 @@ export const renderShare = async (ctx: Context, next: Next) => {
content, content,
shortcutIcon: shortcutIcon:
publicBranding && team?.avatarUrl publicBranding && team?.avatarUrl
? (await team.publicAvatarUrl()) ?? undefined ? ((await team.publicAvatarUrl()) ?? undefined)
: undefined, : undefined,
analytics, analytics,
isShare: true, isShare: true,
+1 -5
View File
@@ -19,11 +19,7 @@ describe("getVersionInfo", () => {
it("should return version info when Docker Hub is accessible", async () => { it("should return version info when Docker Hub is accessible", async () => {
fetchMock.mockResponseOnce( fetchMock.mockResponseOnce(
JSON.stringify({ JSON.stringify({
results: [ results: [{ name: "0.81.0" }, { name: "0.80.0" }, { name: "0.79.0" }],
{ name: "0.81.0" },
{ name: "0.80.0" },
{ name: "0.79.0" },
],
next: null, next: null,
}) })
); );
@@ -5,9 +5,9 @@ describe("getDocumentHighlightColors", () => {
it("returns empty array when no highlights exist", () => { it("returns empty array when no highlights exist", () => {
const testDoc = doc([p("Plain text without highlights")]); const testDoc = doc([p("Plain text without highlights")]);
const state = createEditorState(testDoc); const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state); const colors = getDocumentHighlightColors(state);
expect(colors).toEqual([]); expect(colors).toEqual([]);
}); });
@@ -15,18 +15,18 @@ describe("getDocumentHighlightColors", () => {
// Create text with highlight marks // Create text with highlight marks
const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" }); const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" });
const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" }); const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" });
const text1 = schema.text("Highlighted text 1", [highlightMark1]); const text1 = schema.text("Highlighted text 1", [highlightMark1]);
const text2 = schema.text(" and more ", [highlightMark2]); const text2 = schema.text(" and more ", [highlightMark2]);
const text3 = schema.text("and again", [highlightMark1]); const text3 = schema.text("and again", [highlightMark1]);
const testDoc = doc([ const testDoc = doc([
schema.nodes.paragraph.create(null, [text1, text2, text3]) schema.nodes.paragraph.create(null, [text1, text2, text3]),
]); ]);
const state = createEditorState(testDoc); const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state); const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(2); expect(colors).toHaveLength(2);
expect(colors).toContain("#FDEA9B"); expect(colors).toContain("#FDEA9B");
expect(colors).toContain("#FED46A"); expect(colors).toContain("#FED46A");
@@ -34,18 +34,18 @@ describe("getDocumentHighlightColors", () => {
it("deduplicates colors used multiple times", () => { it("deduplicates colors used multiple times", () => {
const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" }); const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" });
const text1 = schema.text("First highlight", [highlightMark]); const text1 = schema.text("First highlight", [highlightMark]);
const text2 = schema.text("Second highlight", [highlightMark]); const text2 = schema.text("Second highlight", [highlightMark]);
const testDoc = doc([ const testDoc = doc([
schema.nodes.paragraph.create(null, [text1]), schema.nodes.paragraph.create(null, [text1]),
schema.nodes.paragraph.create(null, [text2]) schema.nodes.paragraph.create(null, [text2]),
]); ]);
const state = createEditorState(testDoc); const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state); const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(1); expect(colors).toHaveLength(1);
expect(colors).toContain("#FDEA9B"); expect(colors).toContain("#FDEA9B");
}); });
@@ -54,20 +54,20 @@ describe("getDocumentHighlightColors", () => {
const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" }); const highlightMark1 = schema.marks.highlight.create({ color: "#FDEA9B" });
const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" }); const highlightMark2 = schema.marks.highlight.create({ color: "#FED46A" });
const highlightMark3 = schema.marks.highlight.create({ color: "#FA551E" }); const highlightMark3 = schema.marks.highlight.create({ color: "#FA551E" });
const text1 = schema.text("First paragraph", [highlightMark1]); const text1 = schema.text("First paragraph", [highlightMark1]);
const text2 = schema.text("Second paragraph", [highlightMark2]); const text2 = schema.text("Second paragraph", [highlightMark2]);
const text3 = schema.text("Third paragraph", [highlightMark3]); const text3 = schema.text("Third paragraph", [highlightMark3]);
const testDoc = doc([ const testDoc = doc([
schema.nodes.paragraph.create(null, [text1]), schema.nodes.paragraph.create(null, [text1]),
schema.nodes.paragraph.create(null, [text2]), schema.nodes.paragraph.create(null, [text2]),
schema.nodes.paragraph.create(null, [text3]) schema.nodes.paragraph.create(null, [text3]),
]); ]);
const state = createEditorState(testDoc); const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state); const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(3); expect(colors).toHaveLength(3);
expect(colors).toContain("#FDEA9B"); expect(colors).toContain("#FDEA9B");
expect(colors).toContain("#FED46A"); expect(colors).toContain("#FED46A");
@@ -77,17 +77,17 @@ describe("getDocumentHighlightColors", () => {
it("ignores text with other marks but no highlight", () => { it("ignores text with other marks but no highlight", () => {
const boldMark = schema.marks.strong.create(); const boldMark = schema.marks.strong.create();
const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" }); const highlightMark = schema.marks.highlight.create({ color: "#FDEA9B" });
const boldText = schema.text("Bold text", [boldMark]); const boldText = schema.text("Bold text", [boldMark]);
const highlightedText = schema.text("Highlighted text", [highlightMark]); const highlightedText = schema.text("Highlighted text", [highlightMark]);
const testDoc = doc([ const testDoc = doc([
schema.nodes.paragraph.create(null, [boldText, highlightedText]) schema.nodes.paragraph.create(null, [boldText, highlightedText]),
]); ]);
const state = createEditorState(testDoc); const state = createEditorState(testDoc);
const colors = getDocumentHighlightColors(state); const colors = getDocumentHighlightColors(state);
expect(colors).toHaveLength(1); expect(colors).toHaveLength(1);
expect(colors).toContain("#FDEA9B"); expect(colors).toContain("#FDEA9B");
}); });
@@ -11,7 +11,9 @@ export function getDocumentHighlightColors(state: EditorState): string[] {
state.doc.descendants((node) => { state.doc.descendants((node) => {
if (node.isText) { if (node.isText) {
const highlightMark = node.marks.find((mark) => mark.type.name === "highlight"); const highlightMark = node.marks.find(
(mark) => mark.type.name === "highlight"
);
if (highlightMark?.attrs.color) { if (highlightMark?.attrs.color) {
colors.add(highlightMark.attrs.color); colors.add(highlightMark.attrs.color);
} }
+5 -2
View File
@@ -54,14 +54,17 @@ export default function markdownItAlphaLists(md: MarkdownIt): void {
// Post-process tokens to add the listStyle attribute // Post-process tokens to add the listStyle attribute
md.core.ruler.after("block", "alpha_lists_postprocess", (state) => { md.core.ruler.after("block", "alpha_lists_postprocess", (state) => {
if (!state.env.alphaListMarkers || state.env.alphaListMarkers.length === 0) { if (
!state.env.alphaListMarkers ||
state.env.alphaListMarkers.length === 0
) {
return; return;
} }
const markers = state.env.alphaListMarkers; const markers = state.env.alphaListMarkers;
// Build a map of line numbers to markers for more reliable matching // Build a map of line numbers to markers for more reliable matching
const lineToMarkerMap = new Map<number, typeof markers[0]>(); const lineToMarkerMap = new Map<number, (typeof markers)[0]>();
for (const marker of markers) { for (const marker of markers) {
lineToMarkerMap.set(marker.lineIndex, marker); lineToMarkerMap.set(marker.lineIndex, marker);
} }