Skip to content

Commit

Permalink
Merge pull request #13 from koyopro/feature/i18n
Browse files Browse the repository at this point in the history
Internationalization (I18n)
  • Loading branch information
koyopro authored Jun 16, 2024
2 parents a1d4943 + fd37d04 commit b137126
Show file tree
Hide file tree
Showing 25 changed files with 650 additions and 38 deletions.
45 changes: 45 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"devDependencies": {
"@types/node": "^20.11.19",
"better-sqlite3": "^9.4.1",
"i18next": "^23.11.5",
"mysql2": "^3.9.2",
"tsx": "^4.7.1",
"typescript": "^5.3.3",
Expand Down
3 changes: 3 additions & 0 deletions packages/accel-record-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,8 @@
"get-port": "^7.0.0",
"knex": "^3.0.0",
"prisma": "^5.0.0"
},
"optionalDependencies": {
"i18next": "^23.0.0"
}
}
2 changes: 2 additions & 0 deletions packages/accel-record-core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "path";
import { fileURLToPath } from "url";
import { loadDmmf } from "./fields.js";
import { Model } from "./index.js";
import { loadI18n } from "./model/naming.js";
// @ts-ignore
import SyncRpc from "./sync-rpc/index.cjs";

Expand Down Expand Up @@ -75,6 +76,7 @@ export const initAccelRecord = async (config: Config) => {
knexConfig: getKnexConfig(config),
});
await loadDmmf();
await loadI18n();

