Skip to content
Closed
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
24 changes: 24 additions & 0 deletions ai-context/core-features-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,30 @@ This document provides the correct import paths and type definitions for commonl

---

### StringFormatter

- **Import:** `import { StringFormatter } from "@webiny/api-core/features/stringFormatter/index.js"`
- **Interface Type:** See `packages/api-core/src/features/stringFormatter/abstractions.ts`
- **Usage:** Consumer-facing string transforms for backend code. Exposes `slugify(value)` (URL-friendly slug); more methods will be added over time. Inject via a use case / repository's `dependencies` and call `this.stringFormatter.slugify(...)`. To change slug logic, decorate the fine-grained `Slugify` feature — not this one.

---

### Slugify

- **Import:** `import { Slugify } from "@webiny/api-core/features/slugify/index.js"`
- **Interface Type:** See `packages/api-core/src/features/slugify/abstractions.ts`
- **Usage:** Single-method (`execute(value)`) transform holding Webiny's canonical slug options. This is the fine-grained seam behind `StringFormatter.slugify()`. Prefer injecting `StringFormatter`; decorate `Slugify` when a project needs to change slug generation everywhere at once.

---

### DateFormatter

- **Import:** `import { DateFormatter } from "@webiny/api-core/features/dateFormatter/index.js"`
- **Interface Type:** See `packages/api-core/src/features/dateFormatter/abstractions.ts`
- **Usage:** Formats an absolute date/time as a string (`format(date)`). Backend output is deterministic (UTC, `YYYY-MM-DD HH:mm`), not viewer-locale dependent. Inject via `dependencies` and decorate the abstraction to change the format everywhere.

---

## Headless CMS Features

