From 742efd61cf1c116cbebbf2491b579f0e6e084d17 Mon Sep 17 00:00:00 2001 From: 3y3 <3y3@ya.ru> Date: Wed, 4 Dec 2024 19:33:02 +0300 Subject: [PATCH] wip --- src/commands/build/core/toc/TocService.ts | 179 +++++++++ src/commands/build/core/toc/index.spec.ts | 339 ++++++++++++++++++ src/commands/build/core/toc/index.ts | 5 + src/commands/build/core/toc/loader.ts | 337 +++++++++++++++++ src/commands/build/core/toc/types.ts | 47 +++ src/commands/build/core/toc/utils.ts | 3 + .../build/errors/InsecureAccessError.ts | 19 + src/commands/build/errors/index.ts | 1 + src/commands/build/features/html/index.ts | 34 ++ .../build/features/singlepage/index.ts | 60 ++++ src/commands/build/handler.ts | 13 +- src/commands/build/index.ts | 10 + src/commands/build/run.ts | 5 + src/models.ts | 2 - src/services/tocs.ts | 276 +------------- src/services/utils.ts | 20 -- src/steps/processExcludedFiles.ts | 15 +- src/steps/processPages.ts | 46 +-- src/steps/processServiceFiles.ts | 26 +- src/utils/index.ts | 1 - src/utils/singlePage.ts | 33 +- src/utils/toc.ts | 71 ---- 22 files changed, 1077 insertions(+), 465 deletions(-) create mode 100644 src/commands/build/core/toc/TocService.ts create mode 100644 src/commands/build/core/toc/index.spec.ts create mode 100644 src/commands/build/core/toc/index.ts create mode 100644 src/commands/build/core/toc/loader.ts create mode 100644 src/commands/build/core/toc/types.ts create mode 100644 src/commands/build/core/toc/utils.ts create mode 100644 src/commands/build/errors/InsecureAccessError.ts create mode 100644 src/commands/build/errors/index.ts create mode 100644 src/commands/build/features/html/index.ts delete mode 100644 src/utils/toc.ts diff --git a/src/commands/build/core/toc/TocService.ts b/src/commands/build/core/toc/TocService.ts new file mode 100644 index 00000000..2cf9a6f2 --- /dev/null +++ b/src/commands/build/core/toc/TocService.ts @@ -0,0 +1,179 @@ +import type {BuildConfig, Run} from '~/commands/build'; +import type {LoaderContext} from './loader'; +import type {RawToc, RawTocItem, WithItems} from './types'; + +import {ok} from 'node:assert'; +import {basename, dirname, join} from 'node:path'; +import {dump, load} from 'js-yaml'; +import {AsyncParallelHook, AsyncSeriesWaterfallHook} from 'tapable'; + +import loader, {TocIncludeMode} from './loader'; +import {freeze, isExternalHref, own} from '~/utils'; + +type TocServiceConfig = { + ignoreStage: BuildConfig['ignoreStage']; + template: BuildConfig['template']; + removeHiddenTocItems: BuildConfig['removeHiddenTocItems']; +}; + +type WalkStepResult = RawTocItem | RawTocItem[] | void; + +type TocServiceHooks = { + /** + * Called before item data processing (but after data interpolation) + */ + Item: AsyncSeriesWaterfallHook<[RawTocItem, RelativePath]>; + Resolved: AsyncParallelHook<[Toc, RelativePath]>; + Included: AsyncParallelHook<[Toc, RelativePath, TocIncludeMode]>; +}; + +// TODO: addSourcePath(fileContent, sourcePath); +export class TocService { + hooks: TocServiceHooks; + + private run: Run; + + private fs: Run['fs']; + + private logger: Run['logger']; + + private vars: Run['vars']; + + private config: TocServiceConfig; + + private tocs: Map = new Map(); + + private entries: Set = new Set(); + + constructor(run: Run) { + this.run = run; + this.fs = run.fs; + this.logger = run.logger; + this.vars = run.vars; + this.config = run.config; + this.hooks = { + Item: new AsyncSeriesWaterfallHook(['item', 'path']), + Resolved: new AsyncParallelHook(['toc', 'path']), + Included: new AsyncParallelHook(['toc', 'path', 'mode']), + }; + } + + async load(path: RelativePath, from?: RelativePath, mode = TocIncludeMode.RootMerge) { + this.logger.proc(path); + + const file = join(this.run.input, path); + + ok(file.startsWith(this.run.input), `Requested toc '${file}' is out of project scope.`); + + const context: LoaderContext = { + root: this.run.input, + path: path, + vars: await this.vars.load(path), + toc: this, + options: { + ignoreStage: this.config.ignoreStage, + resolveConditions: this.config.template.features.conditions, + resolveSubstitutions: this.config.template.features.substitutions, + removeHiddenItems: this.config.removeHiddenTocItems, + }, + }; + + const content = load(await this.fs.readFile(file, 'utf8')) as RawToc; + + if (from) { + if (mode === TocIncludeMode.Link) { + context.base = from; + } + + if (mode === TocIncludeMode.RootMerge || mode === TocIncludeMode.Merge) { + await this.run.copy(dirname(file), join(this.run.input, from), [basename(file)]); + } + } + + const toc = await loader.call(context, content); + + // If this is not a part of other toc.yaml + if (!from) { + freeze(toc); + // TODO: we don't need to store tocs in future + // All processing should subscribe on toc.hooks.Resolved + this.tocs.set(path, toc); + await this.walkItems([toc], (item) => { + if (own(item, 'href') && !isExternalHref(item.href)) { + this.entries.add(join(dirname(path), item.href)); + } + + return item; + }); + + await this.hooks.Resolved.promise(toc, path); + } else { + await this.hooks.Included.promise(toc, from, mode); + } + + return toc; + } + + dump(toc: Toc) { + return dump(toc); + } + + async walkItems( + items: T, + actor: (item: RawTocItem) => Promise | WalkStepResult, + ): Promise { + if (!items || !items.length) { + return items; + } + + const results: RawTocItem[] = []; + const queue = [...items]; + while (queue.length) { + const item = queue.shift() as RawTocItem; + + const result = await actor(item); + if (result !== undefined) { + if (Array.isArray(result)) { + results.push(...result); + } else { + results.push(result); + } + } + + if (hasItems(result)) { + result.items = await this.walkItems(result.items, actor); + } + } + + return results as T; + } + + /** + * Resolves toc path and data for any page path + * + * @param {RelativePath} path - any page path + * + * @returns [RelativePath, Toc] + */ + for(path: RelativePath): [RelativePath, Toc] { + // TODO: assert relative + + if (!path) { + throw new Error('Error while finding toc dir.'); + } + + const tocPath = join(dirname(path), 'toc.yaml'); + + if (this.tocs.has(tocPath)) { + return [tocPath, this.tocs.get(tocPath)]; + } + + return this.for(dirname(path)); + } + + async applyIncluder(path: string) {} +} + +function hasItems(item: any): item is WithItems { + return item && typeof item === 'object' && item.items && item.items.length; +} diff --git a/src/commands/build/core/toc/index.spec.ts b/src/commands/build/core/toc/index.spec.ts new file mode 100644 index 00000000..88bac2bf --- /dev/null +++ b/src/commands/build/core/toc/index.spec.ts @@ -0,0 +1,339 @@ +import {join} from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; +import {when} from 'vitest-when'; +import {dedent} from 'ts-dedent'; + +import {TocService} from './TocService'; + +function test(content: string, options = {}, vars = {}, files = {}) { + return async () => { + const input = '/dev/null/input' as AbsolutePath; + const output = '/dev/null/output' as AbsolutePath; + const run = { + input, + output, + config: { + ignoreStage: options.ignoreStage || [], + removeHiddenItems: Boolean( + 'removeHiddenItems' in options ? options.removeHiddenItems : false, + ), + template: { + enabled: true, + features: { + conditions: Boolean( + 'resolveConditions' in options ? options.resolveConditions : true, + ), + substitutions: Boolean( + 'resolveSubstitutions' in options ? options.resolveSubstitutions : true, + ), + }, + }, + }, + vars: { + load: vi.fn(), + }, + fs: { + readFile: vi.fn(), + }, + }; + const toc = new TocService(run); + + when(run.vars.load).calledWith('./toc.yaml').thenResolve(vars); + + when(run.fs.readFile) + .calledWith(join(input, './toc.yaml'), expect.anything()) + .thenResolve(content); + + for (const [path, content] of Object.entries(files)) { + when(run.fs.readFile) + .calledWith(join(input, path), expect.anything()) + .thenResolve(content); + } + + const result = await toc.load('./toc.yaml' as RelativePath); + + expect(toc.dump(result)).toMatchSnapshot(); + }; +} + +describe.skip('toc-loader', () => { + it( + 'should handle simple title', + test(dedent` + title: Title + `), + ); + + it( + 'should handle filter title', + test( + dedent` + title: + - text: Title A + when: var == "A" + - text: Title B + when: var == "B" + `, + {}, + {var: 'B'}, + ), + ); + + it( + 'should interpolate title', + test( + dedent` + title: Title {{var}} + `, + {}, + {var: 'C'}, + ), + ); + + it( + 'should interpolate conditions in title', + test( + dedent` + title: Title {% if var == "C"%} IF {% endif %} + `, + {}, + {var: 'C'}, + ), + ); + + it( + 'should interpolate filter title', + test( + dedent` + title: + - text: Title A + when: var == "A" + - text: Title B + when: var == "B" + - text: Title {{var}} + when: var == "C" + `, + {}, + {var: 'C'}, + ), + ); + + it( + 'should not interpolate title if templating is disabled', + test( + dedent` + title: Title {{var}} + `, + {resolveConditions: false}, + {var: 'C'}, + ), + ); + + it( + 'should not interpolate title if substitutions is disabled', + test( + dedent` + title: Title {{var}} + `, + {resolveSubstitutions: false}, + {var: 'C'}, + ), + ); + + it( + 'should not filter item with accepted rule', + test( + dedent` + items: + - name: Visible Item 1 + - name: Visible Item {{name}} + when: stage == 'test' + - name: Visible Item 3 + `, + {}, + {stage: 'test', name: 2}, + ), + ); + + it( + 'should filter item with declined rule', + test( + dedent` + items: + - name: Visible Item 1 + - name: Item {{name}} + when: stage == 'test' + - name: Visible Item 2 + `, + {}, + {stage: 'dev'}, + ), + ); + + it( + 'should filter hidden item', + test( + dedent` + items: + - name: Visible Item 1 + - name: Hidden Item {{name}} + hidden: true + when: stage == 'test' + - name: Visible Item 2 + when: stage == 'test' + `, + {removeHiddenItems: true}, + {stage: 'test'}, + ), + ); + + it( + 'should interpolate item name', + test( + dedent` + items: + - name: Item {{name}} + `, + {}, + {name: 'C'}, + ), + ); + + it( + 'should interpolate item href', + test( + dedent` + items: + - href: "{{file}}" + `, + {}, + {file: './file.md'}, + ), + ); + + it( + 'should interpolate nested item', + test( + dedent` + items: + - name: Parent + items: + - name: Item {{name}} + href: "{{file}}" + `, + {}, + {name: 'C', file: './file.md'}, + ), + ); + + describe('includes', () => { + it( + 'should rebase items href for includes in link mode', + test( + dedent` + items: + - name: Outer Item + include: + path: _includes/core/i-toc.yaml + mode: link + `, + {}, + {}, + { + '_includes/core/i-toc.yaml': dedent` + items: + - name: Inner Item 1 + href: item-1.md + items: + - name: Inner Sub Item 1 + href: sub-item-1.md + - name: Inner Item 2 + href: ./item-2.md + - name: Inner Item 3 + href: ./sub/item-3.md + - name: Inner Item 4 + href: sub/item-4.md + - name: Inner Item 5 + href: ../item-5.md + - name: Inner Item 6 + href: https://example.com + - name: Inner Item 7 + href: //example.com + `, + }, + ), + ); + + it( + 'should merge includes in link mode', + test( + dedent` + items: + - name: Outer Item + include: + path: _includes/core/i-toc.yaml + mode: link + `, + {}, + {}, + { + '_includes/core/i-toc.yaml': dedent` + items: + - name: Inner Item 1 + `, + }, + ), + ); + + it( + 'should merge includes in flat link mode', + test( + dedent` + items: + - include: + path: _includes/core/i-toc.yaml + mode: link + `, + {}, + {}, + { + '_includes/core/i-toc.yaml': dedent` + items: + - name: Inner Item 1 + `, + }, + ), + ); + + it( + 'should merge deep includes in link mode', + test( + dedent` + items: + - name: Outer Item + include: + path: _includes/core/i-toc.yaml + mode: link + `, + {}, + {}, + { + '_includes/core/i-toc.yaml': dedent` + items: + - name: Inner Item 1 + href: item-1.md + - name: Inner Item 2 + include: + path: ../lib/i-toc.yaml + mode: link + `, + '_includes/lib/i-toc.yaml': dedent` + items: + - name: Inner Lib Item 1 + href: item-1.md + `, + }, + ), + ); + }); +}); diff --git a/src/commands/build/core/toc/index.ts b/src/commands/build/core/toc/index.ts new file mode 100644 index 00000000..7ecab092 --- /dev/null +++ b/src/commands/build/core/toc/index.ts @@ -0,0 +1,5 @@ +export type {RawToc, RawTocItem} from './types'; +export type {LoaderContext} from './loader'; + +export {default as loader} from './loader'; +export {TocService} from './TocService'; diff --git a/src/commands/build/core/toc/loader.ts b/src/commands/build/core/toc/loader.ts new file mode 100644 index 00000000..f80228ae --- /dev/null +++ b/src/commands/build/core/toc/loader.ts @@ -0,0 +1,337 @@ +import type {TocService} from '../../services'; +import type {RawToc, RawTocItem, TextFilter} from './types'; + +import {ok} from 'node:assert'; +import {dirname, join, relative} from 'node:path'; +import evalExp from '@diplodoc/transform/lib/liquid/evaluation'; +import {liquidSnippet} from '@diplodoc/transform/lib/liquid'; + +import {Stage} from '~/constants'; +import {own} from '~/utils'; + +import {TocIncludeMode} from './types'; +import {isRelative} from './utils'; + +type Toc = { + title?: string; + label?: string; + stage?: string; + navigation?: boolean | Navigation; + items?: TocItem[]; +}; + +type TocItem = { + href: RelativePath; +}; + +export type {RawToc}; + +export type LoaderContext = { + root: AbsolutePath; + path: RelativePath; + base?: RelativePath; + vars: Hash; + options: { + ignoreStage: string[]; + resolveConditions: boolean; + resolveSubstitutions: boolean; + removeHiddenItems: boolean; + }; + toc: TocService; +}; + +export {TocIncludeMode}; + +export default async function (this: LoaderContext, toc: RawToc): Promise { + const {ignoreStage} = this.options; + + if (toc.stage && ignoreStage.length && ignoreStage.includes(toc.stage)) { + return toc; + } + + // Designed to be isolated loaders in future + toc = await resolveFields.call(this, toc); + toc = await resolveItems.call(this, toc); + toc = await templateFields.call(this, toc); + toc = await rebaseItems.call(this, toc); + toc = await processItems.call(this, toc); + + return toc; +} + +async function resolveFields(this: LoaderContext, toc: RawToc): Promise { + for (const field of ['title', 'label'] as const) { + const value = toc[field]; + if (value) { + toc[field] = getFirstValuable(value, this.vars); + } + } + + return toc; +} + +async function templateFields(this: LoaderContext, toc: RawToc): Promise { + const {resolveConditions, resolveSubstitutions} = this.options; + const interpolate = (box: Hash, field: string) => { + const value = box[field]; + if (typeof value !== 'string') { + return; + } + + box[field] = liquidSnippet(value, this.vars, this.path, { + substitutions: resolveSubstitutions, + conditions: resolveConditions, + keepNotVar: true, + withSourceMap: false, + }); + }; + + if (!resolveConditions && !resolveSubstitutions) { + return toc; + } + + for (const field of ['title', 'label', 'navigation'] as const) { + interpolate(toc, field); + } + + toc.items = await this.toc.walkItems(toc.items, (item: RawTocItem) => { + for (const field of ['name', 'href'] as const) { + interpolate(item, field); + } + + return item; + }); + + return toc; +} + +async function resolveItems(this: LoaderContext, toc: RawToc): Promise { + const {removeHiddenItems, resolveConditions} = this.options; + + if (!removeHiddenItems && !resolveConditions) { + return toc; + } + + toc.items = await this.toc.walkItems(toc.items, (item: RawTocItem) => { + let when = true; + + if (resolveConditions) { + when = + typeof item.when === 'string' ? evalExp(item.when, this.vars) : item.when !== false; + delete item.when; + } + + if (removeHiddenItems) { + when = when && !item.hidden; + delete item.hidden; + } + + return when ? item : undefined; + }); + + return toc; +} + +async function rebaseItems(this: LoaderContext, toc: RawToc): Promise { + if (!this.base) { + return toc; + } + + const rebase = (item: RawTocItem) => { + if (own(item, 'href') && isRelative(item.href)) { + const absBase = dirname(join(this.root, this.base as RelativePath)); + const absPath = join(this.root, dirname(this.path), item.href); + + item.href = relative(absBase, absPath); + } + + return item; + }; + + if (own(toc, 'href')) { + rebase(toc as RawTocItem); + } + + toc.items = await this.toc.walkItems(toc.items, rebase); + + return toc; +} + +async function processItems(this: LoaderContext, toc: RawToc): Promise { + toc.items = await this.toc.walkItems(toc.items, async (item: RawTocItem) => { + item = await this.toc.hooks.Item.promise(item, this.path); + + if (!own(item, 'include')) { + return item; + } + + if (own(item.include, 'includers')) { + const {include} = item; + + ok( + include.mode === TocIncludeMode.Link || !include.mode, + 'Invalid mode value for include with includers.', + ); + ok(include.path?.length > 0, 'Invalid value for include path.'); + ok(Array.isArray(include.includers), 'Includers should be an array.'); + + for (const includer of include.includers) { + ok(typeof includer.name === 'string', 'Includer name should be a string.'); + // ok(this.toc.hooks.Includer.for(includer.name).length, `Includer with name '${includer.name}' is not registered.`); + + this.toc.applyIncluder(this.path); + } + + item.include = { + mode: TocIncludeMode.Link, + path: join(dirname(include.path), 'toc.yaml'), + }; + } + + const {mode = TocIncludeMode.RootMerge} = item.include; + const includePath = + mode === TocIncludeMode.RootMerge + ? item.include.path + : join(dirname(this.path), item.include.path); + const includeBase = mode === TocIncludeMode.Link ? this.base || this.path : undefined; + + const include = await this.toc.load(includePath, includeBase, mode); + delete (item as RawTocItem).include; + + // Should ignore included toc with tech-preview stage. + // TODO(major): remove this + if (include.stage === Stage.TECH_PREVIEW) { + return item; + } + + if (item.name) { + item.items = (item.items || []).concat(include.items || []); + + return item; + } else { + return include.items; + } + }); + + return toc; +} + +function getFirstValuable( + items: TextFilter[] | string, + vars: Hash, + fallback?: T, +): T | undefined { + if (typeof items === 'string') { + items = [{text: items, when: true}]; + } + + if (!Array.isArray(items)) { + items = []; + } + + for (const item of items) { + let {when = true} = item; + delete item.when; + + if (typeof when === 'string') { + when = evalExp(when, vars); + } + + if (when) { + return item.text as T; + } + } + + return fallback; +} + +/** + * Replaces include fields in toc file by resolved toc. + * @param path + * @param items + * @param tocDir + * @param sourcesDir + * @param vars + * @return + * @private + */ +// async function _replaceIncludes( +// path: string, +// items: YfmToc[], +// tocDir: string, +// sourcesDir: string, +// vars: Record, +// ): Promise { +// const result: YfmToc[] = []; +// +// for (const item of items) { +// let includedInlineItems: YfmToc[] | null = null; +// +// if (item.name) { +// const tocPath = join(tocDir, 'toc.yaml'); +// +// item.name = _liquidSubstitutions(item.name, vars, tocPath); +// } +// +// try { +// await applyIncluders(path, item, vars); +// } catch (err) { +// if (err instanceof Error || err instanceof IncludersError) { +// const message = err.toString(); +// +// const file = err instanceof IncludersError ? err.path : path; +// +// logger.error(file, message); +// } +// } +// +// if (item.include) { +// const {mode = IncludeMode.ROOT_MERGE} = item.include; +// const includeTocPath = +// mode === IncludeMode.ROOT_MERGE +// ? resolve(sourcesDir, item.include.path) +// : resolve(tocDir, item.include.path); +// const includeTocDir = dirname(includeTocPath); +// +// try { +// const includeToc = load(readFileSync(includeTocPath, 'utf8')) as YfmToc; +// +// /* Save the path to exclude toc from the output directory in the next step */ +// addIncludeTocPath(includeTocPath); +// +// let includedTocItems = (item.items || []).concat(includeToc.items); +// +// /* Resolve nested toc inclusions */ +// const baseTocDir = mode === IncludeMode.LINK ? includeTocDir : tocDir; +// includedTocItems = await processTocItems( +// path, +// includedTocItems, +// baseTocDir, +// sourcesDir, +// vars, +// ); +// } catch (err) { +// const message = `Error while including toc: ${bold(includeTocPath)} to ${bold( +// join(tocDir, 'toc.yaml'), +// )}`; +// +// log.error(message); +// +// continue; +// } finally { +// delete item.include; +// } +// } else if (item.items) { +// item.items = await processTocItems(path, item.items, tocDir, sourcesDir, vars); +// } +// +// if (includedInlineItems) { +// result.push(...includedInlineItems); +// } else { +// result.push(item); +// } +// } +// +// return result; +// } +// diff --git a/src/commands/build/core/toc/types.ts b/src/commands/build/core/toc/types.ts new file mode 100644 index 00000000..93a50306 --- /dev/null +++ b/src/commands/build/core/toc/types.ts @@ -0,0 +1,47 @@ +type YfmString = string & { + __interpolable: true; +}; + +export type Filter = { + when?: string | boolean; +}; + +export type TextFilter = { + text: string; +} & Filter; + +export type WithItems = { + items: RawTocItem[]; +}; + +export type RawToc = { + title?: YfmString | TextFilter[]; + label?: YfmString | TextFilter[]; + stage?: string; + navigation?: boolean | YfmString | Navigation; +} & Partial; + +export type RawTocItem = Filter & + Partial & {hidden?: boolean} & (RawNamedTocItem | RawIncludeTocItem); + +type RawNamedTocItem = { + name: YfmString; + href: YfmString & (RelativePath | URIString); +}; + +type RawIncludeTocItem = { + name?: YfmString; + include: TocInclude; +}; + +type TocInclude = { + mode?: TocIncludeMode; + path: RelativePath; + includers?: Includer[]; +}; + +export enum TocIncludeMode { + RootMerge = 'root_merge', + Merge = 'merge', + Link = 'link', +} diff --git a/src/commands/build/core/toc/utils.ts b/src/commands/build/core/toc/utils.ts new file mode 100644 index 00000000..b9e94a22 --- /dev/null +++ b/src/commands/build/core/toc/utils.ts @@ -0,0 +1,3 @@ +export function isRelative(path: AnyPath): path is RelativePath { + return /^\.{1,2}\//.test(path) || !/^(\w{0,7}:)?\/\//.test(path); +} diff --git a/src/commands/build/errors/InsecureAccessError.ts b/src/commands/build/errors/InsecureAccessError.ts new file mode 100644 index 00000000..921fec89 --- /dev/null +++ b/src/commands/build/errors/InsecureAccessError.ts @@ -0,0 +1,19 @@ +export class InsecureAccessError extends Error { + readonly realpath: AbsolutePath; + + readonly realstack: AbsolutePath[]; + + constructor(file: AbsolutePath, stack?: AbsolutePath[]) { + const message = [ + `Requested file '${file}' is out of project scope.`, + stack && 'File resolution stack:\n\t' + stack.join('\n\t'), + ] + .filter(Boolean) + .join('\n'); + + super(message); + + this.realpath = file; + this.realstack = stack || []; + } +} diff --git a/src/commands/build/errors/index.ts b/src/commands/build/errors/index.ts new file mode 100644 index 00000000..ca6b1c2e --- /dev/null +++ b/src/commands/build/errors/index.ts @@ -0,0 +1 @@ +export {InsecureAccessError} from './InsecureAccessError'; diff --git a/src/commands/build/features/html/index.ts b/src/commands/build/features/html/index.ts new file mode 100644 index 00000000..7605918d --- /dev/null +++ b/src/commands/build/features/html/index.ts @@ -0,0 +1,34 @@ +import type {Build} from '../..'; + +import {basename, dirname, extname, join} from 'path'; +import {isExternalHref, own} from '~/utils'; +import {dedent} from 'ts-dedent'; + +export class Html { + apply(program: Build) { + program.hooks.BeforeRun.for('html').tap('Html', async (run) => { + run.toc.hooks.Resolved.tapPromise('Build', async (toc, path) => { + const copy = toc; + // const copy = JSON.parse(JSON.stringify(toc)) as Toc; + await run.toc.walkItems([copy], (item) => { + if (own(item, 'href') && !isExternalHref(item.href)) { + if (item.href.endsWith('/')) { + item.href += 'index.yaml'; + } + + const fileExtension: string = extname(item.href); + const filename: string = basename(item.href, fileExtension) + '.html'; + + item.href = join(dirname(path), dirname(item.href), filename); + } + + return item; + }); + + const file = join(run.output, dirname(path), 'toc.js'); + + await run.write(file, `window.__DATA__.data.toc = ${JSON.stringify(copy)};`); + }); + }); + } +} diff --git a/src/commands/build/features/singlepage/index.ts b/src/commands/build/features/singlepage/index.ts index be266f63..7b3c2cf6 100644 --- a/src/commands/build/features/singlepage/index.ts +++ b/src/commands/build/features/singlepage/index.ts @@ -1,7 +1,11 @@ import type {Build} from '~/commands'; import type {Command} from '~/config'; + +import {dirname, join} from 'node:path'; +import {dedent} from 'ts-dedent'; import {defined} from '~/config'; import {options} from './config'; +import {isExternalHref, own} from '~/utils'; export type SinglePageArgs = { singlePage: boolean; @@ -22,5 +26,61 @@ export class SinglePage { return config; }); + + program.hooks.BeforeRun.for('html').tap('SinglePage', (run) => { + run.toc.hooks.Resolved.tapPromise('SinglePage', async (toc, path) => { + const copy = JSON.parse(JSON.stringify(toc)) as Toc; + await run.toc.walkItems([copy], (item) => { + if (own(item, 'href') && !isExternalHref(item.href)) { + item.href = getSinglePageUrl(dirname(path), item.href); + } + + return item; + }); + + const file = join(run.output, dirname(path), 'single-page-toc.js'); + + await run.write(file, `window.__DATA__.data.toc = ${JSON.stringify(copy)};`); + }); + }); } } + +function dropExt(path: string) { + return path.replace(/\.(md|ya?ml|html)$/i, ''); +} + +function toUrl(path: string) { + // replace windows backslashes + return path.replace(/\\/g, '/').replace(/^\.\//, ''); +} + +function relativeTo(root: string, path: string) { + root = toUrl(root); + path = toUrl(path); + + if (root && path.startsWith(root + '/')) { + path = path.replace(root + '/', ''); + } + + return path; +} + +function getAnchorId(tocDir: string, path: string) { + const [pathname, hash] = path.split('#'); + const url = toUrl(dropExt(pathname)) + (hash ? '#' + hash : ''); + + // TODO: encodeURIComponent will be best option + return relativeTo(tocDir, url.replace(/\.\.\/|[/#]/g, '_')); +} + +export function getSinglePageUrl(tocDir: string, path: string) { + const prefix = toUrl(tocDir) || '.'; + const suffix = getAnchorId(tocDir, path); + + if (prefix === '.') { + return '#' + suffix; + } + + return prefix + '/single-page.html#' + suffix; +} diff --git a/src/commands/build/handler.ts b/src/commands/build/handler.ts index ab9eb08a..e4fe2876 100644 --- a/src/commands/build/handler.ts +++ b/src/commands/build/handler.ts @@ -38,6 +38,14 @@ export async function handler(run: Run) { await run.vars.load(preset); } + const tocs = (await glob('**/toc.yaml', { + cwd: run.input, + ignore: run.config.ignore, + })) as RelativePath[]; + for (const toc of tocs) { + await run.toc.load(toc); + } + await preparingPresetFiles(run); await preparingTocFiles(run); processExcludedFiles(); @@ -46,8 +54,6 @@ export async function handler(run: Run) { prepareMapFile(); } - const outputBundlePath = run.bundlePath; - if (!lintDisabled) { /* Initialize workers in advance to avoid a timeout failure due to not receiving a message from them */ await initLinterWorkers(); @@ -55,7 +61,7 @@ export async function handler(run: Run) { const processes = [ !lintDisabled && processLinter(), - !buildDisabled && processPages(outputBundlePath), + !buildDisabled && processPages(run), ].filter(Boolean) as Promise[]; await Promise.all(processes); @@ -69,6 +75,7 @@ export async function handler(run: Run) { await SearchService.release(); } } catch (error) { + console.log(error); run.logger.error(error); } finally { processLogs(run.input); diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index d444a6e4..1cce2772 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -24,6 +24,7 @@ import {SinglePage, SinglePageArgs, SinglePageConfig} from './features/singlepag import {Redirects} from './features/redirects'; import {Lint, LintArgs, LintConfig, LintRawConfig} from './features/linter'; import {Changelogs, ChangelogsArgs, ChangelogsConfig} from './features/changelogs'; +import {Html} from './features/html'; import {Search, SearchArgs, SearchConfig, SearchRawConfig} from './features/search'; import {Legacy, LegacyArgs, LegacyConfig, LegacyRawConfig} from './features/legacy'; import shell from 'shelljs'; @@ -190,6 +191,8 @@ export class Build readonly changelogs = new Changelogs(); + readonly html = new Html(); + readonly search = new Search(); readonly legacy = new Legacy(); @@ -256,8 +259,14 @@ export class Build return presets; }); + + run.toc.hooks.Resolved.tapPromise('Build', async (toc, path) => { + await run.write(join(run.output, path), run.toc.dump(toc)); + }); }); + this.hooks.AfterRun.for('md').tap('Build', async (run) => { + // TODO: save normalized config instead if (run.config[configPath]) { shell.cp(run.config[configPath], run.output); } @@ -270,6 +279,7 @@ export class Build this.linter.apply(this); this.changelogs.apply(this); this.search.apply(this); + this.html.apply(this); this.legacy.apply(this); super.apply(program); diff --git a/src/commands/build/run.ts b/src/commands/build/run.ts index a82225e4..47bf0124 100644 --- a/src/commands/build/run.ts +++ b/src/commands/build/run.ts @@ -17,6 +17,7 @@ import {LogLevel, Logger} from '~/logger'; import {BuildConfig} from '.'; import {InsecureAccessError} from './errors'; import {VarsService} from './core/vars'; +import {TocService} from './core/toc'; type FileSystem = { access: typeof access; @@ -55,6 +56,8 @@ export class Run { readonly vars: VarsService; + readonly toc: TocService; + get bundlePath() { return join(this.output, BUNDLE_FOLDER); } @@ -84,6 +87,8 @@ export class Run { ]); this.vars = new VarsService(this); + this.toc = new TocService(this); + this.legacyConfig = { rootInput: this.originalInput, input: this.input, diff --git a/src/models.ts b/src/models.ts index 16680fd5..e90c7138 100644 --- a/src/models.ts +++ b/src/models.ts @@ -108,8 +108,6 @@ export interface YfmArgv extends YfmConfig { export type DocPreset = { default: YfmPreset; [varsPreset: string]: YfmPreset; -} & { - __metadata: Record[]; }; export interface YfmTocLabel extends Filter { diff --git a/src/services/tocs.ts b/src/services/tocs.ts index c8da036f..eef3a620 100644 --- a/src/services/tocs.ts +++ b/src/services/tocs.ts @@ -1,174 +1,35 @@ -import {dirname, extname, join, normalize, parse, relative, resolve, sep} from 'path'; -import {existsSync, readFileSync, writeFileSync} from 'fs'; -import {dump, load} from 'js-yaml'; -import shell from 'shelljs'; -import walkSync from 'walk-sync'; -import {liquidSnippet} from '@diplodoc/transform/lib/liquid'; +import {dirname, join, normalize, parse, resolve, sep} from 'path'; +import {readFileSync} from 'fs'; +import {load} from 'js-yaml'; import log from '@diplodoc/transform/lib/log'; import {bold} from 'chalk'; -import {ArgvService, PresetService} from './index'; +import {ArgvService} from './index'; import {YfmToc} from '../models'; import {IncludeMode, Stage} from '../constants'; import {isExternalHref, logger} from '../utils'; -import {filterFiles, firstFilterItem, firstFilterTextItems, liquidField} from './utils'; import {IncludersError, applyIncluders} from './includers'; import {addSourcePath} from './metadata'; +import type {TocService} from '~/commands/build/core/toc'; export interface TocServiceData { - storage: Map; - tocs: Map; navigationPaths: string[]; - includedTocPaths: Set; } -const storage: TocServiceData['storage'] = new Map(); -const paths: Map = new Map(); -const tocs: TocServiceData['tocs'] = new Map(); let navigationPaths: TocServiceData['navigationPaths'] = []; -const includedTocPaths: TocServiceData['includedTocPaths'] = new Set(); const tocFileCopyMap = new Map(); -async function init(tocFilePaths: string[]) { - for (const path of tocFilePaths) { - logger.proc(path); - - await add(path); - await addNavigation(path); - } -} - -async function add(path: string) { - const { - input: inputFolderPath, - output: outputFolderPath, - outputFormat, - ignoreStage, - vars, - } = ArgvService.getConfig(); - - const pathToDir = dirname(path); - const content = readFileSync(resolve(inputFolderPath, path), 'utf8'); - const parsedToc = load(content) as YfmToc; - - // Should ignore toc with specified stage. - if (parsedToc.stage === ignoreStage) { - return; - } - - const combinedVars = { - ...PresetService.get(pathToDir), - ...vars, - }; - - if (parsedToc.title) { - parsedToc.title = firstFilterTextItems(parsedToc.title, combinedVars, { - resolveConditions: true, - }); - } - - if (typeof parsedToc.title === 'string') { - parsedToc.title = liquidField(parsedToc.title, combinedVars, path); - } - - if (typeof parsedToc.navigation === 'string') { - parsedToc.navigation = liquidField(parsedToc.navigation, combinedVars, path); - } - - if (parsedToc.label) { - parsedToc.label = firstFilterItem(parsedToc.label, combinedVars, { - resolveConditions: true, - }); - } - - parsedToc.items = await processTocItems( - path, - parsedToc.items, - join(inputFolderPath, pathToDir), - resolve(inputFolderPath), - combinedVars, - ); - - /* Store parsed toc for .md output format */ - storage.set(path, parsedToc); - - /* save toc in distinct set, storage includes .md files too */ - tocs.set(path, parsedToc); - - if (outputFormat === 'md') { - /* Should copy resolved and filtered toc to output folder */ - const outputPath = resolve(outputFolderPath, path); - const outputToc = dump(parsedToc); - shell.mkdir('-p', dirname(outputPath)); - writeFileSync(outputPath, outputToc); - } -} - -// To collect root toc.yaml we need to move from root into deep -async function addNavigation(path: string) { - const parsedToc = getForPath(path)[1]; - - if (!parsedToc) { - return; - } - - const pathToDir = dirname(path); - prepareNavigationPaths(parsedToc, pathToDir); -} - -async function processTocItems( - path: string, - items: YfmToc[], - tocDir: string, - sourcesDir: string, - vars: Record, -) { - const {resolveConditions, removeHiddenTocItems} = ArgvService.getConfig(); - - let preparedItems = items; - - /* Should remove all links with false expressions */ - if (resolveConditions || removeHiddenTocItems) { - try { - preparedItems = filterFiles(items, 'items', vars, { - resolveConditions, - removeHiddenTocItems, - }); - } catch (error) { - log.error(`Error while filtering toc file: ${path}. Error message: ${error}`); - } - } - - /* Should resolve all includes */ - return _replaceIncludes(path, preparedItems, tocDir, sourcesDir, vars); +let toc: TocService; +async function init(service: TocService) { + toc = service; } function getForPath(path: string): [string | null, YfmToc | null] { - let tocPath = paths.get(path) || null; - let tocData = storage.get(path) || null; - - // TODO: normalize paths on add - if (!tocData && path.endsWith('.md')) { - path = path.replace('.md', ''); - tocPath = paths.get(path) || null; - tocData = storage.get(path) || null; - } - - if (!tocData && path.endsWith('index.yaml')) { - path = path.replace('index.yaml', ''); - tocPath = paths.get(path) || null; - tocData = storage.get(path) || null; - } - - return [tocPath, tocData]; + return toc.for(path); } function getNavigationPaths(): string[] { - return [...navigationPaths]; -} - -function getIncludedTocPaths(): string[] { - return [...includedTocPaths]; + return [...toc.entries]; } function prepareNavigationPaths(parsedToc: YfmToc, dirPath: string) { @@ -185,8 +46,6 @@ function prepareNavigationPaths(parsedToc: YfmToc, dirPath: string) { if (item.href && !isExternalHref(item.href)) { const href = join(pathToDir, item.href); - storage.set(href, parsedToc); - paths.set(href, dirPath); const navigationPath = _normalizeHref(href); navigationPaths.push(navigationPath); @@ -219,98 +78,6 @@ function _normalizeHref(href: string): string { return `${preparedHref}.md`; } -/** - * Copies all files of include toc to original dir. - * @param tocPath - * @param destDir - * @return - * @private - */ -function _copyTocDir(tocPath: string, destDir: string) { - const {input: inputFolderPath} = ArgvService.getConfig(); - - const {dir: tocDir} = parse(tocPath); - const files: string[] = walkSync(tocDir, { - globs: ['**/*.*'], - ignore: ['**/toc.yaml'], - directories: false, - }); - - files.forEach((relPath) => { - const from = resolve(tocDir, relPath); - const to = resolve(destDir, relPath); - const fileExtension = extname(relPath); - const isMdFile = fileExtension === '.md'; - - shell.mkdir('-p', parse(to).dir); - - if (isMdFile) { - const fileContent = readFileSync(from, 'utf8'); - const sourcePath = relative(inputFolderPath, from); - const updatedFileContent = addSourcePath(fileContent, sourcePath); - - writeFileSync(to, updatedFileContent); - } else { - shell.cp(from, to); - } - - const relFrom = relative(inputFolderPath, from); - const relTo = relative(inputFolderPath, to); - tocFileCopyMap.set(relTo, relFrom); - }); -} - -/** - * Make hrefs relative to the main toc in the included toc. - * @param items - * @param includeTocDir - * @param tocDir - * @return - * @private - */ -function _replaceIncludesHrefs(items: YfmToc[], includeTocDir: string, tocDir: string): YfmToc[] { - return items.reduce((acc, tocItem) => { - if (tocItem.href) { - tocItem.href = relative(tocDir, resolve(includeTocDir, tocItem.href)); - } - - if (tocItem.items) { - tocItem.items = _replaceIncludesHrefs(tocItem.items, includeTocDir, tocDir); - } - - if (tocItem.include) { - const {path} = tocItem.include; - tocItem.include.path = relative(tocDir, resolve(includeTocDir, path)); - } - - return acc.concat(tocItem); - }, [] as YfmToc[]); -} - -/** - * Liquid substitutions in toc file. - * @param input - * @param vars - * @param path - * @return {string} - * @private - */ -function _liquidSubstitutions(input: string, vars: Record, path: string) { - const {outputFormat, applyPresets} = ArgvService.getConfig(); - if (outputFormat === 'md' && !applyPresets) { - return input; - } - - return liquidSnippet(input, vars, path, { - conditions: false, - substitutions: true, - }); -} - -function addIncludeTocPath(includeTocPath: string) { - includedTocPaths.add(includeTocPath); -} - /** * Replaces include fields in toc file by resolved toc. * @param path @@ -426,20 +193,7 @@ async function _replaceIncludes( } function getTocDir(pagePath: string): string { - const {input: inputFolderPath} = ArgvService.getConfig(); - - const tocDir = dirname(pagePath); - const tocPath = resolve(tocDir, 'toc.yaml'); - - if (!tocDir.includes(inputFolderPath)) { - throw new Error('Error while finding toc dir'); - } - - if (existsSync(tocPath)) { - return tocDir; - } - - return getTocDir(tocDir); + return toc.for(pagePath)[0]; } function setNavigationPaths(paths: TocServiceData['navigationPaths']) { @@ -450,19 +204,11 @@ function getCopyFileMap() { return tocFileCopyMap; } -function getAllTocs() { - return [...tocs.entries()]; -} - export default { init, - add, - process, getForPath, getNavigationPaths, getTocDir, - getIncludedTocPaths, setNavigationPaths, getCopyFileMap, - getAllTocs, }; diff --git a/src/services/utils.ts b/src/services/utils.ts index 16c5d30d..7c3397c2 100644 --- a/src/services/utils.ts +++ b/src/services/utils.ts @@ -93,26 +93,6 @@ export function firstFilterTextItems( return filteredItems[0] || ''; } -export function firstFilterItem( - itemOrItems: T | T[], - vars: Record, - options?: FilterFilesOptions, -) { - const items = Array.isArray(itemOrItems) ? itemOrItems : [itemOrItems]; - - const filteredItems = items.reduce((result: T[], item) => { - const useItem = shouldProcessItem(item, vars, options); - - if (useItem) { - result.push(item); - } - - return result; - }, []); - - return filteredItems[0]; -} - function shouldProcessItem( item: T, vars: Record, diff --git a/src/steps/processExcludedFiles.ts b/src/steps/processExcludedFiles.ts index 20fb99f2..e855f10f 100644 --- a/src/steps/processExcludedFiles.ts +++ b/src/steps/processExcludedFiles.ts @@ -1,4 +1,4 @@ -import {relative, resolve} from 'path'; +import {resolve} from 'path'; import walkSync from 'walk-sync'; import shell from 'shelljs'; @@ -10,7 +10,7 @@ import {convertBackSlashToSlash} from '../utils'; * @return {void} */ export function processExcludedFiles() { - const {input: inputFolderPath, output: outputFolderPath, ignore} = ArgvService.getConfig(); + const {input: inputFolderPath, ignore} = ArgvService.getConfig(); const allContentFiles: string[] = walkSync(inputFolderPath, { directories: false, @@ -28,15 +28,4 @@ export function processExcludedFiles() { if (excludedFiles.length) { shell.rm('-f', excludedFiles); } - - const includedTocPaths = TocService.getIncludedTocPaths().map((filePath) => { - const relativeTocPath = relative(inputFolderPath, filePath); - const destTocPath = resolve(outputFolderPath, relativeTocPath); - - return convertBackSlashToSlash(destTocPath); - }); - - if (includedTocPaths.length) { - shell.rm('-rf', includedTocPaths); - } } diff --git a/src/steps/processPages.ts b/src/steps/processPages.ts index 9ce664b6..144ffc33 100644 --- a/src/steps/processPages.ts +++ b/src/steps/processPages.ts @@ -1,12 +1,11 @@ import type {DocInnerProps} from '@diplodoc/client'; import {basename, dirname, extname, join, resolve} from 'path'; -import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'fs'; +import {existsSync, readFileSync, writeFileSync} from 'fs'; import log from '@diplodoc/transform/lib/log'; import {asyncify, mapLimit} from 'async'; import {bold} from 'chalk'; import {dump, load} from 'js-yaml'; import shell from 'shelljs'; -import dedent from 'ts-dedent'; import { Lang, @@ -27,21 +26,16 @@ import {resolveMd2HTML, resolveMd2Md} from '../resolvers'; import {ArgvService, LeadingService, PluginService, SearchService, TocService} from '../services'; import {generateStaticMarkup} from '~/pages/document'; import {generateStaticRedirect} from '~/pages/redirect'; -import { - getDepth, - joinSinglePageResults, - logger, - transformToc, - transformTocForSinglePage, -} from '../utils'; +import {getDepth, joinSinglePageResults, logger} from '../utils'; import {getVCSConnector} from '../vcs-connector'; import {VCSConnector} from '../vcs-connector/connector-models'; +import {Run} from '~/commands/build'; const singlePageResults: Record = {}; const singlePagePaths: Record> = {}; // Processes files of documentation (like index.yaml, *.md) -export async function processPages(outputBundlePath: string): Promise { +export async function processPages(run: Run): Promise { const { input: inputFolderPath, output: outputFolderPath, @@ -65,13 +59,16 @@ export async function processPages(outputBundlePath: string): Promise { inputFolderPath, outputFolderPath, outputFormat, - outputBundlePath, + run.bundlePath, ); - logger.proc(pathToFile); + console.log(pathToFile, pathData); + run.logger.proc(pathToFile, pathData); const metaDataOptions = getMetaDataOptions(pathData, vcsConnector); + return; + await preparingPagesByOutputFormat( pathData, metaDataOptions, @@ -83,15 +80,10 @@ export async function processPages(outputBundlePath: string): Promise { if (singlePage) { await saveSinglePages(); - - if (outputFormat === 'html') { - await saveTocData(transformTocForSinglePage, 'single-page-toc'); - } } if (outputFormat === 'html') { saveRedirectPage(outputFolderPath); - await saveTocData(transformToc, 'toc'); } } @@ -110,7 +102,7 @@ function getPathData( const outputFileName = `${fileBaseName}.${outputFormat}`; const outputPath = resolve(outputDir, outputFileName); const resolvedPathToFile = resolve(inputFolderPath, pathToFile); - const outputTocDir = TocService.getTocDir(resolvedPathToFile); + const outputTocDir = TocService.getTocDir(pathToFile); const pathData: PathData = { pathToFile, @@ -130,23 +122,6 @@ function getPathData( return pathData; } -async function saveTocData(transform: (toc: YfmToc, tocDir: string) => YfmToc, filename: string) { - const tocs = TocService.getAllTocs(); - const {output} = ArgvService.getConfig(); - - for (const [path, toc] of tocs) { - const outputPath = join(output, dirname(path), filename + '.js'); - mkdirSync(dirname(outputPath), {recursive: true}); - writeFileSync( - outputPath, - dedent` - window.__DATA__.data.toc = ${JSON.stringify(transform(toc, dirname(path)))}; - `, - 'utf8', - ); - } -} - async function saveSinglePages() { const { input: inputFolderPath, @@ -168,7 +143,6 @@ async function saveSinglePages() { .replace(/^\/?/, ''); const singlePageBody = joinSinglePageResults( singlePageResults[tocDir], - inputFolderPath, relativeTocDir, ); diff --git a/src/steps/processServiceFiles.ts b/src/steps/processServiceFiles.ts index df1a3bfc..53b3764c 100644 --- a/src/steps/processServiceFiles.ts +++ b/src/steps/processServiceFiles.ts @@ -1,30 +1,10 @@ -import walkSync from 'walk-sync'; -import log from '@diplodoc/transform/lib/log'; - -import {ArgvService, PresetService, TocService} from '../services'; +import {PresetService, TocService} from '../services'; import {Run} from '~/commands/build'; -const getFilePathsByGlobals = (globs: string[]): string[] => { - const {input, ignore = []} = ArgvService.getConfig(); - - return walkSync(input, { - directories: false, - includeBasePath: false, - globs, - ignore, - }); -}; - export async function preparingPresetFiles(run: Run) { PresetService.init(run.vars); } -export async function preparingTocFiles(): Promise { - try { - const tocFilePaths = getFilePathsByGlobals(['**/toc.yaml']); - await TocService.init(tocFilePaths); - } catch (error) { - log.error(`Preparing toc.yaml files failed. Error: ${error}`); - throw error; - } +export async function preparingTocFiles(run: Run): Promise { + TocService.init(run.toc); } diff --git a/src/utils/index.ts b/src/utils/index.ts index a495e0b1..fb2db113 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -3,6 +3,5 @@ export * from './logger'; export * from './singlePage'; export * from './url'; export * from './path'; -export * from './toc'; export * from './presets'; export * from './file'; diff --git a/src/utils/singlePage.ts b/src/utils/singlePage.ts index c9794b1a..dc211546 100644 --- a/src/utils/singlePage.ts +++ b/src/utils/singlePage.ts @@ -4,6 +4,8 @@ import HTMLElement from 'node-html-parser/dist/nodes/html'; import {parse} from 'node-html-parser'; import {dirname, join} from 'path'; +import {getSinglePageUrl} from '~/commands/build/features/singlepage'; + interface PreprocessSinglePageOptions { path: string; tocDir: string; @@ -21,17 +23,6 @@ function toUrl(path: string) { return path.replace(/\\/g, '/').replace(/^\.\//, ''); } -function relativeTo(root: string, path: string) { - root = toUrl(root); - path = toUrl(path); - - if (root && path.startsWith(root + '/')) { - path = path.replace(root + '/', ''); - } - - return path; -} - function all(root: HTMLElement, selector: string): HTMLElement[] { return Array.from(root.querySelectorAll(selector)); } @@ -122,28 +113,8 @@ export function addMainTitle(root: HTMLElement, options: PreprocessSinglePageOpt } } -function getAnchorId(tocDir: string, path: string) { - const [pathname, hash] = path.split('#'); - const url = toUrl(dropExt(pathname)) + (hash ? '#' + hash : ''); - - // TODO: encodeURIComponent will be best option - return relativeTo(tocDir, url.replace(/\.\.\/|[/#]/g, '_')); -} - -export function getSinglePageUrl(tocDir: string, path: string) { - const prefix = toUrl(tocDir) || '.'; - const suffix = getAnchorId(tocDir, path); - - if (prefix === '.') { - return '#' + suffix; - } - - return prefix + '/single-page.html#' + suffix; -} - export function joinSinglePageResults( singlePageResults: SinglePageResult[], - root: string, tocDir: string, ): string { const delimeter = `
`; diff --git a/src/utils/toc.ts b/src/utils/toc.ts deleted file mode 100644 index 9f903039..00000000 --- a/src/utils/toc.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type {YfmToc} from '~/models'; - -import {basename, dirname, extname, join} from 'node:path'; - -import {filterFiles} from '../services/utils'; -import {isExternalHref} from './url'; -import {getSinglePageUrl} from './singlePage'; - -function baseTransformToc(toc: YfmToc, transformItemHref: (href: string) => string): YfmToc { - const localToc: YfmToc = JSON.parse(JSON.stringify(toc)); - - if (localToc.items) { - localToc.items = filterFiles( - localToc.items, - 'items', - {}, - { - removeHiddenTocItems: true, - }, - ); - } - - const queue = [localToc]; - - while (queue.length) { - const item = queue.shift(); - - if (!item) { - continue; - } - - const {items, href} = item; - - if (items) { - queue.push(...items); - } - - if (href) { - item.href = transformItemHref(href); - } - } - - return localToc; -} - -export function transformToc(toc: YfmToc, tocDir: string) { - return baseTransformToc(toc, (href: string) => { - if (isExternalHref(href)) { - return href; - } - - if (href.endsWith('/')) { - href += 'index.yaml'; - } - - const fileExtension: string = extname(href); - const filename: string = basename(href, fileExtension) + '.html'; - - return join(tocDir, dirname(href), filename); - }); -} - -export function transformTocForSinglePage(toc: YfmToc, tocDir: string) { - return baseTransformToc(toc, (href: string) => { - if (isExternalHref(href)) { - return href; - } - - return getSinglePageUrl(tocDir, href); - }); -}