From b8c375fa92ee047ae4c782d4dd2d5012d90f44bc Mon Sep 17 00:00:00 2001 From: spring-raining Date: Mon, 13 Jan 2025 03:51:18 +0900 Subject: [PATCH] Pass the webbook tests --- src/config/resolve.ts | 32 +++++- src/output/webbook.ts | 31 +++-- src/processor/compile.ts | 36 +++--- src/processor/theme.ts | 18 --- tests/__snapshots__/webbook.test.ts.snap | 110 ++++++------------ tests/webbook.test.ts | 137 ++++++++++++----------- 6 files changed, 170 insertions(+), 194 deletions(-) diff --git a/src/config/resolve.ts b/src/config/resolve.ts index f1311ed2..b07135fa 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -2,6 +2,7 @@ import { Metadata, StringifyMarkdownOptions, VFM } from '@vivliostyle/vfm'; import { lookup as mime } from 'mime-types'; import fs from 'node:fs'; import { pathToFileURL } from 'node:url'; +import npa from 'npm-package-arg'; import { Processor } from 'unified'; import upath from 'upath'; import { ResolvedConfig as ResolvedViteConfig, UserConfig } from 'vite'; @@ -28,7 +29,6 @@ import { import { CONTAINER_IMAGE, CONTAINER_LOCAL_HOSTNAME } from '../container.js'; import { Logger } from '../logger.js'; import { readMarkdownMetadata } from '../processor/markdown.js'; -import { parsePackageName } from '../processor/theme.js'; import { cwd as defaultCwd, getEpubRootDir, @@ -155,6 +155,7 @@ export interface EpubOpfEntryConfig { export interface WebBookEntryConfig { type: 'webbook'; webbookEntryUrl: string; + webbookPath: string | undefined; } export type ViewerInputConfig = @@ -295,6 +296,31 @@ export function isWebPubConfig( return config.viewerInput.type === 'webpub'; } +export function isWebbookConfig( + config: ResolvedTaskConfig, +): config is ResolvedTaskConfig & { + viewerInput: WebBookEntryConfig; +} { + return config.viewerInput.type === 'webbook'; +} + +export function parsePackageName( + specifier: string, + cwd: string, +): npa.Result | null { + try { + let result = npa(specifier, cwd); + // #373: Relative path specifiers may be assumed as shorthand of hosted git + // (ex: foo/bar -> github:foo/bar) + if (result.type === 'git' && result.saveSpec?.startsWith('github:')) { + result = npa(`file:${specifier}`, cwd); + } + return result; + } catch (error) { + return null; + } +} + // parse theme locator export function parseTheme({ theme, @@ -817,6 +843,7 @@ function resolveSingleInputConfig({ }; } else if (inputFormat === 'webbook') { let webbookEntryUrl: string; + let webbookPath: string | undefined; if (isValidUri(sourcePath)) { const url = new URL(sourcePath); // Ensures trailing slash or explicit HTML extensions @@ -832,8 +859,9 @@ function resolveSingleInputConfig({ const rootFileUrl = pathToFileURL(workspaceDir).href; const urlPath = pathToFileURL(sourcePath).href.slice(rootFileUrl.length); webbookEntryUrl = `${base}${urlPath}`; + webbookPath = sourcePath; } - viewerInput = { type: 'webbook', webbookEntryUrl }; + viewerInput = { type: 'webbook', webbookEntryUrl, webbookPath }; } else if (inputFormat === 'pub-manifest') { viewerInput = { type: 'webpub', diff --git a/src/output/webbook.ts b/src/output/webbook.ts index 9341e174..fb3e3b04 100644 --- a/src/output/webbook.ts +++ b/src/output/webbook.ts @@ -1,19 +1,22 @@ import { copy, remove } from 'fs-extra/esm'; import { lookup as mime } from 'mime-types'; import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { glob } from 'tinyglobby'; import upath from 'upath'; import { EpubOutput, + isWebbookConfig, ResolvedTaskConfig, + WebBookEntryConfig, WebPublicationOutput, } from '../config/resolve.js'; import { ArticleEntryConfig } from '../config/schema.js'; import { MANIFEST_FILENAME } from '../const.js'; import { Logger } from '../logger.js'; import { - getDefaultIgnorePatterns, getIgnoreAssetPatterns, + getIgnoreThemeExamplePatterns, globAssetFiles, } from '../processor/compile.js'; import { @@ -192,15 +195,18 @@ export function writePublicationManifest( } export async function retrieveWebbookEntry({ - webbookEntryUrl, + viewerInput, outputDir, }: { - webbookEntryUrl: string; + viewerInput: WebBookEntryConfig; outputDir: string; }): Promise<{ entryHtmlFile: string; manifest: PublicationManifest | undefined; }> { + const webbookEntryUrl = viewerInput.webbookPath + ? pathToFileURL(viewerInput.webbookPath).href + : viewerInput.webbookEntryUrl; if (/^https?:/i.test(webbookEntryUrl)) { Logger.logUpdate('Fetching remote contents'); } @@ -209,6 +215,10 @@ export async function retrieveWebbookEntry({ src: webbookEntryUrl, resourceLoader, }); + const entryHtml = viewerInput.webbookPath + ? upath.basename(viewerInput.webbookPath) + : decodeURI(dom.window.location.pathname); + const { manifest, manifestUrl } = (await fetchLinkedPublicationManifest({ dom, @@ -295,12 +305,7 @@ export async function retrieveWebbookEntry({ ); return { - entryHtmlFile: upath.join( - outputDir, - upath.extname(webbookEntryUrl) - ? upath.basename(webbookEntryUrl) - : 'index.html', - ), + entryHtmlFile: upath.join(outputDir, entryHtml), manifest, }; } @@ -409,10 +414,12 @@ export async function copyWebPublicationAssets({ outputs, entries, }), - ...getDefaultIgnorePatterns({ + ...getIgnoreThemeExamplePatterns({ cwd: input, themesDir, }), + // Ignore node_modules in the root directory + 'node_modules/**', // only include dotfiles starting with `.vs-` '**/.!(vs-*)/**', ], @@ -552,9 +559,9 @@ export async function buildWebPublication({ ); } } - } else if (config.viewerInput.type === 'webbook') { + } else if (isWebbookConfig(config)) { const ret = await retrieveWebbookEntry({ - webbookEntryUrl: config.viewerInput.webbookEntryUrl, + viewerInput: config.viewerInput, outputDir, }); entryHtmlFile = ret.entryHtmlFile; diff --git a/src/processor/compile.ts b/src/processor/compile.ts index 3e6ac3d3..2a5b91a3 100644 --- a/src/processor/compile.ts +++ b/src/processor/compile.ts @@ -83,10 +83,16 @@ export async function cleanupWorkspace({ entryContextDir, workspaceDir, themesDir, + entries, }: ResolvedTaskConfig) { if ( pathEquals(workspaceDir, entryContextDir) || - pathContains(workspaceDir, entryContextDir) + pathContains(workspaceDir, entryContextDir) || + entries.some( + (entry) => + entry.source?.type === 'file' && + pathContains(workspaceDir, entry.source.pathname), + ) ) { return; } @@ -383,24 +389,18 @@ export async function compile( } } -export function getDefaultIgnorePatterns({ +export function getIgnoreThemeExamplePatterns({ themesDir, cwd, }: Pick & { cwd: string; }): string[] { - const ignorePatterns = [ - // ignore node_modules directory - '**/node_modules', - ]; - if (pathContains(cwd, themesDir)) { - // ignore example files of theme packages - ignorePatterns.push( - `${upath.relative(cwd, themesDir)}/packages/*/example`, - `${upath.relative(cwd, themesDir)}/packages/*/*/example`, - ); - } - return ignorePatterns; + return pathContains(cwd, themesDir) + ? [ + `${upath.relative(cwd, themesDir)}/node_modules/*/example`, + `${upath.relative(cwd, themesDir)}/node_modules/*/*/example`, + ] + : []; } export function getIgnoreAssetPatterns({ @@ -445,16 +445,18 @@ function getAssetMatcherSettings({ ...excludes, ...getIgnoreAssetPatterns({ outputs, entries, cwd }), ]; - const weakIgnorePatterns = getDefaultIgnorePatterns({ themesDir, cwd }); Logger.debug('globAssetFiles > ignorePatterns', ignorePatterns); - Logger.debug('globAssetFiles > weakIgnorePatterns', weakIgnorePatterns); return [ // Step 1: Glob files with an extension in `fileExtension` // Ignore files in node_modules directory, theme example files and files matched `excludes` { patterns: fileExtensions.map((ext) => `**/*.${ext}`), - ignore: [...ignorePatterns, ...weakIgnorePatterns], + ignore: [ + '**/node_modules/**', + ...ignorePatterns, + ...getIgnoreThemeExamplePatterns({ themesDir, cwd }), + ], }, // Step 2: Glob files matched with `includes` // Ignore only files matched `excludes` diff --git a/src/processor/theme.ts b/src/processor/theme.ts index 9aba5996..a58e5be5 100644 --- a/src/processor/theme.ts +++ b/src/processor/theme.ts @@ -1,6 +1,5 @@ import Arborist from '@npmcli/arborist'; import fs from 'node:fs'; -import npa from 'npm-package-arg'; import { ResolvedTaskConfig } from '../config/resolve.js'; import { DetailError } from '../util.js'; @@ -60,20 +59,3 @@ export async function installThemeDependencies({ ); } } - -export function parsePackageName( - specifier: string, - cwd: string, -): npa.Result | null { - try { - let result = npa(specifier, cwd); - // #373: Relative path specifiers may be assumed as shorthand of hosted git - // (ex: foo/bar -> github:foo/bar) - if (result.type === 'git' && result.saveSpec?.startsWith('github:')) { - result = npa(`file:${specifier}`, cwd); - } - return result; - } catch (error) { - return null; - } -} diff --git a/tests/__snapshots__/webbook.test.ts.snap b/tests/__snapshots__/webbook.test.ts.snap index 3ed30599..bfe14961 100644 --- a/tests/__snapshots__/webbook.test.ts.snap +++ b/tests/__snapshots__/webbook.test.ts.snap @@ -4,47 +4,38 @@ exports[`copy webpub assets properly 1`] = ` "/ └─ work/ └─ input/ - ├─ doc.html - ├─ doc.md - ├─ node_modules/ - │ └─ pkgA/ - │ ├─ a.css - │ └─ a.html - ├─ output1/ + ├─ .vivliostyle/ │ ├─ doc.html │ ├─ publication.json │ └─ themes/ - │ └─ packages/ + │ └─ node_modules/ │ ├─ @org/ │ │ └─ themeB/ + │ │ ├─ example/ + │ │ │ └─ a.css + │ │ ├─ package.json │ │ └─ theme.css │ └─ themeA/ + │ ├─ example/ + │ │ └─ a.css + │ ├─ package.json │ └─ theme.css - ├─ output2/ + ├─ doc.md + ├─ node_modules/ + │ └─ pkgA/ + │ ├─ a.css + │ └─ a.html + ├─ output/ │ ├─ doc.html │ ├─ publication.json │ └─ themes/ - │ └─ packages/ + │ └─ node_modules/ │ ├─ @org/ │ │ └─ themeB/ │ │ └─ theme.css │ └─ themeA/ │ └─ theme.css ├─ package.json - ├─ publication.json - ├─ themes/ - │ └─ packages/ - │ ├─ @org/ - │ │ └─ themeB/ - │ │ ├─ example/ - │ │ │ └─ a.css - │ │ ├─ package.json - │ │ └─ theme.css - │ └─ themeA/ - │ ├─ example/ - │ │ └─ a.css - │ ├─ package.json - │ └─ theme.css └─ vivliostyle.config.json" `; @@ -253,30 +244,30 @@ Object { } `; -exports[`generate webpub from vivliostyle.config.js > cover.html 1`] = ` -" - - - \\"Cover - - -" -`; - exports[`generate webpub from vivliostyle.config.js 1`] = ` "/ └─ work/ ├─ input/ - │ ├─ cover.html + │ ├─ .vivliostyle/ + │ │ ├─ cover.html + │ │ ├─ cover.png + │ │ ├─ doc/ + │ │ │ ├─ escape check%.html + │ │ │ ├─ one.html + │ │ │ └─ two.html + │ │ ├─ index.html + │ │ ├─ publication.json + │ │ ├─ style sheet.css + │ │ └─ themes/ + │ │ └─ node_modules/ + │ │ └─ mytheme/ + │ │ ├─ %style%.css + │ │ └─ package.json │ ├─ cover.png │ ├─ doc/ - │ │ ├─ escape check%.html │ │ ├─ escape check%.md - │ │ ├─ one.html │ │ ├─ one.md - │ │ ├─ two.html │ │ └─ two.md - │ ├─ index.html │ ├─ output/ │ │ ├─ cover.html │ │ ├─ cover.png @@ -288,16 +279,10 @@ exports[`generate webpub from vivliostyle.config.js 1`] = ` │ │ ├─ publication.json │ │ ├─ style sheet.css │ │ └─ themes/ - │ │ └─ packages/ + │ │ └─ node_modules/ │ │ └─ mytheme/ │ │ └─ %style%.css - │ ├─ publication.json │ ├─ style sheet.css - │ ├─ themes/ - │ │ └─ packages/ - │ │ └─ mytheme/ - │ │ ├─ %style%.css - │ │ └─ package.json │ ├─ tmpl/ │ │ ├─ cover-template.html │ │ └─ toc-template.html @@ -316,7 +301,7 @@ exports[`generate webpub from vivliostyle.config.js 1`] = ` ├─ publication.json ├─ style sheet.css └─ themes/ - └─ packages/ + └─ node_modules/ └─ mytheme/ └─ %style%.css" `; @@ -361,41 +346,12 @@ Object { "url": "cover.png", }, "style%20sheet.css", - "themes/packages/mytheme/%25style%25.css", + "themes/node_modules/mytheme/%25style%25.css", ], "type": "Book", } `; -exports[`generate webpub from vivliostyle.config.js 3`] = ` -" - - - - - - - - - -" -`; - exports[`generate webpub with complex copyAsset settings 1`] = ` "/ └─ work/ diff --git a/tests/webbook.test.ts b/tests/webbook.test.ts index 2b8d5cb7..c14d3ad5 100644 --- a/tests/webbook.test.ts +++ b/tests/webbook.test.ts @@ -4,25 +4,24 @@ import './mocks/vivliostyle__jsdom.js'; import { vol } from 'memfs'; import { format } from 'prettier'; import { beforeEach, expect, it, vi } from 'vitest'; -import { build } from '../src/index.js'; -import { VivliostyleConfigSchema } from '../src/input/schema.js'; -import { toTree } from './command-util.js'; +import { VivliostyleConfigSchema } from '../src/config/schema.js'; +import { runCommand, toTree } from './command-util.js'; -vi.mock('../src/processor/theme.ts', async (importOriginal) => ({ - ...(await importOriginal()), - checkThemeInstallationNecessity: () => Promise.resolve(false), - installThemeDependencies: () => Promise.resolve(), +const mockedThemeModule = vi.hoisted(() => ({ + checkThemeInstallationNecessity: vi.fn(), + installThemeDependencies: vi.fn(), })); +vi.mock('../src/processor/theme', () => mockedThemeModule); + beforeEach(() => vol.reset()); it('generate webpub from single markdown file', async () => { vol.fromJSON({ '/work/input/foo.md': '# Hi', }); - await build({ - input: '/work/input/foo.md', - targets: [{ path: '/work/output', format: 'webpub' }], + await runCommand(['build', './input/foo.md', '-o', 'output'], { + cwd: '/work', }); expect(toTree(vol)).toMatchSnapshot(); @@ -33,6 +32,20 @@ it('generate webpub from single markdown file', async () => { }); it('generate webpub from vivliostyle.config.js', async () => { + const themeDir = { + 'mytheme/package.json': JSON.stringify({ + name: 'mytheme', + main: './%style%.css', + }), + 'mytheme/%style%.css': '/* style */', + }; + mockedThemeModule.checkThemeInstallationNecessity.mockImplementationOnce( + () => true, + ); + mockedThemeModule.installThemeDependencies.mockImplementationOnce(() => { + vol.fromJSON(themeDir, '/work/input/.vivliostyle/themes/node_modules'); + }); + const config: VivliostyleConfigSchema = { entry: [ { rel: 'cover', path: 'tmpl/cover-template.html', output: 'cover.html' }, @@ -58,40 +71,21 @@ it('generate webpub from vivliostyle.config.js', async () => { '', '/work/input/tmpl/toc-template.html': '', - '/work/mytheme/package.json': JSON.stringify({ - name: 'mytheme', - main: './%style%.css', - }), - '/work/mytheme/%style%.css': '/* style */', - '/work/input/themes/packages/mytheme/package.json': JSON.stringify({ - name: 'mytheme', - main: './%style%.css', - }), - '/work/input/themes/packages/mytheme/%style%.css': '/* style */', - }); - await build({ - configPath: '/work/input/vivliostyle.config.json', }); + vol.fromJSON(themeDir, '/work'); + await runCommand(['build'], { cwd: '/work/input' }); expect(toTree(vol)).toMatchSnapshot(); const file = vol.toJSON(); - const manifest = JSON.parse(file['/work/output/publication.json'] as string); + const manifest = JSON.parse(file['/work/output/publication.json']!); delete manifest.dateModified; expect(manifest).toMatchSnapshot(); - const toc = file['/work/output/index.html']; - expect(await format(toc as string, { parser: 'html' })).toMatchSnapshot(); - const cover = file['/work/output/cover.html']; - expect(await format(cover as string, { parser: 'html' })).toMatchSnapshot( - 'cover.html', - ); const manifest2 = JSON.parse( file['/work/input/output/publication.json'] as string, ); delete manifest2.dateModified; expect(manifest2).toEqual(manifest); - const toc2 = file['/work/input/output/index.html']; - expect(toc2).toEqual(toc); }); it('generate webpub from a plain HTML', async () => { @@ -109,18 +103,17 @@ it('generate webpub from a plain HTML', async () => { `, '/work/input/style.css': '', }); - await build({ - input: '/work/input/webbook.html', - targets: [{ path: '/work/output', format: 'webpub' }], + await runCommand(['build', 'input/webbook.html', '-o', 'output'], { + cwd: '/work', }); expect(toTree(vol)).toMatchSnapshot(); const file = vol.toJSON(); - const manifest = JSON.parse(file['/work/output/publication.json'] as string); + const manifest = JSON.parse(file['/work/output/publication.json']!); delete manifest.dateModified; expect(manifest).toMatchSnapshot(); - const entry = file['/work/output/webbook.html']; - expect(await format(entry as string, { parser: 'html' })).toMatchSnapshot(); + const entry = file['/work/output/webbook.html']!; + expect(await format(entry, { parser: 'html' })).toMatchSnapshot(); }); it('generate webpub from a single-document publication', async () => { @@ -176,15 +169,14 @@ it('generate webpub from a single-document publication', async () => { '/work/input/assets/日本語.svg': '', '/work/input/assets/subdir.css': '', }); - await build({ - input: '/work/input/webbook.html', - targets: [{ path: '/work/output', format: 'webpub' }], + await runCommand(['build', 'input/webbook.html', '-o', 'output'], { + cwd: '/work', }); expect(toTree(vol)).toMatchSnapshot(); const file = vol.toJSON(); - const entry = file['/work/output/webbook.html']; - expect(await format(entry as string, { parser: 'html' })).toMatchSnapshot(); + const entry = file['/work/output/webbook.html']!; + expect(await format(entry, { parser: 'html' })).toMatchSnapshot(); expect(file['/work/output/escape check%.html']).toBe( file['/work/input/escape check%.html'], ); @@ -223,10 +215,10 @@ it('generate webpub from remote HTML documents with publication manifest', async }), '/assets/日本語.png': 'image', }); - await build({ - input: 'https://example.com/remote/dir', - targets: [{ path: '/work/output', format: 'webpub' }], - }); + await runCommand( + ['build', 'https://example.com/remote/dir', '-o', 'output'], + { cwd: '/work' }, + ); expect(toTree(vol)).toMatchSnapshot(); const file = vol.toJSON(); expect(file['/work/output/remote/dir/index.html']).toBe( @@ -257,17 +249,22 @@ it('generate webpub from a remote HTML document', async () => { `, '/remote/あ/日本語.css': '/* css */', }); - await build({ - input: 'https://example.com/remote/foo%20bar%25/escape%20check%25.html', - targets: [{ path: '/work/output', format: 'webpub' }], - }); + await runCommand( + [ + 'build', + 'https://example.com/remote/foo%20bar%25/escape%20check%25.html', + '-o', + 'output', + ], + { cwd: '/work' }, + ); expect(toTree(vol)).toMatchSnapshot(); const file = vol.toJSON(); - const manifest = JSON.parse(file['/work/output/publication.json'] as string); + const manifest = JSON.parse(file['/work/output/publication.json']!); delete manifest.dateModified; expect(manifest).toMatchSnapshot(); - const entry = file['/work/output/remote/foo bar%/escape check%.html']; - expect(await format(entry as string, { parser: 'html' })).toMatchSnapshot(); + const entry = file['/work/output/remote/foo bar%/escape check%.html']!; + expect(await format(entry, { parser: 'html' })).toMatchSnapshot(); expect(file['/work/output/remote/あ/日本語.css']).toBe( file['/remote/あ/日本語.css'], ); @@ -296,17 +293,30 @@ it('generate webpub with complex copyAsset settings', async () => { '/work/input/node_modules/pkgB/a.html': '', '/work/input/node_modules/pkgB/bar/b.html': '', }); - await build({ - configPath: '/work/input/vivliostyle.config.json', - }); + await runCommand(['build'], { cwd: '/work/input' }); expect(toTree(vol)).toMatchSnapshot(); }); it('copy webpub assets properly', async () => { + const themeDir = { + 'themeA/package.json': '{"main": "theme.css"}', + 'themeA/theme.css': '', + 'themeA/example/a.css': '', + '@org/themeB/package.json': '{"main": "theme.css"}', + '@org/themeB/theme.css': '', + '@org/themeB/example/a.css': '', + }; + mockedThemeModule.checkThemeInstallationNecessity.mockImplementationOnce( + () => true, + ); + mockedThemeModule.installThemeDependencies.mockImplementationOnce(() => { + vol.fromJSON(themeDir, '/work/input/.vivliostyle/themes/node_modules'); + }); + const config: VivliostyleConfigSchema = { entry: ['doc.md'], - output: ['/work/input/output1', '/work/input/output2'], + output: ['/work/input/output'], theme: ['themeA', '@org/themeB'], }; vol.fromJSON({ @@ -315,17 +325,8 @@ it('copy webpub assets properly', async () => { '/work/input/doc.md': 'yuno', '/work/input/node_modules/pkgA/a.html': '', '/work/input/node_modules/pkgA/a.css': '', - '/work/input/themes/packages/themeA/package.json': '{"main": "theme.css"}', - '/work/input/themes/packages/themeA/theme.css': '', - '/work/input/themes/packages/themeA/example/a.css': '', - '/work/input/themes/packages/@org/themeB/package.json': - '{"main": "theme.css"}', - '/work/input/themes/packages/@org/themeB/theme.css': '', - '/work/input/themes/packages/@org/themeB/example/a.css': '', - }); - await build({ - configPath: '/work/input/vivliostyle.config.json', }); + await runCommand(['build'], { cwd: '/work/input' }); expect(toTree(vol)).toMatchSnapshot(); });