diff --git a/journey/design.md b/journey/design.md index a6a029b..6f84e6e 100644 --- a/journey/design.md +++ b/journey/design.md @@ -632,6 +632,14 @@ ### Clamp strategy +- Text segmenters are shared per granularity and constructed only when segmentation is needed. + Importing the root package is safe without `Intl.Segmenter`, and WrapClamp-only consumer builds + discard unrelated text segmentation. This is an import guarantee, not a Unicode fallback: + unit-safe grapheme preparation skips segmentation, while word boundaries (including ASCII words) + and non-unit-safe graphemes still require the native API. Shared text preparation remains deferred + until its boundary metadata is read. Package integration tests build fresh library artifacts, + import them in a separate process without the API, and check real Vite/Rolldown consumer output; + a text-component control ensures the bundle check does not pass by dropping all components. - The text clamp pass in `LineClamp`: - starts from the `text` prop - normalizes `location` to an internal ratio before clamp rendering diff --git a/packages/vue-clamp/src/text.ts b/packages/vue-clamp/src/text.ts index 6c81dae..b566d37 100644 --- a/packages/vue-clamp/src/text.ts +++ b/packages/vue-clamp/src/text.ts @@ -8,7 +8,8 @@ import type { ClampBoundary, ClampLength, LineClampLocation } from "./types.ts"; // reuse the same boundary list instead of segmenting the source text again. // Segmenters are built on first use: module evaluation then stays side effect free, // so bundlers can drop this module for consumers that only import layout clamping, -// and ASCII-only content never constructs one. +// and the unit-safe grapheme path needs none. Word boundaries still require a +// word segmenter, including for ASCII sources. let graphemeSegmenter: Intl.Segmenter | undefined; let wordSegmenter: Intl.Segmenter | undefined; diff --git a/packages/vue-clamp/tests/package-build.test.ts b/packages/vue-clamp/tests/package-build.test.ts new file mode 100644 index 0000000..1590476 --- /dev/null +++ b/packages/vue-clamp/tests/package-build.test.ts @@ -0,0 +1,97 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { build } from "vite-plus"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +const execFileAsync = promisify(execFile); +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +let outputDirectory: string; + +beforeAll(async () => { + // Keep fresh artifacts near the package's dependencies without touching dist. + outputDirectory = await mkdtemp(join(packageRoot, "node_modules/.package-test-")); + await execFileAsync( + "vp", + ["pack", "src/index.ts", "src/pretext.ts", "--out-dir", outputDirectory], + { cwd: packageRoot, timeout: 25_000 }, + ); +}, 30_000); + +afterAll(async () => { + if (outputDirectory) await rm(outputDirectory, { recursive: true, force: true }); +}); + +async function buildConsumer(component: "WrapClamp" | "LineClamp"): Promise { + const entry = join(outputDirectory, `consumer-${component}.js`); + const props = + component === "WrapClamp" + ? { maxLines: 2, items: ["alpha", "beta"] } + : { maxLines: 2, text: "alpha beta" }; + await writeFile( + entry, + ` + import { createApp, h } from "vue"; + import { ${component} } from "./index.js"; + createApp({ + render: () => h(${component}, ${JSON.stringify(props)}, { + item: ({ item }) => h("span", item), + }), + }).mount("#app"); + `, + ); + const result = await build({ + configFile: false, + root: packageRoot, + logLevel: "silent", + build: { + write: false, + minify: false, + rolldownOptions: { input: entry }, + }, + }); + const outputs = Array.isArray(result) ? result : [result]; + return outputs + .flatMap((output) => { + if (!("output" in output)) throw new Error("Expected a completed consumer build"); + return output.output; + }) + .filter((output) => output.type === "chunk") + .map((chunk) => chunk.code) + .join("\n"); +} + +describe("built package segmentation contract", () => { + it("imports the root entry without Intl.Segmenter in a fresh process", async () => { + const entry = pathToFileURL(join(outputDirectory, "index.js")).href; + // A separate process avoids module caches, source transforms and test mocks. + await execFileAsync(process.execPath, [ + "--input-type=module", + "--eval", + ` + import { strict as assert } from "node:assert"; + delete Intl.Segmenter; + const components = await import(${JSON.stringify(entry)}); + assert.deepEqual(Object.keys(components).sort(), + ["InlineClamp", "LineClamp", "RichLineClamp", "WrapClamp"]); + for (const [name, component] of Object.entries(components)) { + assert.equal(component.name, name); + } + `, + ]); + }); + + it.each([ + ["WrapClamp", false], + ["LineClamp", true], + ] as const)( + "keeps only required segmentation in a %s consumer", + async (component, needsSegmenter) => { + const code = await buildConsumer(component); + expect(code.includes(component), `${component} component retained`).toBe(true); + expect(code.includes("Intl.Segmenter"), `${component} segmentation`).toBe(needsSegmenter); + }, + ); +});