perf: reduce initial bundle size by lazy-loading sync providers, validation, and jira2md

This commit is contained in:
Johannes Millan 2026-03-09 20:50:27 +01:00
parent 6a682472c6
commit beb5f3425c
15 changed files with 266 additions and 144 deletions

View file

@ -50,7 +50,6 @@
"spark-md5",
"chrono-node",
"dayjs",
"jira2md",
"query-string"
]
},

View file

@ -248,7 +248,6 @@
"jasmine-core": "^5.13.0",
"jasmine-marbles": "^0.9.2",
"jasmine-spec-reporter": "~7.0.0",
"jira2md": "git+https://github.com/johannesjo/J2M.git",
"karma": "~6.4.2",
"karma-chrome-launcher": "~3.2.0",
"karma-cli": "^2.0.0",

View file

@ -4,7 +4,10 @@ import { ConfigFormSection, SyncConfig } from '../global-config.model';
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import { IS_ELECTRON } from '../../../app.constants';
import { fileSyncDroid, fileSyncElectron } from '../../../op-log/model/model-config';
import {
loadSyncProviders,
LocalFileSyncPicker,
} from '../../../op-log/sync-providers/sync-providers.factory';
import { FormlyFieldConfig } from '@ngx-formly/core';
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
import { SUPER_SYNC_DEFAULT_BASE_URL } from '../../../op-log/sync-providers/super-sync/super-sync.model';
@ -159,8 +162,12 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
key: 'syncFolderPath',
templateOptions: {
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
onClick: () => {
return fileSyncElectron.pickDirectory();
onClick: async () => {
const providers = await loadSyncProviders();
const localProvider = providers.find(
(p) => p.id === SyncProviderId.LocalFile,
);
return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory();
},
},
expressions: {
@ -189,9 +196,13 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
key: 'safFolderUri',
templateOptions: {
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
onClick: () => {
onClick: async () => {
// NOTE: this actually sets the value in the model
return fileSyncDroid.setupSaf();
const providers = await loadSyncProviders();
const localProvider = providers.find(
(p) => p.id === SyncProviderId.LocalFile,
);
return (localProvider as LocalFileSyncPicker | undefined)?.setupSaf();
},
},
expressions: {

View file

@ -123,7 +123,7 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
}
// Load the provider's stored configuration
const provider = this._providerManager.getProviderById(providerId);
const provider = await this._providerManager.getProviderById(providerId);
if (!provider) {
// Provider not yet configured, keep current form state
return;

View file

@ -28,7 +28,9 @@ export class DropboxEffects {
if (syncCfg.syncProvider !== SyncProviderId.Dropbox) {
return;
}
const provider = this._providerManager.getProviderById(SyncProviderId.Dropbox);
const provider = await this._providerManager.getProviderById(
SyncProviderId.Dropbox,
);
if (!provider) {
return;
}

View file

@ -228,7 +228,7 @@ export class SyncConfigService {
syncProviderId?: SyncProviderId,
): Promise<void> {
const activeProvider = syncProviderId
? this._providerManager.getProviderById(syncProviderId)
? await this._providerManager.getProviderById(syncProviderId)
: this._providerManager.getActiveProvider();
if (!activeProvider) {
// During initial sync setup, no provider exists yet to store the key.
@ -283,7 +283,7 @@ export class SyncConfigService {
// This ensures sync services see isEncryptionEnabled=true when SuperSync encryption is enabled
// Note: We need to check the SAVED private config because Formly doesn't include hidden fields
if (providerId === SyncProviderId.SuperSync) {
const activeProvider = this._providerManager.getProviderById(providerId);
const activeProvider = await this._providerManager.getProviderById(providerId);
const savedPrivateCfg = activeProvider
? await activeProvider.privateCfg.load()
: null;
@ -305,7 +305,7 @@ export class SyncConfigService {
const prop = PROP_MAP_TO_FORM[providerId];
// Load existing config to preserve OAuth tokens and other settings
const activeProvider = this._providerManager.getProviderById(providerId);
const activeProvider = await this._providerManager.getProviderById(providerId);
const oldConfig = activeProvider ? await activeProvider.privateCfg.load() : {};
// Form fields contain provider-specific settings, but Dropbox uses OAuth tokens

View file

@ -627,7 +627,7 @@ export class SyncWrapperService {
providerId: SyncProviderId,
force = false,
): Promise<{ wasConfigured: boolean }> {
const provider = this._providerManager.getProviderById(providerId);
const provider = await this._providerManager.getProviderById(providerId);
if (!provider) {
return { wasConfigured: false };

View file

@ -1,5 +1,4 @@
import { AllModelData, ModelCfg } from '../core/types/sync.types';
import { Dropbox } from '../sync-providers/file-based/dropbox/dropbox';
import { ProjectState } from '../../features/project/project.model';
import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model';
import { GlobalConfigState } from '../../features/config/global-config.model';
@ -28,14 +27,6 @@ import { initialTaskState } from '../../features/tasks/store/task.reducer';
import { initialTagState } from '../../features/tag/store/tag.reducer';
import { initialSimpleCounterState } from '../../features/simple-counter/store/simple-counter.reducer';
import { initialTaskRepeatCfgState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.reducer';
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
import { Webdav } from '../sync-providers/file-based/webdav/webdav';
import { SuperSyncProvider } from '../sync-providers/super-sync/super-sync';
import { LocalFileSyncElectron } from '../sync-providers/file-based/local-file/local-file-sync-electron';
import { IS_ELECTRON } from '../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { LocalFileSyncAndroid } from '../sync-providers/file-based/local-file/local-file-sync-android';
import { environment } from '../../../environments/environment';
import {
ArchiveModel,
TimeTrackingState,
@ -183,17 +174,3 @@ export const getDefaultMainModelData = (): Partial<AppDataComplete> => {
}
return result;
};
export const fileSyncElectron = new LocalFileSyncElectron();
export const fileSyncDroid = new LocalFileSyncAndroid();
export const SYNC_PROVIDERS = [
new Dropbox({
appKey: DROPBOX_APP_KEY,
basePath: environment.production ? `/` : `/DEV/`,
}),
new Webdav(environment.production ? undefined : `/DEV`),
new SuperSyncProvider(environment.production ? undefined : `/DEV`),
...(IS_ELECTRON ? [fileSyncElectron] : []),
...(IS_ANDROID_WEB_VIEW ? [fileSyncDroid] : []),
];

View file

@ -59,9 +59,8 @@ export {
isFileSyncProvider,
} from './sync-providers/provider.interface';
// Providers
export { Dropbox } from './sync-providers/file-based/dropbox/dropbox';
export { DropboxPrivateCfg } from './sync-providers/file-based/dropbox/dropbox';
// Provider types
export type { DropboxPrivateCfg } from './sync-providers/file-based/dropbox/dropbox';
// VectorClock from core
export { VectorClock } from '../core/util/vector-clock';

View file

@ -19,17 +19,7 @@ import {
PrivateCfgByProviderId,
CurrentProviderPrivateCfg,
} from '../core/types/sync.types';
import { environment } from '../../../environments/environment';
// Import providers
import { Dropbox } from './file-based/dropbox/dropbox';
import { Webdav } from './file-based/webdav/webdav';
import { SuperSyncProvider } from './super-sync/super-sync';
import { LocalFileSyncElectron } from './file-based/local-file/local-file-sync-electron';
import { LocalFileSyncAndroid } from './file-based/local-file/local-file-sync-android';
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
import { IS_ELECTRON } from '../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { loadSyncProviders } from './sync-providers.factory';
/**
* Sync status change payload type
@ -40,53 +30,10 @@ export type SyncStatusChangePayload =
| 'IN_SYNC'
| 'SYNCING';
/**
* Array of all available sync providers
* Cast to generic SyncProviderBase - each provider implements
* a specific SyncProviderId but we store them in a generic array.
*/
const SYNC_PROVIDERS: SyncProviderBase<SyncProviderId>[] = [
new Dropbox({
appKey: DROPBOX_APP_KEY,
basePath: environment.production ? `/` : `/DEV/`,
}) as SyncProviderBase<SyncProviderId>,
new Webdav(
environment.production ? undefined : `/DEV`,
) as SyncProviderBase<SyncProviderId>,
new SuperSyncProvider(
environment.production ? undefined : `/DEV`,
) as SyncProviderBase<SyncProviderId>,
...(IS_ELECTRON
? [new LocalFileSyncElectron() as SyncProviderBase<SyncProviderId>]
: []),
...(IS_ANDROID_WEB_VIEW
? [new LocalFileSyncAndroid() as SyncProviderBase<SyncProviderId>]
: []),
];
/**
* Service for managing sync providers.
*
* This service replaces the provider management parts of PfapiService.
* It handles:
* - Active provider selection based on user config
* - Provider readiness state
* - Encryption/compression configuration
* - Provider credential management
*
* ## Usage
* ```typescript
* const manager = inject(SyncProviderManager);
*
* // Get active provider
* const provider = manager.getActiveProvider();
*
* // Subscribe to provider readiness
* manager.isProviderReady$.subscribe(ready => ...);
*
* // Get provider by ID
* const dropbox = manager.getProviderById(SyncProviderId.Dropbox);
* ```
* Providers are lazily loaded on first use to reduce initial bundle size.
*/
@Injectable({
providedIn: 'root',
@ -95,6 +42,9 @@ export class SyncProviderManager {
private _dataInitStateService = inject(DataInitStateService);
private _store = inject(Store);
// Lazily loaded providers (cached after first load)
private _providers: SyncProviderBase<SyncProviderId>[] | null = null;
// Current active provider
private _activeProvider: SyncProviderBase<SyncProviderId> | null = null;
private _activeProviderId$ = new BehaviorSubject<SyncProviderId | null>(null);
@ -195,7 +145,21 @@ export class SyncProviderManager {
}
/**
* Gets the currently active sync provider
* Ensures providers are loaded. Returns cached providers if already loaded.
* Deduplication of concurrent calls is handled by `loadSyncProviders()`.
*/
private async _ensureProviders(): Promise<SyncProviderBase<SyncProviderId>[]> {
if (this._providers) {
return this._providers;
}
this._providers = await loadSyncProviders();
return this._providers;
}
/**
* Gets the currently active sync provider.
* Note: May return null during initial lazy-load of provider modules.
* Callers should gate on `isProviderReady$` before using the returned provider.
*/
getActiveProvider(): SyncProviderBase<SyncProviderId> | null {
return this._activeProvider;
@ -204,17 +168,19 @@ export class SyncProviderManager {
/**
* Gets a sync provider by ID
*/
getProviderById(
async getProviderById(
providerId: SyncProviderId,
): SyncProviderBase<SyncProviderId> | undefined {
return SYNC_PROVIDERS.find((p) => p.id === providerId);
): Promise<SyncProviderBase<SyncProviderId> | undefined> {
const providers = await this._ensureProviders();
return providers.find((p) => p.id === providerId);
}
/**
* Gets all available sync providers
*/
getAllProviders(): SyncProviderBase<SyncProviderId>[] {
return [...SYNC_PROVIDERS];
async getAllProviders(): Promise<SyncProviderBase<SyncProviderId>[]> {
const providers = await this._ensureProviders();
return [...providers];
}
/**
@ -245,7 +211,7 @@ export class SyncProviderManager {
async getProviderConfig<PID extends SyncProviderId>(
providerId: PID,
): Promise<PrivateCfgByProviderId<PID> | null> {
const provider = this.getProviderById(providerId);
const provider = await this.getProviderById(providerId);
if (!provider) {
return null;
}
@ -259,7 +225,7 @@ export class SyncProviderManager {
providerId: PID,
config: PrivateCfgByProviderId<PID>,
): Promise<void> {
const provider = this.getProviderById(providerId);
const provider = await this.getProviderById(providerId);
if (!provider) {
throw new Error(`Provider not found: ${providerId}`);
}
@ -288,7 +254,7 @@ export class SyncProviderManager {
* Updates readiness state and config observable after clearing.
*/
async clearAuthCredentials(providerId: SyncProviderId): Promise<void> {
const provider = this.getProviderById(providerId);
const provider = await this.getProviderById(providerId);
if (!provider?.clearAuthCredentials) {
return;
}
@ -314,7 +280,7 @@ export class SyncProviderManager {
}
/**
* Sets the active sync provider
* Sets the active sync provider (loads providers lazily on first call)
*/
private _setActiveProvider(providerId: SyncProviderId | null): void {
this._activeProviderId$.next(providerId);
@ -326,22 +292,57 @@ export class SyncProviderManager {
return;
}
const provider = SYNC_PROVIDERS.find((p) => p.id === providerId);
if (provider) {
this._activeProvider = provider;
provider.isReady().then((ready) => this._isProviderReady$.next(ready));
// Load providers lazily and set the active one
this._ensureProviders()
.then((providers) => {
// Guard against stale resolution if provider changed while loading
if (this._activeProviderId$.getValue() !== providerId) {
return;
}
const provider = providers.find((p) => p.id === providerId);
if (provider) {
this._activeProvider = provider;
provider
.isReady()
.then((ready) => {
if (this._activeProviderId$.getValue() !== providerId) return;
this._isProviderReady$.next(ready);
})
.catch((err) => {
SyncLog.err(
'SyncProviderManager: Failed to check provider readiness:',
err,
);
if (this._activeProviderId$.getValue() === providerId) {
this._isProviderReady$.next(false);
}
});
// Emit provider config to observable
provider.privateCfg.load().then((privateCfg) => {
this._currentProviderPrivateCfg$.next({
providerId,
privateCfg,
});
// Emit provider config to observable
provider.privateCfg
.load()
.then((privateCfg) => {
if (this._activeProviderId$.getValue() !== providerId) return;
this._currentProviderPrivateCfg$.next({
providerId,
privateCfg,
});
})
.catch((err) =>
SyncLog.err('SyncProviderManager: Failed to load provider config:', err),
);
SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`);
} else {
SyncLog.err(`SyncProviderManager: Provider not found: ${providerId}`);
}
})
.catch((err) => {
if (this._activeProviderId$.getValue() !== providerId) {
return;
}
SyncLog.err('SyncProviderManager: Failed to load providers:', err);
this._isProviderReady$.next(false);
});
SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`);
} else {
SyncLog.err(`SyncProviderManager: Provider not found: ${providerId}`);
}
}
}

View file

@ -0,0 +1,67 @@
import { SyncProviderId } from './provider.const';
import { SyncProviderBase } from './provider.interface';
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
import { IS_ELECTRON } from '../../app.constants';
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
import { environment } from '../../../environments/environment';
let _providersPromise: Promise<SyncProviderBase<SyncProviderId>[]> | null = null;
/**
* Lazily loads and instantiates all sync providers.
* Provider modules (Dropbox, WebDAV, SuperSync, LocalFile) are only imported
* when this function is first called, keeping them out of the initial bundle.
*/
export const loadSyncProviders = (): Promise<SyncProviderBase<SyncProviderId>[]> => {
if (!_providersPromise) {
_providersPromise = _createProviders().catch((err) => {
_providersPromise = null;
throw err;
});
}
return _providersPromise;
};
/**
* Narrow interface for LocalFile sync providers that expose directory picker methods.
* Used to avoid `as any` casts in sync-form.const.ts.
*/
export interface LocalFileSyncPicker {
pickDirectory(): Promise<string | void>;
setupSaf(): Promise<string>;
}
const _createProviders = async (): Promise<SyncProviderBase<SyncProviderId>[]> => {
const [{ Dropbox }, { Webdav }, { SuperSyncProvider }] = await Promise.all([
import('./file-based/dropbox/dropbox'),
import('./file-based/webdav/webdav'),
import('./super-sync/super-sync'),
]);
const providers: SyncProviderBase<SyncProviderId>[] = [
new Dropbox({
appKey: DROPBOX_APP_KEY,
basePath: environment.production ? `/` : `/DEV/`,
}) as SyncProviderBase<SyncProviderId>,
new Webdav(
environment.production ? undefined : `/DEV`,
) as SyncProviderBase<SyncProviderId>,
new SuperSyncProvider(
environment.production ? undefined : `/DEV`,
) as SyncProviderBase<SyncProviderId>,
];
if (IS_ELECTRON) {
const { LocalFileSyncElectron } =
await import('./file-based/local-file/local-file-sync-electron');
providers.push(new LocalFileSyncElectron() as SyncProviderBase<SyncProviderId>);
}
if (IS_ANDROID_WEB_VIEW) {
const { LocalFileSyncAndroid } =
await import('./file-based/local-file/local-file-sync-android');
providers.push(new LocalFileSyncAndroid() as SyncProviderBase<SyncProviderId>);
}
return providers;
};

View file

@ -20,7 +20,12 @@ let _validateFullPromise:
| undefined;
const _loadValidateFull = (): Promise<typeof import('./validation-fn').validateFull> => {
if (!_validateFullPromise) {
_validateFullPromise = import('./validation-fn').then((m) => m.validateFull);
_validateFullPromise = import('./validation-fn')
.then((m) => m.validateFull)
.catch((err) => {
_validateFullPromise = undefined;
throw err;
});
}
return _validateFullPromise;
};

View file

@ -188,11 +188,11 @@
</h2>
<section class="config-section tour-syncSection">
@let syncSettingsForm = syncSettingsService.syncSettingsForm$ | async;
@if (syncSettingsForm) {
@if (syncSettingsForm && globalSyncConfigFormCfg(); as syncCfg) {
<config-section
(save)="syncSettingsService.updateSettingsFromForm($event.config)"
[cfg]="syncSettingsForm"
[section]="globalSyncConfigFormCfg"
[section]="syncCfg"
></config-section>
}
</section>

View file

@ -6,6 +6,7 @@ import {
inject,
OnDestroy,
OnInit,
signal,
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { GlobalConfigService } from '../../features/config/global-config.service';
@ -19,6 +20,7 @@ import {
} from '../../features/config/global-config-form-config.const';
import {
ConfigFormConfig,
ConfigFormSection,
GlobalConfigSectionKey,
GlobalConfigState,
GlobalSectionConfig,
@ -110,7 +112,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
pluginsShortcutsFormCfg: ConfigFormConfig;
globalImexFormCfg: ConfigFormConfig;
globalProductivityConfigFormCfg: ConfigFormConfig;
globalSyncConfigFormCfg = this._buildSyncFormConfig();
globalSyncConfigFormCfg = signal<ConfigFormSection<SyncConfig> | undefined>(undefined);
globalCfg?: GlobalConfigState;
@ -145,6 +147,17 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
this.globalProductivityConfigFormCfg = GLOBAL_PRODUCTIVITY_FORM_CONFIG.slice();
this.globalTasksFormCfg = GLOBAL_TASKS_FORM_CONFIG.slice();
// Build sync form config asynchronously (providers are lazy-loaded)
this._buildSyncFormConfig()
.then((cfg) => this.globalSyncConfigFormCfg.set(cfg))
.catch((err) => {
Log.err('Failed to build sync form config:', err);
this._snackService.open({
type: 'ERROR',
msg: T.GLOBAL_SNACK.OPEN_SETTINGS_ERROR,
});
});
// NOTE: needs special handling cause of the async stuff
if (IS_ANDROID_WEB_VIEW) {
this.globalImexFormCfg = [...this.globalImexFormCfg, getAutomaticBackUpFormCfg()];
@ -254,7 +267,12 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
this._subs.unsubscribe();
}
private _buildSyncFormConfig(): typeof SYNC_FORM {
private async _buildSyncFormConfig(): Promise<typeof SYNC_FORM> {
// Pre-load dropbox provider for use in the form config
const dropboxProvider = await this._providerManager.getProviderById(
SyncProviderId.Dropbox,
);
// Deep clone the SYNC_FORM items to avoid mutating the original
const items = SYNC_FORM.items!.map((item) => {
// Find the WebDAV fieldGroup and add the Test Connection button
@ -376,10 +394,6 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
// Find the Dropbox fieldGroup and add the authentication button
if ((item as any).props?.dropboxAuth && item.fieldGroup) {
// Check if Dropbox is already authenticated
const dropboxProvider = this._providerManager.getProviderById(
SyncProviderId.Dropbox,
);
// We need to check auth status asynchronously, so we'll set initial state
// and update it when the form is rendered
let isAuthenticated = false;

View file

@ -1,25 +1,73 @@
import { Pipe, PipeTransform } from '@angular/core';
interface J2mModule {
to_markdown(input: string): string;
}
let _j2mPromise: Promise<J2mModule> | undefined;
const _loadJ2m = (): Promise<J2mModule> => {
if (!_j2mPromise) {
// @ts-ignore
_j2mPromise = import('jira2md').then((m: { default: J2mModule }) => m.default);
}
return _j2mPromise;
};
/**
* Converts JIRA wiki markup to Markdown.
* Inlined from jira2md to avoid bundling its CommonJS `marked` dependency.
*/
const jiraToMarkdown = (str: string): string =>
str
// Un-ordered lists (JIRA: * for bullets)
.replace(/^[ \t]*(\*+)\s+/gm, (_, stars: string) => {
return ' '.repeat(stars.length - 1) + '* ';
})
// Ordered lists (JIRA: # for numbered)
.replace(/^[ \t]*(#+)\s+/gm, (_, nums: string) => {
return ' '.repeat(nums.length - 1) + '1. ';
})
// Headers 1-6
.replace(/^h([1-6])\.(.*)$/gm, (_, level: string, content: string) => {
return '#'.repeat(parseInt(level)) + content;
})
// Bold (intentionally lazy to handle multiple bold spans per line)
.replace(/\*(\S.*?)\*/g, '**$1**')
// Italic (intentionally lazy to handle multiple italic spans per line)
.replace(/\_(\S.*?)\_/g, '*$1*')
// Monospaced text
.replace(/\{\{([^}]+)\}\}/g, '`$1`')
// Inserts
.replace(/\+([^+]*)\+/g, '<ins>$1</ins>')
// Superscript
.replace(/\^([^^]*)\^/g, '<sup>$1</sup>')
// Subscript
.replace(/~([^~]*)~/g, '<sub>$1</sub>')
// Strikethrough
.replace(/(\s+)-(\S+.*?\S)-(\s+)/g, '$1~~$2~~$3')
// Code Block
.replace(
/\{code(:([a-z]+))?([:|]?(title|borderStyle|borderColor|borderWidth|bgColor|titleBGColor)=.+?)*\}([^]*?)\n?\{code\}/gm,
'```$2$5\n```',
)
// Pre-formatted text
.replace(/{noformat}/g, '```')
// Un-named Links
.replace(/\[([^|]+?)\]/g, '<$1>')
// Images
.replace(/!(.+)!/g, '![]($1)')
// Named Links
.replace(/\[(.+?)\|(.+?)\]/g, '[$1]($2)')
// Single Paragraph Blockquote
.replace(/^bq\.\s+/gm, '> ')
// Remove color: unsupported in md
.replace(/\{color:[^}]+\}([^]*)\{color\}/gm, '$1')
// panel into table
.replace(
/\{panel:title=([^}]*)\}\n?([^]*?)\n?\{panel\}/gm,
'\n| $1 |\n| --- |\n| $2 |',
)
// table header
.replace(/^[ \t]*((?:\|\|.*?)+\|\|)[ \t]*$/gm, (_, headers: string) => {
const singleBarred = headers.replace(/\|\|/g, '|');
return '\n' + singleBarred + '\n' + singleBarred.replace(/\|[^|]+/g, '| --- ');
})
// remove leading-space of table headers and rows
.replace(/^[ \t]*\|/gm, '|');
@Pipe({ name: 'jiraToMarkdown' })
export class JiraToMarkdownPipe implements PipeTransform {
transform(value: string): Promise<string> {
transform(value: string): string {
if (!value) {
return Promise.resolve(value);
return value;
}
return _loadJ2m().then((j2m) => j2m.to_markdown(value));
return jiraToMarkdown(value);
}
}