Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions journey/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/vue-clamp/src/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
97 changes: 97 additions & 0 deletions packages/vue-clamp/tests/package-build.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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);
},
);
});
Loading