Model.queryBuilder.constructor.prototype.execute = function () {
return exec(this);
Expand Down
11 changes: 11 additions & 0 deletions packages/accel-record-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Fields } from "./fields.js";
import { ModelMeta } from "./meta.js";
import { Dirty } from "./model/dirty.js";
import { Import } from "./model/import.js";
import { Naming } from "./model/naming.js";
import { Serialization } from "./model/serialization.js";
import { Validations } from "./model/validations.js";
import { Persistence } from "./persistence.js";
Expand Down Expand Up @@ -60,6 +61,7 @@ export class Model extends classIncludes(
Dirty,
Fields,
Import,
Naming,
Persistence,
Query,
Serialization,
Expand All @@ -68,6 +70,15 @@ export class Model extends classIncludes(
) {
associations: Map<string, Association<Model, Model>> = new Map();

/**
* Returns the model class for the current instance.
*
* @returns The model class.
*/
class<T extends typeof Model>(this: InstanceType<T>): T {
return this.constructor as T;
}

/**
* Checks if the current instance is equal to another instance of the same type.
* @param other The other instance to compare with.
Expand Down
52 changes: 52 additions & 0 deletions packages/accel-record-core/src/model/naming.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
type i18n = {
t: (key: string, name: string) => string | undefined;
};
export let i18n: i18n | undefined = undefined;

/**
* Represents a class for handling naming conventions.
*
* This class is intended to be inherited by the Model class.
*/
export class Naming {
/**
* Gets the model name.
* @returns An object with a `human` property representing the human-readable model name.
*/
static get modelName() {
const default_ = this.name;
const key = `accelrecord.models.${this.name}`;
return {
/**
* the human-readable model name.
*/
get human() {
return i18n?.t(key, "") || default_;
},
};
}

/**
* Gets the human-readable attribute name for the specified attribute.
* @param attribute - The attribute name.
* @returns The human-readable attribute name.
*/
static humanAttributeName(attribute: string) {
const key = `accelrecord.attributes.${this.name}.${attribute}`;
return i18n?.t(key, "") || toPascalCase(attribute);
}
}

const toPascalCase = (str: string) => {
return str
.replace(/_./g, (s) => s[1].toUpperCase())
.replace(/^./, (s) => s.toUpperCase());
};

export const loadI18n = async () => {
try {
i18n = await import("i18next");
} catch (e) {
// i18next not found
}
};
6 changes: 5 additions & 1 deletion packages/accel-record-core/src/model/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,11 @@ type ValidatesOptions<T> = {
* This class is intended to be inherited by the Model class.
*/
export class Validations {
errors = new Errors();
private _errors: Errors | undefined;

get errors() {
return (this._errors ||= new Errors(this as any));
}

/**
* Checks if the model is valid by performing attribute and association validations.
Expand Down
55 changes: 44 additions & 11 deletions packages/accel-record-core/src/validation/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,16 @@
import { Model } from "../index.js";
import { i18n } from "../model/naming.js";

const defaultMessages: Record<string, string | undefined> = {
blank: "can't be blank",
accepted: "must be accepted",
invalid: "is invalid",
inclusion: "is not included in the list",
tooShort: "is too short (minimum is %{count} characters)",
tooLong: "is too long (maximum is %{count} characters)",
taken: "has already been taken",
};

/**
* Represents an error object.
*/
Expand All @@ -8,16 +21,39 @@ export class Error {
* @param message - The error message.
*/
constructor(
private base: Model,
public attribute: string,
public message: string
public message: string,
private options: { count?: number } = {}
) {}

/**
* Gets the full error message.
* @returns The full error message.
*/
get fullMessage() {
return `${toPascalCase(this.attribute)} ${this.message}`;
const attrName = this.base.class().humanAttributeName(this.attribute);
let message = this.translatedMessage;
if (this.options.count) {
message = message.replace("%{count}", this.options.count.toString());
}
return `${attrName} ${message}`;
}

private get translatedMessage() {
const model = this.base.constructor.name;
const keys = [
`accelrecord.errors.models.${model}.attributes.${this.attribute}.${this.message}`,
`accelrecord.errors.models.${model}.${this.message}`,
"accelrecord.errors.messages.blank",
`errors.attributes.${this.attribute}.${this.message}`,
`errors.messages.${this.message}`,
];
for (const key of keys) {
const message = i18n?.t(key, "");
if (message) return message;
}
return defaultMessages[this.message] || this.message;
}
}

Expand All @@ -27,6 +63,8 @@ export class Error {
export class Errors {
private errors = {} as Record<string, Error[]>;

constructor(private base: Model) {}

/**
* Gets the full error messages.
* @returns An array of full error messages.
Expand All @@ -42,8 +80,10 @@ export class Errors {
* @param attribute - The attribute associated with the error.
* @param error - The error message.
*/
add(attribute: string, error: string) {
(this.errors[attribute] ||= []).push(new Error(attribute, error));
add(attribute: string, error: string, options?: { count: number }) {
(this.errors[attribute] ||= []).push(
new Error(this.base, attribute, error, options)
);
}

/**
Expand Down Expand Up @@ -80,10 +120,3 @@ export class Errors {
return Object.keys(this.errors).length === 0;
}
}

function toPascalCase(str: string): string {
return str
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("");
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,7 @@ export class AcceptanceValidator<T extends Model> extends Validator<T> {
? [this.options.accept].flat()
: undefined) ?? [true, "1"];
if (!accept.includes(this.record[this.attribute] as any)) {
this.errors.add(
this.attribute,
this.options.message ?? "must be accepted"
);
this.errors.add(this.attribute, this.options.message ?? "accepted");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class FormatValidator<T extends Model> extends Validator<T> {
}
validate() {
if (!this.options.with.test(this.record[this.attribute] as any)) {
this.errors.add(this.attribute, this.options.message ?? "is invalid");
this.errors.add(this.attribute, this.options.message ?? "invalid");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,7 @@ export class InclusionValidator<T extends Model> extends Validator<T> {
}
validate() {
if (!this.options.in.includes(this.record[this.attribute])) {
this.errors.add(
this.attribute,
this.options.message ?? "is not included in the list"
);
this.errors.add(this.attribute, this.options.message ?? "inclusion");
}
}
}
16 changes: 6 additions & 10 deletions packages/accel-record-core/src/validation/validator/length.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,14 @@ export class LengthValidator<T extends Model> extends Validator<T> {
validate() {
const value = this.record[this.attribute] as any;
if (this.options.minimum && value.length < this.options.minimum) {
this.errors.add(
this.attribute,
this.options.message ??
`is too short (minimum is ${this.options.minimum} characters)`
);
this.errors.add(this.attribute, this.options.message ?? "tooShort", {
count: this.options.minimum,
});
}
if (this.options.maximum && value.length > this.options.maximum) {
this.errors.add(
this.attribute,
this.options.message ??
`is too long (maximum is ${this.options.maximum} characters)`
);
this.errors.add(this.attribute, this.options.message ?? "tooLong", {
count: this.options.maximum,
});
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Collection } from "../../associations/collectionProxy.js";
import { Model } from "../../index.js";
import { DefualtOptions, Validator } from "./index.js";

export type PresenceOptions = boolean & DefualtOptions;
export type PresenceOptions = boolean | DefualtOptions;

export class PresenceValidator<T extends Model> extends Validator<T> {
constructor(
Expand All @@ -14,7 +14,9 @@ export class PresenceValidator<T extends Model> extends Validator<T> {
}
validate() {
if (this.options && isBlank(this.record[this.attribute])) {
this.errors.add(this.attribute, this.options.message ?? "can't be blank");
const message =
typeof this.options === "object" ? this.options.message : undefined;
this.errors.add(this.attribute, message ?? "blank");
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export class UniquenessValidator<T extends Model> extends Validator<T> {
.where({ [this.attribute]: this.record[this.attribute] })
.first();
if (found?.equals(this.record) === false) {
this.errors.add(this.attribute, "has already been taken");
this.errors.add(this.attribute, "taken");
}
}
}
Loading

0 comments on commit b137126

Please sign in to comment.