diff --git a/.github/prompts/openspec-apply.prompt.md b/.github/prompts/openspec-apply.prompt.md new file mode 100644 index 00000000..c964ead6 --- /dev/null +++ b/.github/prompts/openspec-apply.prompt.md @@ -0,0 +1,22 @@ +--- +description: Implement an approved OpenSpec change and keep tasks in sync. +--- + +$ARGUMENTS + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +Track these steps as TODOs and complete them one by one. +1. Read `changes//proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria. +2. Work through tasks sequentially, keeping edits minimal and focused on the requested change. +3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished. +4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality. +5. Reference `openspec list` or `openspec show ` when additional context is required. + +**Reference** +- Use `openspec show --json --deltas-only` if you need additional context from the proposal while implementing. + diff --git a/.github/prompts/openspec-archive.prompt.md b/.github/prompts/openspec-archive.prompt.md new file mode 100644 index 00000000..9378a6b6 --- /dev/null +++ b/.github/prompts/openspec-archive.prompt.md @@ -0,0 +1,26 @@ +--- +description: Archive a deployed OpenSpec change and update specs. +--- + +$ARGUMENTS + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. + +**Steps** +1. Determine the change ID to archive: + - If this prompt already includes a specific change ID (for example inside a `` block populated by slash-command arguments), use that value after trimming whitespace. + - If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends. + - Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding. + - If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet. +2. Validate the change ID by running `openspec list` (or `openspec show `) and stop if the change is missing, already archived, or otherwise not ready to archive. +3. Run `openspec archive --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work). +4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`. +5. Validate with `openspec validate --strict` and inspect with `openspec show ` if anything looks off. + +**Reference** +- Use `openspec list` to confirm change IDs before archiving. +- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off. + diff --git a/.github/prompts/openspec-proposal.prompt.md b/.github/prompts/openspec-proposal.prompt.md new file mode 100644 index 00000000..c400466f --- /dev/null +++ b/.github/prompts/openspec-proposal.prompt.md @@ -0,0 +1,27 @@ +--- +description: Scaffold a new OpenSpec change and validate strictly. +--- + +$ARGUMENTS + +**Guardrails** +- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required. +- Keep changes tightly scoped to the requested outcome. +- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications. +- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files. +- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval. + +**Steps** +1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes//`. +3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing. +4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs. +5. Draft spec deltas in `changes//specs//spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant. +6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work. +7. Validate with `openspec validate --strict` and resolve every issue before sharing the proposal. + +**Reference** +- Use `openspec show --json --deltas-only` or `openspec show --type spec` to inspect details when validation fails. +- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones. +- Explore the codebase with `rg `, `ls`, or direct file reads so proposals align with current implementation realities. + diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..a8ad12cc --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +pnpm test \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index a467e5e8..8930abd4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -5,6 +5,7 @@ const tsParser = require('@typescript-eslint/parser') const importPlugin = require('eslint-plugin-import') const promisePlugin = require('eslint-plugin-promise') const eslintConfigPrettier = require('eslint-config-prettier') +const stylistic = require('@stylistic/eslint-plugin') const tsEslintRecommendedAdjustments = tsPlugin.configs['eslint-recommended'].overrides?.[0]?.rules ?? {} @@ -80,7 +81,8 @@ module.exports = [ plugins: { '@typescript-eslint': tsPlugin, import: importPlugin, - promise: promisePlugin + promise: promisePlugin, + '@stylistic': stylistic }, rules: { ...tsEslintRecommendedAdjustments, @@ -122,7 +124,9 @@ module.exports = [ 'import/no-named-as-default-member': 'off', 'promise/always-return': 'off', 'promise/no-promise-in-callback': 'off', - semi: ['error', 'never'] + '@stylistic/indent': ['error', 2], + '@stylistic/semi': ['error', 'never'], + 'no-unexpected-multiline': 'error' }, settings: { 'import/resolver': { diff --git a/package.json b/package.json index 673954c5..89f076e9 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ }, "scripts": { "start": "node ./bin/picgo", - "lint": "eslint src --ext .ts && tsc --noEmit", - "test:unit": "vitest run", + "lint": "tsc --noEmit && eslint src --ext .ts", + "test": "vitest run", "build": "cross-env NODE_ENV=production rimraf ./dist && rollup -c rollup.config.js", "dev": "cross-env NODE_ENV=development rollup -c rollup.config.js -w", "patch": "npm version patch && git push origin master && git push origin --tags", @@ -55,6 +55,7 @@ "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.3.0", + "@stylistic/eslint-plugin": "^5.7.0", "@types/cross-spawn": "^6.0.0", "@types/fs-extra": "^5.0.4", "@types/image-size": "^0.0.29", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84901763..f876eef8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@rollup/plugin-typescript': specifier: ^12.3.0 version: 12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@5.9.3) + '@stylistic/eslint-plugin': + specifier: ^5.7.0 + version: 5.7.0(eslint@9.39.1(jiti@2.6.1)) '@types/cross-spawn': specifier: ^6.0.0 version: 6.0.6 @@ -492,6 +495,12 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@eslint-community/regexpp@4.12.2': resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -911,6 +920,12 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@stylistic/eslint-plugin@5.7.0': + resolution: {integrity: sha512-PsSugIf9ip1H/mWKj4bi/BlEoerxXAda9ByRFsYuwsmr6af9NxJL0AaiNXs8Le7R21QR5KMiD/KdxZZ71LjAxQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: '>=9.0.0' + '@types/bson@4.2.4': resolution: {integrity: sha512-SG23E3JDH6y8qF20a4G9txLuUl+TCV16gxsKyntmGiJez2V9VBJr1Y8WxTBBD6OgBVcvspQ7sxgdNMkXFVcaEA==} deprecated: This is a stub types definition. bson provides its own type definitions, so you do not need this installed. @@ -1071,6 +1086,10 @@ packages: resolution: {integrity: sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.52.0': + resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.49.0': resolution: {integrity: sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1817,6 +1836,10 @@ packages: resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint-visitor-keys@5.0.0: + resolution: {integrity: sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + eslint@9.39.1: resolution: {integrity: sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1831,6 +1854,10 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + espree@11.0.0: + resolution: {integrity: sha512-+gMeWRrIh/NsG+3NaLeWHuyeyk70p2tbvZIWBYcqQ4/7Xvars6GYTZNhF1sIeLcc6Wb11He5ffz3hsHyXFrw5A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -4081,6 +4108,11 @@ snapshots: eslint: 9.39.1(jiti@2.6.1) eslint-visitor-keys: 3.4.3 + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.1(jiti@2.6.1))': + dependencies: + eslint: 9.39.1(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + '@eslint-community/regexpp@4.12.2': {} '@eslint/config-array@0.21.1': @@ -4463,6 +4495,16 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@stylistic/eslint-plugin@5.7.0(eslint@9.39.1(jiti@2.6.1))': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.1(jiti@2.6.1)) + '@typescript-eslint/types': 8.52.0 + eslint: 9.39.1(jiti@2.6.1) + eslint-visitor-keys: 5.0.0 + espree: 11.0.0 + estraverse: 5.3.0 + picomatch: 4.0.3 + '@types/bson@4.2.4': dependencies: bson: 7.0.0 @@ -4668,6 +4710,8 @@ snapshots: '@typescript-eslint/types@8.49.0': {} + '@typescript-eslint/types@8.52.0': {} + '@typescript-eslint/typescript-estree@8.49.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.49.0(typescript@5.9.3) @@ -5592,6 +5636,8 @@ snapshots: eslint-visitor-keys@4.2.1: {} + eslint-visitor-keys@5.0.0: {} + eslint@9.39.1(jiti@2.6.1): dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.1(jiti@2.6.1)) @@ -5639,6 +5685,12 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 + espree@11.0.0: + dependencies: + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) + eslint-visitor-keys: 5.0.0 + esprima@4.0.1: {} esquery@1.6.0: diff --git a/src/__tests__/unit/urlRewrite.spec.ts b/src/__tests__/unit/urlRewrite.spec.ts new file mode 100644 index 00000000..0e6962e4 --- /dev/null +++ b/src/__tests__/unit/urlRewrite.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyUrlRewriteToImgInfo, applyUrlRewriteToOutput } from '../../utils/urlRewrite' +import type { IImgInfo, IUrlRewriteRule } from '../../types' + +const createCtx = (output: IImgInfo[], rules?: IUrlRewriteRule[]) => { + return { + output, + getConfig: (key?: string) => { + if (key === 'settings.urlRewrite.rules') return rules + return undefined + }, + log: { + error: vi.fn(), + warn: vi.fn() + } + } +} + +describe('urlRewrite', () => { + it('no-ops silently when rules missing', () => { + const img: IImgInfo = { imgUrl: 'https://a.com/uploads/1.png' } + const ctx = createCtx([img], undefined) + + applyUrlRewriteToOutput(ctx) + + expect(img.imgUrl).toBe('https://a.com/uploads/1.png') + expect(ctx.log.error).not.toHaveBeenCalled() + expect(ctx.log.warn).not.toHaveBeenCalled() + }) + + it('enable defaults to true; explicit false skips', () => { + const img: IImgInfo = { imgUrl: 'https://test.com/uploads/1.png' } + const rules: IUrlRewriteRule[] = [ + { match: 'test', replace: 'prod', enable: false }, + { match: '^https://test\\.com/uploads/(.+)$', replace: 'https://cdn.test.com/$1' } + ] + + applyUrlRewriteToImgInfo(img, rules, createCtx([], rules)) + + expect(img.imgUrl).toBe('https://cdn.test.com/1.png') + }) + + it('first match wins (no chain rewrite)', () => { + const img: IImgInfo = { imgUrl: 'https://test.com/uploads/1.png' } + const rules: IUrlRewriteRule[] = [ + { match: '^https://test\\.com/uploads/(.+)$', replace: 'https://cdn.test.com/$1' }, + { match: 'cdn', replace: 'cdn2', global: true } + ] + + applyUrlRewriteToImgInfo(img, rules, createCtx([], rules)) + + expect(img.imgUrl).toBe('https://cdn.test.com/1.png') + }) + + it('sets originImgUrl only once when rewrite changes url', () => { + const img: IImgInfo = { imgUrl: 'https://test.com/uploads/1.png' } + const rules: IUrlRewriteRule[] = [ + { match: '^https://test\\.com/uploads/(.+)$', replace: 'https://cdn.test.com/$1' } + ] + + applyUrlRewriteToImgInfo(img, rules, createCtx([], rules)) + expect(img.originImgUrl).toBe('https://test.com/uploads/1.png') + + // second rewrite should not overwrite + applyUrlRewriteToImgInfo(img, [{ match: 'cdn', replace: 'cdn2', global: true }], createCtx([], rules)) + expect(img.originImgUrl).toBe('https://test.com/uploads/1.png') + }) + + it('supports ignoreCase and global flags mapping', () => { + const img: IImgInfo = { imgUrl: 'https://TEST.com/uploads/a.png' } + const rules: IUrlRewriteRule[] = [ + { match: 'test', replace: 'prod', ignoreCase: true } + ] + + applyUrlRewriteToImgInfo(img, rules, createCtx([], rules)) + + // ignoreCase affects matching, not the replacement string casing + expect(img.imgUrl).toBe('https://prod.com/uploads/a.png') + }) + + it('global flag replaces all occurrences within a single rule', () => { + const img: IImgInfo = { imgUrl: 'https://a.com/test/test.png' } + const rules: IUrlRewriteRule[] = [ + { match: 'test', replace: 'x', global: true } + ] + + applyUrlRewriteToImgInfo(img, rules, createCtx([], rules)) + + expect(img.imgUrl).toBe('https://a.com/x/x.png') + }) + + it('logs error and skips on invalid regexp', () => { + const img: IImgInfo = { imgUrl: 'https://test.com/uploads/1.png' } + const rules: IUrlRewriteRule[] = [ + { match: '(', replace: 'x' }, + { match: 'test', replace: 'prod', global: true } + ] + const ctx = createCtx([], rules) + + applyUrlRewriteToImgInfo(img, rules, ctx) + + expect(ctx.log.error).toHaveBeenCalledTimes(1) + expect(img.imgUrl).toBe('https://prod.com/uploads/1.png') + }) + + it('warns but allows empty replacement result', () => { + const img: IImgInfo = { imgUrl: 'https://test.com/uploads/1.png' } + const rules: IUrlRewriteRule[] = [ + { match: '^https://test\\.com/uploads/(.+)$', replace: '' } + ] + const ctx = createCtx([], rules) + + applyUrlRewriteToImgInfo(img, rules, ctx) + + expect(img.imgUrl).toBe('') + expect(ctx.log.warn).toHaveBeenCalledTimes(1) + }) + + it('integration: rewrite happens before afterUpload plugin reads imgUrl', async () => { + const calls: string[] = [] + + const ctx = { + configPath: '', + baseDir: '', + VERSION: 'test', + GUI_VERSION: undefined, + cmd: {} as any, + Request: {} as any, + i18n: {} as any, + pluginLoader: {} as any, + pluginHandler: {} as any, + uploaderConfig: {} as any, + input: [], + output: [{ imgUrl: 'https://test.com/uploads/1.png' }], + helper: { + afterUploadPlugins: { + getList: () => [ + { + handle: async (ctx2: any) => { + calls.push(`plugin-sees:${ctx2.output[0].imgUrl}`) + } + } + ], + getIdList: () => ['testPlugin'], + getName: () => 'afterUploadPlugins' + } + } as any, + log: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn() + }, + getConfig: (key?: string) => { + if (key === 'settings.urlRewrite.rules') { + return [{ match: '^https://test\\.com/uploads/(.+)$', replace: 'https://cdn.test.com/$1' }] + } + if (key === 'settings.encodeOutputURL') return false + return undefined + }, + saveConfig: vi.fn(), + removeConfig: vi.fn(), + setConfig: vi.fn(), + unsetConfig: vi.fn(), + upload: vi.fn(), + request: vi.fn(), + addListener: vi.fn(), + on: vi.fn(), + once: vi.fn(), + removeListener: vi.fn(), + off: vi.fn(), + removeAllListeners: vi.fn(), + setMaxListeners: vi.fn(), + getMaxListeners: vi.fn(), + listeners: vi.fn(), + rawListeners: vi.fn(), + emit: vi.fn(), + listenerCount: vi.fn(), + prependListener: vi.fn(), + prependOnceListener: vi.fn(), + eventNames: vi.fn() + } + + const { Lifecycle } = await import('../../core/Lifecycle') + const lifecycle = new Lifecycle(ctx as any) + + await (lifecycle as any).afterUpload(ctx) + + expect(calls).toEqual(['plugin-sees:https://cdn.test.com/1.png']) + }) +}) diff --git a/src/core/Lifecycle.ts b/src/core/Lifecycle.ts index c6f83720..71004b59 100644 --- a/src/core/Lifecycle.ts +++ b/src/core/Lifecycle.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events' import { ILifecyclePlugins, IPicGo, IPlugin, Undefinable } from '../types' import { handleUrlEncode } from '../utils/common' +import { applyUrlRewriteToOutput } from '../utils/urlRewrite' import { IBuildInEvent } from '../utils/enum' import { createContext } from '../utils/createContext' @@ -94,6 +95,9 @@ export class Lifecycle extends EventEmitter { private async afterUpload (ctx: IPicGo): Promise { ctx.emit(IBuildInEvent.AFTER_UPLOAD, ctx) ctx.emit(IBuildInEvent.UPLOAD_PROGRESS, 100) + + applyUrlRewriteToOutput(ctx) + await this.handlePlugins(ctx.helper.afterUploadPlugins, ctx) let msg = '' const length = ctx.output.length diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 2daf6787..5d4e82a6 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -9,6 +9,9 @@ export const EN: ILocales = { SERVER_ERROR: 'Server error, please try again later', AUTH_FAILED: 'Authentication failed', + // url rewrite + URL_REWRITE_EMPTY_RESULT: 'URL rewrite produced an empty result, please check your rule config', + // smms PICBED_SMMS: 'SM.MS', PICBED_SMMS_TOKEN: 'Set Token', diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index 5bd15fca..0a93b021 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -7,6 +7,9 @@ export const ZH_CN = { SERVER_ERROR: '服务端出错,请重试', AUTH_FAILED: '认证失败', + // url rewrite + URL_REWRITE_EMPTY_RESULT: 'URL 重写结果为空,请检查你的规则配置', + // smms PICBED_SMMS: 'SM.MS', PICBED_SMMS_TOKEN: '设定Token', diff --git a/src/i18n/zh-TW.ts b/src/i18n/zh-TW.ts index 1251ce03..154e91a7 100644 --- a/src/i18n/zh-TW.ts +++ b/src/i18n/zh-TW.ts @@ -9,6 +9,9 @@ export const ZH_TW: ILocales = { SERVER_ERROR: '伺服器出錯,請重試', AUTH_FAILED: '認證失敗', + // url rewrite + URL_REWRITE_EMPTY_RESULT: 'URL 重寫結果為空,請檢查你的規則設定', + // smms PICBED_SMMS: 'SM.MS', PICBED_SMMS_TOKEN: '設定Token', diff --git a/src/index.ts b/src/index.ts index b15f1992..9736d2e8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { applyUrlRewriteToImgInfo } from './utils/urlRewrite' + export { PicGo } from './core/PicGo' export { Lifecycle } from './core/Lifecycle' @@ -7,7 +9,13 @@ export { LifecyclePlugins } from './lib/LifecyclePlugins' export { Commander } from './lib/Commander' export { PluginLoader } from './lib/PluginLoader' export { Request } from './lib/Request' -export * as PicGoUtils from './utils/common' + +import * as PicGoCommonUtils from './utils/common' + +export const PicGoUtils = { + ...PicGoCommonUtils, + applyUrlRewriteToImgInfo +} export * from './types' export * from './utils/enum' diff --git a/src/types/index.ts b/src/types/index.ts index 5dee6b27..33fdb160 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -291,12 +291,21 @@ export interface IImgInfo { height?: number extname?: string imgUrl?: string + originImgUrl?: string mimeType?: string filePath?: string size?: number [propName: string]: any } +export interface IUrlRewriteRule { + match: string + replace: string + enable?: boolean + global?: boolean + ignoreCase?: boolean +} + export interface IPathTransformedImgInfo extends IImgInfo { success: boolean } diff --git a/src/utils/common.ts b/src/utils/common.ts index ec8c731c..5a3364f5 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -11,27 +11,29 @@ import { import { URL } from 'url' export const isUrl = (url: string): boolean => (url.startsWith('http://') || url.startsWith('https://')) -export const isUrlEncode = (url: string): boolean => { - url = url || '' - try { - // the whole url encode or decode shold not use encodeURIComponent or decodeURIComponent - return url !== decodeURI(url) - } catch (e) { - // if some error caught, try to let it go - return false - } -} /** - * just encode the url with encodeURI + * handle url encode */ -export const handleUrlEncode = (url: string): string => { - if (!isUrlEncode(url)) { - url = encodeURI(url) +export const handleUrlEncode = (urlStr: string): string => { + if (!urlStr) return '' + + try { + return new URL(urlStr).href + } catch (e) { + if (urlStr.startsWith('//')) { + try { + const tempUrl = new URL('http:' + urlStr) + return tempUrl.href.slice(5) // remove 'http:' + } catch (_) {} + } + + // fallback + return encodeURI(urlStr) } - return url } + /** * @param urlPath the url path need to be encoded safely * @returns the safely encoded url path @@ -108,20 +110,22 @@ export const getURLFile = async (url: string, ctx: IPicGo): Promise void + warn: (...args: any[]) => void +} + +const compileRuleRegExp = (rule: IUrlRewriteRule): RegExp => { + const flags = `${rule.global === true ? 'g' : ''}${rule.ignoreCase === true ? 'i' : ''}` + return new RegExp(rule.match, flags) +} + +export const applyUrlRewriteToImgInfo = (imgInfo: IImgInfo, rules: IUrlRewriteRule[], ctx: { log: IUrlRewriteLogger, i18n?: II18nManager }): void => { + if (!imgInfo.imgUrl) return + + for (const rule of rules) { + const enabled = rule.enable !== false + if (!enabled) continue + + let regex: RegExp + try { + regex = compileRuleRegExp(rule) + } catch (e: any) { + ctx.log.error(e) + continue + } + + if (!regex.test(imgInfo.imgUrl)) continue + + const originalUrl = imgInfo.imgUrl + const nextUrl = originalUrl.replace(regex, rule.replace) + + if (nextUrl !== originalUrl && typeof imgInfo.originImgUrl === 'undefined') { + imgInfo.originImgUrl = originalUrl + } + + imgInfo.imgUrl = nextUrl + + if (nextUrl === '') { + const warnMsg = ctx.i18n?.translate('URL_REWRITE_EMPTY_RESULT') ?? 'URL_REWRITE_EMPTY_RESULT' + ctx.log.warn(warnMsg) + } + + // First Match Wins + break + } +} + +export const applyUrlRewriteToOutput = (ctx: { getConfig: (name?: string) => any, log: IUrlRewriteLogger, output: IImgInfo[], i18n?: II18nManager }): void => { + const rules = ctx.getConfig('settings.urlRewrite.rules') as Undefinable + if (!Array.isArray(rules) || rules.length === 0) return + + for (const imgInfo of ctx.output) { + applyUrlRewriteToImgInfo(imgInfo, rules, ctx) + } +}