### Content Entry Features
Expand Down
1 change: 1 addition & 0 deletions packages/api-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"minimatch": "^10.2.5",
"pino": "^10.3.1",
"pino-lambda": "^4.4.1",
"slugify": "^1.6.9",
"zod": "4.4.3"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions packages/api-core/src/ApiCoreFeature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { BuildParamsFeature } from "~/features/buildParams/feature.js";
import { EncryptionFeature } from "~/features/encryption/feature.js";
import { FeatureFlagsFeature } from "~/features/featureFlags/feature.js";
import { MaskerFeature } from "~/features/masker/feature.js";
import { StringFormatterFeature } from "~/features/stringFormatter/feature.js";
import { DateFormatterFeature } from "~/features/dateFormatter/feature.js";
import { AiFeature } from "~/features/ai/feature.js";
import { NullWebhookDispatcher } from "./features/webhooks/WebhookDispatcher/NullWebhookDispatcher.js";
import { WebhookProviderFeature } from "~/features/webhooks/index.js";
Expand All @@ -22,6 +24,8 @@ export const ApiCoreFeature = createFeature({
register(container: Container, config: ApiCoreStorageOperations) {
// Register features
MaskerFeature.register(container);
StringFormatterFeature.register(container);
DateFormatterFeature.register(container);
AiFeature.register(container);
LoggerFeature.register(container);
EventPublisherFeature.register(container);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { DateFormatter } from "./abstractions.js";
import type { FormattableDate } from "./abstractions.js";

const pad = (value: number): string => {
return String(value).padStart(2, "0");
};

/**
* Default absolute date/time format: `YYYY-MM-DD HH:mm`, 24-hour, UTC. Built from UTC parts rather
* than `Intl.DateTimeFormat` so the output is fully deterministic — backend formatting must not
* depend on the server locale or the bundled ICU version. Projects can change it by decorating
* DateFormatter.
*/
class DefaultDateFormatterImpl implements DateFormatter.Interface {
format(date: FormattableDate): string {
const value = new Date(date);
const year = value.getUTCFullYear();
const month = pad(value.getUTCMonth() + 1);
const day = pad(value.getUTCDate());
const hours = pad(value.getUTCHours());
const minutes = pad(value.getUTCMinutes());
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
}

export const DefaultDateFormatter = DateFormatter.createImplementation({
implementation: DefaultDateFormatterImpl,
dependencies: []
});
18 changes: 18 additions & 0 deletions packages/api-core/src/features/dateFormatter/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createAbstraction } from "@webiny/feature/api";

export type FormattableDate = Date | string | number;

export interface IDateFormatter {
/**
* Formats an absolute date/time as a string using Webiny's default format. Change the format
* everywhere at once by decorating this abstraction.
*/
format(date: FormattableDate): string;
}

export const DateFormatter = createAbstraction<IDateFormatter>("DateFormatter");

export namespace DateFormatter {
export type Interface = IDateFormatter;
export type Value = FormattableDate;
}
9 changes: 9 additions & 0 deletions packages/api-core/src/features/dateFormatter/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFeature } from "@webiny/feature/api";
import { DefaultDateFormatter } from "./DefaultDateFormatter.js";

export const DateFormatterFeature = createFeature({
name: "DateFormatterFeature",
register(container) {
container.register(DefaultDateFormatter);
}
});
3 changes: 3 additions & 0 deletions packages/api-core/src/features/dateFormatter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { DateFormatter } from "./abstractions.js";
export type { IDateFormatter, FormattableDate } from "./abstractions.js";
export { DateFormatterFeature } from "./feature.js";
25 changes: 25 additions & 0 deletions packages/api-core/src/features/slugify/DefaultSlugify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import baseSlugify from "slugify";
import { Slugify } from "./abstractions.js";

/**
* Webiny's canonical slug options. These were previously duplicated at every call site; keeping them
* in one private place means projects can change slug generation everywhere at once by decorating
* Slugify — not by passing per-call options, so the abstraction stays independent of this library.
*/
const DEFAULT_OPTIONS = {
replacement: "-",
lower: true,
remove: /[*#?<>_{}[\]+~.()'"!:;@]/g,
trim: false
};

class DefaultSlugifyImpl implements Slugify.Interface {
execute(value: string): string {
return baseSlugify(value, DEFAULT_OPTIONS);
}
}

export const DefaultSlugify = Slugify.createImplementation({
implementation: DefaultSlugifyImpl,
dependencies: []
});
16 changes: 16 additions & 0 deletions packages/api-core/src/features/slugify/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { createAbstraction } from "@webiny/feature/api";

export interface ISlugify {
/**
* Turns a value into a URL-friendly slug using Webiny's canonical options. This is the
* fine-grained seam behind `StringFormatter.slugify()` — decorate `Slugify` alone to change slug
* logic without touching the rest of the string formatter.
*/
execute(value: string): string;
}

export const Slugify = createAbstraction<ISlugify>("Slugify");

export namespace Slugify {
export type Interface = ISlugify;
}
9 changes: 9 additions & 0 deletions packages/api-core/src/features/slugify/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { createFeature } from "@webiny/feature/api";
import { DefaultSlugify } from "./DefaultSlugify.js";

export const SlugifyFeature = createFeature({
name: "SlugifyFeature",
register(container) {
container.register(DefaultSlugify);
}
});
3 changes: 3 additions & 0 deletions packages/api-core/src/features/slugify/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { Slugify } from "./abstractions.js";
export type { ISlugify } from "./abstractions.js";
export { SlugifyFeature } from "./feature.js";
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Slugify } from "~/features/slugify/abstractions.js";
import { StringFormatter } from "./abstractions.js";

/**
* The string formatter is the consumer-facing API for string transforms. It keeps each transform's
* logic in its own fine-grained, decoratable feature (e.g. `Slugify`) and just delegates to it, so a
* project can change one transform without reimplementing the whole formatter. More methods will be
* added here over time.
*/
class DefaultStringFormatterImpl implements StringFormatter.Interface {
constructor(private readonly slugifier: Slugify.Interface) {}

slugify(value: string): string {
return this.slugifier.execute(value);
}
}

export const DefaultStringFormatter = StringFormatter.createImplementation({
implementation: DefaultStringFormatterImpl,
dependencies: [Slugify]
});
15 changes: 15 additions & 0 deletions packages/api-core/src/features/stringFormatter/abstractions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createAbstraction } from "@webiny/feature/api";

export interface IStringFormatter {
/**
* Turns a value into a URL-friendly slug. Delegates to the `Slugify` feature, so to change slug
* logic decorate `Slugify` alone rather than the whole string formatter.
*/
slugify(value: string): string;
}

export const StringFormatter = createAbstraction<IStringFormatter>("StringFormatter");

export namespace StringFormatter {
export type Interface = IStringFormatter;
}
12 changes: 12 additions & 0 deletions packages/api-core/src/features/stringFormatter/feature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { createFeature } from "@webiny/feature/api";
import { SlugifyFeature } from "~/features/slugify/feature.js";
import { DefaultStringFormatter } from "./DefaultStringFormatter.js";

export const StringFormatterFeature = createFeature({
name: "StringFormatterFeature",
register(container) {
// Register the transforms the formatter delegates to, then the formatter itself.
SlugifyFeature.register(container);
container.register(DefaultStringFormatter);
}
});
3 changes: 3 additions & 0 deletions packages/api-core/src/features/stringFormatter/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { StringFormatter } from "./abstractions.js";
export type { IStringFormatter } from "./abstractions.js";
export { StringFormatterFeature } from "./feature.js";
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { GroupSlugTakenError } from "~/domain/contentModelGroup/errors.js";
import { GroupPersistenceError } from "~/domain/contentModelGroup/errors.js";
import { StorageOperations } from "~/features/shared/abstractions.js";
import { TenantContext } from "@webiny/api-core/features/tenancy/TenantContext/index.js";
import { toSlug } from "~/utils/toSlug.js";
import { StringFormatter } from "@webiny/api-core/features/stringFormatter/index.js";
import { generateAlphaNumericId } from "@webiny/utils";
import type { CmsGroup } from "~/types/index.js";

Expand All @@ -26,7 +26,8 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface {
private groupCache: GroupCache.Interface,
private pluginGroupsProvider: PluginGroupsProvider.Interface,
private storageOperations: StorageOperations.Interface,
private tenantContext: TenantContext.Interface
private tenantContext: TenantContext.Interface,
private stringFormatter: StringFormatter.Interface
) {}

async execute(group: CmsGroup): Promise<Result<void, RepositoryAbstraction.Error>> {
Expand Down Expand Up @@ -86,7 +87,7 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface {
}

// Generate slug from name
const baseSlug = toSlug(group.name);
const baseSlug = this.stringFormatter.slugify(group.name);
const existingBySlug = await this.storageOperations.groups.list({
where: {
tenant,
Expand All @@ -109,5 +110,11 @@ class CreateGroupRepositoryImpl implements RepositoryAbstraction.Interface {
export const CreateGroupRepository = createImplementation({
abstraction: RepositoryAbstraction,
implementation: CreateGroupRepositoryImpl,
dependencies: [GroupCache, PluginGroupsProvider, StorageOperations, TenantContext]
dependencies: [
GroupCache,
PluginGroupsProvider,
StorageOperations,
TenantContext,
StringFormatter
]
});
2 changes: 1 addition & 1 deletion packages/cli/files/references.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -10728,6 +10728,7 @@ __metadata:
pino: "npm:^10.3.1"
pino-lambda: "npm:^4.4.1"
rimraf: "npm:^6.1.3"
slugify: "npm:^1.6.9"
typescript: "npm:7.0.2"
vitest: "npm:^4.1.10"
zod: "npm:4.4.3"
Expand Down