From c9cb205041e1a1e7483ac5149e25e4fde924e477 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:46:59 -0300 Subject: [PATCH 01/70] feat: add toStandardSchema, a Standard Schema wrapper for the validators `toStandardSchema(isValidCpf, { message, options })` wraps any `isValid*` utility in a Standard Schema (the `~standard` interface), so the validators plug into TanStack Form, tRPC, Hono, react-hook-form's standardSchemaResolver and whatever else accepts the interface, next to Zod, Valibot and ArkType schemas. The types of the specification are copied into the package as the specification intends, so there is still no runtime dependency, and they are exported. The wrapper is generic rather than one prebuilt schema per validator: a module importing every validator would pull the dataset-backed ones into any bundle that failed to tree-shake it, while this costs the validator handed to it and nothing else, and covers options and future validators for free. Like the validators it never throws. Available from the root and as the `to-standard-schema` subpath. --- docs/pt-br/utilities.md | 48 +++++ docs/utilities.md | 48 +++++ reports/api/brazilian-utils.api.md | 58 ++++++ src/index.test.ts | 21 +++ src/index.ts | 13 ++ .../to-standard-schema.test.ts | 165 ++++++++++++++++++ src/to-standard-schema/to-standard-schema.ts | 141 +++++++++++++++ 7 files changed, 494 insertions(+) create mode 100644 src/to-standard-schema/to-standard-schema.test.ts create mode 100644 src/to-standard-schema/to-standard-schema.ts diff --git a/docs/pt-br/utilities.md b/docs/pt-br/utilities.md index 22503866e..becc6a813 100644 --- a/docs/pt-br/utilities.md +++ b/docs/pt-br/utilities.md @@ -2216,6 +2216,54 @@ removeAccents('Açaí'); // 'Acai' removeAccents(''); // '' ``` +## Standard Schema + +### toStandardSchema + +Embrulha um utilitário `isValid*` (ou qualquer função com o mesmo formato) em um [Standard Schema](https://standardschema.dev), a interface que bibliotecas de formulário, roteadores e frameworks de API aceitam como validador, seja qual for a biblioteca que o produziu: TanStack Form, tRPC, Hono, react-hook-form e os demais, ao lado de schemas feitos com Zod, Valibot ou ArkType. Os tipos da especificação são copiados para dentro do pacote, então nada é instalado. O schema valida de forma síncrona e não transforma: um valor válido volta como foi passado, um inválido gera uma única issue. `config.options` (parte de `ToStandardSchemaOptions`) é repassado ao validador a cada chamada, e `config.message` é a mensagem dessa issue (padrão `'Invalid value'`). Como os validadores que embrulha, nunca lança exceção: um primeiro argumento que não é função gera um schema que rejeita tudo, e uma `message` que não é string cai no padrão. Validadores que recebem um único objeto (`isValidBankAccount`, `isValidRegistroProfissional`, `isValidIe`) funcionam do mesmo jeito, sendo o objeto o valor validado; embrulhe o `isValidIe`, que tem sobrecarga, em uma arrow function, `toStandardSchema((params) => isValidIe(params))`. Os tipos `StandardSchemaV1`, `StandardSchemaV1Result`, `StandardSchemaV1Issue` e os demais da especificação também são exportados. + +```javascript +import { isValidCnpj, isValidCpf, toStandardSchema } from '@brazilian-utils/brazilian-utils'; + +const cpf = toStandardSchema(isValidCpf, { message: 'CPF inválido' }); + +cpf['~standard'].validate('123.456.789-09'); // { value: '123.456.789-09' } +cpf['~standard'].validate('123'); // { issues: [{ message: 'CPF inválido' }] } + +const cnpj = toStandardSchema(isValidCnpj, { options: { version: 2 } }); // CNPJ alfanumérico +``` + +Tudo que recebe um Standard Schema aceita o resultado como está, um campo do TanStack Form por exemplo: + +```javascript + +``` + +Dentro de um schema do Zod ou do Valibot os validadores entram direto, sem wrapper, e o resultado já é um Standard Schema, que é como se valida um formulário inteiro com o react-hook-form: + +```javascript +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; +import { isValidCep, isValidCpf } from '@brazilian-utils/brazilian-utils'; +import { useForm } from 'react-hook-form'; +import * as v from 'valibot'; +import { z } from 'zod'; + +// Zod +const zodSchema = z.object({ + cpf: z.string().refine(isValidCpf, 'CPF inválido'), + cep: z.string().refine(isValidCep, 'CEP inválido'), +}); + +// Valibot +const valibotSchema = v.object({ + cpf: v.pipe(v.string(), v.check(isValidCpf, 'CPF inválido')), + cep: v.pipe(v.string(), v.check(isValidCep, 'CEP inválido')), +}); + +// react-hook-form, com qualquer um dos dois +const form = useForm({ resolver: standardSchemaResolver(zodSchema) }); +``` + ## isValidIe Valida se a inscrição estadual de um estado é válida. A UF é case-insensitive. Regras notáveis por estado: GO aceita os prefixos `10`, `11` e `15`; PA aceita `15` e `75`-`79`; MS aceita `28` e `50`; SP tem o padrão de produtor rural `P0MMMSSSSD000`; TO usa códigos de tipo de 11 dígitos (`01`, `02`, `03`, `99`). O TO também aceita uma forma de 9 dígitos, aplicando a mesma regra módulo 11 sobre os oito primeiros dígitos; a página do SINTEGRA documenta apenas a de 11 dígitos, então essa forma é comportamento da 2.3.0 mantido por compatibilidade, e não regra publicada. Uma inscrição só de zeros é aceita em todo estado cuja fórmula publicada produz dígito verificador 0 para ela (AM, BA com 8 ou 9 dígitos, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE, SP e TO com 9 dígitos), diferente de `isValidCpf` e `isValidCnpj`, que rejeitam dígitos repetidos. O AM entra nessa lista apenas pelo segundo ramo da fórmula publicada: o primeiro ramo da página, `Se Soma < 11 Então Dígito = 11 - Soma`, dá 11 para a inscrição só de zeros, enquanto o ramo `resto <= 1 ⇒ 0`, o implementado aqui, dá 0. A inscrição e a UF vão juntas num único objeto, tipado como `IsValidIeParams`; a forma da 2.3.0, `isValidIe(stateCode, ie)`, continua funcionando e está deprecada. diff --git a/docs/utilities.md b/docs/utilities.md index a4b07b62a..0c24281f6 100644 --- a/docs/utilities.md +++ b/docs/utilities.md @@ -2216,6 +2216,54 @@ removeAccents('Açaí'); // 'Acai' removeAccents(''); // '' ``` +## Standard Schema + +### toStandardSchema + +Wraps an `isValid*` utility (or any function of the same shape) in a [Standard Schema](https://standardschema.dev), the interface that form libraries, routers and API frameworks accept as a validator whichever library produced it: TanStack Form, tRPC, Hono, react-hook-form and the rest, next to schemas made with Zod, Valibot or ArkType. The types of the specification are copied into the package, so nothing is installed. The schema validates synchronously and does not transform: a valid value comes back as it was given, an invalid one yields a single issue. `config.options` (part of `ToStandardSchemaOptions`) is handed to the validator on every call, and `config.message` is the message of that issue (default `'Invalid value'`). Like the validators it wraps it never throws: a first argument that is not a function gives a schema that rejects everything, and a `message` that is not a string falls back to the default. Validators that take a single object (`isValidBankAccount`, `isValidRegistroProfissional`, `isValidIe`) work the same way, the object being the value under validation; wrap the overloaded `isValidIe` in an arrow function, `toStandardSchema((params) => isValidIe(params))`. The types `StandardSchemaV1`, `StandardSchemaV1Result`, `StandardSchemaV1Issue` and the others of the specification are exported too. + +```javascript +import { isValidCnpj, isValidCpf, toStandardSchema } from '@brazilian-utils/brazilian-utils'; + +const cpf = toStandardSchema(isValidCpf, { message: 'CPF inválido' }); + +cpf['~standard'].validate('123.456.789-09'); // { value: '123.456.789-09' } +cpf['~standard'].validate('123'); // { issues: [{ message: 'CPF inválido' }] } + +const cnpj = toStandardSchema(isValidCnpj, { options: { version: 2 } }); // alphanumeric CNPJ +``` + +Anything that takes a Standard Schema takes it as is, a TanStack Form field for example: + +```javascript + +``` + +Inside a Zod or Valibot schema the validators plug in directly, no wrapper needed, and the result is itself a Standard Schema, which is how a whole form is validated with react-hook-form: + +```javascript +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; +import { isValidCep, isValidCpf } from '@brazilian-utils/brazilian-utils'; +import { useForm } from 'react-hook-form'; +import * as v from 'valibot'; +import { z } from 'zod'; + +// Zod +const zodSchema = z.object({ + cpf: z.string().refine(isValidCpf, 'CPF inválido'), + cep: z.string().refine(isValidCep, 'CEP inválido'), +}); + +// Valibot +const valibotSchema = v.object({ + cpf: v.pipe(v.string(), v.check(isValidCpf, 'CPF inválido')), + cep: v.pipe(v.string(), v.check(isValidCep, 'CEP inválido')), +}); + +// react-hook-form, with either one +const form = useForm({ resolver: standardSchemaResolver(zodSchema) }); +``` + ## isValidIe Check if inscrição estadual (state registration) is valid. The state code is case-insensitive. Notable per-state rules: GO accepts prefixes `10`, `11` and `15`; PA accepts `15` and `75`-`79`; MS accepts `28` and `50`; SP has a produtor rural pattern `P0MMMSSSSD000`; TO uses 11-digit type codes (`01`, `02`, `03`, `99`). TO also accepts a 9-digit form, applying the same modulus 11 rule to the first eight digits; the SINTEGRA page documents only the 11-digit one, so that shape is 2.3.0 behaviour kept for compatibility rather than a published rule. An all-zero registration is accepted wherever the published formula yields a check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE, SP and TO with 9 digits), unlike `isValidCpf` and `isValidCnpj`, which reject repeated digits. AM is on that list through the second branch of its published formula only: the page's first branch, `Se Soma < 11 Então Dígito = 11 - Soma`, gives 11 for an all-zero registration, while the `resto <= 1 ⇒ 0` branch, the one implemented here, gives 0. The registration and the state code go together in a single object, typed as `IsValidIeParams`; the 2.3.0 form, `isValidIe(stateCode, ie)`, still works and is deprecated. diff --git a/reports/api/brazilian-utils.api.md b/reports/api/brazilian-utils.api.md index 31135c54f..75b328bda 100644 --- a/reports/api/brazilian-utils.api.md +++ b/reports/api/brazilian-utils.api.md @@ -979,6 +979,55 @@ export type RegistroProfissionalCouncil = "OAB" | "CRM" | "CRO" | "CRP" | "CRC"; // @public export const removeAccents: (value: string) => string; +// @public +export type StandardSchemaV1 = { + readonly "~standard": StandardSchemaV1Props; +}; + +// @public +export type StandardSchemaV1FailureResult = { + readonly issues: readonly StandardSchemaV1Issue[]; +}; + +// @public +export type StandardSchemaV1Issue = { + readonly message: string; + readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[]; +}; + +// @public +export type StandardSchemaV1Options = { + readonly libraryOptions?: Record; +}; + +// @public +export type StandardSchemaV1PathSegment = { + readonly key: PropertyKey; +}; + +// @public +export type StandardSchemaV1Props = { + readonly version: 1; + readonly vendor: string; + readonly validate: (value: unknown, options?: StandardSchemaV1Options) => StandardSchemaV1Result | Promise>; + readonly types?: StandardSchemaV1Types; +}; + +// @public +export type StandardSchemaV1Result = StandardSchemaV1SuccessResult | StandardSchemaV1FailureResult; + +// @public +export type StandardSchemaV1SuccessResult = { + readonly value: Output; + readonly issues?: undefined; +}; + +// @public +export type StandardSchemaV1Types = { + readonly input: Input; + readonly output: Output; +}; + // @public export type State = { readonly code: "AC"; @@ -1153,6 +1202,15 @@ export type StateName = State["name"]; // @public export const subBusinessDays: (date: Date, amount: number, options?: BusinessDayOptions) => Date | null; +// @public +export const toStandardSchema: (validate: (value: Value, options?: Options) => boolean, config?: ToStandardSchemaOptions) => StandardSchemaV1; + +// @public +export type ToStandardSchemaOptions = { + options?: Options; + message?: string; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/src/index.test.ts b/src/index.test.ts index 9e44e849e..877a4bdfd 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -88,7 +88,17 @@ import { type RegistroProfissionalCouncil, type State, type StateCode, + type StandardSchemaV1, + type StandardSchemaV1FailureResult, + type StandardSchemaV1Issue, + type StandardSchemaV1Options, + type StandardSchemaV1PathSegment, + type StandardSchemaV1Props, + type StandardSchemaV1Result, + type StandardSchemaV1SuccessResult, + type StandardSchemaV1Types, type StateName, + type ToStandardSchemaOptions, } from "./index"; import * as brazilianUtils from "./index"; @@ -248,6 +258,7 @@ const PUBLIC = [ "parseVoterId", "removeAccents", "subBusinessDays", + "toStandardSchema", ].sort(); const NETWORK_ENTRY_POINTS = new Set(["getAddressInfoByCep", "getCepInfoByAddress"]); @@ -365,7 +376,17 @@ describe("Public API", () => { RegistroProfissionalCouncil: RegistroProfissionalCouncil; State: State; StateCode: StateCode; + StandardSchemaV1: StandardSchemaV1; + StandardSchemaV1FailureResult: StandardSchemaV1FailureResult; + StandardSchemaV1Issue: StandardSchemaV1Issue; + StandardSchemaV1Options: StandardSchemaV1Options; + StandardSchemaV1PathSegment: StandardSchemaV1PathSegment; + StandardSchemaV1Props: StandardSchemaV1Props; + StandardSchemaV1Result: StandardSchemaV1Result; + StandardSchemaV1SuccessResult: StandardSchemaV1SuccessResult; + StandardSchemaV1Types: StandardSchemaV1Types; StateName: StateName; + ToStandardSchemaOptions: ToStandardSchemaOptions; }> = {}; expect(publicTypes).toEqual({}); diff --git a/src/index.ts b/src/index.ts index ea1666f62..a9e85d45f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -246,6 +246,19 @@ export { parseProcessoJuridico } from "./parse-processo-juridico/parse-processo- export { parseVoterId } from "./parse-voter-id/parse-voter-id"; export { removeAccents } from "./remove-accents/remove-accents"; export { subBusinessDays } from "./sub-business-days/sub-business-days"; +export { + type StandardSchemaV1, + type StandardSchemaV1FailureResult, + type StandardSchemaV1Issue, + type StandardSchemaV1Options, + type StandardSchemaV1PathSegment, + type StandardSchemaV1Props, + type StandardSchemaV1Result, + type StandardSchemaV1SuccessResult, + type StandardSchemaV1Types, + type ToStandardSchemaOptions, + toStandardSchema, +} from "./to-standard-schema/to-standard-schema"; /** * The parameters of `generateProcessoJuridico`, the 2.3.0 name of diff --git a/src/to-standard-schema/to-standard-schema.test.ts b/src/to-standard-schema/to-standard-schema.test.ts new file mode 100644 index 000000000..cdd07dceb --- /dev/null +++ b/src/to-standard-schema/to-standard-schema.test.ts @@ -0,0 +1,165 @@ +import * as fc from "fast-check"; + +import { anyGarbage, anyValue } from "../_internals/test/arbitraries"; +import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; +import { isValidBankAccount } from "../is-valid-bank-account/is-valid-bank-account"; +import { isValidCnpj } from "../is-valid-cnpj/is-valid-cnpj"; +import { isValidCpf } from "../is-valid-cpf/is-valid-cpf"; +import { type IsValidIeParams, isValidIe } from "../is-valid-ie/is-valid-ie"; +import { + type StandardSchemaV1, + type StandardSchemaV1Result, + type ToStandardSchemaOptions, + toStandardSchema, +} from "./to-standard-schema"; + +describe("toStandardSchema", () => { + test("should expose the Standard Schema properties", () => { + const schema = toStandardSchema(isValidCpf); + + expect(schema["~standard"].version).toBe(1); + expect(schema["~standard"].vendor).toBe("brazilian-utils"); + expect(typeof schema["~standard"].validate).toBe("function"); + }); + + test("should return the value as it was given when it is valid", () => { + const schema = toStandardSchema(isValidCpf); + + expect(schema["~standard"].validate("123.456.789-09")).toStrictEqual({ + value: "123.456.789-09", + }); + expect(schema["~standard"].validate("12345678909")).toStrictEqual({ value: "12345678909" }); + }); + + test("should return a single issue with the default message when the value is invalid", () => { + const schema = toStandardSchema(isValidCpf); + + expect(schema["~standard"].validate("123")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + expect(schema["~standard"].validate(null)).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + }); + + test("should report the message given in the options", () => { + const schema = toStandardSchema(isValidCpf, { message: "CPF inválido" }); + + expect(schema["~standard"].validate("123")).toStrictEqual({ + issues: [{ message: "CPF inválido" }], + }); + expect(schema["~standard"].validate("12345678909")).toStrictEqual({ value: "12345678909" }); + }); + + test("should hand the options to the validator on every call", () => { + const numeric = toStandardSchema(isValidCnpj); + const alphanumeric = toStandardSchema(isValidCnpj, { options: { version: 2 } }); + + expect(numeric["~standard"].validate("Q0SLFMBD7VX439")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + expect(alphanumeric["~standard"].validate("Q0SLFMBD7VX439")).toStrictEqual({ + value: "Q0SLFMBD7VX439", + }); + expect(alphanumeric["~standard"].validate("12345678000195")).toStrictEqual({ + value: "12345678000195", + }); + }); + + test("should validate the object of a validator that takes a single object", () => { + const ie = toStandardSchema((params: IsValidIeParams) => isValidIe(params)); + const account = toStandardSchema(isValidBankAccount); + const validIe = { value: "110042490114", stateCode: "SP" } as const; + + expect(ie["~standard"].validate(validIe)).toStrictEqual({ value: validIe }); + expect(ie["~standard"].validate({ value: "110042490115", stateCode: "SP" })).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + expect(account["~standard"].validate({})).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + }); + + test("should only accept a validator that answers exactly true", () => { + const truthy = toStandardSchema((() => 1) as unknown as (value: string) => boolean); + + expect(truthy["~standard"].validate("anything")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + }); + + test("should reject everything when the validator is not a function", () => { + const notFunctions: unknown[] = [null, undefined, "isValidCpf", 1, {}]; + + for (const notAFunction of notFunctions) { + const schema = toStandardSchema(notAFunction as (value: string) => boolean); + + expect(schema["~standard"].validate("12345678909")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + } + }); + + test("should fall back to the default message when the message is not a string", () => { + const notStrings: unknown[] = [null, undefined, 1, {}]; + + for (const message of notStrings) { + const schema = toStandardSchema(isValidCpf, { message: message as string }); + + expect(schema["~standard"].validate("123")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + } + }); + + test("should accept a config that is null", () => { + const schema = toStandardSchema(isValidCpf, null as unknown as ToStandardSchemaOptions); + + expect(schema["~standard"].validate("12345678909")).toStrictEqual({ value: "12345678909" }); + expect(schema["~standard"].validate("123")).toStrictEqual({ + issues: [{ message: "Invalid value" }], + }); + }); + + describe("properties", () => { + test("should agree with the validator it wraps, whatever the value", () => { + const schema = toStandardSchema(isValidCpf); + + fc.assert( + fc.property(anyValue, (value) => { + const result = schema["~standard"].validate(value) as StandardSchemaV1Result; + + expect(result.issues === undefined).toBe(isValidCpf(value as string)); + }), + ); + }); + + test("should never throw, whatever the validator, the config and the value", () => { + fc.assert( + fc.property(anyGarbage, anyGarbage, anyGarbage, (validate, config, value) => { + const schema = toStandardSchema( + validate as (value: unknown) => boolean, + config as ToStandardSchemaOptions, + ); + + expect(() => schema["~standard"].validate(value)).not.toThrow(); + }), + ); + }); + }); +}); + +describe("toStandardSchema types", () => { + test("should infer the value and the options from the validator", () => { + expectTypeOf(toStandardSchema(isValidCpf)).toEqualTypeOf>(); + expectTypeOf(toStandardSchema(isValidCnpj, { options: { version: 2 } })).toEqualTypeOf< + StandardSchemaV1 + >(); + expectTypeOf(toStandardSchema((params: IsValidIeParams) => isValidIe(params))).toEqualTypeOf< + StandardSchemaV1 + >(); + expectTypeOf(toStandardSchema) + .parameter(1) + .toEqualTypeOf | undefined>(); + }); +}); diff --git a/src/to-standard-schema/to-standard-schema.ts b/src/to-standard-schema/to-standard-schema.ts new file mode 100644 index 000000000..6fda55bf7 --- /dev/null +++ b/src/to-standard-schema/to-standard-schema.ts @@ -0,0 +1,141 @@ +/** + * The Standard Schema interface, version 1: what form libraries, routers and API frameworks accept + * as a validator without knowing which library produced it. The types below are the ones the + * specification publishes (`@standard-schema/spec`, MIT), written as `type` aliases (optional + * properties without the redundant `| undefined`) and copied here, as the specification intends, so + * the package keeps its zero runtime dependencies. + * + * @see Official: https://standardschema.dev + * @see Official: https://github.com/standard-schema/standard-schema/blob/main/packages/spec/src/index.ts + */ +export type StandardSchemaV1 = { + /** The Standard Schema properties. */ + readonly "~standard": StandardSchemaV1Props; +}; + +/** The Standard Schema properties. */ +export type StandardSchemaV1Props = { + /** The version number of the standard. */ + readonly version: 1; + /** The vendor name of the schema library. */ + readonly vendor: string; + /** Validates unknown input values. */ + readonly validate: ( + value: unknown, + options?: StandardSchemaV1Options, + ) => StandardSchemaV1Result | Promise>; + /** Inferred types associated with the schema. */ + readonly types?: StandardSchemaV1Types; +}; + +/** The result of the validate function. */ +export type StandardSchemaV1Result = + | StandardSchemaV1SuccessResult + | StandardSchemaV1FailureResult; + +/** The result if validation succeeds. */ +export type StandardSchemaV1SuccessResult = { + /** The typed output value. */ + readonly value: Output; + /** A falsy value for `issues` indicates success. */ + readonly issues?: undefined; +}; + +/** The options of the validate function. */ +export type StandardSchemaV1Options = { + /** Explicit support for additional vendor-specific parameters, if needed. */ + readonly libraryOptions?: Record; +}; + +/** The result if validation fails. */ +export type StandardSchemaV1FailureResult = { + /** The issues of failed validation. */ + readonly issues: readonly StandardSchemaV1Issue[]; +}; + +/** An issue of a failed validation. */ +export type StandardSchemaV1Issue = { + /** The error message of the issue. */ + readonly message: string; + /** The path of the issue, if any. */ + readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[]; +}; + +/** A path segment of an issue. */ +export type StandardSchemaV1PathSegment = { + /** The key representing a path segment. */ + readonly key: PropertyKey; +}; + +/** The types a schema carries for inference. */ +export type StandardSchemaV1Types = { + /** The input type of the schema. */ + readonly input: Input; + /** The output type of the schema. */ + readonly output: Output; +}; + +/** Options of `toStandardSchema`. */ +export type ToStandardSchemaOptions = { + /** The options handed to the validator on every call, e.g. `{ version: 2 }` for `isValidCnpj`. */ + options?: Options; + /** The message of the issue reported for an invalid value (default: `"Invalid value"`). */ + message?: string; +}; + +const VENDOR = "brazilian-utils"; + +const DEFAULT_MESSAGE = "Invalid value"; + +/** + * Wraps one of the `isValid*` utilities (or any function of the same shape) in a Standard Schema, + * so it plugs into whatever accepts the interface: react-hook-form's `standardSchemaResolver`, + * TanStack Form, tRPC, Hono and the rest, next to schemas made with Zod, Valibot or ArkType. + * + * The schema validates synchronously and does not transform: a valid value is returned as it was + * given, and an invalid one yields a single issue. It never throws, like the validators it wraps: + * a first argument that is not a function gives a schema that rejects everything, and a `message` + * that is not a string falls back to the default. + * + * Validators that take a single object (`isValidIe`, `isValidBankAccount`, + * `isValidRegistroProfissional`) work the same way, the object being the value under validation; + * wrap the overloaded `isValidIe` in an arrow function so its object form is the one picked: + * `toStandardSchema((params: IsValidIeParams) => isValidIe(params))`. + * + * @param {(value: Value, options?: Options) => boolean} validate - The validator to wrap. + * @param {ToStandardSchemaOptions} [config] - Optional configuration. + * @param {Options} [config.options] - The options handed to the validator on every call. + * @param {string} [config.message] - The message of the issue reported for an invalid value. + * @returns {StandardSchemaV1} A Standard Schema whose input and output are the validator's value. + * + * @example + * ```typescript + * const cpf = toStandardSchema(isValidCpf, { message: "CPF inválido" }); + * + * cpf["~standard"].validate("123.456.789-09"); // { value: "123.456.789-09" } + * cpf["~standard"].validate("123"); // { issues: [{ message: "CPF inválido" }] } + * + * const cnpj = toStandardSchema(isValidCnpj, { options: { version: 2 } }); + * ``` + * + * @see Official: https://standardschema.dev + */ +export const toStandardSchema = ( + validate: (value: Value, options?: Options) => boolean, + config?: ToStandardSchemaOptions, +): StandardSchemaV1 => { + const message = typeof config?.message === "string" ? config.message : DEFAULT_MESSAGE; + + // A predicate, so that an accepted `unknown` is returned as the validator's value type. + const isValue = (value: unknown): value is Value => + typeof validate === "function" && + Reflect.apply(validate, undefined, [value, config?.options]) === true; + + return { + "~standard": { + version: 1, + vendor: VENDOR, + validate: (value) => (isValue(value) ? { value } : { issues: [{ message }] }), + }, + }; +}; From c9bee7511db81abd6ea50e2254268bebd19002b0 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:46:59 -0300 Subject: [PATCH 02/70] test: draw valid documents from fast-check arbitraries instead of generate* 35 properties in 22 test files called generateCpf(), generatePhone(type) and the like inside fc.property. Those utilities draw from Math.random(), which the seed fast-check reports on a failure does not control, so a failing document could be neither replayed nor shrunk; several were properties in name only (fc.constant("landline") as the single arbitrary). They now draw the document with fc.gen(): `const cpf = g(cpfs)`. The arbitraries live where the project keeps its test arbitraries, src/_internals/test/: cpfs, cnpjs, cnhs, pisNumbers, voterIds, processosJuridicos, boletos and phones, built from fast-check primitives plus the check-digit internals, in four files (the lint rule caps a file at ten imports). A base that must not be one digit repeated gets its second character offset from the first, so nothing is filtered. The generate* utilities keep their own tests. The boleto assembly moves from generateBoleto into two internals shared with the boletos arbitrary; no behavior change. --- .../assemble-boleto-arrecadacao.test.ts | 76 +++++++++++++++++ .../assemble-boleto-arrecadacao.ts | 63 ++++++++++++++ .../assemble-boleto-bancario.test.ts | 37 +++++++++ .../assemble-boleto-bancario.ts | 60 ++++++++++++++ src/_internals/test/boleto-arbitraries.ts | 26 ++++++ src/_internals/test/document-arbitraries.ts | 83 +++++++++++++++++++ src/_internals/test/phone-arbitraries.ts | 52 ++++++++++++ src/_internals/test/registry-arbitraries.ts | 52 ++++++++++++ .../convert-license-plate-to-mercosul.test.ts | 10 +-- src/format-boleto/format-boleto.test.ts | 6 +- src/format-phone/format-phone.test.ts | 10 +-- src/generate-boleto/generate-boleto.ts | 78 ++++------------- src/get-boleto-info/get-boleto-info.test.ts | 10 +-- .../get-format-license-plate.test.ts | 6 +- .../get-pix-payload-info.test.ts | 5 +- src/is-valid-boleto/is-valid-boleto.test.ts | 10 +-- src/is-valid-cnh/is-valid-cnh.test.ts | 6 +- src/is-valid-cnpj/is-valid-cnpj.test.ts | 5 +- src/is-valid-cpf/is-valid-cpf.test.ts | 5 +- .../is-valid-landline-phone.test.ts | 10 +-- .../is-valid-license-plate.test.ts | 10 +-- .../is-valid-mobile-phone.test.ts | 14 ++-- src/is-valid-phone/is-valid-phone.test.ts | 14 ++-- src/is-valid-pis/is-valid-pis.test.ts | 6 +- src/is-valid-pix-key/is-valid-pix-key.test.ts | 9 +- .../is-valid-pix-payload.test.ts | 10 +-- .../is-valid-processo-juridico.test.ts | 6 +- .../is-valid-service-phone.test.ts | 10 +-- .../is-valid-voter-id.test.ts | 5 +- src/parse-boleto/parse-boleto.test.ts | 6 +- src/parse-phone/parse-phone.test.ts | 6 +- 31 files changed, 559 insertions(+), 147 deletions(-) create mode 100644 src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.test.ts create mode 100644 src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.ts create mode 100644 src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.test.ts create mode 100644 src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.ts create mode 100644 src/_internals/test/boleto-arbitraries.ts create mode 100644 src/_internals/test/document-arbitraries.ts create mode 100644 src/_internals/test/phone-arbitraries.ts create mode 100644 src/_internals/test/registry-arbitraries.ts diff --git a/src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.test.ts b/src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.test.ts new file mode 100644 index 000000000..29d0a8d31 --- /dev/null +++ b/src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.test.ts @@ -0,0 +1,76 @@ +import { isValidBoleto } from "../../is-valid-boleto/is-valid-boleto"; +import { describe, expect, test } from "../test/runtime"; +import { assembleBoletoArrecadacao } from "./assemble-boleto-arrecadacao"; + +const ZEROS = "0".repeat(40); + +describe("assembleBoletoArrecadacao", () => { + test("should name the modulus and the kind of value in the value identifier", () => { + expect( + assembleBoletoArrecadacao({ + segment: "1", + useMod11: false, + hasEffectiveValue: true, + body: ZEROS, + }), + ).toBe("816900000000000000000000000000000000000000000000"); + expect( + assembleBoletoArrecadacao({ + segment: "1", + useMod11: false, + hasEffectiveValue: false, + body: ZEROS, + }), + ).toBe("817700000000000000000000000000000000000000000000"); + expect( + assembleBoletoArrecadacao({ + segment: "1", + useMod11: true, + hasEffectiveValue: true, + body: ZEROS, + }), + ).toBe("818400000001000000000000000000000000000000000000"); + expect( + assembleBoletoArrecadacao({ + segment: "1", + useMod11: true, + hasEffectiveValue: false, + body: ZEROS, + }), + ).toBe("819200000006000000000000000000000000000000000000"); + }); + + test("should add a check digit to each of the four blocks", () => { + expect( + assembleBoletoArrecadacao({ + segment: "4", + useMod11: true, + hasEffectiveValue: true, + body: "1234567890".repeat(4), + }), + ).toBe("848712345677890123456785901234567894012345678900"); + expect( + assembleBoletoArrecadacao({ + segment: "7", + useMod11: false, + hasEffectiveValue: false, + body: "9876543210".repeat(4), + }), + ).toBe("877398765439210987654329109876543215098765432103"); + }); + + test("should build lines the validator accepts", () => { + for (const useMod11 of [true, false]) { + for (const hasEffectiveValue of [true, false]) { + const line = assembleBoletoArrecadacao({ + segment: "3", + useMod11, + hasEffectiveValue, + body: "1234567890".repeat(4), + }); + + expect(isValidBoleto(line)).toBe(true); + } + } + }); +}); diff --git a/src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.ts b/src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.ts new file mode 100644 index 000000000..d8bbabe59 --- /dev/null +++ b/src/_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao.ts @@ -0,0 +1,63 @@ +import { ARRECADACAO_PRODUCT } from "../constants/arrecadacao"; +import { mod10 } from "../mod10/mod10"; +import { mod11 } from "../mod11/mod11"; + +/** The choices and free digits of a boleto de arrecadação. */ +export type BoletoArrecadacaoParts = { + /** The segment digit, one of `ARRECADACAO_SEGMENTS`. */ + segment: string; + /** Whether the check digits use modulo 11 (value identifiers 8 and 9) instead of modulo 10 (6 and 7). */ + useMod11: boolean; + /** Whether the value field is the effective value (identifiers 6 and 8) or a reference (7 and 9). */ + hasEffectiveValue: boolean; + /** The 40 digits after the general check digit: value, company and free field. */ + body: string; +}; + +const VALUE_IDENTIFIERS = { + mod10: { effective: "6", reference: "7" }, + mod11: { effective: "8", reference: "9" }, +}; + +/** + * Assembles the 48 digit linha digitável of a boleto de arrecadação: the 44 digit barcode (product, + * segment, value identifier, general check digit and `body`) split into four blocks of 11 digits, + * each followed by its own check digit, all computed with the modulus the value identifier names. + * + * Shared by `generateBoleto` and by the `boletos` arbitrary of the test suite. + * + * @param {BoletoArrecadacaoParts} parts - The choices and the free digits. + * @returns {string} The linha digitável, digits only. + * + * @example + * ```typescript + * assembleBoletoArrecadacao({ + * segment: "1", + * useMod11: false, + * hasEffectiveValue: true, + * body: "0".repeat(40), + * }); // "816900000000000000000000000000000000000000000000" + * ``` + */ +export const assembleBoletoArrecadacao = ({ + segment, + useMod11, + hasEffectiveValue, + body, +}: BoletoArrecadacaoParts): string => { + const checkDigit = useMod11 + ? (value: string): number => mod11(value, { variant: "arrecadacao" }) + : mod10; + const identifier = + VALUE_IDENTIFIERS[useMod11 ? "mod11" : "mod10"][hasEffectiveValue ? "effective" : "reference"]; + const head = `${ARRECADACAO_PRODUCT}${segment}${identifier}`; + const barcode = head + checkDigit(head + body) + body; + let line = ""; + + for (let block = 0; block < 4; block++) { + const value = barcode.slice(block * 11, block * 11 + 11); + line += value + checkDigit(value); + } + + return line; +}; diff --git a/src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.test.ts b/src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.test.ts new file mode 100644 index 000000000..5cc5e897b --- /dev/null +++ b/src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.test.ts @@ -0,0 +1,37 @@ +import { isValidBoleto } from "../../is-valid-boleto/is-valid-boleto"; +import { describe, expect, test } from "../test/runtime"; +import { assembleBoletoBancario } from "./assemble-boleto-bancario"; + +describe("assembleBoletoBancario", () => { + test("should add the three field check digits and the general check digit", () => { + expect( + assembleBoletoBancario({ + field1: "001900000", + field2: "0000000000", + field3: "0000000000", + tail: "000000000000000", + }), + ).toBe("00190000090000000000000000000000500000000000000"); + expect( + assembleBoletoBancario({ + field1: "341912345", + field2: "6789012345", + field3: "6789012345", + tail: "712340000012345", + }), + ).toBe("34191234546789012345767890123457812340000012345"); + }); + + test("should build a line the validator accepts", () => { + expect( + isValidBoleto( + assembleBoletoBancario({ + field1: "341912345", + field2: "6789012345", + field3: "6789012345", + tail: "712340000012345", + }), + ), + ).toBe(true); + }); +}); diff --git a/src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.ts b/src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.ts new file mode 100644 index 000000000..fc2b4f0ad --- /dev/null +++ b/src/_internals/assemble-boleto-bancario/assemble-boleto-bancario.ts @@ -0,0 +1,60 @@ +import { mod10 } from "../mod10/mod10"; +import { mod11 } from "../mod11/mod11"; + +/** The free digits of a boleto bancário's linha digitável, the ones no check digit is derived into. */ +export type BoletoBancarioParts = { + /** The 9 digits of the first field: bank, currency and the start of the free field. */ + field1: string; + /** The 10 digits of the second field. */ + field2: string; + /** The 10 digits of the third field. */ + field3: string; + /** The last 15 digits: the general check digit's slot, the due date factor and the amount. */ + tail: string; +}; + +/** + * Assembles the 47 digit linha digitável of a boleto bancário from its free digits: each of the + * three fields gets its modulo 10 check digit, and the general modulo 11 check digit, computed + * over the barcode the line encodes, replaces the first digit of `tail`. + * + * Shared by `generateBoleto` and by the `boletos` arbitrary of the test suite + * (`src/_internals/test/document-arbitraries.ts`), so that both build a boleto the same way, from + * random digits or from digits a property-based test can shrink. + * + * @param {BoletoBancarioParts} parts - The free digits. + * @returns {string} The linha digitável, digits only. + * + * @example + * ```typescript + * assembleBoletoBancario({ + * field1: "001900000", + * field2: "0000000000", + * field3: "0000000000", + * tail: "000000000000000", + * }); // "00190000090000000000000000000000500000000000000" + * ``` + */ +export const assembleBoletoBancario = ({ + field1, + field2, + field3, + tail, +}: BoletoBancarioParts): string => { + const line = + field1 + + mod10(field1).toString() + + field2 + + mod10(field2).toString() + + field3 + + mod10(field3).toString() + + tail; + const barcodeWithoutCheck = + line.slice(0, 4) + + line.slice(33, 47) + + line.slice(4, 9) + + line.slice(10, 20) + + line.slice(21, 31); + + return line.slice(0, 32) + mod11(barcodeWithoutCheck).toString() + line.slice(33); +}; diff --git a/src/_internals/test/boleto-arbitraries.ts b/src/_internals/test/boleto-arbitraries.ts new file mode 100644 index 000000000..abad2bb88 --- /dev/null +++ b/src/_internals/test/boleto-arbitraries.ts @@ -0,0 +1,26 @@ +import * as fc from "fast-check"; + +import { assembleBoletoArrecadacao } from "../assemble-boleto-arrecadacao/assemble-boleto-arrecadacao"; +import { assembleBoletoBancario } from "../assemble-boleto-bancario/assemble-boleto-bancario"; +import { ARRECADACAO_SEGMENTS } from "../constants/arrecadacao"; +import { digits } from "./arbitraries"; + +/** Arbitraries of valid boletos; see `document-arbitraries.ts`. */ + +/** + * @param {"bancario" | "arrecadacao"} [type] The type of the boletos; bancário by default. + * @returns {fc.Arbitrary} Valid linhas digitáveis, unmasked. + */ +export const boletos = (type?: "bancario" | "arrecadacao"): fc.Arbitrary => + type === "arrecadacao" + ? fc + .record({ + segment: fc.constantFrom(...ARRECADACAO_SEGMENTS), + useMod11: fc.boolean(), + hasEffectiveValue: fc.boolean(), + body: digits(40), + }) + .map((parts) => assembleBoletoArrecadacao(parts)) + : fc + .record({ field1: digits(9), field2: digits(10), field3: digits(10), tail: digits(15) }) + .map((parts) => assembleBoletoBancario(parts)); diff --git a/src/_internals/test/document-arbitraries.ts b/src/_internals/test/document-arbitraries.ts new file mode 100644 index 000000000..4d8f90d2d --- /dev/null +++ b/src/_internals/test/document-arbitraries.ts @@ -0,0 +1,83 @@ +import * as fc from "fast-check"; + +import { calculateCnhFirstVerifier } from "../calculate-cnh-first-verifier/calculate-cnh-first-verifier"; +import { calculateCnhSecondVerifier } from "../calculate-cnh-second-verifier/calculate-cnh-second-verifier"; +import { calculateCnpjCheckDigit } from "../calculate-cnpj-check-digit/calculate-cnpj-check-digit"; +import { calculateCpfCheckDigit } from "../calculate-cpf-check-digit/calculate-cpf-check-digit"; +import { calculatePisCheckDigit } from "../calculate-pis-check-digit/calculate-pis-check-digit"; +import { CNPJ_FIRST_DIGIT_WEIGHTS, CNPJ_SECOND_DIGIT_WEIGHTS } from "../constants/cnpj"; + +/** + * Arbitraries of valid documents, for the properties that need one ("a valid CPF stays valid under + * any mask"). A property must not call a `generate*` utility for that: those draw from + * `Math.random()`, which the seed fast-check reports on a failure does not control, so the failing + * document could be neither replayed nor shrunk. These are built from fast-check primitives (the + * free digits plus the check digits computed from them) and are drawn inside a property with + * `fc.gen()`: `fc.property(fc.gen(), (g) => { const cpf = g(cpfs); ... })`. + * + * The `generate*` utilities keep their own tests; these do not replace them. The voter ID and + * processo arbitraries are in `registry-arbitraries.ts`, the boleto ones in `boleto-arbitraries.ts` + * and the phone ones in `phone-arbitraries.ts`: a file imports at most ten modules. + */ + +const NUMERIC = "0123456789"; + +const ALPHANUMERIC = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +/** + * Strings of `length` characters of `alphabet` that are never one character repeated: the second + * character is the first one moved by a non-zero offset, so a document that rejects a repeated + * base (`000.000.000-00`) is valid by construction, with nothing filtered out. + * @param {string} alphabet The characters to draw from, two or more. + * @param {number} length How many characters the string holds, two or more. + * @returns {fc.Arbitrary} Strings whose first two characters differ. + */ +const unrepeated = (alphabet: string, length: number): fc.Arbitrary => + fc + .tuple( + fc.integer({ min: 0, max: alphabet.length - 1 }), + fc.integer({ min: 1, max: alphabet.length - 1 }), + fc.array(fc.integer({ min: 0, max: alphabet.length - 1 }), { + minLength: length - 2, + maxLength: length - 2, + }), + ) + .map(([first, offset, rest]) => + [first, (first + offset) % alphabet.length, ...rest] + .map((index) => alphabet.charAt(index)) + .join(""), + ); + +/** + * @returns {fc.Arbitrary} Valid CPFs, unmasked. + */ +export const cpfs = (): fc.Arbitrary => + unrepeated(NUMERIC, 9).map((base) => { + const first = String(calculateCpfCheckDigit(base)); + return base + first + String(calculateCpfCheckDigit(base + first)); + }); + +/** + * @param {1 | 2} [version] The CNPJ version: numeric (`1`, the default) or alphanumeric (`2`). + * @returns {fc.Arbitrary} Valid CNPJs of that version, unmasked. + */ +export const cnpjs = (version?: 1 | 2): fc.Arbitrary => + unrepeated(version === 2 ? ALPHANUMERIC : NUMERIC, 12).map((base) => { + const first = String(calculateCnpjCheckDigit(base, CNPJ_FIRST_DIGIT_WEIGHTS)); + return base + first + String(calculateCnpjCheckDigit(base + first, CNPJ_SECOND_DIGIT_WEIGHTS)); + }); + +/** + * @returns {fc.Arbitrary} Valid CNH numbers. + */ +export const cnhs = (): fc.Arbitrary => + unrepeated(NUMERIC, 9).map((base) => { + const { firstVerifier, decrement } = calculateCnhFirstVerifier(base); + return `${base}${firstVerifier}${calculateCnhSecondVerifier({ base, decrement })}`; + }); + +/** + * @returns {fc.Arbitrary} Valid PIS/PASEP numbers, unmasked. + */ +export const pisNumbers = (): fc.Arbitrary => + unrepeated(NUMERIC, 10).map((base) => `${base}${calculatePisCheckDigit(base)}`); diff --git a/src/_internals/test/phone-arbitraries.ts b/src/_internals/test/phone-arbitraries.ts new file mode 100644 index 000000000..330f68d85 --- /dev/null +++ b/src/_internals/test/phone-arbitraries.ts @@ -0,0 +1,52 @@ +import * as fc from "fast-check"; + +import { type GeneratePhoneType } from "../../generate-phone/generate-phone"; +import { VALID_AREA_CODES } from "../constants/area-codes"; +import { + SERVICE_PHONE_ABBREVIATED_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH, + SERVICE_PHONE_ABBREVIATED_ROOTS, + SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH, + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, +} from "../constants/service-phone"; +import { digits } from "./arbitraries"; + +/** Arbitraries of valid phone numbers; see `document-arbitraries.ts`. */ + +/** + * @param {readonly string[]} starts The prefixes to draw from. + * @param {number} rest How many digits follow the prefix. + * @returns {fc.Arbitrary} One of `starts` followed by `rest` digits. + */ +const prefixed = (starts: readonly string[], rest: number): fc.Arbitrary => + fc.tuple(fc.constantFrom(...starts), digits(rest)).map((parts) => parts.join("")); + +/** + * @param {GeneratePhoneType} [type] The type of the numbers; mobile or landline by default. + * @returns {fc.Arbitrary} Valid phone numbers, unmasked and without the country code. + */ +export const phones = (type?: GeneratePhoneType): fc.Arbitrary => { + if (type === "service") { + return fc.oneof( + prefixed( + SERVICE_PHONE_NON_GEOGRAPHIC_PREFIXES, + SERVICE_PHONE_NON_GEOGRAPHIC_LENGTH - SERVICE_PHONE_NON_GEOGRAPHIC_PREFIX_LENGTH, + ), + prefixed( + SERVICE_PHONE_ABBREVIATED_ROOTS, + SERVICE_PHONE_ABBREVIATED_LENGTH - SERVICE_PHONE_ABBREVIATED_ROOT_LENGTH, + ), + ); + } + + const areaCodes = VALID_AREA_CODES.map(String); + const mobile = prefixed(areaCodes, 8).map((value) => `${value.slice(0, 2)}9${value.slice(2)}`); + const landline = fc + .tuple(fc.constantFrom(...areaCodes), fc.integer({ min: 2, max: 6 }), digits(7)) + .map((parts) => parts.join("")); + + if (type === "mobile") return mobile; + + return type === "landline" ? landline : fc.oneof(mobile, landline); +}; diff --git a/src/_internals/test/registry-arbitraries.ts b/src/_internals/test/registry-arbitraries.ts new file mode 100644 index 000000000..e929d299f --- /dev/null +++ b/src/_internals/test/registry-arbitraries.ts @@ -0,0 +1,52 @@ +import * as fc from "fast-check"; + +import { UF_TO_VOTER_ID_CODE } from "../../is-valid-voter-id/constants"; +import { calculateProcessoJuridicoCheckDigits } from "../calculate-processo-juridico-check-digits/calculate-processo-juridico-check-digits"; +import { calculateVoterIdFirstDigit } from "../calculate-voter-id-first-digit/calculate-voter-id-first-digit"; +import { calculateVoterIdSecondDigit } from "../calculate-voter-id-second-digit/calculate-voter-id-second-digit"; +import { PROCESSO_JURIDICO_TRIBUNALS } from "../constants/processo-juridico"; +import { type StateCode } from "../constants/states"; +import { digits } from "./arbitraries"; + +/** Arbitraries of valid voter IDs and processo numbers; see `document-arbitraries.ts`. */ + +/** + * @param {StateCode | "ZZ"} [state] The state the voter IDs belong to; any of them by default. + * @returns {fc.Arbitrary} Valid voter IDs, unmasked. + */ +export const voterIds = (state?: StateCode | "ZZ"): fc.Arbitrary => + fc + .tuple( + digits(8), + state === undefined + ? fc.constantFrom(...Object.values(UF_TO_VOTER_ID_CODE)) + : fc.constant(UF_TO_VOTER_ID_CODE[state]), + ) + .map(([sequentialNumber, federativeUnion]) => { + const firstDigit = calculateVoterIdFirstDigit({ sequentialNumber, federativeUnion }); + const secondDigit = calculateVoterIdSecondDigit({ federativeUnion, firstDigit }); + return `${sequentialNumber}${federativeUnion}${firstDigit}${secondDigit}`; + }); + +/** + * @returns {fc.Arbitrary} Valid CNJ processo numbers, unmasked, with the órgão and the + * tribunal drawn from the pairs Resolução CNJ nº 65/2008 allows. + */ +export const processosJuridicos = (): fc.Arbitrary => { + const courtsAndTribunals = [...PROCESSO_JURIDICO_TRIBUNALS].flatMap(([court, tribunals]) => + tribunals.map((tribunal) => `${court}${String(tribunal).padStart(2, "0")}`), + ); + + return fc + .tuple( + digits(7), + fc.integer({ min: 1000, max: 9999 }), + fc.constantFrom(...courtsAndTribunals), + digits(4), + ) + .map(([sequential, year, courtAndTribunal, origin]) => { + const tail = `${year}${courtAndTribunal}${origin}`; + const checkDigits = calculateProcessoJuridicoCheckDigits(sequential + tail); + return `${sequential}${String(checkDigits).padStart(2, "0")}${tail}`; + }); +}; diff --git a/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts b/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts index 5fb761168..e2117b8e6 100644 --- a/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts +++ b/src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { licensePlates } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generateLicensePlate } from "../generate-license-plate/generate-license-plate"; import { getFormatLicensePlate } from "../get-format-license-plate/get-format-license-plate"; import { DIGIT_TO_MERCOSUL_LETTER } from "./constants"; import { convertLicensePlateToMercosul } from "./convert-license-plate-to-mercosul"; @@ -70,8 +70,8 @@ describe("convertLicensePlateToMercosul", () => { describe("properties", () => { test("should turn every old format plate into a Mercosul one", () => { fc.assert( - fc.property(fc.constant("LLLNNNN" as const), (format) => { - const plate = generateLicensePlate(format); + fc.property(fc.gen(), fc.constant("LLLNNNN" as const), (g, format) => { + const plate = g(licensePlates, format); const converted = convertLicensePlateToMercosul(plate); expect(getFormatLicensePlate(converted)).toBe("LLLNLNN"); @@ -84,8 +84,8 @@ describe("convertLicensePlateToMercosul", () => { test("should convert a plate only once", () => { fc.assert( - fc.property(fc.constantFrom("LLLNNNN", "LLLNLNN"), (format) => { - const converted = convertLicensePlateToMercosul(generateLicensePlate(format)); + fc.property(fc.gen(), fc.constantFrom("LLLNNNN", "LLLNLNN"), (g, format) => { + const converted = convertLicensePlateToMercosul(g(licensePlates, format)); expect(convertLicensePlateToMercosul(converted)).toBe(""); }), diff --git a/src/format-boleto/format-boleto.test.ts b/src/format-boleto/format-boleto.test.ts index 78c4581f6..2023f2253 100644 --- a/src/format-boleto/format-boleto.test.ts +++ b/src/format-boleto/format-boleto.test.ts @@ -2,8 +2,8 @@ import * as fc from "fast-check"; import { ARRECADACAO_LINE_LENGTH } from "../_internals/constants/arrecadacao"; import { BOLETO_LENGTH } from "../_internals/constants/boleto"; +import { boletos } from "../_internals/test/boleto-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generateBoleto } from "../generate-boleto/generate-boleto"; import { type FormatBoletoOptions, formatBoleto } from "./format-boleto"; describe("formatBoleto", () => { @@ -171,8 +171,8 @@ describe("formatBoleto", () => { test("should print a generated bank slip in the mask of its kind", () => { fc.assert( - fc.property(fc.constantFrom("bancario", "arrecadacao"), (type) => { - const value = generateBoleto({ type }); + fc.property(fc.gen(), fc.constantFrom("bancario", "arrecadacao"), (g, type) => { + const value = g(boletos, type); const mask = type === "arrecadacao" ? arrecadacaoMask : bancarioMask; expect(mask.test(formatBoleto(value))).toBe(true); diff --git a/src/format-phone/format-phone.test.ts b/src/format-phone/format-phone.test.ts index 082c7f916..4cc88c9cc 100644 --- a/src/format-phone/format-phone.test.ts +++ b/src/format-phone/format-phone.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generatePhone } from "../generate-phone/generate-phone"; import { parsePhone } from "../parse-phone/parse-phone"; import { type FormatPhoneOptions, type PhoneMask, formatPhone } from "./format-phone"; @@ -183,8 +183,8 @@ describe("formatPhone", () => { test("should print a generated number in E.164 and read it back", () => { fc.assert( - fc.property(fc.constantFrom(...geographic), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constantFrom(...geographic), (g, type) => { + const phone = g(phones, type); const formatted = formatPhone(phone, { mask: "e164" }); expect(formatted).toBe(`+55${phone}`); @@ -195,8 +195,8 @@ describe("formatPhone", () => { test("should keep every digit of a generated number under the auto mask", () => { fc.assert( - fc.property(fc.constantFrom(...geographic), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constantFrom(...geographic), (g, type) => { + const phone = g(phones, type); const international = formatPhone(phone, { mask: "international" }); expect(parsePhone(formatPhone(phone, { mask: "auto" }))).toBe(phone); diff --git a/src/generate-boleto/generate-boleto.ts b/src/generate-boleto/generate-boleto.ts index 6a46e4866..c1cccb07d 100644 --- a/src/generate-boleto/generate-boleto.ts +++ b/src/generate-boleto/generate-boleto.ts @@ -1,7 +1,7 @@ -import { ARRECADACAO_PRODUCT, ARRECADACAO_SEGMENTS } from "../_internals/constants/arrecadacao"; +import { assembleBoletoArrecadacao } from "../_internals/assemble-boleto-arrecadacao/assemble-boleto-arrecadacao"; +import { assembleBoletoBancario } from "../_internals/assemble-boleto-bancario/assemble-boleto-bancario"; +import { ARRECADACAO_SEGMENTS } from "../_internals/constants/arrecadacao"; import { generateRandomNumber } from "../_internals/generate-random-number/generate-random-number"; -import { mod10 } from "../_internals/mod10/mod10"; -import { mod11 } from "../_internals/mod11/mod11"; /** The parameters of `generateBoleto`. */ export type GenerateBoletoParams = { @@ -9,63 +9,21 @@ export type GenerateBoletoParams = { type?: "bancario" | "arrecadacao"; }; -const ARRECADACAO_VALUE_IDENTIFIERS = { - mod10: { effective: "6", reference: "7" }, - mod11: { effective: "8", reference: "9" }, -}; - -const generateBancario = (): string => { - const p1Base = generateRandomNumber(9); - const p2Base = generateRandomNumber(10); - const p3Base = generateRandomNumber(10); - const lastDigits = generateRandomNumber(15); - - const line = - p1Base + - mod10(p1Base).toString() + - p2Base + - mod10(p2Base).toString() + - p3Base + - mod10(p3Base).toString() + - lastDigits; - - const boletoWithoutCheck = - line.slice(0, 4) + - line.slice(33, 47) + - line.slice(4, 9) + - line.slice(10, 20) + - line.slice(21, 31); - - const mainCheck = mod11(boletoWithoutCheck); - - return line.slice(0, 32) + mainCheck.toString() + line.slice(33); -}; - -const generateArrecadacao = (): string => { - const segment = ARRECADACAO_SEGMENTS[Math.floor(Math.random() * ARRECADACAO_SEGMENTS.length)]; - const useMod11 = Math.random() < 0.5; - const hasEffectiveValue = Math.random() < 0.5; - const checkDigit = useMod11 - ? (value: string): number => mod11(value, { variant: "arrecadacao" }) - : mod10; - const identifier = - ARRECADACAO_VALUE_IDENTIFIERS[useMod11 ? "mod11" : "mod10"][ - hasEffectiveValue ? "effective" : "reference" - ]; - - const body = generateRandomNumber(40); - const head = `${ARRECADACAO_PRODUCT}${segment}${identifier}`; - const barcode = head + checkDigit(head + body) + body; - - let line = ""; - - for (let block = 0; block < 4; block++) { - const value = barcode.slice(block * 11, block * 11 + 11); - line += value + checkDigit(value); - } - - return line; -}; +const generateBancario = (): string => + assembleBoletoBancario({ + field1: generateRandomNumber(9), + field2: generateRandomNumber(10), + field3: generateRandomNumber(10), + tail: generateRandomNumber(15), + }); + +const generateArrecadacao = (): string => + assembleBoletoArrecadacao({ + segment: ARRECADACAO_SEGMENTS[Math.floor(Math.random() * ARRECADACAO_SEGMENTS.length)], + useMod11: Math.random() < 0.5, + hasEffectiveValue: Math.random() < 0.5, + body: generateRandomNumber(40), + }); /** * Generates a valid random Brazilian bank slip (boleto) number. diff --git a/src/get-boleto-info/get-boleto-info.test.ts b/src/get-boleto-info/get-boleto-info.test.ts index b4fca4845..addda272b 100644 --- a/src/get-boleto-info/get-boleto-info.test.ts +++ b/src/get-boleto-info/get-boleto-info.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { boletos } from "../_internals/test/boleto-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generateBoleto } from "../generate-boleto/generate-boleto"; import { isValidBoleto } from "../is-valid-boleto/is-valid-boleto"; import { type BoletoInfo, type GetBoletoInfoOptions, getBoletoInfo } from "./get-boleto-info"; @@ -206,8 +206,8 @@ describe("getBoletoInfo", () => { describe("properties", () => { test("should read the bank code and the amount of a generated bank slip", () => { fc.assert( - fc.property(fc.date({ noInvalidDate: true }), (referenceDate) => { - const value = generateBoleto(); + fc.property(fc.gen(), fc.date({ noInvalidDate: true }), (g, referenceDate) => { + const value = g(boletos); const info = getBoletoInfo(value, { referenceDate }); expect(info?.bankCode).toBe(value.slice(0, 3)); @@ -219,8 +219,8 @@ describe("getBoletoInfo", () => { test("should describe a generated arrecadação bank slip", () => { fc.assert( - fc.property(fc.constant("arrecadacao" as const), (type) => { - const info = getBoletoInfo(generateBoleto({ type })); + fc.property(fc.gen(), fc.constant("arrecadacao" as const), (g, type) => { + const info = getBoletoInfo(g(boletos, type)); expect(info?.type).toBe("arrecadacao"); expect(info?.bankCode).toBe(""); diff --git a/src/get-format-license-plate/get-format-license-plate.test.ts b/src/get-format-license-plate/get-format-license-plate.test.ts index d70aa44ee..5b40a9cfa 100644 --- a/src/get-format-license-plate/get-format-license-plate.test.ts +++ b/src/get-format-license-plate/get-format-license-plate.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { licensePlates } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generateLicensePlate } from "../generate-license-plate/generate-license-plate"; import { type LicensePlateFormat, getFormatLicensePlate } from "./get-format-license-plate"; describe("getFormatLicensePlate", () => { @@ -32,8 +32,8 @@ describe("getFormatLicensePlate", () => { describe("properties", () => { test("should name the format of every generated plate", () => { fc.assert( - fc.property(fc.constantFrom("LLLNNNN", "LLLNLNN"), (format) => { - const plate = generateLicensePlate(format); + fc.property(fc.gen(), fc.constantFrom("LLLNNNN", "LLLNLNN"), (g, format) => { + const plate = g(licensePlates, format); expect(getFormatLicensePlate(plate)).toBe(format); expect(getFormatLicensePlate(plate.toLowerCase())).toBe(format); diff --git a/src/get-pix-payload-info/get-pix-payload-info.test.ts b/src/get-pix-payload-info/get-pix-payload-info.test.ts index 79b1765f3..d9c990998 100644 --- a/src/get-pix-payload-info/get-pix-payload-info.test.ts +++ b/src/get-pix-payload-info/get-pix-payload-info.test.ts @@ -1,6 +1,7 @@ import * as fc from "fast-check"; import { crc16Ccitt } from "../_internals/crc16-ccitt/crc16-ccitt"; +import { cpfs } from "../_internals/test/document-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePixPayload } from "../generate-pix-payload/generate-pix-payload"; @@ -566,8 +567,8 @@ describe("getPixPayloadInfo", () => { test("should return null when the CRC does not match the payload", () => { fc.assert( - fc.property(names, fc.integer({ min: 0, max: 3 }), (merchantName, index) => { - const key = generateCpf(); + fc.property(fc.gen(), names, fc.integer({ min: 0, max: 3 }), (g, merchantName, index) => { + const key = g(cpfs); const payload = generatePixPayload({ key, merchantName, merchantCity: "BRASILIA" }); const crc = (payload ?? "").slice(-4); const replacement = crc.charAt(index) === "0" ? "1" : "0"; diff --git a/src/is-valid-boleto/is-valid-boleto.test.ts b/src/is-valid-boleto/is-valid-boleto.test.ts index df85e9a79..e406d5c03 100644 --- a/src/is-valid-boleto/is-valid-boleto.test.ts +++ b/src/is-valid-boleto/is-valid-boleto.test.ts @@ -1,8 +1,8 @@ import * as fc from "fast-check"; import { BOLETO_LENGTH } from "../_internals/constants/boleto"; +import { boletos } from "../_internals/test/boleto-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generateBoleto } from "../generate-boleto/generate-boleto"; import { isValidBoleto } from "./is-valid-boleto"; describe("isValidBoleto", () => { @@ -133,16 +133,16 @@ describe("isValidBoleto", () => { test("should accept every generated bank slip", () => { fc.assert( - fc.property(fc.constantFrom(...types), (type) => { - expect(isValidBoleto(generateBoleto({ type }))).toBe(true); + fc.property(fc.gen(), fc.constantFrom(...types), (g, type) => { + expect(isValidBoleto(g(boletos, type))).toBe(true); }), ); }); test("should ignore the mask characters of a generated bank slip", () => { fc.assert( - fc.property(fc.constantFrom(...types), (type) => { - const value = generateBoleto({ type }); + fc.property(fc.gen(), fc.constantFrom(...types), (g, type) => { + const value = g(boletos, type); const masked = `${value.slice(0, 5)}. ${value.slice(5, 20)}-${value.slice(20)}`; expect(isValidBoleto(masked)).toBe(true); diff --git a/src/is-valid-cnh/is-valid-cnh.test.ts b/src/is-valid-cnh/is-valid-cnh.test.ts index 0e62eba9f..0e69c87db 100644 --- a/src/is-valid-cnh/is-valid-cnh.test.ts +++ b/src/is-valid-cnh/is-valid-cnh.test.ts @@ -1,9 +1,9 @@ import * as fc from "fast-check"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; +import { cnhs } from "../_internals/test/document-arbitraries"; import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generateCnh } from "../generate-cnh/generate-cnh"; import { isValidCnh } from "./is-valid-cnh"; describe("isValidCnh", () => { @@ -49,8 +49,8 @@ describe("isValidCnh", () => { describe("properties", () => { test("should accept a generated CNH whatever mask characters surround its digits", () => { fc.assert( - fc.property(maskSeparators([".", "-", " "], 3, 3), (separators) => { - const cnh = generateCnh(); + fc.property(fc.gen(), maskSeparators([".", "-", " "], 3, 3), (g, separators) => { + const cnh = g(cnhs); const base = `${separators[0]}${cnh.slice(0, 9)}${separators[1]}`; expect(isValidCnh(`${base}${cnh.slice(9)}${separators[2]}`)).toBe(true); diff --git a/src/is-valid-cnpj/is-valid-cnpj.test.ts b/src/is-valid-cnpj/is-valid-cnpj.test.ts index 23ce07304..473b2fd16 100644 --- a/src/is-valid-cnpj/is-valid-cnpj.test.ts +++ b/src/is-valid-cnpj/is-valid-cnpj.test.ts @@ -2,6 +2,7 @@ import * as fc from "fast-check"; import { CNPJ_LENGTH } from "../_internals/constants/cnpj"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; +import { cnpjs } from "../_internals/test/document-arbitraries"; import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { isValidCnpj, type IsValidCnpjOptions } from "./is-valid-cnpj"; @@ -172,8 +173,8 @@ describe("isValidCnpj", () => { test("should accept a generated CNPJ written with any of the documented masks", () => { fc.assert( - fc.property(version, masks, (currentVersion, separators) => { - const cnpj = generateCnpj(currentVersion); + fc.property(fc.gen(), version, masks, (g, currentVersion, separators) => { + const cnpj = g(cnpjs, currentVersion); const head = `${cnpj.slice(0, 2)}${separators[0]}${cnpj.slice(2, 5)}`; const body = `${separators[1]}${cnpj.slice(5, 8)}${separators[2]}`; const tail = `${cnpj.slice(8, 12)}${separators[3]}${cnpj.slice(12)}`; diff --git a/src/is-valid-cpf/is-valid-cpf.test.ts b/src/is-valid-cpf/is-valid-cpf.test.ts index e639dcdb0..087bf0b77 100644 --- a/src/is-valid-cpf/is-valid-cpf.test.ts +++ b/src/is-valid-cpf/is-valid-cpf.test.ts @@ -2,6 +2,7 @@ import * as fc from "fast-check"; import { CPF_LENGTH } from "../_internals/constants/cpf"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; +import { cpfs } from "../_internals/test/document-arbitraries"; import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; import { bench, describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCpf } from "../generate-cpf/generate-cpf"; @@ -124,8 +125,8 @@ describe("isValidCpf", () => { test("should accept a generated CPF written with any of the documented masks", () => { fc.assert( - fc.property(masks, spaces, spaces, (separators, before, after) => { - const cpf = generateCpf(); + fc.property(fc.gen(), masks, spaces, spaces, (g, separators, before, after) => { + const cpf = g(cpfs); const body = `${cpf.slice(0, 3)}${separators[0]}${cpf.slice(3, 6)}${separators[1]}${cpf.slice(6, 9)}${separators[2]}${cpf.slice(9)}`; expect(isValidCpf(`${before}${body}${after}`)).toBe(isValidCpf(cpf)); diff --git a/src/is-valid-landline-phone/is-valid-landline-phone.test.ts b/src/is-valid-landline-phone/is-valid-landline-phone.test.ts index 8923ec380..3a43e2e90 100644 --- a/src/is-valid-landline-phone/is-valid-landline-phone.test.ts +++ b/src/is-valid-landline-phone/is-valid-landline-phone.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generatePhone } from "../generate-phone/generate-phone"; import { isValidLandlinePhone } from "./is-valid-landline-phone"; describe("isValidLandlinePhone", () => { @@ -64,8 +64,8 @@ describe("isValidLandlinePhone", () => { describe("properties", () => { test("should accept every generated landline number, masked or not", () => { fc.assert( - fc.property(fc.constant("landline" as const), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constant("landline" as const), (g, type) => { + const phone = g(phones, type); const masked = `(${phone.slice(0, 2)}) ${phone.slice(2, 6)}-${phone.slice(6)}`; expect(isValidLandlinePhone(phone)).toBe(true); @@ -77,8 +77,8 @@ describe("isValidLandlinePhone", () => { test("should reject every generated mobile number", () => { fc.assert( - fc.property(fc.constant("mobile" as const), (type) => { - expect(isValidLandlinePhone(generatePhone(type))).toBe(false); + fc.property(fc.gen(), fc.constant("mobile" as const), (g, type) => { + expect(isValidLandlinePhone(g(phones, type))).toBe(false); }), ); }); diff --git a/src/is-valid-license-plate/is-valid-license-plate.test.ts b/src/is-valid-license-plate/is-valid-license-plate.test.ts index c729fa088..d9cc9cefd 100644 --- a/src/is-valid-license-plate/is-valid-license-plate.test.ts +++ b/src/is-valid-license-plate/is-valid-license-plate.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { licensePlates } from "../_internals/test/arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generateLicensePlate } from "../generate-license-plate/generate-license-plate"; import { getFormatLicensePlate } from "../get-format-license-plate/get-format-license-plate"; import { isValidLicensePlate } from "./is-valid-license-plate"; @@ -83,16 +83,16 @@ describe("isValidLicensePlate", () => { test("should accept every generated plate of both formats", () => { fc.assert( - fc.property(fc.constantFrom(...formats), (format) => { - expect(isValidLicensePlate(generateLicensePlate(format))).toBe(true); + fc.property(fc.gen(), fc.constantFrom(...formats), (g, format) => { + expect(isValidLicensePlate(g(licensePlates, format))).toBe(true); }), ); }); test("should ignore the separator and the case of a generated plate", () => { fc.assert( - fc.property(fc.constantFrom(...formats), (format) => { - const plate = generateLicensePlate(format); + fc.property(fc.gen(), fc.constantFrom(...formats), (g, format) => { + const plate = g(licensePlates, format); const masked = `${plate.slice(0, 3)}-${plate.slice(3)}`; expect(isValidLicensePlate(masked.toLowerCase())).toBe(true); diff --git a/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts b/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts index f2a6e1a86..0ecfbd02c 100644 --- a/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts +++ b/src/is-valid-mobile-phone/is-valid-mobile-phone.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generatePhone } from "../generate-phone/generate-phone"; import { type IsValidMobilePhoneOptions, type PhoneVersion, @@ -87,8 +87,8 @@ describe("isValidMobilePhone", () => { describe("properties", () => { test("should accept every generated mobile number under both rules", () => { fc.assert( - fc.property(fc.constantFrom(1, 2), (version) => { - const phone = generatePhone("mobile"); + fc.property(fc.gen(), fc.constantFrom(1, 2), (g, version) => { + const phone = g(phones, "mobile"); expect(isValidMobilePhone(phone, { version })).toBe(true); }), @@ -97,8 +97,8 @@ describe("isValidMobilePhone", () => { test("should ignore the mask and the country code of a generated mobile number", () => { fc.assert( - fc.property(fc.constant("mobile" as const), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constant("mobile" as const), (g, type) => { + const phone = g(phones, type); const masked = `(${phone.slice(0, 2)}) ${phone.slice(2, 7)}-${phone.slice(7)}`; expect(isValidMobilePhone(masked)).toBe(true); @@ -109,8 +109,8 @@ describe("isValidMobilePhone", () => { test("should reject every generated landline number", () => { fc.assert( - fc.property(fc.constant("landline" as const), (type) => { - expect(isValidMobilePhone(generatePhone(type))).toBe(false); + fc.property(fc.gen(), fc.constant("landline" as const), (g, type) => { + expect(isValidMobilePhone(g(phones, type))).toBe(false); }), ); }); diff --git a/src/is-valid-phone/is-valid-phone.test.ts b/src/is-valid-phone/is-valid-phone.test.ts index 49b8051ef..1d83215df 100644 --- a/src/is-valid-phone/is-valid-phone.test.ts +++ b/src/is-valid-phone/is-valid-phone.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generatePhone } from "../generate-phone/generate-phone"; import { type IsValidPhoneOptions, type PhoneType, @@ -117,8 +117,8 @@ describe("isValidPhone", () => { test("should accept every generated geographic number, masked or not", () => { fc.assert( - fc.property(fc.constantFrom(...geographic), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constantFrom(...geographic), (g, type) => { + const phone = g(phones, type); const masked = `(${phone.slice(0, 2)}) ${phone.slice(2, -4)}-${phone.slice(-4)}`; expect(isValidPhone(phone)).toBe(true); @@ -131,8 +131,8 @@ describe("isValidPhone", () => { test("should only accept a generated service number when asked to", () => { fc.assert( - fc.property(fc.constant("service" as const), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constant("service" as const), (g, type) => { + const phone = g(phones, type); expect(isValidPhone(phone)).toBe(false); expect(isValidPhone(phone, { accept: ["service"] })).toBe(true); @@ -142,8 +142,8 @@ describe("isValidPhone", () => { test("should accept nothing when no kind of number is accepted", () => { fc.assert( - fc.property(fc.constantFrom("mobile", "landline", "service"), (type) => { - expect(isValidPhone(generatePhone(type), { accept: [] })).toBe(false); + fc.property(fc.gen(), fc.constantFrom("mobile", "landline", "service"), (g, type) => { + expect(isValidPhone(g(phones, type), { accept: [] })).toBe(false); }), ); }); diff --git a/src/is-valid-pis/is-valid-pis.test.ts b/src/is-valid-pis/is-valid-pis.test.ts index b379fe1bd..d6252c365 100644 --- a/src/is-valid-pis/is-valid-pis.test.ts +++ b/src/is-valid-pis/is-valid-pis.test.ts @@ -2,9 +2,9 @@ import * as fc from "fast-check"; import { PIS_LENGTH } from "../_internals/constants/pis"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; +import { pisNumbers } from "../_internals/test/document-arbitraries"; import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generatePis } from "../generate-pis/generate-pis"; import { isValidPis } from "./is-valid-pis"; const REPEATED_DIGITS = [ @@ -114,8 +114,8 @@ describe("isValidPis", () => { const masks = maskSeparators([".", "-", "/", " ", "(", ")", ",", "*"], 4, 3); fc.assert( - fc.property(masks, (separators) => { - const pis = generatePis(); + fc.property(fc.gen(), masks, (g, separators) => { + const pis = g(pisNumbers); const head = `${separators[0]}${pis.slice(0, 3)}${separators[1]}`; const tail = `${pis.slice(3, 8)}${separators[2]}${pis.slice(8)}`; diff --git a/src/is-valid-pix-key/is-valid-pix-key.test.ts b/src/is-valid-pix-key/is-valid-pix-key.test.ts index eca5f1b4e..de8fdcf65 100644 --- a/src/is-valid-pix-key/is-valid-pix-key.test.ts +++ b/src/is-valid-pix-key/is-valid-pix-key.test.ts @@ -1,9 +1,10 @@ import * as fc from "fast-check"; +import { cnpjs, cpfs } from "../_internals/test/document-arbitraries"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; import { generateCnpj } from "../generate-cnpj/generate-cnpj"; import { generateCpf } from "../generate-cpf/generate-cpf"; -import { generatePhone } from "../generate-phone/generate-phone"; import { type PixKeyType, getPixKeyInfo } from "../get-pix-key-info/get-pix-key-info"; import { type IsValidPixKeyOptions, isValidPixKey } from "./is-valid-pix-key"; @@ -110,10 +111,10 @@ describe("isValidPixKey", () => { test("should accept every kind of key the DICT defines", () => { fc.assert( - fc.property(emails, fc.uuid(), (email, evp) => { - const phone = `+55${generatePhone("mobile")}`; + fc.property(fc.gen(), emails, fc.uuid(), (g, email, evp) => { + const phone = `+55${g(phones, "mobile")}`; - for (const key of [generateCpf(), generateCnpj(), email, evp, phone]) { + for (const key of [g(cpfs), g(cnpjs), email, evp, phone]) { expect(isValidPixKey(key)).toBe(true); } }), diff --git a/src/is-valid-pix-payload/is-valid-pix-payload.test.ts b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts index 79e3344e2..8e3e141b3 100644 --- a/src/is-valid-pix-payload/is-valid-pix-payload.test.ts +++ b/src/is-valid-pix-payload/is-valid-pix-payload.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { cpfs } from "../_internals/test/document-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generateCpf } from "../generate-cpf/generate-cpf"; import { generatePixPayload } from "../generate-pix-payload/generate-pix-payload"; import { isValidPixPayload } from "./is-valid-pix-payload"; @@ -247,8 +247,8 @@ describe("isValidPixPayload", () => { test("should accept every generated payload", () => { fc.assert( - fc.property(names, cities, (merchantName, merchantCity) => { - const key = generateCpf(); + fc.property(fc.gen(), names, cities, (g, merchantName, merchantCity) => { + const key = g(cpfs); const payload = generatePixPayload({ key, merchantName, merchantCity }); expect(isValidPixPayload(payload ?? "")).toBe(true); @@ -259,8 +259,8 @@ describe("isValidPixPayload", () => { test("should reject a payload whose text was changed", () => { fc.assert( - fc.property(names, fc.nat(), (merchantName, offset) => { - const key = generateCpf(); + fc.property(fc.gen(), names, fc.nat(), (g, merchantName, offset) => { + const key = g(cpfs); const payload = generatePixPayload({ key, merchantName, merchantCity: "BRASILIA" }); const text = payload ?? ""; const index = offset % (text.length - 4); diff --git a/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts b/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts index 3c369f6ce..e76904b33 100644 --- a/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts +++ b/src/is-valid-processo-juridico/is-valid-processo-juridico.test.ts @@ -6,8 +6,8 @@ import { } from "../_internals/constants/processo-juridico"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; +import { processosJuridicos } from "../_internals/test/registry-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generateProcessoJuridico } from "../generate-processo-juridico/generate-processo-juridico"; import { isValidProcessoJuridico } from "./is-valid-processo-juridico"; describe("isValidProcessoJuridico", () => { @@ -161,8 +161,8 @@ describe("isValidProcessoJuridico", () => { describe("properties", () => { test("should accept a generated number whatever mask separates its fields", () => { fc.assert( - fc.property(maskSeparators([".", "-", " "], 5, 3), (separators) => { - const value = generateProcessoJuridico() as string; + fc.property(fc.gen(), maskSeparators([".", "-", " "], 5, 3), (g, separators) => { + const value = g(processosJuridicos); const head = `${value.slice(0, 7)}${separators[0]}${value.slice(7, 9)}`; const body = `${separators[1]}${value.slice(9, 13)}${separators[2]}`; const court = `${value.slice(13, 14)}${separators[3]}${value.slice(14, 16)}`; diff --git a/src/is-valid-service-phone/is-valid-service-phone.test.ts b/src/is-valid-service-phone/is-valid-service-phone.test.ts index f5c2b7efb..bec5243d5 100644 --- a/src/is-valid-service-phone/is-valid-service-phone.test.ts +++ b/src/is-valid-service-phone/is-valid-service-phone.test.ts @@ -1,8 +1,8 @@ import * as fc from "fast-check"; import { SERVICE_PHONE_UTILITY_CODES } from "../_internals/constants/service-phone"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, test } from "../_internals/test/runtime"; -import { generatePhone } from "../generate-phone/generate-phone"; import { isValidServicePhone } from "./is-valid-service-phone"; describe("isValidServicePhone", () => { @@ -115,8 +115,8 @@ describe("isValidServicePhone", () => { describe("properties", () => { test("should accept every generated service number", () => { fc.assert( - fc.property(fc.constant("service" as const), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constant("service" as const), (g, type) => { + const phone = g(phones, type); expect(isValidServicePhone(phone)).toBe(true); expect([8, 11].includes(phone.length)).toBe(true); @@ -134,8 +134,8 @@ describe("isValidServicePhone", () => { test("should reject every generated geographic number", () => { fc.assert( - fc.property(fc.constantFrom("mobile", "landline"), (type) => { - expect(isValidServicePhone(generatePhone(type))).toBe(false); + fc.property(fc.gen(), fc.constantFrom("mobile", "landline"), (g, type) => { + expect(isValidServicePhone(g(phones, type))).toBe(false); }), ); }); diff --git a/src/is-valid-voter-id/is-valid-voter-id.test.ts b/src/is-valid-voter-id/is-valid-voter-id.test.ts index e4586e497..c23418094 100644 --- a/src/is-valid-voter-id/is-valid-voter-id.test.ts +++ b/src/is-valid-voter-id/is-valid-voter-id.test.ts @@ -2,6 +2,7 @@ import * as fc from "fast-check"; import { anyValue, digitsOfOtherLength, maskSeparators } from "../_internals/test/arbitraries"; import { expectAlwaysReturnsType, expectRejected } from "../_internals/test/properties"; +import { voterIds } from "../_internals/test/registry-arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; import { generateVoterId } from "../generate-voter-id/generate-voter-id"; import { isValidVoterId } from "./is-valid-voter-id"; @@ -110,8 +111,8 @@ describe("isValidVoterId", () => { describe("properties", () => { test("should accept a generated voter id whatever mask surrounds its digits", () => { fc.assert( - fc.property(maskSeparators([".", " "], 3, 3), (separators) => { - const voterId = generateVoterId(); + fc.property(fc.gen(), maskSeparators([".", " "], 3, 3), (g, separators) => { + const voterId = g(voterIds); const head = `${separators[0]}${voterId.slice(0, 8)}${separators[1]}`; expect(isValidVoterId(`${head}${voterId.slice(8)}${separators[2]}`)).toBe(true); diff --git a/src/parse-boleto/parse-boleto.test.ts b/src/parse-boleto/parse-boleto.test.ts index c016fcd8b..12153533e 100644 --- a/src/parse-boleto/parse-boleto.test.ts +++ b/src/parse-boleto/parse-boleto.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { boletos } from "../_internals/test/boleto-arbitraries"; import { bench, describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generateBoleto } from "../generate-boleto/generate-boleto"; import { getBoletoInfo } from "../get-boleto-info/get-boleto-info"; import { isValidBoleto } from "../is-valid-boleto/is-valid-boleto"; import { parseBoleto } from "./parse-boleto"; @@ -74,8 +74,8 @@ describe("parseBoleto", () => { test("should give back the digits of a masked generated bank slip", () => { fc.assert( - fc.property(fc.constantFrom("bancario", "arrecadacao"), (type) => { - const value = generateBoleto({ type }); + fc.property(fc.gen(), fc.constantFrom("bancario", "arrecadacao"), (g, type) => { + const value = g(boletos, type); const masked = `${value.slice(0, 5)}. ${value.slice(5, 20)}-${value.slice(20)}`; expect(parseBoleto(masked)).toBe(value); diff --git a/src/parse-phone/parse-phone.test.ts b/src/parse-phone/parse-phone.test.ts index 11c422660..c3a68618f 100644 --- a/src/parse-phone/parse-phone.test.ts +++ b/src/parse-phone/parse-phone.test.ts @@ -1,7 +1,7 @@ import * as fc from "fast-check"; +import { phones } from "../_internals/test/phone-arbitraries"; import { describe, expect, expectTypeOf, it, test } from "../_internals/test/runtime"; -import { generatePhone } from "../generate-phone/generate-phone"; import { parsePhone } from "./parse-phone"; describe("parsePhone", () => { @@ -78,8 +78,8 @@ describe("parsePhone", () => { test("should drop the country code of a generated number however it is written", () => { fc.assert( - fc.property(fc.constantFrom("mobile", "landline"), (type) => { - const phone = generatePhone(type); + fc.property(fc.gen(), fc.constantFrom("mobile", "landline"), (g, type) => { + const phone = g(phones, type); expect(parsePhone(phone)).toBe(phone); expect(parsePhone(`+55 ${phone}`)).toBe(phone); From 31c1e83c9fbb3df2178aa7be1347b067175eaaf9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:46:59 -0300 Subject: [PATCH 03/70] ci(datasets): describe what changed in the dataset refresh pull request The weekly Update datasets workflow already rebuilds every generated table (Banco Central, CBO, CFOP, IBGE, CNAE, CONCLA, NCM) and opens a pull request; its description now comes from scripts/data-summary.ts: per table, how many entries were added and removed, with a sample of each, and a reminder to check the source when a table shrinks. The title stops naming IBGE/CONCLA only. --- .github/workflows/datasets.yml | 15 +++-- scripts/data-summary.ts | 118 +++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 scripts/data-summary.ts diff --git a/.github/workflows/datasets.yml b/.github/workflows/datasets.yml index 3c3530574..f93a1466f 100644 --- a/.github/workflows/datasets.yml +++ b/.github/workflows/datasets.yml @@ -11,7 +11,7 @@ permissions: jobs: update-datasets: - name: Update IBGE/CONCLA datasets + name: Update the embedded datasets runs-on: ubuntu-latest # Only this job pushes the dataset branch and opens the pull request. permissions: @@ -43,14 +43,17 @@ jobs: echo "changed=true" >> "$GITHUB_OUTPUT" fi + # Written outside the workspace, so the pull request does not pick the file up as a change. + - name: Summarize what changed + if: steps.diff.outputs.changed == 'true' + run: node scripts/data-summary.ts "$RUNNER_TEMP/dataset-summary.md" + - name: Open pull request if: steps.diff.outputs.changed == 'true' uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11 with: - commit-message: "chore(data): update IBGE/CONCLA datasets" + commit-message: "chore(data): update the embedded datasets" branch: chore/update-datasets base: ${{ github.event.repository.default_branch }} - title: "chore(data): update IBGE/CONCLA datasets" - body: | - Automated weekly refresh of the cities, states and legal-natures - datasets sourced from IBGE/CONCLA. + title: "chore(data): update the embedded datasets" + body-path: ${{ runner.temp }}/dataset-summary.md diff --git a/scripts/data-summary.ts b/scripts/data-summary.ts new file mode 100644 index 000000000..3dd872471 --- /dev/null +++ b/scripts/data-summary.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node + +/** + * Writes the Markdown body of the dataset refresh pull request: for every generated dataset file + * that `npm run build:data` changed, how many entries were added and removed and a sample of + * them, read from `git diff`. The generated files hold about one entry per line, so a line + * diff is an entry diff: a changed entry shows up once as removed and once as added. + * + * Usage: + * node scripts/data-summary.ts [output.md] + * Print the summary, or write it to `output.md`. + */ + +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; + +/** The source each generated file is rebuilt from, as named in the file's own header. */ +const DATASETS: Record = { + "src/_internals/constants/banks.ts": "Banks (Banco Central, STR participants)", + "src/_internals/constants/cbo.ts": "CBO 2002 occupations (Ministério do Trabalho e Emprego)", + "src/_internals/constants/cfop.ts": "CFOP codes (CONFAZ, Convênio SINIEF s/nº 1970)", + "src/_internals/constants/cities.ts": "Municipalities (IBGE)", + "src/_internals/constants/cnae.ts": "CNAE subclasses (IBGE/CONCLA)", + "src/_internals/constants/states.ts": "States (IBGE)", + "src/is-valid-legal-nature/constants.ts": "Legal natures (IBGE/CONCLA)", + "src/is-valid-ncm/constants.ts": "NCM codes (Siscomex)", +}; + +const SAMPLE_SIZE = 15; + +/** + * Runs a command of the toolchain (resolved from `PATH`, as `scripts/data.ts` does with `node` and + * `vp`: the script only ever runs in a checkout, by a maintainer or by CI) and returns its output. + * @param {string} command - The executable. + * @param {string[]} args - Its arguments. + * @returns {string} What it printed. + */ +const capture = (command: string, args: string[]): string => + execFileSync(command, args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 }); + +/** + * @param {string} file - A path relative to the repository root. + * @returns {{ added: string[]; removed: string[] }} The lines the working tree adds to and removes + * from the committed file, without their `+`/`-` marker. + */ +const changedLines = (file: string): { added: string[]; removed: string[] } => { + const diff = capture("git", ["diff", "--unified=0", "--no-color", "--", file]); + const added: string[] = []; + const removed: string[] = []; + + for (const line of diff.split("\n")) { + if (line.startsWith("+++") || line.startsWith("---")) continue; + if (line.startsWith("+")) added.push(line.slice(1).trim()); + if (line.startsWith("-")) removed.push(line.slice(1).trim()); + } + + return { added: added.filter(Boolean), removed: removed.filter(Boolean) }; +}; + +/** + * @param {string} title - "Added" or "Removed". + * @param {string[]} lines - The entries. + * @returns {string[]} A collapsed Markdown block with up to `SAMPLE_SIZE` of them. + */ +const sample = (title: string, lines: string[]): string[] => { + if (lines.length === 0) return []; + + const rest = lines.length - SAMPLE_SIZE; + + return [ + `
${title} (${lines.length})`, + "", + "```text", + ...lines.slice(0, SAMPLE_SIZE), + ...(rest > 0 ? [`... and ${rest} more`] : []), + "```", + "", + "
", + "", + ]; +}; + +const sections: string[] = []; + +for (const [file, label] of Object.entries(DATASETS)) { + const { added, removed } = changedLines(file); + + if (added.length === 0 && removed.length === 0) continue; + + sections.push( + `### ${label}`, + "", + `\`${file}\`: ${added.length} line(s) added, ${removed.length} removed. A changed entry counts once on each side.`, + "", + ...sample("Added", added), + ...sample("Removed", removed), + ); +} + +const body = [ + "Automated refresh of the datasets the package embeds, rebuilt from their official sources by", + "`npm run build:data`. The check and the test suite passed on the result.", + "", + "Review each table against its source for plausibility before merging: a table that lost a", + "large share of its rows is a broken scraper or a source outage, not news.", + "", + "## What changed", + "", + ...(sections.length > 0 ? sections : ["No dataset file changed."]), +].join("\n"); + +const [output] = process.argv.slice(2); + +if (output === undefined) { + console.log(body); +} else { + writeFileSync(output, `${body}\n`); +} From e0c534623d284d3a7dbc70f1f2e6a92409632db8 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:46:59 -0300 Subject: [PATCH 04/70] ci(preview): publish every pull request's build to pkg.pr.new A Preview workflow builds the package and publishes it with pkg-pr-new (pinned to 0.0.88), so a change can be installed from its pull request before it is merged; the pkg.pr.new GitHub App posts the install command as a comment. The job needs no token and only contents: read, so it also runs for forks. It stays off until the app is installed and the PKG_PR_NEW repository variable is true. --- .github/workflows/preview.yml | 41 +++++++++++++++++++++++++++++++++++ .github/zizmor.yml | 1 + 2 files changed, 42 insertions(+) create mode 100644 .github/workflows/preview.yml diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml new file mode 100644 index 000000000..74b0f22cd --- /dev/null +++ b/.github/workflows/preview.yml @@ -0,0 +1,41 @@ +# Publishes every pull request's build to pkg.pr.new, so a change can be installed and tried before +# it is merged: `npm i https://pkg.pr.new/@brazilian-utils/brazilian-utils@`. The +# pkg.pr.new GitHub App (https://github.com/apps/pkg-pr-new), installed on this repository, posts +# the install command as a comment; the job itself needs no token and no write permission, which is +# why it can run for pull requests from forks. +name: Preview + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + preview: + name: Publish a preview to pkg.pr.new + # Off until the app is installed and the PKG_PR_NEW repository variable is set to `true`, so + # pull requests do not carry a failing check in the meantime. + if: ${{ vars.PKG_PR_NEW == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Run build + run: vp run build + + - name: Publish the preview + run: npx --yes pkg-pr-new@0.0.88 publish --compact --comment=update diff --git a/.github/zizmor.yml b/.github/zizmor.yml index dc83a972c..890da5459 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -10,5 +10,6 @@ rules: - datasets.yml - live-tests.yml - mutation.yml + - preview.yml - release.yml - tests.yml From b8d68e50e2c4d1c1f94783e516b728fd9057296b Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:46:59 -0300 Subject: [PATCH 05/70] ci(jsr): publish the package to JSR, from its TypeScript sources jsr.json names the package, its files and its exports: the root plus one subpath per utility folder, mirrored from the npm subpaths by scripts/jsr.ts (npm run build:jsr; the Check workflow fails when it is stale). The public API has no slow types, so nothing had to change in src/. - Tests workflow, Deno job: `deno publish --dry-run` on every pull request. - Release workflow: a publish-jsr job publishes through OIDC (id-token: write, no token stored) when a release is created, off until the JSR package exists, the repository is linked to it and the JSR_PUBLISH variable is true. - release-please bumps the version in jsr.json together with package.json. --- .github/workflows/check.yml | 3 + .github/workflows/release.yml | 39 +++++++++ .github/workflows/tests.yml | 5 ++ jsr.json | 151 ++++++++++++++++++++++++++++++++++ package.json | 1 + release-please-config.json | 4 +- scripts/jsr.ts | 48 +++++++++++ 7 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 jsr.json create mode 100644 scripts/jsr.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a6b2b227b..4818c94b4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -56,6 +56,9 @@ jobs: - name: Check llms.txt/llms-full.txt are up to date run: npm run build:llms && git diff --exit-code -- docs/llms.txt docs/llms-full.txt + - name: Check the JSR exports are up to date + run: npm run build:jsr && git diff --exit-code -- jsr.json + - name: Check the docs site shells and sitemap are up to date # --intent-to-add makes a shell written for a new page show up in the diff as well. run: npm run build:site && git add --intent-to-add docs && git diff --exit-code -- docs/sitemap.xml 'docs/*.html' 'docs/pt-br/*.html' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11bfe1d5a..d2e0e897a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,6 +26,13 @@ # 4. Save. No NPM_TOKEN secret is needed: npm exchanges the workflow's OIDC token for a # short-lived token automatically. # +# One-time JSR setup (done by a package maintainer, not by CI): +# 1. Sign in at jsr.io with GitHub and create the scope `brazilian-utils` and the package +# `brazilian-utils` in it. +# 2. Package Settings -> GitHub Actions -> link `brazilian-utils/javascript`. JSR then trusts +# this repository's OIDC token; no JSR token is stored anywhere. +# 3. Repository Settings -> Secrets and variables -> Actions -> Variables -> `JSR_PUBLISH` = `true`. +# # One-time GitHub Environment setup (done by a package maintainer, not by CI): # Repository Settings -> Environments -> New environment -> name it `npm`. No protection rules # are needed: the environment only exists so the npm trusted publisher can be bound to it. @@ -120,6 +127,38 @@ jobs: - name: Stage on npm run: npm stage publish --provenance --access public + publish-jsr: + name: Publish to JSR + needs: release-please + # JSR publishes through OIDC as well: no token is stored. The job stays off until a maintainer + # has created the @brazilian-utils/brazilian-utils package on jsr.io, linked this repository + # to it (package Settings -> GitHub Actions) and set the JSR_PUBLISH repository variable to + # `true`, so a release never fails on a registry that is not set up yet. + if: ${{ needs.release-please.outputs.release_created == 'true' && vars.JSR_PUBLISH == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + id-token: write + + steps: + - name: Checkout the release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.release-please.outputs.tag_name }} + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + + - name: Setup Deno + uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed # v2.0.5 + with: + deno-version: v2.x + + - name: Publish to JSR + run: deno publish + sbom: name: Record the SBOM of the release needs: release-please diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 09f28837d..867cfa08b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -97,6 +97,11 @@ jobs: - name: Run tests run: deno test --unstable-sloppy-imports --allow-net --allow-env src + # What JSR would reject at release time fails here instead: slow types in the public API, + # an export of jsr.json that does not resolve, a file outside the publish list. + - name: Dry-run the JSR publication + run: deno publish --dry-run + test-browsers: name: Test on Browsers (${{ matrix.browser }}) runs-on: ubuntu-latest diff --git a/jsr.json b/jsr.json new file mode 100644 index 000000000..4b41130dd --- /dev/null +++ b/jsr.json @@ -0,0 +1,151 @@ +{ + "name": "@brazilian-utils/brazilian-utils", + "version": "2.4.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts", + "./add-business-days": "./src/add-business-days/add-business-days.ts", + "./capitalize": "./src/capitalize/capitalize.ts", + "./convert-currency-to-words": "./src/convert-currency-to-words/convert-currency-to-words.ts", + "./convert-date-to-words": "./src/convert-date-to-words/convert-date-to-words.ts", + "./convert-license-plate-to-mercosul": "./src/convert-license-plate-to-mercosul/convert-license-plate-to-mercosul.ts", + "./convert-number-to-words": "./src/convert-number-to-words/convert-number-to-words.ts", + "./difference-in-business-days": "./src/difference-in-business-days/difference-in-business-days.ts", + "./format-boleto": "./src/format-boleto/format-boleto.ts", + "./format-caepf": "./src/format-caepf/format-caepf.ts", + "./format-cei": "./src/format-cei/format-cei.ts", + "./format-cep": "./src/format-cep/format-cep.ts", + "./format-certidao": "./src/format-certidao/format-certidao.ts", + "./format-cnae": "./src/format-cnae/format-cnae.ts", + "./format-cnh": "./src/format-cnh/format-cnh.ts", + "./format-cno": "./src/format-cno/format-cno.ts", + "./format-cnpj": "./src/format-cnpj/format-cnpj.ts", + "./format-cns": "./src/format-cns/format-cns.ts", + "./format-cpf": "./src/format-cpf/format-cpf.ts", + "./format-currency": "./src/format-currency/format-currency.ts", + "./format-iban": "./src/format-iban/format-iban.ts", + "./format-legal-nature": "./src/format-legal-nature/format-legal-nature.ts", + "./format-license-plate": "./src/format-license-plate/format-license-plate.ts", + "./format-ncm": "./src/format-ncm/format-ncm.ts", + "./format-nfe-key": "./src/format-nfe-key/format-nfe-key.ts", + "./format-passport": "./src/format-passport/format-passport.ts", + "./format-phone": "./src/format-phone/format-phone.ts", + "./format-pis": "./src/format-pis/format-pis.ts", + "./format-processo-juridico": "./src/format-processo-juridico/format-processo-juridico.ts", + "./format-voter-id": "./src/format-voter-id/format-voter-id.ts", + "./generate-boleto": "./src/generate-boleto/generate-boleto.ts", + "./generate-cep": "./src/generate-cep/generate-cep.ts", + "./generate-cnh": "./src/generate-cnh/generate-cnh.ts", + "./generate-cnpj": "./src/generate-cnpj/generate-cnpj.ts", + "./generate-cpf": "./src/generate-cpf/generate-cpf.ts", + "./generate-legal-nature": "./src/generate-legal-nature/generate-legal-nature.ts", + "./generate-license-plate": "./src/generate-license-plate/generate-license-plate.ts", + "./generate-passport": "./src/generate-passport/generate-passport.ts", + "./generate-phone": "./src/generate-phone/generate-phone.ts", + "./generate-pis": "./src/generate-pis/generate-pis.ts", + "./generate-pix-payload": "./src/generate-pix-payload/generate-pix-payload.ts", + "./generate-processo-juridico": "./src/generate-processo-juridico/generate-processo-juridico.ts", + "./generate-renavam": "./src/generate-renavam/generate-renavam.ts", + "./generate-voter-id": "./src/generate-voter-id/generate-voter-id.ts", + "./get-address-info-by-cep": "./src/get-address-info-by-cep/get-address-info-by-cep.ts", + "./get-area-code-info": "./src/get-area-code-info/get-area-code-info.ts", + "./get-area-codes-by-state": "./src/get-area-codes-by-state/get-area-codes-by-state.ts", + "./get-bank-by-code": "./src/get-bank-by-code/get-bank-by-code.ts", + "./get-bank-by-ispb": "./src/get-bank-by-ispb/get-bank-by-ispb.ts", + "./get-banks": "./src/get-banks/get-banks.ts", + "./get-boleto-info": "./src/get-boleto-info/get-boleto-info.ts", + "./get-cbo": "./src/get-cbo/get-cbo.ts", + "./get-cep-info-by-address": "./src/get-cep-info-by-address/get-cep-info-by-address.ts", + "./get-certidao-info": "./src/get-certidao-info/get-certidao-info.ts", + "./get-cfop": "./src/get-cfop/get-cfop.ts", + "./get-cities": "./src/get-cities/get-cities.ts", + "./get-cnae": "./src/get-cnae/get-cnae.ts", + "./get-format-license-plate": "./src/get-format-license-plate/get-format-license-plate.ts", + "./get-holidays": "./src/get-holidays/get-holidays.ts", + "./get-iban-info": "./src/get-iban-info/get-iban-info.ts", + "./get-legal-nature": "./src/get-legal-nature/get-legal-nature.ts", + "./get-legal-natures": "./src/get-legal-natures/get-legal-natures.ts", + "./get-legal-natures-by-category": "./src/get-legal-natures-by-category/get-legal-natures-by-category.ts", + "./get-municipalities": "./src/get-municipalities/get-municipalities.ts", + "./get-municipality": "./src/get-municipality/get-municipality.ts", + "./get-municipality-by-code": "./src/get-municipality-by-code/get-municipality-by-code.ts", + "./get-nfe-key-info": "./src/get-nfe-key-info/get-nfe-key-info.ts", + "./get-pix-key-info": "./src/get-pix-key-info/get-pix-key-info.ts", + "./get-pix-payload-info": "./src/get-pix-payload-info/get-pix-payload-info.ts", + "./get-state-by-ibge-code": "./src/get-state-by-ibge-code/get-state-by-ibge-code.ts", + "./get-state-code-by-name": "./src/get-state-code-by-name/get-state-code-by-name.ts", + "./get-state-name-by-code": "./src/get-state-name-by-code/get-state-name-by-code.ts", + "./get-states": "./src/get-states/get-states.ts", + "./get-timezone-by-state": "./src/get-timezone-by-state/get-timezone-by-state.ts", + "./is-business-day": "./src/is-business-day/is-business-day.ts", + "./is-holiday": "./src/is-holiday/is-holiday.ts", + "./is-valid-bank-account": "./src/is-valid-bank-account/is-valid-bank-account.ts", + "./is-valid-boleto": "./src/is-valid-boleto/is-valid-boleto.ts", + "./is-valid-caepf": "./src/is-valid-caepf/is-valid-caepf.ts", + "./is-valid-cbo": "./src/is-valid-cbo/is-valid-cbo.ts", + "./is-valid-cei": "./src/is-valid-cei/is-valid-cei.ts", + "./is-valid-cep": "./src/is-valid-cep/is-valid-cep.ts", + "./is-valid-certidao": "./src/is-valid-certidao/is-valid-certidao.ts", + "./is-valid-cfop": "./src/is-valid-cfop/is-valid-cfop.ts", + "./is-valid-cnae": "./src/is-valid-cnae/is-valid-cnae.ts", + "./is-valid-cnh": "./src/is-valid-cnh/is-valid-cnh.ts", + "./is-valid-cno": "./src/is-valid-cno/is-valid-cno.ts", + "./is-valid-cnpj": "./src/is-valid-cnpj/is-valid-cnpj.ts", + "./is-valid-cns": "./src/is-valid-cns/is-valid-cns.ts", + "./is-valid-cpf": "./src/is-valid-cpf/is-valid-cpf.ts", + "./is-valid-credit-card": "./src/is-valid-credit-card/is-valid-credit-card.ts", + "./is-valid-csosn": "./src/is-valid-csosn/is-valid-csosn.ts", + "./is-valid-cst": "./src/is-valid-cst/is-valid-cst.ts", + "./is-valid-email": "./src/is-valid-email/is-valid-email.ts", + "./is-valid-iban": "./src/is-valid-iban/is-valid-iban.ts", + "./is-valid-ie": "./src/is-valid-ie/is-valid-ie.ts", + "./is-valid-landline-phone": "./src/is-valid-landline-phone/is-valid-landline-phone.ts", + "./is-valid-legal-nature": "./src/is-valid-legal-nature/is-valid-legal-nature.ts", + "./is-valid-license-plate": "./src/is-valid-license-plate/is-valid-license-plate.ts", + "./is-valid-mobile-phone": "./src/is-valid-mobile-phone/is-valid-mobile-phone.ts", + "./is-valid-ncm": "./src/is-valid-ncm/is-valid-ncm.ts", + "./is-valid-nfe-key": "./src/is-valid-nfe-key/is-valid-nfe-key.ts", + "./is-valid-passport": "./src/is-valid-passport/is-valid-passport.ts", + "./is-valid-phone": "./src/is-valid-phone/is-valid-phone.ts", + "./is-valid-pis": "./src/is-valid-pis/is-valid-pis.ts", + "./is-valid-pix-key": "./src/is-valid-pix-key/is-valid-pix-key.ts", + "./is-valid-pix-payload": "./src/is-valid-pix-payload/is-valid-pix-payload.ts", + "./is-valid-processo-juridico": "./src/is-valid-processo-juridico/is-valid-processo-juridico.ts", + "./is-valid-registro-profissional": "./src/is-valid-registro-profissional/is-valid-registro-profissional.ts", + "./is-valid-renavam": "./src/is-valid-renavam/is-valid-renavam.ts", + "./is-valid-service-phone": "./src/is-valid-service-phone/is-valid-service-phone.ts", + "./is-valid-vin": "./src/is-valid-vin/is-valid-vin.ts", + "./is-valid-voter-id": "./src/is-valid-voter-id/is-valid-voter-id.ts", + "./parse-boleto": "./src/parse-boleto/parse-boleto.ts", + "./parse-caepf": "./src/parse-caepf/parse-caepf.ts", + "./parse-cbo": "./src/parse-cbo/parse-cbo.ts", + "./parse-cei": "./src/parse-cei/parse-cei.ts", + "./parse-cep": "./src/parse-cep/parse-cep.ts", + "./parse-certidao": "./src/parse-certidao/parse-certidao.ts", + "./parse-cfop": "./src/parse-cfop/parse-cfop.ts", + "./parse-cnae": "./src/parse-cnae/parse-cnae.ts", + "./parse-cnh": "./src/parse-cnh/parse-cnh.ts", + "./parse-cno": "./src/parse-cno/parse-cno.ts", + "./parse-cnpj": "./src/parse-cnpj/parse-cnpj.ts", + "./parse-cns": "./src/parse-cns/parse-cns.ts", + "./parse-cpf": "./src/parse-cpf/parse-cpf.ts", + "./parse-currency": "./src/parse-currency/parse-currency.ts", + "./parse-iban": "./src/parse-iban/parse-iban.ts", + "./parse-legal-nature": "./src/parse-legal-nature/parse-legal-nature.ts", + "./parse-license-plate": "./src/parse-license-plate/parse-license-plate.ts", + "./parse-ncm": "./src/parse-ncm/parse-ncm.ts", + "./parse-nfe-key": "./src/parse-nfe-key/parse-nfe-key.ts", + "./parse-passport": "./src/parse-passport/parse-passport.ts", + "./parse-phone": "./src/parse-phone/parse-phone.ts", + "./parse-pis": "./src/parse-pis/parse-pis.ts", + "./parse-processo-juridico": "./src/parse-processo-juridico/parse-processo-juridico.ts", + "./parse-voter-id": "./src/parse-voter-id/parse-voter-id.ts", + "./remove-accents": "./src/remove-accents/remove-accents.ts", + "./sub-business-days": "./src/sub-business-days/sub-business-days.ts", + "./to-standard-schema": "./src/to-standard-schema/to-standard-schema.ts" + }, + "publish": { + "include": ["LICENSE", "README.md", "jsr.json", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts", "src/_internals/test/**"] + } +} diff --git a/package.json b/package.json index 475e1de5c..37cec62ae 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "build:data": "node ./scripts/data.ts", "build:llms": "node ./scripts/llms.ts", "build:site": "node ./scripts/site.ts", + "build:jsr": "node ./scripts/jsr.ts", "prepublishOnly": "vp run build", "check:dependencies": "node -e \"const d=require('./package.json').dependencies||{};if(Object.keys(d).length){console.error('runtime dependencies are not allowed:',Object.keys(d).join(', '));process.exit(1)}\"" }, diff --git a/release-please-config.json b/release-please-config.json index 35182664a..35b4d413d 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -22,6 +22,8 @@ { "type": "style", "section": "Style", "hidden": true } ], "packages": { - ".": {} + ".": { + "extra-files": [{ "type": "json", "path": "jsr.json", "jsonpath": "$.version" }] + } } } diff --git a/scripts/jsr.ts b/scripts/jsr.ts new file mode 100644 index 000000000..ea7dc977e --- /dev/null +++ b/scripts/jsr.ts @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +/** + * Keeps the `exports` of `jsr.json` in step with `src/`: the package root plus one subpath per + * utility folder, the same folders `vite.config.ts` turns into the npm subpaths, so + * `jsr:@brazilian-utils/brazilian-utils/is-valid-cpf` resolves like its npm counterpart. JSR + * publishes the TypeScript sources as they are, so an export points at the `.ts` file. + * + * Everything else in `jsr.json` is left alone, the `version` in particular: release-please bumps + * it together with `package.json` (see `extra-files` in `release-please-config.json`). The Check + * workflow fails when the file is stale. + */ + +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const ROOT = join(import.meta.dirname, ".."); +const SRC_DIR = join(ROOT, "src"); +const JSR_PATH = join(ROOT, "jsr.json"); + +const utilNames = readdirSync(SRC_DIR, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith("_")) + .map((entry) => entry.name) + .filter((name) => existsSync(join(SRC_DIR, name, `${name}.ts`))) + .toSorted(); + +const exports: Record = { ".": "./src/index.ts" }; + +for (const name of utilNames) { + exports[`./${name}`] = `./src/${name}/${name}.ts`; +} + +// Only the `exports` block is rewritten, in place: the rest of the file keeps the layout the +// formatter gave it, so a second run (the staleness check of the Check workflow) finds nothing to +// change, and the `version` release-please maintains is never touched. +const EXPORTS_BLOCK = /"exports": \{[^}]*\}/; +const current = readFileSync(JSR_PATH, "utf8"); + +if (!EXPORTS_BLOCK.test(current)) { + throw new Error("jsr.json has no exports block"); +} + +const block = JSON.stringify(exports, null, "\t").replaceAll("\n", "\n\t"); + +writeFileSync( + JSR_PATH, + current.replace(EXPORTS_BLOCK, () => `"exports": ${block}`), +); From ab6c6ad7de70621d0bc74a235e8d66675fb31556 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 09:46:59 -0300 Subject: [PATCH 06/70] docs(site): add a playground that runs the published package in the browser A Playground page (en and pt-BR): pick any exported function, edit its arguments as a JSON array and see the result. playground.js, loaded only on that page, imports the ESM build of the latest release from jsDelivr (unpkg as fallback), so there is no build step and the page runs what npm install gives. Arguments go through JSON.parse, never eval; a Date is written as {"$date": "..."}; async utilities are awaited and a rejection is shown, not thrown. `?fn=` opens on a given function. The UMD build is not used: jsDelivr serves .cjs as application/node with nosniff, which a browser refuses as a script. The pages join the sidebars, so build:site gives them their shells and sitemap entries. --- docs/404.html | 35 +++ docs/_sidebar.md | 1 + docs/getting-started.html | 35 +++ docs/getting-started.md | 2 +- docs/index.html | 35 +++ docs/llms-full.txt | 51 ++++- docs/llms.txt | 1 + docs/migration-v1-to-v2.html | 35 +++ docs/playground.html | 343 +++++++++++++++++++++++++++++ docs/playground.js | 227 +++++++++++++++++++ docs/playground.md | 11 + docs/pt-br/_sidebar.md | 1 + docs/pt-br/getting-started.html | 35 +++ docs/pt-br/getting-started.md | 2 +- docs/pt-br/index.html | 35 +++ docs/pt-br/migration-v1-to-v2.html | 35 +++ docs/pt-br/playground.html | 343 +++++++++++++++++++++++++++++ docs/pt-br/playground.md | 11 + docs/pt-br/utilities.html | 35 +++ docs/sitemap.xml | 12 + docs/utilities.html | 35 +++ 21 files changed, 1317 insertions(+), 3 deletions(-) create mode 100644 docs/playground.html create mode 100644 docs/playground.js create mode 100644 docs/playground.md create mode 100644 docs/pt-br/playground.html create mode 100644 docs/pt-br/playground.md diff --git a/docs/404.html b/docs/404.html index 7ab45c672..8764230c9 100644 --- a/docs/404.html +++ b/docs/404.html @@ -96,6 +96,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -158,6 +179,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 478e19202..a91f26a06 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,3 +1,4 @@ * [Getting Started](getting-started.md) * [Utilities](utilities.md) +* [Playground](playground.md) * [Migration v1 to v2](migration-v1-to-v2.md) diff --git a/docs/getting-started.html b/docs/getting-started.html index aad70064a..aad4a3fad 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/getting-started.md b/docs/getting-started.md index b88032c2d..7830474b9 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,7 +63,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -You can check a list of utilities [by clicking here](utilities.md). +You can check a list of utilities [by clicking here](utilities.md), and run any of them in the browser in the [playground](playground.md). ## AI assistants diff --git a/docs/index.html b/docs/index.html index b42c1373e..80b634bd2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -100,6 +100,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -162,6 +183,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/llms-full.txt b/docs/llms-full.txt index 11a71008d..cc2d89694 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -145,6 +145,7 @@ - [isValidCsosn](#isvalidcsosn) - [capitalize](#capitalize) - [removeAccents](#removeaccents) + - [toStandardSchema](#tostandardschema) - [isValidIe](#isvalidie) - [isValidEmail](#isvalidemail) - [isValidCreditCard](#isvalidcreditcard) @@ -212,7 +213,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -You can check a list of utilities [by clicking here](utilities.md). +You can check a list of utilities [by clicking here](utilities.md), and run any of them in the browser in the [playground](playground.md). ### AI assistants @@ -2478,6 +2479,54 @@ removeAccents('Açaí'); // 'Acai' removeAccents(''); // '' ``` +### Standard Schema + +#### toStandardSchema + +Wraps an `isValid*` utility (or any function of the same shape) in a [Standard Schema](https://standardschema.dev), the interface that form libraries, routers and API frameworks accept as a validator whichever library produced it: TanStack Form, tRPC, Hono, react-hook-form and the rest, next to schemas made with Zod, Valibot or ArkType. The types of the specification are copied into the package, so nothing is installed. The schema validates synchronously and does not transform: a valid value comes back as it was given, an invalid one yields a single issue. `config.options` (part of `ToStandardSchemaOptions`) is handed to the validator on every call, and `config.message` is the message of that issue (default `'Invalid value'`). Like the validators it wraps it never throws: a first argument that is not a function gives a schema that rejects everything, and a `message` that is not a string falls back to the default. Validators that take a single object (`isValidBankAccount`, `isValidRegistroProfissional`, `isValidIe`) work the same way, the object being the value under validation; wrap the overloaded `isValidIe` in an arrow function, `toStandardSchema((params) => isValidIe(params))`. The types `StandardSchemaV1`, `StandardSchemaV1Result`, `StandardSchemaV1Issue` and the others of the specification are exported too. + +```javascript +import { isValidCnpj, isValidCpf, toStandardSchema } from '@brazilian-utils/brazilian-utils'; + +const cpf = toStandardSchema(isValidCpf, { message: 'CPF inválido' }); + +cpf['~standard'].validate('123.456.789-09'); // { value: '123.456.789-09' } +cpf['~standard'].validate('123'); // { issues: [{ message: 'CPF inválido' }] } + +const cnpj = toStandardSchema(isValidCnpj, { options: { version: 2 } }); // alphanumeric CNPJ +``` + +Anything that takes a Standard Schema takes it as is, a TanStack Form field for example: + +```javascript + +``` + +Inside a Zod or Valibot schema the validators plug in directly, no wrapper needed, and the result is itself a Standard Schema, which is how a whole form is validated with react-hook-form: + +```javascript +import { standardSchemaResolver } from '@hookform/resolvers/standard-schema'; +import { isValidCep, isValidCpf } from '@brazilian-utils/brazilian-utils'; +import { useForm } from 'react-hook-form'; +import * as v from 'valibot'; +import { z } from 'zod'; + +// Zod +const zodSchema = z.object({ + cpf: z.string().refine(isValidCpf, 'CPF inválido'), + cep: z.string().refine(isValidCep, 'CEP inválido'), +}); + +// Valibot +const valibotSchema = v.object({ + cpf: v.pipe(v.string(), v.check(isValidCpf, 'CPF inválido')), + cep: v.pipe(v.string(), v.check(isValidCep, 'CEP inválido')), +}); + +// react-hook-form, with either one +const form = useForm({ resolver: standardSchemaResolver(zodSchema) }); +``` + ### isValidIe Check if inscrição estadual (state registration) is valid. The state code is case-insensitive. Notable per-state rules: GO accepts prefixes `10`, `11` and `15`; PA accepts `15` and `75`-`79`; MS accepts `28` and `50`; SP has a produtor rural pattern `P0MMMSSSSD000`; TO uses 11-digit type codes (`01`, `02`, `03`, `99`). TO also accepts a 9-digit form, applying the same modulus 11 rule to the first eight digits; the SINTEGRA page documents only the 11-digit one, so that shape is 2.3.0 behaviour kept for compatibility rather than a published rule. An all-zero registration is accepted wherever the published formula yields a check digit of 0 for it (AM, BA with 8 or 9 digits, CE, ES, MG, MT, PB, PE, PI, PR, RJ, RS, SC, SE, SP and TO with 9 digits), unlike `isValidCpf` and `isValidCnpj`, which reject repeated digits. AM is on that list through the second branch of its published formula only: the page's first branch, `Se Soma < 11 Então Dígito = 11 - Soma`, gives 11 for an all-zero registration, while the `resto <= 1 ⇒ 0` branch, the one implemented here, gives 0. The registration and the state code go together in a single object, typed as `IsValidIeParams`; the 2.3.0 form, `isValidIe(stateCode, ie)`, still works and is deprecated. diff --git a/docs/llms.txt b/docs/llms.txt index 763684371..f68090fe5 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -177,6 +177,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [differenceInBusinessDays](https://brazilian-utils.com.br/utilities.md#differenceinbusinessdays): Count the number of Brazilian business days (dias úteis) between two dates, mirroring the semantics of date-fns' `differenceInBusinessDays` (verified against its source), argument order included: `differenceInBusinessDays(laterDate, earlierDate, options?)`. - [capitalize](https://brazilian-utils.com.br/utilities.md#capitalize): Transforms the first letter into a capital one of each word, the way a Brazilian name, company name or address is written, with no options needed. - [removeAccents](https://brazilian-utils.com.br/utilities.md#removeaccents): Remove diacritical marks (accents, tildes, cedillas) from a string, decomposing every accented character into its base letter plus combining marks (Unicode NFD) and dropping the combining marks. +- [toStandardSchema](https://brazilian-utils.com.br/utilities.md#tostandardschema): Wraps an `isValid*` utility (or any function of the same shape) in a Standard Schema, the interface that form libraries, routers and API frameworks accept as a validator whichever library produced it: TanStack Form, tRPC, Hono, react-hook-form and the rest, next to schemas made with Zod, Valibot or ArkType. ## Optional diff --git a/docs/migration-v1-to-v2.html b/docs/migration-v1-to-v2.html index 970fe9b30..f806ef16e 100644 --- a/docs/migration-v1-to-v2.html +++ b/docs/migration-v1-to-v2.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/playground.html b/docs/playground.html new file mode 100644 index 000000000..18354a50d --- /dev/null +++ b/docs/playground.html @@ -0,0 +1,343 @@ + + + + + + Playground · Brazilian Utils + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/docs/playground.js b/docs/playground.js new file mode 100644 index 000000000..59a72aa3d --- /dev/null +++ b/docs/playground.js @@ -0,0 +1,227 @@ +/* + * The "try it" playground of the docs site (playground.md): runs any exported function of the + * published package in the browser. Loaded by index.html only when a page has a #try-it + * element. The library itself is imported from a CDN, the ESM build of the latest release, so the + * playground needs no build step and always shows what `npm install` gives. + * + * Arguments are read with JSON.parse, never evaluated as code. + */ +(function () { + 'use strict'; + + var PACKAGE = '@brazilian-utils/brazilian-utils'; + var CDNS = ['https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/']; + + var TEXT = { + en: { + loading: 'Loading the latest release from the CDN...', + failed: 'Could not load the package from the CDN. Check the connection and reload.', + version: 'Running version', + fn: 'Function', + args: 'Arguments, as a JSON array', + hint: 'One entry per argument. A Date is written as {"$date": "2026-01-01"}.', + run: 'Run', + code: 'Code', + result: 'Result', + invalidJson: 'The arguments are not a valid JSON array: ', + notArray: 'The arguments must be a JSON array, e.g. ["123.456.789-09"].', + threw: 'Threw: ', + }, + 'pt-br': { + loading: 'Carregando a última versão publicada pelo CDN...', + failed: 'Não foi possível carregar o pacote pelo CDN. Verifique a conexão e recarregue.', + version: 'Rodando a versão', + fn: 'Função', + args: 'Argumentos, como um array JSON', + hint: 'Uma entrada por argumento. Uma Date é escrita como {"$date": "2026-01-01"}.', + run: 'Executar', + code: 'Código', + result: 'Resultado', + invalidJson: 'Os argumentos não são um array JSON válido: ', + notArray: 'Os argumentos precisam ser um array JSON, por exemplo ["123.456.789-09"].', + threw: 'Lançou: ', + }, + }; + + // The arguments a function opens with; anything not listed starts from its family's default. + var EXAMPLES = { + isValidCpf: ['123.456.789-09'], + formatCpf: ['12345678909'], + parseCpf: ['123.456.789-09'], + generateCpf: ['SP'], + isValidCnpj: ['Q0.SLF.MBD/7VX4-39', { version: 2 }], + formatCnpj: ['12345678000195'], + parseCnpj: ['12.345.678/0001-95'], + generateCnpj: [{ version: 2 }], + isValidCep: ['01310-100'], + formatCep: ['01310100'], + getAddressInfoByCep: ['01310100'], + isValidBoleto: ['00190000090114971860168524522114675860000102656'], + getBoletoInfo: ['00190000090114971860168524522114675860000102656'], + isValidPhone: ['(11) 98765-4321'], + formatPhone: ['11987654321'], + isValidPixKey: ['123.456.789-09'], + getPixKeyInfo: ['+5511987654321'], + formatCurrency: [1234.56], + parseCurrency: ['R$ 1.234,56'], + convertNumberToWords: [1234], + convertCurrencyToWords: [1234.56], + capitalize: ['JOSÉ DA SILVA LTDA'], + removeAccents: ['São Paulo'], + getHolidays: [2026], + isHoliday: [{ date: { $date: '2026-09-07' } }], + isBusinessDay: [{ $date: '2026-09-07' }], + addBusinessDays: [{ $date: '2026-09-04' }, 3], + getStates: [], + getMunicipalities: ['AC'], + getMunicipalityByCode: ['3550308'], + getBankByCode: ['001'], + getCfop: ['5.102'], + getCnae: ['6201-5/01'], + isValidIe: [{ value: '110042490114', stateCode: 'SP' }], + isValidLicensePlate: ['ABC1D23'], + isValidEmail: ['contato@exemplo.com.br'], + }; + + function defaultArgs(name) { + if (Object.prototype.hasOwnProperty.call(EXAMPLES, name)) return EXAMPLES[name]; + if (/^(generate|get[A-Z].*s$)/.test(name)) return []; + return ['']; + } + + function reviveDates(key, value) { + return value && typeof value === 'object' && typeof value.$date === 'string' ? new Date(value.$date) : value; + } + + function show(value) { + if (value === undefined) return 'undefined'; + if (value instanceof Date) return 'Date ' + value.toISOString(); + try { + return JSON.stringify(value, null, 2); + } catch (error) { + return String(value); + } + } + + function element(tag, attributes, children) { + var node = document.createElement(tag); + Object.keys(attributes || {}).forEach(function (name) { + node.setAttribute(name, attributes[name]); + }); + (children || []).forEach(function (child) { + node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); + }); + return node; + } + + function load(index) { + var base = CDNS[index]; + return fetch(base + PACKAGE + '/package.json') + .then(function (response) { + if (!response.ok) throw new Error(String(response.status)); + return response.json(); + }) + .then(function (manifest) { + return import(base + PACKAGE + '@' + manifest.version + '/dist/brazilian-utils.js').then(function (library) { + return { library: library, version: manifest.version }; + }); + }) + .catch(function (error) { + if (index + 1 < CDNS.length) return load(index + 1); + throw error; + }); + } + + var loaded; + + window.BrazilianUtilsPlayground = function (root) { + var text = location.pathname.indexOf('/pt-br') === 0 ? TEXT['pt-br'] : TEXT.en; + root.textContent = text.loading; + loaded = loaded || load(0); + + loaded.then( + function (result) { + var library = result.library; + var names = Object.keys(library) + .filter(function (name) { + return typeof library[name] === 'function' && /^[a-z]/.test(name); + }) + .sort(); + + var select = element('select', { id: 'playground-fn' }, names.map(function (name) { + return element('option', { value: name }, [name]); + })); + var args = element('textarea', { id: 'playground-args', rows: '4', spellcheck: 'false' }); + var run = element('button', { type: 'button' }, [text.run]); + var code = element('code'); + var output = element('pre', { 'aria-live': 'polite' }); + + function describe() { + var inner = args.value.trim().replace(/^\[/, '').replace(/\]$/, '').trim(); + code.textContent = + "import { " + select.value + " } from '" + PACKAGE + "';\n\n" + select.value + '(' + inner + ');'; + } + + function execute() { + var parsed; + describe(); + try { + parsed = JSON.parse(args.value || '[]', reviveDates); + } catch (error) { + output.textContent = text.invalidJson + error.message; + return; + } + if (!Array.isArray(parsed)) { + output.textContent = text.notArray; + return; + } + Promise.resolve() + .then(function () { + return library[select.value].apply(undefined, parsed); + }) + .then( + function (value) { + output.textContent = show(value); + }, + function (error) { + output.textContent = text.threw + (error && error.name ? error.name + ': ' + error.message : String(error)); + } + ); + } + + function pick(name) { + select.value = name; + args.value = JSON.stringify(defaultArgs(name)); + execute(); + } + + select.addEventListener('change', function () { + pick(select.value); + }); + run.addEventListener('click', execute); + args.addEventListener('keydown', function (event) { + if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) execute(); + }); + + root.textContent = ''; + root.appendChild(element('p', { class: 'playground-version' }, [text.version + ' ' + result.version])); + root.appendChild(element('label', { for: 'playground-fn' }, [text.fn])); + root.appendChild(select); + root.appendChild(element('label', { for: 'playground-args' }, [text.args])); + root.appendChild(args); + root.appendChild(element('p', { class: 'playground-hint' }, [text.hint])); + root.appendChild(run); + root.appendChild(element('h4', {}, [text.code])); + root.appendChild(element('pre', {}, [code])); + root.appendChild(element('h4', {}, [text.result])); + root.appendChild(output); + + var wanted = new URLSearchParams(location.search).get('fn'); + pick(names.indexOf(wanted) === -1 ? 'isValidCpf' : wanted); + }, + function () { + root.textContent = text.failed; + } + ); + }; +})(); diff --git a/docs/playground.md b/docs/playground.md new file mode 100644 index 000000000..5c262a939 --- /dev/null +++ b/docs/playground.md @@ -0,0 +1,11 @@ +--- +title: "Playground" +description: "Try any Brazilian Utils function in the browser: pick a function, edit its arguments and see the result, running the latest published release." +keywords: ["playground", "try it", "demo", "CPF validator online", "CNPJ validator online", "REPL"] +--- + +Pick a function, edit its arguments and run it. The playground imports the latest release from a CDN, the same code `npm install` gives you, so nothing here is a mock. Arguments are read as JSON, one entry per argument: `["123.456.789-09"]` calls the function with one string, `["Q0SLFMBD7VX439", { "version": 2 }]` with a string and an options object. Press Ctrl+Enter (Cmd+Enter on macOS) in the arguments box to run again. + +
+ +The [utilities reference](utilities.md) documents what each function accepts and returns. diff --git a/docs/pt-br/_sidebar.md b/docs/pt-br/_sidebar.md index 58c08e931..600810646 100644 --- a/docs/pt-br/_sidebar.md +++ b/docs/pt-br/_sidebar.md @@ -1,3 +1,4 @@ * [Introdução](pt-br/getting-started.md) * [Utilitários](pt-br/utilities.md) +* [Playground](pt-br/playground.md) * [Migração v1 para v2](pt-br/migration-v1-to-v2.md) diff --git a/docs/pt-br/getting-started.html b/docs/pt-br/getting-started.html index dc71c6c4c..a34686ead 100644 --- a/docs/pt-br/getting-started.html +++ b/docs/pt-br/getting-started.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 51c7bbb5d..707cffdb6 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -63,7 +63,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -Você pode conferir a lista de utilitários [clicando aqui](pt-br/utilities.md). +Você pode conferir a lista de utilitários [clicando aqui](pt-br/utilities.md) e executar qualquer um deles no navegador no [playground](pt-br/playground.md). ## Assistentes de IA diff --git a/docs/pt-br/index.html b/docs/pt-br/index.html index dc71c6c4c..a34686ead 100644 --- a/docs/pt-br/index.html +++ b/docs/pt-br/index.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/migration-v1-to-v2.html b/docs/pt-br/migration-v1-to-v2.html index 4871841de..8b94db8fe 100644 --- a/docs/pt-br/migration-v1-to-v2.html +++ b/docs/pt-br/migration-v1-to-v2.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/playground.html b/docs/pt-br/playground.html new file mode 100644 index 000000000..adea2b56f --- /dev/null +++ b/docs/pt-br/playground.html @@ -0,0 +1,343 @@ + + + + + + Playground · Brazilian Utils + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + diff --git a/docs/pt-br/playground.md b/docs/pt-br/playground.md new file mode 100644 index 000000000..a2f20078e --- /dev/null +++ b/docs/pt-br/playground.md @@ -0,0 +1,11 @@ +--- +title: "Playground" +description: "Experimente qualquer função do Brazilian Utils no navegador: escolha a função, edite os argumentos e veja o resultado, rodando a última versão publicada." +keywords: ["playground", "experimente", "demo", "validador de CPF online", "validador de CNPJ online", "REPL"] +--- + +Escolha uma função, edite os argumentos e execute. O playground importa a última versão publicada por um CDN, o mesmo código que o `npm install` entrega, então nada aqui é simulado. Os argumentos são lidos como JSON, uma entrada por argumento: `["123.456.789-09"]` chama a função com uma string, `["Q0SLFMBD7VX439", { "version": 2 }]` com uma string e um objeto de opções. Pressione Ctrl+Enter (Cmd+Enter no macOS) na caixa de argumentos para executar de novo. + +
+ +A [referência de utilitários](pt-br/utilities.md) documenta o que cada função aceita e retorna. diff --git a/docs/pt-br/utilities.html b/docs/pt-br/utilities.html index 383835487..487b3aa3b 100644 --- a/docs/pt-br/utilities.html +++ b/docs/pt-br/utilities.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/sitemap.xml b/docs/sitemap.xml index 30e9345a5..d03f8343e 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -17,6 +17,12 @@ + + https://brazilian-utils.com.br/playground + + + + https://brazilian-utils.com.br/migration-v1-to-v2 @@ -35,6 +41,12 @@ + + https://brazilian-utils.com.br/pt-br/playground + + + + https://brazilian-utils.com.br/pt-br/migration-v1-to-v2 diff --git a/docs/utilities.html b/docs/utilities.html index 987fe725b..5cb9a604e 100644 --- a/docs/utilities.html +++ b/docs/utilities.html @@ -101,6 +101,27 @@ --cover-bg: var(--color-bg); --cover-bg-overlay: none; } + + /* The "try it" playground (playground.md, playground.js) */ + .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } + .playground select, .playground textarea { + width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; + border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; + font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + } + .playground button { + margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; + background: var(--theme-color); color: #fff; font-weight: 600; + } + .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { + outline: 2px solid var(--theme-color); outline-offset: 2px; + } + .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } + .playground pre { + white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; + padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); + } + .playground pre code { padding: 0; background: none; } @@ -163,6 +184,20 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The "try it" playground: a page with a #try-it element (playground.md) gets + // playground.js, loaded once and on that page only, which imports the published package + // from a CDN. + function (hook) { + hook.doneEach(function () { + var root = document.getElementById('try-it'); + if (!root) return; + if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); + var script = document.createElement('script'); + script.src = '/playground.js'; + script.onload = function () { window.BrazilianUtilsPlayground(root); }; + document.body.appendChild(script); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically From f06445f7ff8cd7dee18eb0594e82d78a806d3774 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:08:52 -0300 Subject: [PATCH 07/70] ci(docs): deploy a preview of the documentation site for every pull request vercel.json publishes docs/ as it is (no install, no build) as a Vercel preview deployment per pull request, with the URL posted as a comment by the Vercel app, forks included. cleanUrls serves /getting-started from getting-started.html, as GitHub Pages does, so the history-mode URLs behave the same. Production stays on GitHub Pages: deployments of main are turned off, every response carries X-Robots-Tag: noindex, and a deployment is skipped when nothing under docs/ changed. --- vercel.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 vercel.json diff --git a/vercel.json b/vercel.json new file mode 100644 index 000000000..68885d9c7 --- /dev/null +++ b/vercel.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": null, + "installCommand": "", + "buildCommand": "", + "outputDirectory": "docs", + "cleanUrls": true, + "ignoreCommand": "git diff --quiet HEAD^ HEAD -- docs vercel.json", + "git": { + "deploymentEnabled": { + "main": false + } + }, + "headers": [ + { + "source": "/(.*)", + "headers": [ + { "key": "X-Robots-Tag", "value": "noindex, nofollow" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ] +} From 3722d3d5d5f0f8f617d80a51aaf3efe28b070c38 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:08:52 -0300 Subject: [PATCH 08/70] chore(context7): tell agents about toStandardSchema, keep the playground out of the index --- context7.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/context7.json b/context7.json index b6e2a4506..f60397696 100644 --- a/context7.json +++ b/context7.json @@ -20,7 +20,9 @@ "_coverpage.md", "llms.txt", "llms-full.txt", - "robots.txt" + "robots.txt", + "playground.md", + "playground.js" ], "previousVersions": [ { @@ -44,6 +46,7 @@ "Deprecated since 2.4.0: the *Options type aliases of the single-object-argument functions; use the *Params names (GetHolidaysParams, IsHolidayParams, GetMunicipalityParams, ...).", "Every util is synchronous and pure except getAddressInfoByCep, getCepInfoByAddress and the deprecated getMunicipality, which are async; the first two query public CEP APIs over HTTPS.", "The generate* utils draw random values with Math.random() (generatePixPayload is the exception, built from its parameters), so use them for tests and fixtures, never for security.", - "Supported runtimes: Node.js ^20.19.0 || >=22.12.0, Bun, Deno and evergreen browsers. Types ship with the package; import option and result types by name from the root." + "Supported runtimes: Node.js ^20.19.0 || >=22.12.0, Bun, Deno and evergreen browsers. Types ship with the package; import option and result types by name from the root.", + "toStandardSchema(isValidCpf, options) wraps any isValid* in a Standard Schema (the ~standard interface) for TanStack Form, tRPC, Hono and the like; inside Zod use z.string().refine(isValidCpf), inside Valibot v.check(isValidCpf)." ] } From a791bd0677f5228ea2a1e237e50db6a339034ec2 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:08:52 -0300 Subject: [PATCH 09/70] docs(contributing): document the datasets, the test arbitraries, JSR and the previews --- CONTRIBUTING.md | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb98fd889..b189445fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,7 @@ and is invoked through the `npm` scripts below, so you don't need to install any | `npm run build:data` | Regenerates the datasets under `src/_internals/constants` from the IBGE/CONCLA sources (`scripts/data.ts`); run by the scheduled `Update datasets` workflow. | | `npm run build:llms` | Regenerates `docs/llms.txt` and `docs/llms-full.txt` from the docs (`scripts/llms.ts`); CI fails if they're out of date. | | `npm run build:site` | Regenerates the per-page copies of `docs/index.html`, `docs/404.html` and `docs/sitemap.xml` from the sidebars (`scripts/site.ts`); CI fails if they're out of date. | +| `npm run build:jsr` | Regenerates the `exports` of `jsr.json`, one per utility folder (`scripts/jsr.ts`); CI fails if they're out of date. | | `npm run check:dependencies` | Fails if `package.json` declares any runtime `dependencies` (this package ships zero by design). | | `npm run check:tree-shaking` | Builds nothing; measures the single-import size of every export against `dist` (`scripts/tree-shaking.ts`). Run it after `npm run build` when you change a dataset, and update the bundle-size table in `docs/getting-started.md` / `docs/pt-br/getting-started.md`. | | `npm run check:duplication` | Runs [jscpd](https://jscpd.dev) over `src` and `scripts`; any copy-pasted block of 5+ lines / 50+ tokens fails. | @@ -89,6 +90,28 @@ from forks), the maintainers (review, merge, release approval) and the automatio builds, tests and publishes, Dependabot and the `Update datasets` workflow open update pull requests, and release-please turns merged commits into releases. +## Datasets + +The tables under `src/_internals/constants/` fall in two groups, and only the first refreshes +itself: + +- **Generated from an official source** by a script in `scripts/` (`npm run build:data`, run + every Monday by the `Update datasets` workflow): banks (Banco Central, `banks.ts`), CBO + (`cbo.ts`), CFOP (CONFAZ, `cfop.ts`), municipalities and states (IBGE, `cities.ts`, + `states.ts`), CNAE (`cnae.ts`), legal natures (CONCLA, `legal-natures.ts`) and NCM (Siscomex, + `ncm.ts`). When a run changes a file, the workflow opens a pull request whose description, written + by `scripts/data-summary.ts`, lists per table how many entries were added and removed, with a + sample of each. Never edit these files by hand. +- **Maintained by hand**, because the source is a law or a regulation with no machine-readable + form: area codes and their states (Anatel, `area-codes.ts`), service phone prefixes (Anatel, + `service-phone.ts`), national and state holidays (`holidays.ts`), the órgãos and tribunals of the + processo number (Resolução CNJ nº 65/2008, `processo-juridico.ts`), IBAN lengths per country + (`iban.ts`), IBGE state codes (`ibge-uf-codes.ts`), legal nature categories, the CST and CSOSN + tables (`src/is-valid-cst`, `src/is-valid-csosn`), the professional councils + (`src/is-valid-registro-profissional`), the região fiscal digit of each state + (`src/generate-cpf`) and the voter ID state codes (`src/is-valid-voter-id`). A change to one of + these cites the act that changed it (`@see Official:`), like any rule. + ## Adding a new utility Brazilian Utils follows a consistent folder convention for every utility. To add a new one (for @@ -125,7 +148,11 @@ example `formatSomething`): never values computed by the code under test. Close the file with a `describe("properties")` block of [fast-check](https://fast-check.dev) properties that hold by specification (a generated value is valid, format/parse round-trip, masks never change the verdict, arbitrary - input never throws) and a `describe(" types")` block that pins the public signature with + input never throws); a property that needs a valid document draws it with `fc.gen()` from the + arbitraries in `src/_internals/test/` (`const cpf = g(cpfs)`, from `document-arbitraries.ts` and + its siblings), never by calling a `generate*` utility inside the property: those use + `Math.random()`, which the seed fast-check reports does not control, so a failure could be + neither replayed nor shrunk. Then a `describe(" types")` block that pins the public signature with `expectTypeOf` (parameters, options and return type; `vp check` fails on a wrong assertion). A hot path may also get a `describe(" benchmarks")` block of `bench` cases: they are todo entries in a normal run and execute with `npx vp test bench --run`. `describe`, `test`, @@ -346,7 +373,11 @@ browser, with `_sidebar.md`, `_navbar.md` and `_coverpage.md` as its navigation. - Context7 indexes `docs/` as `/brazilian-utils/javascript`; `context7.json` says what it reads, and `.github/workflows/context7.yml` asks for a refresh when the docs change on `main`. -To preview the site, point a static file server that resolves `/page` to `page.html`, the way +Every pull request that touches `docs/` gets a preview deployment on Vercel (`vercel.json`), with +the URL posted as a comment. The file publishes `docs/` as it is (no install, no build), serves +`/getting-started` from `getting-started.html` like GitHub Pages does (`cleanUrls`), marks every +response `noindex` and turns deployments of `main` off: production stays on GitHub Pages. To +preview the site locally, point a static file server that resolves `/page` to `page.html`, the way GitHub Pages does, at `docs/`. ## Commit messages @@ -411,8 +442,17 @@ There are no local release commands to run. confirmation** (npm's proof-of-presence); the trusted publisher only allows staged publishing, so nothing can reach npm without it. +5. The same release is published to [JSR](https://jsr.io/@brazilian-utils/brazilian-utils) by the + `publish-jsr` job, from the TypeScript sources and through OIDC as well. `jsr.json` names what + is published; release-please bumps its `version` with `package.json`, and the Deno job of the + Tests workflow dry-runs the publication on every pull request. + No local `npm login`/`npm publish` or tagging is ever needed to cut a release. +Every pull request also gets an installable preview build from [pkg.pr.new](https://pkg.pr.new) +(the `Preview` workflow), with the install command posted as a comment, so a change can be tried +in a real project before it is merged. + ## Submitting a pull request 1. Fork the repository and create a branch from `main`. From 0f88a090014c65d9e5abc31e5470bb71e9e8dc7e Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:08:52 -0300 Subject: [PATCH 10/70] chore: ignore the lockfile Deno writes next to jsr.json --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 7a784b1d1..43962f121 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,6 @@ reports/* !reports/api/ reports/api/* !reports/api/brazilian-utils.api.md +# Deno writes a lockfile next to jsr.json on every `deno test`/`deno publish`; the npm lockfile is +# the one that pins this project's dependencies. +deno.lock From ab98e0f678c804e2827e3a6b5e8ceaee2e20efa3 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 10:50:28 -0300 Subject: [PATCH 11/70] ci(release): name the npm job publish-npm and always publish to JSR With two registries in the workflow, `publish` says too little: the jobs are now publish-npm and publish-jsr. The JSR job loses its JSR_PUBLISH gate and runs on every release; it needs the one-time JSR setup documented at the top of the workflow, and the npm job does not depend on it. The npm trusted publisher is bound to the workflow file and the environment, not to the job name, so the rename does not affect it. --- .github/workflows/release.yml | 14 ++++++-------- CONTRIBUTING.md | 2 +- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2e0e897a..ad49c2808 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ # published yet. A maintainer reviews and merges that PR: that merge is confirmation #1. # 2. Merging the release PR makes release-please tag the release commit and create a GitHub # Release, which triggers this same workflow again. This time `release_created` is `true`, -# so the `publish` job builds, validates and STAGES the package on npm +# so the `publish-npm` job builds, validates and STAGES the package on npm # (`npm stage publish --provenance`). A staged version is not installable until a # maintainer approves it with 2FA, on npmjs.com (package -> Staged versions) or with # `npm stage approve `. That approval is confirmation #2 (npm's @@ -31,7 +31,6 @@ # `brazilian-utils` in it. # 2. Package Settings -> GitHub Actions -> link `brazilian-utils/javascript`. JSR then trusts # this repository's OIDC token; no JSR token is stored anywhere. -# 3. Repository Settings -> Secrets and variables -> Actions -> Variables -> `JSR_PUBLISH` = `true`. # # One-time GitHub Environment setup (done by a package maintainer, not by CI): # Repository Settings -> Environments -> New environment -> name it `npm`. No protection rules @@ -71,7 +70,7 @@ jobs: config-file: release-please-config.json manifest-file: .release-please-manifest.json - publish: + publish-npm: name: Publish to npm needs: release-please if: ${{ needs.release-please.outputs.release_created == 'true' }} @@ -130,11 +129,10 @@ jobs: publish-jsr: name: Publish to JSR needs: release-please - # JSR publishes through OIDC as well: no token is stored. The job stays off until a maintainer - # has created the @brazilian-utils/brazilian-utils package on jsr.io, linked this repository - # to it (package Settings -> GitHub Actions) and set the JSR_PUBLISH repository variable to - # `true`, so a release never fails on a registry that is not set up yet. - if: ${{ needs.release-please.outputs.release_created == 'true' && vars.JSR_PUBLISH == 'true' }} + # JSR publishes through OIDC as well: no token is stored. It needs the one-time JSR setup + # described at the top of this file; until that is done this job fails, and the npm job, which + # does not depend on it, still publishes. + if: ${{ needs.release-please.outputs.release_created == 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 permissions: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b189445fe..669b8b12c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -433,7 +433,7 @@ There are no local release commands to run. hidden. 2. A maintainer reviews the release PR (version bump, changelog) and merges it. **Merging the release PR is the first confirmation.** Nothing is published yet at this point. -3. Merging tags the release and publishes a GitHub Release, which triggers the `publish` job in +3. Merging tags the release and publishes a GitHub Release, which triggers the `publish-npm` job in `.github/workflows/release.yml`. That job builds and validates the package and **stages** it on npm with `npm stage publish --provenance` (npm Trusted Publishing/OIDC; no npm token is stored in the repository). A staged version is not installable yet. From ff597010b2d2e6148efaf4762fc515115b839c57 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:42:07 -0300 Subject: [PATCH 12/70] ci(preview): publish the pull request preview without an opt-in variable --- .github/workflows/preview.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index 74b0f22cd..81f843e54 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -19,9 +19,6 @@ permissions: jobs: preview: name: Publish a preview to pkg.pr.new - # Off until the app is installed and the PKG_PR_NEW repository variable is set to `true`, so - # pull requests do not carry a failing check in the meantime. - if: ${{ vars.PKG_PR_NEW == 'true' }} runs-on: ubuntu-latest timeout-minutes: 15 From 8e8d326597277bf9d5c49e92058744b24ac30c7f Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:17:19 -0300 Subject: [PATCH 13/70] fix(docs): skip the Vercel preview only when docs are unchanged since the branch's last deployment --- vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index 68885d9c7..b659b6cb2 100644 --- a/vercel.json +++ b/vercel.json @@ -5,7 +5,7 @@ "buildCommand": "", "outputDirectory": "docs", "cleanUrls": true, - "ignoreCommand": "git diff --quiet HEAD^ HEAD -- docs vercel.json", + "ignoreCommand": "test -n \"$VERCEL_GIT_PREVIOUS_SHA\" && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- docs vercel.json", "git": { "deploymentEnabled": { "main": false From d7f4ee79fb2fd7468bda4368508289ef25fdc1f9 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:34:07 -0300 Subject: [PATCH 14/70] docs: replace the playground with a CPF field example in React, Vue, Angular and plain JavaScript --- context7.json | 4 +- docs/404.html | 51 ++-- docs/_sidebar.md | 2 +- docs/{playground.html => examples.html} | 71 +++--- docs/examples.md | 113 +++++++++ docs/getting-started.html | 51 ++-- docs/getting-started.md | 2 +- docs/index.html | 51 ++-- docs/llms-full.txt | 2 +- docs/llms.txt | 1 + docs/migration-v1-to-v2.html | 51 ++-- docs/playground.js | 227 ------------------ docs/playground.md | 11 - docs/pt-br/_sidebar.md | 2 +- docs/pt-br/{playground.html => examples.html} | 71 +++--- docs/pt-br/examples.md | 113 +++++++++ docs/pt-br/getting-started.html | 51 ++-- docs/pt-br/getting-started.md | 2 +- docs/pt-br/index.html | 51 ++-- docs/pt-br/migration-v1-to-v2.html | 51 ++-- docs/pt-br/playground.md | 11 - docs/pt-br/utilities.html | 51 ++-- docs/sitemap.xml | 16 +- docs/utilities.html | 51 ++-- scripts/llms.ts | 1 + 25 files changed, 504 insertions(+), 604 deletions(-) rename docs/{playground.html => examples.html} (85%) create mode 100644 docs/examples.md delete mode 100644 docs/playground.js delete mode 100644 docs/playground.md rename docs/pt-br/{playground.html => examples.html} (85%) create mode 100644 docs/pt-br/examples.md delete mode 100644 docs/pt-br/playground.md diff --git a/context7.json b/context7.json index f60397696..9647ee8de 100644 --- a/context7.json +++ b/context7.json @@ -20,9 +20,7 @@ "_coverpage.md", "llms.txt", "llms-full.txt", - "robots.txt", - "playground.md", - "playground.js" + "robots.txt" ], "previousVersions": [ { diff --git a/docs/404.html b/docs/404.html index 8764230c9..0b96dd7b4 100644 --- a/docs/404.html +++ b/docs/404.html @@ -97,26 +97,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -179,18 +168,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/_sidebar.md b/docs/_sidebar.md index a91f26a06..20ebc8119 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,4 +1,4 @@ * [Getting Started](getting-started.md) * [Utilities](utilities.md) -* [Playground](playground.md) +* [Examples](examples.md) * [Migration v1 to v2](migration-v1-to-v2.md) diff --git a/docs/playground.html b/docs/examples.html similarity index 85% rename from docs/playground.html rename to docs/examples.html index 18354a50d..c18e0096a 100644 --- a/docs/playground.html +++ b/docs/examples.html @@ -3,8 +3,8 @@ - Playground · Brazilian Utils - + Examples · Brazilian Utils + @@ -15,25 +15,25 @@ hook further down rewrites the title, description, canonical, hreflang and Open Graph tags for the page being shown; the values here are the home page's, what a crawler without JavaScript and a link preview see. --> - - - - + + + + - + - - - + + + @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 000000000..7af7f7fa6 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,113 @@ +--- +title: "Examples" +description: "A CPF field that formats as you type and validates the result, with Brazilian Utils in React, Vue, Angular and plain JavaScript." +keywords: ["examples", "CPF input mask", "React CPF", "Vue CPF", "Angular CPF", "format CPF as you type", "validate CPF"] +--- + +One field does both jobs: `formatCpf` formats whatever has been typed so far (`"9438"` becomes `"943.8"`, letters are dropped and digits past the eleventh are cut), and `isValidCpf` checks the result once it is complete. + +## Try it + +
+ + + +
+ +## React + +```jsx +import { useState } from "react"; +import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; + +export function CpfField() { + const [cpf, setCpf] = useState(""); + + return ( + + ); +} +``` + +## Vue + +```html + + + +``` + +Setting `event.target.value` again keeps the field in sync when a keystroke does not change the formatted value, such as a letter. + +## Angular + +```ts +import { Component, computed, signal } from "@angular/core"; +import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; + +@Component({ + selector: "app-cpf-field", + template: ` + + `, +}) +export class CpfFieldComponent { + readonly cpf = signal(""); + readonly valid = computed(() => isValidCpf(this.cpf())); + + onInput(event: Event) { + const input = event.target as HTMLInputElement; + this.cpf.set(formatCpf(input.value)); + input.value = this.cpf(); + } +} +``` + +## Plain JavaScript + +No build step: the package is imported straight from a CDN. + +```html + + + + +``` + +The same field works for a CNPJ (`formatCnpj` and `isValidCnpj`, complete at 18 characters) or a CEP (`formatCep` and `isValidCep`, complete at 9). The [utilities reference](utilities.md) lists every function. diff --git a/docs/getting-started.html b/docs/getting-started.html index aad4a3fad..feb9f1d01 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/getting-started.md b/docs/getting-started.md index 7830474b9..3ea4f4280 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,7 +63,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -You can check a list of utilities [by clicking here](utilities.md), and run any of them in the browser in the [playground](playground.md). +You can check a list of utilities [by clicking here](utilities.md). The [examples](examples.md) show them in a React, Vue or Angular form field. ## AI assistants diff --git a/docs/index.html b/docs/index.html index 80b634bd2..7959b3b48 100644 --- a/docs/index.html +++ b/docs/index.html @@ -101,26 +101,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -183,18 +172,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/llms-full.txt b/docs/llms-full.txt index cc2d89694..4408772b0 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -213,7 +213,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -You can check a list of utilities [by clicking here](utilities.md), and run any of them in the browser in the [playground](playground.md). +You can check a list of utilities [by clicking here](utilities.md). The [examples](examples.md) show them in a React, Vue or Angular form field. ### AI assistants diff --git a/docs/llms.txt b/docs/llms.txt index f68090fe5..3134dc90f 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -21,6 +21,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [Getting started](https://brazilian-utils.com.br/getting-started.md): installation, runtime support, usage and bundle size/subpath imports - [Utilities](https://brazilian-utils.com.br/utilities.md): full English reference, one section per function, with signatures and examples - [Bundle size](https://brazilian-utils.com.br/getting-started.md#bundle-size): tree-shaking behavior and the dataset-backed utils that are worth a subpath import +- [Examples](https://brazilian-utils.com.br/examples.md): a CPF field that formats as you type and validates, in React, Vue, Angular and plain JavaScript ## Validators (isValid*) diff --git a/docs/migration-v1-to-v2.html b/docs/migration-v1-to-v2.html index f806ef16e..0ced544e0 100644 --- a/docs/migration-v1-to-v2.html +++ b/docs/migration-v1-to-v2.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/playground.js b/docs/playground.js deleted file mode 100644 index 59a72aa3d..000000000 --- a/docs/playground.js +++ /dev/null @@ -1,227 +0,0 @@ -/* - * The "try it" playground of the docs site (playground.md): runs any exported function of the - * published package in the browser. Loaded by index.html only when a page has a #try-it - * element. The library itself is imported from a CDN, the ESM build of the latest release, so the - * playground needs no build step and always shows what `npm install` gives. - * - * Arguments are read with JSON.parse, never evaluated as code. - */ -(function () { - 'use strict'; - - var PACKAGE = '@brazilian-utils/brazilian-utils'; - var CDNS = ['https://cdn.jsdelivr.net/npm/', 'https://unpkg.com/']; - - var TEXT = { - en: { - loading: 'Loading the latest release from the CDN...', - failed: 'Could not load the package from the CDN. Check the connection and reload.', - version: 'Running version', - fn: 'Function', - args: 'Arguments, as a JSON array', - hint: 'One entry per argument. A Date is written as {"$date": "2026-01-01"}.', - run: 'Run', - code: 'Code', - result: 'Result', - invalidJson: 'The arguments are not a valid JSON array: ', - notArray: 'The arguments must be a JSON array, e.g. ["123.456.789-09"].', - threw: 'Threw: ', - }, - 'pt-br': { - loading: 'Carregando a última versão publicada pelo CDN...', - failed: 'Não foi possível carregar o pacote pelo CDN. Verifique a conexão e recarregue.', - version: 'Rodando a versão', - fn: 'Função', - args: 'Argumentos, como um array JSON', - hint: 'Uma entrada por argumento. Uma Date é escrita como {"$date": "2026-01-01"}.', - run: 'Executar', - code: 'Código', - result: 'Resultado', - invalidJson: 'Os argumentos não são um array JSON válido: ', - notArray: 'Os argumentos precisam ser um array JSON, por exemplo ["123.456.789-09"].', - threw: 'Lançou: ', - }, - }; - - // The arguments a function opens with; anything not listed starts from its family's default. - var EXAMPLES = { - isValidCpf: ['123.456.789-09'], - formatCpf: ['12345678909'], - parseCpf: ['123.456.789-09'], - generateCpf: ['SP'], - isValidCnpj: ['Q0.SLF.MBD/7VX4-39', { version: 2 }], - formatCnpj: ['12345678000195'], - parseCnpj: ['12.345.678/0001-95'], - generateCnpj: [{ version: 2 }], - isValidCep: ['01310-100'], - formatCep: ['01310100'], - getAddressInfoByCep: ['01310100'], - isValidBoleto: ['00190000090114971860168524522114675860000102656'], - getBoletoInfo: ['00190000090114971860168524522114675860000102656'], - isValidPhone: ['(11) 98765-4321'], - formatPhone: ['11987654321'], - isValidPixKey: ['123.456.789-09'], - getPixKeyInfo: ['+5511987654321'], - formatCurrency: [1234.56], - parseCurrency: ['R$ 1.234,56'], - convertNumberToWords: [1234], - convertCurrencyToWords: [1234.56], - capitalize: ['JOSÉ DA SILVA LTDA'], - removeAccents: ['São Paulo'], - getHolidays: [2026], - isHoliday: [{ date: { $date: '2026-09-07' } }], - isBusinessDay: [{ $date: '2026-09-07' }], - addBusinessDays: [{ $date: '2026-09-04' }, 3], - getStates: [], - getMunicipalities: ['AC'], - getMunicipalityByCode: ['3550308'], - getBankByCode: ['001'], - getCfop: ['5.102'], - getCnae: ['6201-5/01'], - isValidIe: [{ value: '110042490114', stateCode: 'SP' }], - isValidLicensePlate: ['ABC1D23'], - isValidEmail: ['contato@exemplo.com.br'], - }; - - function defaultArgs(name) { - if (Object.prototype.hasOwnProperty.call(EXAMPLES, name)) return EXAMPLES[name]; - if (/^(generate|get[A-Z].*s$)/.test(name)) return []; - return ['']; - } - - function reviveDates(key, value) { - return value && typeof value === 'object' && typeof value.$date === 'string' ? new Date(value.$date) : value; - } - - function show(value) { - if (value === undefined) return 'undefined'; - if (value instanceof Date) return 'Date ' + value.toISOString(); - try { - return JSON.stringify(value, null, 2); - } catch (error) { - return String(value); - } - } - - function element(tag, attributes, children) { - var node = document.createElement(tag); - Object.keys(attributes || {}).forEach(function (name) { - node.setAttribute(name, attributes[name]); - }); - (children || []).forEach(function (child) { - node.appendChild(typeof child === 'string' ? document.createTextNode(child) : child); - }); - return node; - } - - function load(index) { - var base = CDNS[index]; - return fetch(base + PACKAGE + '/package.json') - .then(function (response) { - if (!response.ok) throw new Error(String(response.status)); - return response.json(); - }) - .then(function (manifest) { - return import(base + PACKAGE + '@' + manifest.version + '/dist/brazilian-utils.js').then(function (library) { - return { library: library, version: manifest.version }; - }); - }) - .catch(function (error) { - if (index + 1 < CDNS.length) return load(index + 1); - throw error; - }); - } - - var loaded; - - window.BrazilianUtilsPlayground = function (root) { - var text = location.pathname.indexOf('/pt-br') === 0 ? TEXT['pt-br'] : TEXT.en; - root.textContent = text.loading; - loaded = loaded || load(0); - - loaded.then( - function (result) { - var library = result.library; - var names = Object.keys(library) - .filter(function (name) { - return typeof library[name] === 'function' && /^[a-z]/.test(name); - }) - .sort(); - - var select = element('select', { id: 'playground-fn' }, names.map(function (name) { - return element('option', { value: name }, [name]); - })); - var args = element('textarea', { id: 'playground-args', rows: '4', spellcheck: 'false' }); - var run = element('button', { type: 'button' }, [text.run]); - var code = element('code'); - var output = element('pre', { 'aria-live': 'polite' }); - - function describe() { - var inner = args.value.trim().replace(/^\[/, '').replace(/\]$/, '').trim(); - code.textContent = - "import { " + select.value + " } from '" + PACKAGE + "';\n\n" + select.value + '(' + inner + ');'; - } - - function execute() { - var parsed; - describe(); - try { - parsed = JSON.parse(args.value || '[]', reviveDates); - } catch (error) { - output.textContent = text.invalidJson + error.message; - return; - } - if (!Array.isArray(parsed)) { - output.textContent = text.notArray; - return; - } - Promise.resolve() - .then(function () { - return library[select.value].apply(undefined, parsed); - }) - .then( - function (value) { - output.textContent = show(value); - }, - function (error) { - output.textContent = text.threw + (error && error.name ? error.name + ': ' + error.message : String(error)); - } - ); - } - - function pick(name) { - select.value = name; - args.value = JSON.stringify(defaultArgs(name)); - execute(); - } - - select.addEventListener('change', function () { - pick(select.value); - }); - run.addEventListener('click', execute); - args.addEventListener('keydown', function (event) { - if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) execute(); - }); - - root.textContent = ''; - root.appendChild(element('p', { class: 'playground-version' }, [text.version + ' ' + result.version])); - root.appendChild(element('label', { for: 'playground-fn' }, [text.fn])); - root.appendChild(select); - root.appendChild(element('label', { for: 'playground-args' }, [text.args])); - root.appendChild(args); - root.appendChild(element('p', { class: 'playground-hint' }, [text.hint])); - root.appendChild(run); - root.appendChild(element('h4', {}, [text.code])); - root.appendChild(element('pre', {}, [code])); - root.appendChild(element('h4', {}, [text.result])); - root.appendChild(output); - - var wanted = new URLSearchParams(location.search).get('fn'); - pick(names.indexOf(wanted) === -1 ? 'isValidCpf' : wanted); - }, - function () { - root.textContent = text.failed; - } - ); - }; -})(); diff --git a/docs/playground.md b/docs/playground.md deleted file mode 100644 index 5c262a939..000000000 --- a/docs/playground.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: "Playground" -description: "Try any Brazilian Utils function in the browser: pick a function, edit its arguments and see the result, running the latest published release." -keywords: ["playground", "try it", "demo", "CPF validator online", "CNPJ validator online", "REPL"] ---- - -Pick a function, edit its arguments and run it. The playground imports the latest release from a CDN, the same code `npm install` gives you, so nothing here is a mock. Arguments are read as JSON, one entry per argument: `["123.456.789-09"]` calls the function with one string, `["Q0SLFMBD7VX439", { "version": 2 }]` with a string and an options object. Press Ctrl+Enter (Cmd+Enter on macOS) in the arguments box to run again. - -
- -The [utilities reference](utilities.md) documents what each function accepts and returns. diff --git a/docs/pt-br/_sidebar.md b/docs/pt-br/_sidebar.md index 600810646..4b4133439 100644 --- a/docs/pt-br/_sidebar.md +++ b/docs/pt-br/_sidebar.md @@ -1,4 +1,4 @@ * [Introdução](pt-br/getting-started.md) * [Utilitários](pt-br/utilities.md) -* [Playground](pt-br/playground.md) +* [Exemplos](pt-br/examples.md) * [Migração v1 para v2](pt-br/migration-v1-to-v2.md) diff --git a/docs/pt-br/playground.html b/docs/pt-br/examples.html similarity index 85% rename from docs/pt-br/playground.html rename to docs/pt-br/examples.html index adea2b56f..f193c0ac0 100644 --- a/docs/pt-br/playground.html +++ b/docs/pt-br/examples.html @@ -3,8 +3,8 @@ - Playground · Brazilian Utils - + Exemplos · Brazilian Utils + @@ -15,25 +15,25 @@ hook further down rewrites the title, description, canonical, hreflang and Open Graph tags for the page being shown; the values here are the home page's, what a crawler without JavaScript and a link preview see. --> - - - - + + + + - + - - - + + + @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/pt-br/examples.md b/docs/pt-br/examples.md new file mode 100644 index 000000000..048dffea7 --- /dev/null +++ b/docs/pt-br/examples.md @@ -0,0 +1,113 @@ +--- +title: "Exemplos" +description: "Um campo de CPF que formata enquanto você digita e valida o resultado, com Brazilian Utils em React, Vue, Angular e JavaScript puro." +keywords: ["exemplos", "máscara de CPF", "CPF React", "CPF Vue", "CPF Angular", "formatar CPF ao digitar", "validar CPF"] +--- + +Um campo só faz as duas coisas: `formatCpf` formata o que já foi digitado (`"9438"` vira `"943.8"`, letras são descartadas e dígitos além do décimo primeiro são cortados), e `isValidCpf` confere o resultado quando ele está completo. + +## Experimente + +
+ + + +
+ +## React + +```jsx +import { useState } from "react"; +import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; + +export function CpfField() { + const [cpf, setCpf] = useState(""); + + return ( + + ); +} +``` + +## Vue + +```html + + + +``` + +Atribuir `event.target.value` de novo mantém o campo em sincronia quando uma tecla não muda o valor formatado, como uma letra. + +## Angular + +```ts +import { Component, computed, signal } from "@angular/core"; +import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; + +@Component({ + selector: "app-cpf-field", + template: ` + + `, +}) +export class CpfFieldComponent { + readonly cpf = signal(""); + readonly valid = computed(() => isValidCpf(this.cpf())); + + onInput(event: Event) { + const input = event.target as HTMLInputElement; + this.cpf.set(formatCpf(input.value)); + input.value = this.cpf(); + } +} +``` + +## JavaScript puro + +Sem build: o pacote é importado direto de um CDN. + +```html + + + + +``` + +O mesmo campo serve para CNPJ (`formatCnpj` e `isValidCnpj`, completo com 18 caracteres) ou CEP (`formatCep` e `isValidCep`, completo com 9). A [referência de utilitários](pt-br/utilities.md) lista todas as funções. diff --git a/docs/pt-br/getting-started.html b/docs/pt-br/getting-started.html index a34686ead..9c65b8acb 100644 --- a/docs/pt-br/getting-started.html +++ b/docs/pt-br/getting-started.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/pt-br/getting-started.md b/docs/pt-br/getting-started.md index 707cffdb6..b085946e8 100644 --- a/docs/pt-br/getting-started.md +++ b/docs/pt-br/getting-started.md @@ -63,7 +63,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -Você pode conferir a lista de utilitários [clicando aqui](pt-br/utilities.md) e executar qualquer um deles no navegador no [playground](pt-br/playground.md). +Você pode conferir a lista de utilitários [clicando aqui](pt-br/utilities.md). Os [exemplos](pt-br/examples.md) mostram como usá-los num campo de formulário em React, Vue ou Angular. ## Assistentes de IA diff --git a/docs/pt-br/index.html b/docs/pt-br/index.html index a34686ead..9c65b8acb 100644 --- a/docs/pt-br/index.html +++ b/docs/pt-br/index.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/pt-br/migration-v1-to-v2.html b/docs/pt-br/migration-v1-to-v2.html index 8b94db8fe..6b0440749 100644 --- a/docs/pt-br/migration-v1-to-v2.html +++ b/docs/pt-br/migration-v1-to-v2.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/pt-br/playground.md b/docs/pt-br/playground.md deleted file mode 100644 index a2f20078e..000000000 --- a/docs/pt-br/playground.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: "Playground" -description: "Experimente qualquer função do Brazilian Utils no navegador: escolha a função, edite os argumentos e veja o resultado, rodando a última versão publicada." -keywords: ["playground", "experimente", "demo", "validador de CPF online", "validador de CNPJ online", "REPL"] ---- - -Escolha uma função, edite os argumentos e execute. O playground importa a última versão publicada por um CDN, o mesmo código que o `npm install` entrega, então nada aqui é simulado. Os argumentos são lidos como JSON, uma entrada por argumento: `["123.456.789-09"]` chama a função com uma string, `["Q0SLFMBD7VX439", { "version": 2 }]` com uma string e um objeto de opções. Pressione Ctrl+Enter (Cmd+Enter no macOS) na caixa de argumentos para executar de novo. - -
- -A [referência de utilitários](pt-br/utilities.md) documenta o que cada função aceita e retorna. diff --git a/docs/pt-br/utilities.html b/docs/pt-br/utilities.html index 487b3aa3b..790e2d81e 100644 --- a/docs/pt-br/utilities.html +++ b/docs/pt-br/utilities.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/docs/sitemap.xml b/docs/sitemap.xml index d03f8343e..de04adaab 100644 --- a/docs/sitemap.xml +++ b/docs/sitemap.xml @@ -18,10 +18,10 @@
- https://brazilian-utils.com.br/playground - - - + https://brazilian-utils.com.br/examples + + + https://brazilian-utils.com.br/migration-v1-to-v2 @@ -42,10 +42,10 @@ - https://brazilian-utils.com.br/pt-br/playground - - - + https://brazilian-utils.com.br/pt-br/examples + + + https://brazilian-utils.com.br/pt-br/migration-v1-to-v2 diff --git a/docs/utilities.html b/docs/utilities.html index 5cb9a604e..20a5981d3 100644 --- a/docs/utilities.html +++ b/docs/utilities.html @@ -102,26 +102,15 @@ --cover-bg-overlay: none; } - /* The "try it" playground (playground.md, playground.js) */ - .playground label { display: block; margin: 1em 0 0.35em; font-weight: 600; } - .playground select, .playground textarea { - width: 100%; box-sizing: border-box; padding: 0.6em 0.75em; border-radius: 6px; - border: 1px solid var(--border-color, #444); background: var(--code-bg, #22262b); color: inherit; - font: 0.95em/1.5 var(--font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); + /* The live CPF field of examples.md */ + .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } + .cpf-demo label { font-weight: 600; } + .cpf-demo input { + width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); + background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); } - .playground button { - margin-top: 0.75em; padding: 0.55em 1.4em; border: 0; border-radius: 6px; cursor: pointer; - background: var(--theme-color); color: #fff; font-weight: 600; - } - .playground button:focus-visible, .playground select:focus-visible, .playground textarea:focus-visible { - outline: 2px solid var(--theme-color); outline-offset: 2px; - } - .playground-hint, .playground-version { margin: 0.4em 0; font-size: 0.9em; opacity: 0.75; } - .playground pre { - white-space: pre-wrap; word-break: break-word; max-height: 28em; overflow: auto; - padding: 0.9em 1em; border-radius: 6px; background: var(--code-bg, #22262b); - } - .playground pre code { padding: 0; background: none; } + .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } + .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -184,18 +173,22 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The "try it" playground: a page with a #try-it element (playground.md) gets - // playground.js, loaded once and on that page only, which imports the published package - // from a CDN. + // The live CPF field of examples.md: formats as you type and validates the result, with the + // latest release imported from a CDN on that page only. function (hook) { hook.doneEach(function () { - var root = document.getElementById('try-it'); - if (!root) return; - if (window.BrazilianUtilsPlayground) return window.BrazilianUtilsPlayground(root); - var script = document.createElement('script'); - script.src = '/playground.js'; - script.onload = function () { window.BrazilianUtilsPlayground(root); }; - document.body.appendChild(script); + var demo = document.querySelector('.cpf-demo'); + if (!demo) return; + var input = demo.querySelector('input'); + var output = demo.querySelector('output'); + import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { + input.addEventListener('input', function () { + input.value = utils.formatCpf(input.value); + output.textContent = input.value.length === 14 + ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) + : ''; + }); + }); }); }, // Per-page metadata for search engines and link previews: the title, description, diff --git a/scripts/llms.ts b/scripts/llms.ts index a97d9c013..dc1324381 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -246,6 +246,7 @@ const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities' - [Getting started](${SITE}/getting-started.md): installation, runtime support, usage and bundle size/subpath imports - [Utilities](${SITE}/utilities.md): full English reference, one section per function, with signatures and examples - [Bundle size](${SITE}/getting-started.md#bundle-size): tree-shaking behavior and the dataset-backed utils that are worth a subpath import +- [Examples](${SITE}/examples.md): a CPF field that formats as you type and validates, in React, Vue, Angular and plain JavaScript ${groupSections} From 42c7fdeeb898e422b7041ea636097e35c6bd3a0f Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:42:34 -0300 Subject: [PATCH 15/70] docs(examples): one file per example, shared by both languages, live in a lazy iframe --- docs/404.html | 27 --------- docs/examples.html | 27 --------- docs/examples.md | 90 ++-------------------------- docs/getting-started.html | 27 --------- docs/index.html | 27 --------- docs/migration-v1-to-v2.html | 27 --------- docs/pt-br/examples.html | 27 --------- docs/pt-br/examples.md | 90 ++-------------------------- docs/pt-br/getting-started.html | 27 --------- docs/pt-br/index.html | 27 --------- docs/pt-br/migration-v1-to-v2.html | 27 --------- docs/pt-br/utilities.html | 27 --------- docs/robots.txt | 1 + docs/snippets/cpf-field.component.ts | 25 ++++++++ docs/snippets/cpf-field.html | 17 ++++++ docs/snippets/cpf-field.jsx | 14 +++++ docs/snippets/cpf-field.vue | 19 ++++++ docs/utilities.html | 27 --------- 18 files changed, 86 insertions(+), 467 deletions(-) create mode 100644 docs/snippets/cpf-field.component.ts create mode 100644 docs/snippets/cpf-field.html create mode 100644 docs/snippets/cpf-field.jsx create mode 100644 docs/snippets/cpf-field.vue diff --git a/docs/404.html b/docs/404.html index 0b96dd7b4..9c234bf9c 100644 --- a/docs/404.html +++ b/docs/404.html @@ -97,15 +97,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -168,24 +159,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/examples.html b/docs/examples.html index c18e0096a..f1ef03c23 100644 --- a/docs/examples.html +++ b/docs/examples.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/examples.md b/docs/examples.md index 7af7f7fa6..9d869b610 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,106 +8,26 @@ One field does both jobs: `formatCpf` formats whatever has been typed so far (`" ## Try it -
- - - -
+ ## React -```jsx -import { useState } from "react"; -import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; - -export function CpfField() { - const [cpf, setCpf] = useState(""); - - return ( - - ); -} -``` +[cpf-field.jsx](snippets/cpf-field.jsx ':include :type=code jsx') ## Vue -```html - - - -``` +[cpf-field.vue](snippets/cpf-field.vue ':include :type=code html') Setting `event.target.value` again keeps the field in sync when a keystroke does not change the formatted value, such as a letter. ## Angular -```ts -import { Component, computed, signal } from "@angular/core"; -import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; - -@Component({ - selector: "app-cpf-field", - template: ` - - `, -}) -export class CpfFieldComponent { - readonly cpf = signal(""); - readonly valid = computed(() => isValidCpf(this.cpf())); - - onInput(event: Event) { - const input = event.target as HTMLInputElement; - this.cpf.set(formatCpf(input.value)); - input.value = this.cpf(); - } -} -``` +[cpf-field.component.ts](snippets/cpf-field.component.ts ':include :type=code ts') ## Plain JavaScript No build step: the package is imported straight from a CDN. -```html - - - - -``` +[cpf-field.html](snippets/cpf-field.html ':include :type=code html') The same field works for a CNPJ (`formatCnpj` and `isValidCnpj`, complete at 18 characters) or a CEP (`formatCep` and `isValidCep`, complete at 9). The [utilities reference](utilities.md) lists every function. diff --git a/docs/getting-started.html b/docs/getting-started.html index feb9f1d01..be4fb72de 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/index.html b/docs/index.html index 7959b3b48..81ebb9231 100644 --- a/docs/index.html +++ b/docs/index.html @@ -101,15 +101,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -172,24 +163,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/migration-v1-to-v2.html b/docs/migration-v1-to-v2.html index 0ced544e0..90dc8cdb0 100644 --- a/docs/migration-v1-to-v2.html +++ b/docs/migration-v1-to-v2.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/examples.html b/docs/pt-br/examples.html index f193c0ac0..a32ff6e0b 100644 --- a/docs/pt-br/examples.html +++ b/docs/pt-br/examples.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/examples.md b/docs/pt-br/examples.md index 048dffea7..dd95d13a7 100644 --- a/docs/pt-br/examples.md +++ b/docs/pt-br/examples.md @@ -8,106 +8,26 @@ Um campo só faz as duas coisas: `formatCpf` formata o que já foi digitado (`"9 ## Experimente -
- - - -
+ ## React -```jsx -import { useState } from "react"; -import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; - -export function CpfField() { - const [cpf, setCpf] = useState(""); - - return ( - - ); -} -``` +[cpf-field.jsx](../snippets/cpf-field.jsx ':include :type=code jsx') ## Vue -```html - - - -``` +[cpf-field.vue](../snippets/cpf-field.vue ':include :type=code html') Atribuir `event.target.value` de novo mantém o campo em sincronia quando uma tecla não muda o valor formatado, como uma letra. ## Angular -```ts -import { Component, computed, signal } from "@angular/core"; -import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; - -@Component({ - selector: "app-cpf-field", - template: ` - - `, -}) -export class CpfFieldComponent { - readonly cpf = signal(""); - readonly valid = computed(() => isValidCpf(this.cpf())); - - onInput(event: Event) { - const input = event.target as HTMLInputElement; - this.cpf.set(formatCpf(input.value)); - input.value = this.cpf(); - } -} -``` +[cpf-field.component.ts](../snippets/cpf-field.component.ts ':include :type=code ts') ## JavaScript puro Sem build: o pacote é importado direto de um CDN. -```html - - - - -``` +[cpf-field.html](../snippets/cpf-field.html ':include :type=code html') O mesmo campo serve para CNPJ (`formatCnpj` e `isValidCnpj`, completo com 18 caracteres) ou CEP (`formatCep` e `isValidCep`, completo com 9). A [referência de utilitários](pt-br/utilities.md) lista todas as funções. diff --git a/docs/pt-br/getting-started.html b/docs/pt-br/getting-started.html index 9c65b8acb..6b16be9b1 100644 --- a/docs/pt-br/getting-started.html +++ b/docs/pt-br/getting-started.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/index.html b/docs/pt-br/index.html index 9c65b8acb..6b16be9b1 100644 --- a/docs/pt-br/index.html +++ b/docs/pt-br/index.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/migration-v1-to-v2.html b/docs/pt-br/migration-v1-to-v2.html index 6b0440749..6368a2e8c 100644 --- a/docs/pt-br/migration-v1-to-v2.html +++ b/docs/pt-br/migration-v1-to-v2.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/pt-br/utilities.html b/docs/pt-br/utilities.html index 790e2d81e..02f529783 100644 --- a/docs/pt-br/utilities.html +++ b/docs/pt-br/utilities.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically diff --git a/docs/robots.txt b/docs/robots.txt index 9a0726f8a..4f83e52d2 100644 --- a/docs/robots.txt +++ b/docs/robots.txt @@ -1,4 +1,5 @@ User-agent: * Allow: / +Disallow: /snippets/ Sitemap: https://brazilian-utils.com.br/sitemap.xml diff --git a/docs/snippets/cpf-field.component.ts b/docs/snippets/cpf-field.component.ts new file mode 100644 index 000000000..0e9f8de73 --- /dev/null +++ b/docs/snippets/cpf-field.component.ts @@ -0,0 +1,25 @@ +import { Component, computed, signal } from "@angular/core"; +import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; + +@Component({ + selector: "app-cpf-field", + template: ` + + `, +}) +export class CpfFieldComponent { + readonly cpf = signal(""); + readonly valid = computed(() => isValidCpf(this.cpf())); + + onInput(event: Event) { + const input = event.target as HTMLInputElement; + this.cpf.set(formatCpf(input.value)); + input.value = this.cpf(); + } +} diff --git a/docs/snippets/cpf-field.html b/docs/snippets/cpf-field.html new file mode 100644 index 000000000..d3577f50c --- /dev/null +++ b/docs/snippets/cpf-field.html @@ -0,0 +1,17 @@ + + + + + + + diff --git a/docs/snippets/cpf-field.jsx b/docs/snippets/cpf-field.jsx new file mode 100644 index 000000000..b6d22c014 --- /dev/null +++ b/docs/snippets/cpf-field.jsx @@ -0,0 +1,14 @@ +import { useState } from "react"; +import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; + +export function CpfField() { + const [cpf, setCpf] = useState(""); + + return ( + + ); +} diff --git a/docs/snippets/cpf-field.vue b/docs/snippets/cpf-field.vue new file mode 100644 index 000000000..8822b01aa --- /dev/null +++ b/docs/snippets/cpf-field.vue @@ -0,0 +1,19 @@ + + + diff --git a/docs/utilities.html b/docs/utilities.html index 20a5981d3..5f8c2b58a 100644 --- a/docs/utilities.html +++ b/docs/utilities.html @@ -102,15 +102,6 @@ --cover-bg-overlay: none; } - /* The live CPF field of examples.md */ - .cpf-demo { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5em 0.75em; } - .cpf-demo label { font-weight: 600; } - .cpf-demo input { - width: 12em; padding: 0.55em 0.75em; border-radius: 6px; border: 1px solid var(--border-color, #444); - background: var(--code-bg, #22262b); color: inherit; font: 1em var(--font-family-mono, ui-monospace, monospace); - } - .cpf-demo input:focus-visible { outline: 2px solid var(--theme-color); outline-offset: 2px; } - .cpf-demo output { display: inline; margin: 0; padding: 0; border: 0; background: none; font-weight: 600; } @@ -173,24 +164,6 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, - // The live CPF field of examples.md: formats as you type and validates the result, with the - // latest release imported from a CDN on that page only. - function (hook) { - hook.doneEach(function () { - var demo = document.querySelector('.cpf-demo'); - if (!demo) return; - var input = demo.querySelector('input'); - var output = demo.querySelector('output'); - import('https://cdn.jsdelivr.net/npm/@brazilian-utils/brazilian-utils/+esm').then(function (utils) { - input.addEventListener('input', function () { - input.value = utils.formatCpf(input.value); - output.textContent = input.value.length === 14 - ? (utils.isValidCpf(input.value) ? demo.dataset.valid : demo.dataset.invalid) - : ''; - }); - }); - }); - }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically From 888ebc22587809207a773311ccd4c9127ee4aa8a Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:04:41 -0300 Subject: [PATCH 16/70] docs(examples): framework tabs with live demos and a caret-aware CPF mask --- docs/404.html | 71 ++++++++++++++++++ docs/examples.html | 75 ++++++++++++++++++- docs/examples.md | 32 ++++---- docs/getting-started.html | 71 ++++++++++++++++++ docs/index.html | 71 ++++++++++++++++++ docs/migration-v1-to-v2.html | 71 ++++++++++++++++++ docs/pt-br/examples.html | 75 ++++++++++++++++++- docs/pt-br/examples.md | 32 ++++---- docs/pt-br/getting-started.html | 71 ++++++++++++++++++ docs/pt-br/index.html | 71 ++++++++++++++++++ docs/pt-br/migration-v1-to-v2.html | 71 ++++++++++++++++++ docs/pt-br/utilities.html | 71 ++++++++++++++++++ docs/snippets/cpf-field.component.ts | 66 +++++++++++++++-- docs/snippets/cpf-field.html | 68 +++++++++++++++-- docs/snippets/cpf-field.jsx | 14 ---- docs/snippets/cpf-field.tsx | 71 ++++++++++++++++++ docs/snippets/cpf-field.vue | 68 +++++++++++++++-- docs/snippets/live/angular.html | 3 + docs/snippets/live/react.html | 3 + docs/snippets/live/run.js | 107 +++++++++++++++++++++++++++ docs/snippets/live/vue.html | 3 + docs/snippets/styles.css | 75 +++++++++++++++++++ docs/utilities.html | 71 ++++++++++++++++++ 23 files changed, 1263 insertions(+), 68 deletions(-) delete mode 100644 docs/snippets/cpf-field.jsx create mode 100644 docs/snippets/cpf-field.tsx create mode 100644 docs/snippets/live/angular.html create mode 100644 docs/snippets/live/react.html create mode 100644 docs/snippets/live/run.js create mode 100644 docs/snippets/live/vue.html create mode 100644 docs/snippets/styles.css diff --git a/docs/404.html b/docs/404.html index 9c234bf9c..132da9bb8 100644 --- a/docs/404.html +++ b/docs/404.html @@ -97,6 +97,21 @@ --cover-bg-overlay: none; } + /* The framework tabs of examples.md */ + .example-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 1.5em 0 0; border-bottom: 1px solid var(--color-mono-3, #3d444d); } + .example-tabs button { + padding: 0.55em 1.1em; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; + background: none; color: inherit; font: inherit; font-weight: 600; opacity: 0.7; cursor: pointer; + } + .example-tabs button:hover { opacity: 1; } + .example-tabs button[aria-selected="true"] { opacity: 1; color: var(--theme-color); border-bottom-color: var(--theme-color); } + .example-tabs button:focus { box-shadow: none; outline: none; } + .example-tabs button:focus-visible { box-shadow: none; outline: 2px solid var(--theme-color); outline-offset: -2px; } + .example[hidden] { display: none; } + .example iframe { + display: block; width: 100%; height: 72px; margin: 1em 0; box-sizing: border-box; + border: 1px solid var(--color-mono-3, #3d444d); border-radius: 6px; + } @@ -159,6 +174,57 @@ return title ? '# ' + title + '\n\n' + content : content; }); }, + // The framework tabs of examples.md: every
becomes a + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in + // the site's color scheme and as tall as its content. + function (hook) { + hook.doneEach(function () { + var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); + if (panels.length === 0) return; + var scheme = document.querySelector('meta[name="color-scheme"]'); + var tabs = document.createElement('div'); + tabs.className = 'example-tabs'; + tabs.setAttribute('role', 'tablist'); + panels[0].parentNode.insertBefore(tabs, panels[0]); + + var show = function (name) { + panels.forEach(function (panel, index) { + var selected = panel.dataset.name === name; + tabs.children[index].setAttribute('aria-selected', String(selected)); + panel.hidden = !selected; + if (!selected || panel.querySelector('iframe')) return; + var frame = document.createElement('iframe'); + frame.title = panel.dataset.name + ' demo'; + frame.src = panel.dataset.demo; + // The same scheme on both sides, or the browser paints the frame opaque + if (scheme) frame.style.colorScheme = scheme.content; + frame.addEventListener('load', function () { + var page = frame.contentDocument; + if (scheme) page.documentElement.style.colorScheme = scheme.content; + new ResizeObserver(function () { + frame.style.height = page.documentElement.scrollHeight + 'px'; + }).observe(page.body); + }); + panel.insertBefore(frame, panel.firstChild); + }); + try { localStorage.setItem('example-tab', name); } catch (error) {} + }; + + panels.forEach(function (panel) { + panel.setAttribute('role', 'tabpanel'); + var button = document.createElement('button'); + button.type = 'button'; + button.setAttribute('role', 'tab'); + button.textContent = panel.dataset.name; + button.addEventListener('click', function () { show(panel.dataset.name); }); + tabs.appendChild(button); + }); + + var saved = null; + try { saved = localStorage.getItem('example-tab'); } catch (error) {} + show(panels.some(function (panel) { return panel.dataset.name === saved; }) ? saved : panels[0].dataset.name); + }); + }, // Per-page metadata for search engines and link previews: the title, description, // canonical URL, language and hreflang pair of the page being shown, from its front // matter and route. The generated copies of this shell carry the same values statically @@ -287,6 +353,11 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/docs/examples.html b/docs/examples.html index 12469bdb1..2216e1c85 100644 --- a/docs/examples.html +++ b/docs/examples.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/getting-started.html b/docs/getting-started.html index f0780a90e..50df0c6e4 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/index.html b/docs/index.html index ef179d6f5..f9ea383bc 100644 --- a/docs/index.html +++ b/docs/index.html @@ -92,31 +92,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/migration-v1-to-v2.html b/docs/migration-v1-to-v2.html index 31402106c..42dfbf57b 100644 --- a/docs/migration-v1-to-v2.html +++ b/docs/migration-v1-to-v2.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/pt-br/examples.html b/docs/pt-br/examples.html index 50ce2d6a9..6b05dba71 100644 --- a/docs/pt-br/examples.html +++ b/docs/pt-br/examples.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/pt-br/getting-started.html b/docs/pt-br/getting-started.html index 94b42405c..44c7ac1b8 100644 --- a/docs/pt-br/getting-started.html +++ b/docs/pt-br/getting-started.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/pt-br/index.html b/docs/pt-br/index.html index 94b42405c..44c7ac1b8 100644 --- a/docs/pt-br/index.html +++ b/docs/pt-br/index.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/pt-br/migration-v1-to-v2.html b/docs/pt-br/migration-v1-to-v2.html index bcefb34bd..9cb132917 100644 --- a/docs/pt-br/migration-v1-to-v2.html +++ b/docs/pt-br/migration-v1-to-v2.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/pt-br/utilities.html b/docs/pt-br/utilities.html index 743bab554..d48d06c10 100644 --- a/docs/pt-br/utilities.html +++ b/docs/pt-br/utilities.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + diff --git a/docs/snippets/styles.css b/docs/snippets/styles.css index 84eec21e7..2bee07429 100644 --- a/docs/snippets/styles.css +++ b/docs/snippets/styles.css @@ -1,11 +1,12 @@ /* - * The look of the live demos of examples.md, matched to the docs site: its green, its fonts and its - * dark and light backgrounds. The demo pages take the site's color scheme (run.js), or the - * system's when opened on their own. + * The look of the live demos of examples.md: the site's theme color, from its stylesheet, and its + * dark and light backgrounds. The docs page gives each demo the site's color scheme; opened on its + * own, a demo follows the system's. */ +@import url("../styles.css"); + :root { color-scheme: light dark; - --green: #009739; --red: light-dark(#cf222e, #ff6b6b); --text: light-dark(#333, #ddd); --muted: light-dark(#8c959f, #8b949e); @@ -49,16 +50,16 @@ input::placeholder { input:focus { outline: none; - border-color: var(--green); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--green) 30%, transparent); + border-color: var(--theme-color); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color) 30%, transparent); } output { padding: 4px 10px; border-radius: 999px; font-size: 14px; - color: var(--green); - background: color-mix(in srgb, var(--green) 14%, transparent); + color: var(--theme-color); + background: color-mix(in srgb, var(--theme-color) 14%, transparent); } input[aria-invalid="true"] { diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 000000000..5caa85ea9 --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,28 @@ +/* + * The site's own styles, on top of docsify's core theme and its dark add-on (index.html). Every + * page shell links this file, and the live demos of examples.md import it for the theme color. + */ + +:root { + /* The green of the official palette (github.com/brazilian-utils/brand) */ + --theme-color: #009739; + /* The cover page shares the page background instead of the default gradient */ + --cover-bg: var(--color-bg); + --cover-bg-overlay: none; +} + +/* The framework tabs of examples.md */ +.example-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 1.5em 0 0; border-bottom: 1px solid var(--color-mono-3, #3d444d); } +.example-tabs button { + padding: 0.55em 1.1em; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; + background: none; color: inherit; font: inherit; font-weight: 600; opacity: 0.7; cursor: pointer; +} +.example-tabs button:hover { opacity: 1; } +.example-tabs button[aria-selected="true"] { opacity: 1; color: var(--theme-color); border-bottom-color: var(--theme-color); } +.example-tabs button:focus { box-shadow: none; outline: none; } +.example-tabs button:focus-visible { box-shadow: none; outline: 2px solid var(--theme-color); outline-offset: -2px; } +.example[hidden] { display: none; } +.example iframe { + display: block; width: 100%; height: 72px; margin: 1em 0; box-sizing: border-box; + border: 1px solid var(--color-mono-3, #3d444d); border-radius: 6px; +} diff --git a/docs/utilities.html b/docs/utilities.html index 8eddf5585..cd2e5375e 100644 --- a/docs/utilities.html +++ b/docs/utilities.html @@ -93,31 +93,7 @@ add media="(prefers-color-scheme: dark)" to the add-on to follow the system instead --> - + From 55e068eb6ff69f4189269eb1c842e8028d8afa10 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:10:35 -0300 Subject: [PATCH 18/70] docs(examples): give the demos the site's own theme, fix the tab underline and the iframe scrollbar --- docs/404.html | 16 +++------ docs/examples.html | 16 +++------ docs/getting-started.html | 16 +++------ docs/index.html | 16 +++------ docs/migration-v1-to-v2.html | 16 +++------ docs/pt-br/examples.html | 16 +++------ docs/pt-br/getting-started.html | 16 +++------ docs/pt-br/index.html | 16 +++------ docs/pt-br/migration-v1-to-v2.html | 16 +++------ docs/pt-br/utilities.html | 16 +++------ docs/snippets/styles.css | 56 ++++++++---------------------- docs/styles.css | 16 +++++---- docs/utilities.html | 16 +++------ 13 files changed, 80 insertions(+), 168 deletions(-) diff --git a/docs/404.html b/docs/404.html index 2c984cf04..c9f785bb1 100644 --- a/docs/404.html +++ b/docs/404.html @@ -84,10 +84,7 @@ - - - + @@ -151,13 +148,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -172,13 +168,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/examples.html b/docs/examples.html index 2216e1c85..2628012bc 100644 --- a/docs/examples.html +++ b/docs/examples.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/getting-started.html b/docs/getting-started.html index 50df0c6e4..f76f7fd35 100644 --- a/docs/getting-started.html +++ b/docs/getting-started.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/index.html b/docs/index.html index f9ea383bc..ab7875f6a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -88,10 +88,7 @@ - - - + @@ -155,13 +152,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -176,13 +172,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/migration-v1-to-v2.html b/docs/migration-v1-to-v2.html index 42dfbf57b..b051f2ca6 100644 --- a/docs/migration-v1-to-v2.html +++ b/docs/migration-v1-to-v2.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/pt-br/examples.html b/docs/pt-br/examples.html index 6b05dba71..d3c5e8ae3 100644 --- a/docs/pt-br/examples.html +++ b/docs/pt-br/examples.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/pt-br/getting-started.html b/docs/pt-br/getting-started.html index 44c7ac1b8..66d6c1990 100644 --- a/docs/pt-br/getting-started.html +++ b/docs/pt-br/getting-started.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/pt-br/index.html b/docs/pt-br/index.html index 44c7ac1b8..66d6c1990 100644 --- a/docs/pt-br/index.html +++ b/docs/pt-br/index.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/pt-br/migration-v1-to-v2.html b/docs/pt-br/migration-v1-to-v2.html index 9cb132917..ba55692c4 100644 --- a/docs/pt-br/migration-v1-to-v2.html +++ b/docs/pt-br/migration-v1-to-v2.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/pt-br/utilities.html b/docs/pt-br/utilities.html index d48d06c10..71e67114f 100644 --- a/docs/pt-br/utilities.html +++ b/docs/pt-br/utilities.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); diff --git a/docs/snippets/styles.css b/docs/snippets/styles.css index 2bee07429..893fac31e 100644 --- a/docs/snippets/styles.css +++ b/docs/snippets/styles.css @@ -1,25 +1,19 @@ /* - * The look of the live demos of examples.md: the site's theme color, from its stylesheet, and its - * dark and light backgrounds. The docs page gives each demo the site's color scheme; opened on its - * own, a demo follows the system's. + * The live demos of examples.md: the site's own stylesheet, so a demo looks like the page around + * it (docsify already styles the input), plus the layout of the field and its validity states. */ @import url("../styles.css"); :root { - color-scheme: light dark; - --red: light-dark(#cf222e, #ff6b6b); - --text: light-dark(#333, #ddd); - --muted: light-dark(#8c959f, #8b949e); - --field: light-dark(#fff, #2a3035); - --border: light-dark(#d0d7de, #3d444d); + --invalid-color: #e5534b; +} + +html { + overflow: hidden; } body { - margin: 0; - padding: 14px 16px; - font: 16px/1.5 system-ui, sans-serif; - color: var(--text); - background: transparent; + padding: 16px; } label { @@ -31,46 +25,26 @@ label { } input { - width: 16ch; - padding: 10px 12px; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--field); - color: inherit; - font: 500 16px/1.2 ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; - letter-spacing: 0.03em; - transition: - border-color 0.15s, - box-shadow 0.15s; -} - -input::placeholder { - color: var(--muted); + width: calc(14ch + 1.3em + 2px); + padding: 0.4em 0.65em; + font-family: var(--font-family-mono); + font-weight: normal; } input:focus { outline: none; border-color: var(--theme-color); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-color) 30%, transparent); } output { - padding: 4px 10px; - border-radius: 999px; - font-size: 14px; + font-size: var(--font-size-s, 0.875em); color: var(--theme-color); - background: color-mix(in srgb, var(--theme-color) 14%, transparent); } input[aria-invalid="true"] { - border-color: var(--red); -} - -input[aria-invalid="true"]:focus { - box-shadow: 0 0 0 3px color-mix(in srgb, var(--red) 30%, transparent); + border-color: var(--invalid-color); } input[aria-invalid="true"] ~ output { - color: var(--red); - background: color-mix(in srgb, var(--red) 14%, transparent); + color: var(--invalid-color); } diff --git a/docs/styles.css b/docs/styles.css index 5caa85ea9..27638d73f 100644 --- a/docs/styles.css +++ b/docs/styles.css @@ -1,7 +1,11 @@ /* - * The site's own styles, on top of docsify's core theme and its dark add-on (index.html). Every - * page shell links this file, and the live demos of examples.md import it for the theme color. + * The stylesheet of the docs site, linked by every page shell and imported by the live demos of + * examples.md, so both look the same. docsify 5's core theme with the dark add-on always on, the + * look the site had before v5 (add `screen and (prefers-color-scheme: dark)` to the add-on's + * import to follow the system instead), then the site's own styles. */ +@import url("https://cdn.jsdelivr.net/npm/docsify@5/dist/themes/core.min.css"); +@import url("https://cdn.jsdelivr.net/npm/docsify@5/dist/themes/addons/core-dark.min.css"); :root { /* The green of the official palette (github.com/brazilian-utils/brand) */ @@ -12,9 +16,9 @@ } /* The framework tabs of examples.md */ -.example-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 1.5em 0 0; border-bottom: 1px solid var(--color-mono-3, #3d444d); } +.example-tabs { display: flex; flex-wrap: wrap; gap: 4px; margin: 1.5em 0 0; border-bottom: 1px solid var(--border-color); } .example-tabs button { - padding: 0.55em 1.1em; border: 0; border-bottom: 2px solid transparent; margin-bottom: -1px; + padding: 0.55em 1.1em; border: 0; border-bottom: 2px solid transparent; border-radius: 0; margin-bottom: -1px; background: none; color: inherit; font: inherit; font-weight: 600; opacity: 0.7; cursor: pointer; } .example-tabs button:hover { opacity: 1; } @@ -23,6 +27,6 @@ .example-tabs button:focus-visible { box-shadow: none; outline: 2px solid var(--theme-color); outline-offset: -2px; } .example[hidden] { display: none; } .example iframe { - display: block; width: 100%; height: 72px; margin: 1em 0; box-sizing: border-box; - border: 1px solid var(--color-mono-3, #3d444d); border-radius: 6px; + display: block; width: 100%; height: 74px; margin: 1em 0; + border: 1px solid var(--border-color); border-radius: var(--border-radius); } diff --git a/docs/utilities.html b/docs/utilities.html index cd2e5375e..7e99567a0 100644 --- a/docs/utilities.html +++ b/docs/utilities.html @@ -89,10 +89,7 @@ - - - + @@ -156,13 +153,12 @@ }); }, // The framework tabs of examples.md: every
becomes a - // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown, in - // the site's color scheme and as tall as its content. + // tab; its live demo, an iframe of docs/snippets, loads the first time the tab is shown and + // is as tall as its content. function (hook) { hook.doneEach(function () { var panels = [].slice.call(document.querySelectorAll('.example[data-name]')); if (panels.length === 0) return; - var scheme = document.querySelector('meta[name="color-scheme"]'); var tabs = document.createElement('div'); tabs.className = 'example-tabs'; tabs.setAttribute('role', 'tablist'); @@ -177,13 +173,11 @@ var frame = document.createElement('iframe'); frame.title = panel.dataset.name + ' demo'; frame.src = panel.dataset.demo; - // The same scheme on both sides, or the browser paints the frame opaque - if (scheme) frame.style.colorScheme = scheme.content; frame.addEventListener('load', function () { var page = frame.contentDocument; - if (scheme) page.documentElement.style.colorScheme = scheme.content; new ResizeObserver(function () { - frame.style.height = page.documentElement.scrollHeight + 'px'; + var border = frame.offsetHeight - frame.clientHeight; + frame.style.height = page.body.scrollHeight + border + 'px'; }).observe(page.body); }); panel.insertBefore(frame, panel.firstChild); From ec7ba05ae2dce0bcf0ef737fdfa154671aaaa588 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:12:28 -0300 Subject: [PATCH 19/70] fix(docs): load Angular's compiler before the Angular demo and its packages --- docs/snippets/live/run.js | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/snippets/live/run.js b/docs/snippets/live/run.js index f919ddaed..9db4550e8 100644 --- a/docs/snippets/live/run.js +++ b/docs/snippets/live/run.js @@ -84,21 +84,24 @@ }); } - return Promise.all([import("@angular/compiler"), import("@angular/core"), import("@angular/platform-browser")]).then( - function (angular) { - var component = Object.values(module)[0]; - document.body.appendChild(document.createElement(angular[1].reflectComponentType(component).selector)); - return angular[2].bootstrapApplication(component, { - providers: [angular[1].provideZonelessChangeDetection()], - }); - }, - ); + return Promise.all([import("@angular/core"), import("@angular/platform-browser")]).then(function (angular) { + var component = Object.values(module)[0]; + document.body.appendChild(document.createElement(angular[0].reflectComponentType(component).selector)); + return angular[1].bootstrapApplication(component, { + providers: [angular[0].provideZonelessChangeDetection()], + }); + }); }; Promise.all([loadBabel, read(example)]) .then(function (files) { return /\.vue$/.test(example) ? compileVue(files[1]) : files[1]; }) + .then(function (code) { + // Angular's JIT compiler has to be evaluated before any other Angular package, the example + // included: the partially compiled packages look for it as they load. + return /\.component\.ts$/.test(example) ? import("@angular/compiler").then(function () { return code; }) : code; + }) .then(function (code) { return import(toModule(transpile(code, example.replace(/\.vue$/, ".ts")))); }) From f6e64b9384ebc6580dc9f9f8dcdebbb2ebff0b96 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:14:55 -0300 Subject: [PATCH 20/70] docs: rewrite the getting started, README and migration pages in plain language Replace the inherited "utils library for Brazilian-specific businesses" tagline with "utilities for Brazilian data" everywhere (README, cover pages, site shells, context7.json, llms.txt), rewrite the getting started page in English and Portuguese without the translated-from-English phrasing, and cut the migration guide down to what a reader needs: the renamed exports, the removed helpers, the behaviour changes and the checklist. --- CONTRIBUTING.md | 2 +- README.md | 46 +-- context7.json | 2 +- docs/404.html | 8 +- docs/_coverpage.md | 2 +- docs/examples.html | 4 +- docs/getting-started.html | 8 +- docs/getting-started.md | 65 ++--- docs/index.html | 8 +- docs/llms-full.txt | 65 ++--- docs/llms.txt | 2 +- docs/migration-v1-to-v2.html | 4 +- docs/migration-v1-to-v2.md | 440 ++++------------------------ docs/pt-br/_coverpage.md | 2 +- docs/pt-br/examples.html | 4 +- docs/pt-br/getting-started.html | 8 +- docs/pt-br/getting-started.md | 71 ++--- docs/pt-br/index.html | 8 +- docs/pt-br/migration-v1-to-v2.html | 12 +- docs/pt-br/migration-v1-to-v2.md | 447 ++++------------------------- docs/pt-br/utilities.html | 4 +- docs/utilities.html | 4 +- scripts/llms.ts | 4 +- 23 files changed, 228 insertions(+), 992 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5a71e9d39..f9e163492 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Thank you for your interest in contributing to Brazilian Utils! This project exists thanks to [everyone who contributes](README.md#contributors), and we'd love your help solving the little -day-to-day problems of building software for Brazilian businesses. +day-to-day problems of building software for Brazil. By participating in this project, you agree to abide by our [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/README.md b/README.md index abb4df232..59853d24e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
Brazilian Utils -

Utils library for Brazilian-specific businesses.

+

Utilities for Brazilian data: CPF, CNPJ, CEP, boleto, Pix, holidays and more.

[📖 Documentation](https://brazilian-utils.com.br/getting-started) @@ -29,46 +29,24 @@ # Getting Started -Brazilian Utils is a library focused on solving problems that we face daily in the development of applications for the Brazilian business. +Brazilian Utils is a zero-dependency library of small utilities for the day-to-day problems of building software for Brazil: validating, formatting, parsing and generating CPF, CNPJ, CEP, boleto, Pix, phone numbers, holidays and more. ## Why Brazilian Utils - **Zero runtime dependencies.** Nothing else lands in your `node_modules` or in your bundle. -- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped); every util is also its own subpath entry (`@brazilian-utils/brazilian-utils/get-cities`) for the heavy ones. -- **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, tested in CI on every one of them. -- **Written in TypeScript.** Types ship with the package; the public API is tracked by an API report so nothing changes silently. -- **Validated against the official rules.** Every validator cites the specification, law or dataset it implements (`@see` in the docs), and the test suite is mutation-tested, not just covered. +- **Tree-shakeable, down to the function.** `import { isValidCpf }` costs about 1.4 KB minified (0.8 KB gzipped). Every util is also its own subpath entry, so the heavy ones can be lazy-loaded. +- **Runs everywhere.** Node.js `^20.19.0 || >=22.12.0`, Bun, Deno and evergreen browsers, all tested in CI. +- **Written in TypeScript.** Types ship with the package, and an API report tracks the public API so nothing changes silently. +- **Validated against the official rules.** Every validator cites the specification, law or dataset it implements, and the test suite is mutation-tested, not just covered. - **Documented in English and Portuguese**, with an `llms.txt` for AI assistants. ## Installation -You can install **Brazilian Utils** in a few ways: - -as npm package: - -```bash -npm install --save @brazilian-utils/brazilian-utils -``` - -with yarn package manager: - -```bash -yarn add @brazilian-utils/brazilian-utils -``` - -with pnpm: - -```bash -pnpm add @brazilian-utils/brazilian-utils -``` - -with bun: - ```bash -bun add @brazilian-utils/brazilian-utils +npm install @brazilian-utils/brazilian-utils ``` -or ` @@ -76,9 +54,9 @@ or ` @@ -51,11 +29,16 @@ or ` @@ -201,11 +179,16 @@ or ` ``` -### Suporte a runtimes +### Runtimes suportados -Node `^20.19.0 || >=22.12.0`, Bun, Deno e navegadores modernos. +| Runtime | Suportado | Testado no CI | +| ----------- | ------------------------- | ----------------------------- | +| Node.js | `^20.19.0 \|\| >=22.12.0` | 20, 22, 24, 26 | +| Bun | mais recente | mais recente | +| Deno | 2.x | 2.x | +| Navegadores | modernos | Chrome, Firefox, Edge, Safari | ## Como usar -Para usar um de nossos utilitários, basta importar a função necessária, como no exemplo abaixo: +Importe a função que precisar: ```javascript import { isValidCpf } from '@brazilian-utils/brazilian-utils'; @@ -63,7 +46,7 @@ import { isValidCpf } from '@brazilian-utils/brazilian-utils'; isValidCpf('1232454233345'); // false ``` -Você pode conferir a lista de utilitários [clicando aqui](pt-br/utilities.md). Os [exemplos](pt-br/examples.md) mostram como usá-los num campo de formulário em React, Vue ou Angular. +A [referência de utilitários](pt-br/utilities.md) lista todas as funções, agrupadas por família, com opções e exemplos. Os [exemplos](pt-br/examples.md) mostram um campo de CPF em React, Angular, Vue e JavaScript puro. ## Assistentes de IA @@ -73,17 +56,17 @@ A documentação está indexada no Context7 como [`/brazilian-utils/javascript`] Valide um CNPJ com o Brazilian Utils. use library /brazilian-utils/javascript ``` -Para não repetir isso a cada prompt, coloque a regra no arquivo de instruções do agente (`CLAUDE.md`, regras do Cursor ou equivalente): "Para utilitários de documentos brasileiros, use a biblioteca /brazilian-utils/javascript do Context7". +Para não repetir isso a cada prompt, coloque uma regra no arquivo de instruções do agente (`CLAUDE.md`, regras do Cursor ou equivalente): "Para utilitários de documentos brasileiros, use a biblioteca /brazilian-utils/javascript do Context7". -Sem o Context7, aponte o assistente para o [llms.txt](https://brazilian-utils.com.br/llms.txt), que lista todos os utilitários com uma descrição de uma linha e o link para a seção de cada um, ou para o [llms-full.txt](https://brazilian-utils.com.br/llms-full.txt), a documentação completa em inglês em um único arquivo Markdown. +Sem o Context7, aponte o assistente para o [llms.txt](https://brazilian-utils.com.br/llms.txt), que lista todos os utilitários com uma descrição de uma linha, ou para o [llms-full.txt](https://brazilian-utils.com.br/llms-full.txt), a documentação completa em inglês em um único arquivo Markdown. ## Tamanho do bundle -O pacote é tree-shakeable: importar um utilitário da raiz traz apenas o código daquele utilitário, não o resto da biblioteca. `isValidCpf`, por exemplo, adiciona cerca de 1,4 KB minificado (0,8 KB com gzip) ao seu bundle. Um bundler com suporte a tree-shaking (webpack, Rollup, esbuild, Vite, etc.) descarta todos os outros utilitários. +O pacote é tree-shakeable: importar um utilitário da raiz traz apenas o código daquele utilitário. `isValidCpf`, por exemplo, adiciona cerca de 1,4 KB minificado (0,8 KB com gzip) ao seu bundle. -Alguns utilitários são a exceção: cada um embute um dataset oficial e pesa muito mais que todos os outros utilitários somados. Estes são os tamanhos de um import isolado, minificado e com gzip: +Alguns utilitários embutem uma base de dados oficial e pesam muito mais que todos os outros somados: -| Utilitário | Dataset | Minificado | Gzip | +| Utilitário | Base de dados | Minificado | Gzip | | --- | --- | --- | --- | | `getMunicipalities` · `getMunicipalityByCode` · `getMunicipality` | 5571 municípios do IBGE, com nomes e códigos | 154,9 - 156,5 KB | 50,3 - 50,4 KB | | `getCities` | nomes dos 5571 municípios do IBGE | 154,2 KB | 49,8 KB | @@ -93,9 +76,7 @@ Alguns utilitários são a exceção: cada um embute um dataset oficial e pesa m | `isValidCfop` · `getCfop` | descrições das operações do CFOP | 68,9 KB | 6,9 KB | | `getBanks` · `getBankByCode` · `getBankByIspb` | participantes do STR do Banco Central (COMPE + ISPB) | 38,3 - 38,6 KB | 9,5 - 9,7 KB | -Importar qualquer um deles da raiz, mesmo ao lado de um único utilitário pequeno, traz todo esse dataset para o seu bundle principal, porque este pacote é publicado como um único módulo ESM: um `import()` dinâmico da raiz (`await import('@brazilian-utils/brazilian-utils')`) ainda resolve para esse mesmo arquivo único, então não há como separá-lo sozinho. Um bundler que faz code-splitting precisa de um módulo separado para separar. - -Esses módulos separados são os subpaths por utilitário. Carregue um utilitário pesado sob demanda, apenas onde você realmente precisar dos dados dele: +A raiz do pacote é um único módulo ESM, então o bundler não consegue separar uma dessas bases de dados dele: importar um utilitário pesado da raiz coloca a base inteira no seu bundle principal, e um `import()` dinâmico da raiz não ajuda. Para carregar sob demanda, importe do subpath próprio: ```javascript const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); @@ -111,6 +92,6 @@ const { getMunicipalityByCode } = await import( getMunicipalityByCode('3550308'); ``` -Todos os utilitários estão disponíveis dessa forma, como `@brazilian-utils/brazilian-utils/` (kebab-case, seguindo o nome da função: `isValidCpf` → `is-valid-cpf`), pelo mesmo motivo de lazy-loading/code-splitting. +Todo utilitário tem um subpath, `@brazilian-utils/brazilian-utils/` em kebab-case (`isValidCpf` vira `is-valid-cpf`). -Escolha um estilo por utilitário em cada aplicação: um bundler trata o import da raiz e o import do subpath como dois módulos independentes, então importar `getCities` tanto da raiz quanto de `/get-cities` na mesma aplicação inclui a tabela de 154,2 KB de cidades duas vezes, uma em cada módulo. +Escolha um estilo por utilitário em cada aplicação. O bundler trata o import da raiz e o import do subpath como dois módulos independentes, então importar `getCities` dos dois inclui a tabela de municípios duas vezes. diff --git a/docs/pt-br/index.html b/docs/pt-br/index.html index 66d6c1990..e1da33f5b 100644 --- a/docs/pt-br/index.html +++ b/docs/pt-br/index.html @@ -4,7 +4,7 @@ Introdução · Brazilian Utils - + @@ -32,7 +32,7 @@ - + @@ -50,14 +50,14 @@ "@id": "https://brazilian-utils.com.br/#website", "url": "https://brazilian-utils.com.br/", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "inLanguage": ["en", "pt-BR"] }, { "@type": "SoftwareSourceCode", "@id": "https://brazilian-utils.com.br/#library", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "url": "https://brazilian-utils.com.br/", "codeRepository": "https://github.com/brazilian-utils/javascript", "programmingLanguage": "TypeScript", diff --git a/docs/pt-br/migration-v1-to-v2.html b/docs/pt-br/migration-v1-to-v2.html index ba55692c4..e05c32ff8 100644 --- a/docs/pt-br/migration-v1-to-v2.html +++ b/docs/pt-br/migration-v1-to-v2.html @@ -3,8 +3,8 @@ - Guia de Migração: v1 para v2 · Brazilian Utils - + Guia de migração: v1 para v2 · Brazilian Utils + @@ -31,8 +31,8 @@ - - + + @@ -50,14 +50,14 @@ "@id": "https://brazilian-utils.com.br/#website", "url": "https://brazilian-utils.com.br/", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "inLanguage": ["en", "pt-BR"] }, { "@type": "SoftwareSourceCode", "@id": "https://brazilian-utils.com.br/#library", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "url": "https://brazilian-utils.com.br/", "codeRepository": "https://github.com/brazilian-utils/javascript", "programmingLanguage": "TypeScript", diff --git a/docs/pt-br/migration-v1-to-v2.md b/docs/pt-br/migration-v1-to-v2.md index 21edcf90d..28c89f703 100644 --- a/docs/pt-br/migration-v1-to-v2.md +++ b/docs/pt-br/migration-v1-to-v2.md @@ -1,153 +1,38 @@ --- -title: "Guia de Migração: v1 para v2" -description: "Como migrar um projeto do Brazilian Utils v1.x para a v2: os exports renomeados, os aliases deprecados que ainda funcionam e um checklist para seguir." -keywords: ["migração", "v1", "v2", "deprecado", "exports renomeados", "atualização"] +title: "Guia de migração: v1 para v2" +description: "Como migrar um projeto do Brazilian Utils v1.x para a v2: os exports renomeados, os aliases descontinuados que ainda funcionam e um checklist para seguir." +keywords: ["migração", "v1", "v2", "descontinuado", "exports renomeados", "atualização"] --- -Este guia irá ajudá-lo a migrar do Brazilian Utils v1.x para v2.0.0. +Este guia mostra como migrar um projeto do Brazilian Utils v1.x para a v2. -## TL;DR - Migração Rápida +## Resumo -**Boas notícias!** A v2.x mantém compatibilidade para a maioria das mudanças quebradoras: +A v2 renomeia todas as funções para camelCase (`formatCPF` agora é `formatCpf`), mas mantém os nomes da v1 como aliases descontinuados, então a maioria dos projetos atualiza sem mudar código. O TypeScript e o editor marcam os nomes antigos. Os aliases são removidos na v3.0.0. -**Você pode atualizar para v2.x sem alterar seu código** - nomes antigos de funções como `formatCPF`, `isValidCNPJ`, etc. ainda funcionam -**Você receberá avisos de deprecação** - encorajando você a migrar para os novos nomes -**Nomes antigos serão removidos na v3.0.0** - então migre gradualmente +Quatro helpers da v1 eram internos e não têm alias. Substitua-os antes de atualizar: -**Porém**, você deve remover o uso dessas funções helper antes de atualizar: -- `onlyNumbers` → use `string.replace(/\D/g, '')` -- `isLastChar` → use `index === input.length - 1` -- `generateChecksum` → agora apenas interno -- `generateRandomNumber` → agora apenas interno - -## Melhorias na v2.0.0 - -A versão 2.0.0 traz melhorias significativas em arquitetura, ferramentas e experiência do desenvolvedor: - -### Melhor Tree Shaking - -A biblioteca agora usa exports de módulos ES modernos com o campo `exports` adequado no `package.json`, permitindo melhor tree shaking em bundlers modernos. Você pode importar apenas o que precisa: - -```javascript -// Apenas as funções que você importar serão incluídas no seu bundle -import { isValidCpf, formatCpf } from '@brazilian-utils/brazilian-utils'; -``` - -Desde a 2.4.0 cada utilitário também é um subpath próprio, então um bundler que não faz tree -shaking (ou um `require` simples) ainda carrega um único módulo, e os poucos pesados (`getCities`, -`getMunicipalities`, `isValidNcm`, `isValidCbo`, `isValidCnae`, `getBanks`) podem ser carregados sob -demanda: - -```javascript -import { isValidCpf } from '@brazilian-utils/brazilian-utils/is-valid-cpf'; // ~1,4 KB, 0,8 KB com gzip -const { getCities } = await import('@brazilian-utils/brazilian-utils/get-cities'); // só quando precisar -``` - -Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle) para o tamanho de cada entrada. - -### Estrutura Mais Simples - -O código foi reorganizado para melhor manutenibilidade: -- **v1**: Estrutura complexa com diretórios separados `utilities/` e `helpers/` -- **v2**: Estrutura plana com utilitários internos no diretório `_internals/` -- Cada utilitário é autocontido em seu próprio diretório -- Caminhos de importação mais limpos e melhor organização do código - -### Ferramentas Modernas - -Atualizado para ferramentas modernas e mais rápidas: -- **Build**: Migrado de `tsdx` para uma stack com **Vite+** para builds e scripts mais rápidos -- **Testes**: Migrado de `jest` para **Vitest** (mais rápido, compatível com Jest, nativo ESM) -- **Linting/Formatação**: Migrado de `prettier` + `eslint` para a toolchain do Vite+ (`vp fmt` e `vp check`, sobre o Oxc) -- **TypeScript**: Configuração moderna otimizada para bundlers - -### Testes em Browsers - -Agora inclui suporte para testes cross-browser: -- Testes rodam em browsers reais (Chrome, Firefox, Safari, Edge) -- Garante compatibilidade entre diferentes ambientes de browser -- Melhor confiança na funcionalidade cross-platform - -Execute testes em browsers com: -```bash -npm run test:chrome-browser -npm run test:firefox-browser -npm run test:safari-browser -npm run test:edge-browser -``` - -### Menos Dependências - -Redução de dependências de desenvolvimento mantendo zero dependências de runtime: -- **v1**: Múltiplas ferramentas (tsdx, jest, prettier, eslint, husky, lint-staged, etc.) -- **v2**: Uma toolchain (Vite+ para build, lint, formatação e testes, com suporte de browser do Vitest via webdriverio) mais os gates de qualidade listados no CONTRIBUTING.md (Stryker, knip, jscpd, API Extractor, commitlint) -- Manutenção mais simples e pipelines CI/CD mais rápidos -- Zero dependências de runtime (mantido) - -### Novas Funções e Recursos - -Adicionadas novas utilitários úteis: -- `getHolidays` - Obtém feriados brasileiros (nacionais e estaduais) -- `getBoletoInfo` - Extrai informações de boleto (valor, vencimento, código do banco) -- `formatPhone` - Formata números de telefone com padrões brasileiros -- `formatBoleto` - Formata números de boleto -- `generateBoleto` - Gera números de boleto válidos aleatórios -- `formatPis` - Formata números de PIS -- `isValidRenavam` - Valida RENAVAM (número de registro de veículos) -- `isValidBankAccount` - Valida contas bancárias brasileiras com algoritmos específicos para principais bancos - -A 2.4.0 acrescentou muitas outras famílias a essas, todas listadas na [documentação de utilitários](pt-br/utilities.md): -Pix (`isValidPixKey`, `generatePixPayload`, `getPixPayloadInfo`), chave de NF-e/DF-e, CNS, certidão, -CEI/CNO/CAEPF, IBAN, número de cartão, VIN, registro profissional, consulta de bancos (`getBanks`, -`getBankByCode`, `getBankByIspb`), códigos CBO/CNAE/NCM/CFOP/CST/CSOSN, dias úteis (`isBusinessDay`, -`addBusinessDays`, `differenceInBusinessDays`), categorias de natureza jurídica, municípios offline -(`getMunicipalities`, `getMunicipalityByCode`), DDD e fuso horário, número por extenso e um -`capitalize` que conhece as designações societárias brasileiras. - -#### Suporte a CNPJ Alfanumérico (Versão 2) - -A v2.0.0 adiciona suporte ao novo formato alfanumérico de CNPJ introduzido pela Receita Federal. Tanto `isValidCnpj` quanto `generateCnpj` agora suportam CNPJs versão 2 (alfanuméricos): - -```javascript -import { isValidCnpj, generateCnpj } from '@brazilian-utils/brazilian-utils'; - -// Gerar CNPJ alfanumérico -const alphaCnpj = generateCnpj(2); // ex: "Q0SLFMBD7VX439" - -// Validar CNPJ alfanumérico (requer opção de versão) -isValidCnpj("Q0.SLF.MBD/7VX4-39", { version: 2 }); // true -isValidCnpj("Q0SLFMBD7VX439", { version: 2 }); // true - -// Versão 1 (numérico) é o padrão -isValidCnpj("12.345.678/0001-95"); // true (valida apenas numérico) -isValidCnpj("12.345.678/0001-95", { version: 1 }); // true (explícito) -``` - -**Importante**: Por padrão, `isValidCnpj()` valida apenas CNPJs numéricos (versão 1). Para validar CNPJs alfanuméricos, você deve passar explicitamente `{ version: 2 }`. - -### Melhor Suporte TypeScript - -- Configuração TypeScript moderna otimizada para bundlers -- Melhor inferência de tipos e exports -- Experiência do desenvolvedor melhorada com melhor autocomplete - -## Mudanças Quebradoras - -### Nomes de Funções Alterados (PascalCase → camelCase) - -Todos os nomes de funções foram alterados de PascalCase para camelCase para seguir as convenções de nomenclatura JavaScript. - -**Importante: Compatibilidade com Versões Anteriores** +| v1 | Substituto | +|---|---| +| `onlyNumbers(value)` | `value.replace(/\D/g, '')` | +| `isLastChar(index, input)` | `index === input.length - 1` | +| `generateChecksum` | Não é mais exportada. Escreva o cálculo do dígito verificador que precisar. | +| `generateRandomNumber(length)` | Um laço próprio sobre `Math.floor(Math.random() * 10)`. | -Para facilitar a migração, **a v2.x ainda exporta os nomes antigos em PascalCase como aliases deprecated**. Isso significa: +## O que mudou -- Seu código existente usando `formatCPF`, `isValidCNPJ`, etc. continuará funcionando na v2.x -- Você receberá avisos de deprecação no seu IDE/TypeScript -- Os nomes antigos serão **removidos na v3.0.0** +- **Os nomes são camelCase.** Veja [Funções renomeadas](#funções-renomeadas). +- **O tree-shaking funciona até a função**, e cada utilitário também é um subpath próprio (`@brazilian-utils/brazilian-utils/is-valid-cpf`), então os pesados podem ser carregados sob demanda. Veja [Tamanho do bundle](pt-br/getting-started.md#tamanho-do-bundle). +- **CNPJ alfanumérico.** `isValidCnpj` e `generateCnpj` aceitam o novo formato alfanumérico com `{ version: 2 }`. O numérico (versão 1) continua sendo o padrão. Veja [`generateCnpj` e a versão](#generatecnpj-e-a-versão). +- **`getAddressInfoByCep`** aceita a opção `providers`, completa com zeros um CEP numérico e lança erros tipados: `GetAddressInfoByCepValidationError`, `GetAddressInfoByCepNotFoundError` e `GetAddressInfoByCepServiceError`. Chamadas sem opções funcionam como na v1. +- **`getCities`** retorna a lista em ordem alfabética. Desde a 2.4.0 está descontinuada: `getMunicipalities('SP')` retorna os mesmos municípios com seus códigos IBGE, e `getMunicipalityByCode('3550308')` busca um deles offline. +- **`isValidIe`** recebe um único objeto desde a 2.4.0, `isValidIe({ value, stateCode })`. A forma posicional está descontinuada. +- **Muitos utilitários novos** desde a v2: feriados e dias úteis, Pix, chave de NF-e, leitura de boleto, formatação de telefone, contas bancárias e consulta de bancos, códigos de classificação (CBO, CNAE, NCM, CFOP), municípios offline, números por extenso e mais. Todos estão na [referência de utilitários](pt-br/utilities.md). +- **As ferramentas** mudaram para Vite+ e Vitest, com testes em navegador no CI. Isso só importa para quem contribui; veja o [CONTRIBUTING.md](https://github.com/brazilian-utils/javascript/blob/main/CONTRIBUTING.md). -**Recomendação:** Embora você possa atualizar para v2.x sem alterar seu código imediatamente, recomendamos migrar para os novos nomes em camelCase o quanto antes para se preparar para a v3.0.0. +## Funções renomeadas -#### Funções de Validação +Todos os outros exports mantêm o nome da v1. | v1 | v2 | |---|---| @@ -155,63 +40,15 @@ Para facilitar a migração, **a v2.x ainda exporta os nomes antigos em PascalCa | `isValidCNPJ` | `isValidCnpj` | | `isValidCEP` | `isValidCep` | | `isValidPIS` | `isValidPis` | -| `isValidIE` | `isValidIe` (desde a 2.4.0 prefira a forma objeto, `isValidIe({ value, stateCode })`; a forma posicional está descontinuada) | -| `isValidProcessoJuridico` | `isValidProcessoJuridico` (inalterado) | -| `isValidBoleto` | `isValidBoleto` (inalterado) | -| `isValidEmail` | `isValidEmail` (inalterado) | -| `isValidPhone` | `isValidPhone` (inalterado) | -| `isValidMobilePhone` | `isValidMobilePhone` (inalterado) | -| `isValidLandlinePhone` | `isValidLandlinePhone` (inalterado) | -| `isValidLicensePlate` | `isValidLicensePlate` (inalterado) | -| `isValidRenavam` | `isValidRenavam` (novo) | - -#### Funções de Formatação - -| v1 | v2 | -|---|---| +| `isValidIE` | `isValidIe` | | `formatCPF` | `formatCpf` | | `formatCNPJ` | `formatCnpj` | | `formatCEP` | `formatCep` | -| `formatProcessoJuridico` | `formatProcessoJuridico` (inalterado) | -| `formatBoleto` | `formatBoleto` (inalterado) | -| `formatCurrency` | `formatCurrency` (inalterado) | -| `formatPhone` | `formatPhone` (novo) | - -#### Funções de Geração - -| v1 | v2 | -|---|---| | `generateCPF` | `generateCpf` | | `generateCNPJ` | `generateCnpj` | -| `generateBoleto` | `generateBoleto` (inalterado) | - -**Nota sobre o comportamento do `generateCnpj`:** -Na v2.x, `generateCnpj()` sem argumentos retorna por padrão a versão 1 (CNPJ numérico). Na v3.0.0, este comportamento mudará para selecionar aleatoriamente entre versão 1 (numérico) e versão 2 (alfanumérico) para melhor aleatoriedade. Se você precisa de uma versão específica, sempre passe o parâmetro de versão explicitamente: +Antes (v1): -```javascript -// Recomendado: Sempre especifique a versão -generateCnpj(1); // Sempre gera CNPJ numérico -generateCnpj(2); // Sempre gera CNPJ alfanumérico - -// Não recomendado: Depender do comportamento padrão -generateCnpj(); // Atualmente gera numérico (v1), mas será aleatório na v3.0.0 -``` - -#### Outras Funções - -| v1 | v2 | -|---|---| -| `parseCurrency` | `parseCurrency` (inalterado) | -| `capitalize` | `capitalize` (inalterado) | -| `getStates` | `getStates` (inalterado) | -| `getCities` | `getCities` (inalterado; descontinuado na 2.4.0 em favor de `getMunicipalities`) | -| `getMunicipality` | `getMunicipality` (descontinuado na 2.4.0 em favor de `getMunicipalityByCode`, que é síncrono e offline) | -| `getAddressInfoByCep` | `getAddressInfoByCep` (API alterada, veja abaixo) | - -### Exemplo de Migração - -**Antes (v1):** ```javascript import { isValidCPF, formatCPF, generateCNPJ } from '@brazilian-utils/brazilian-utils'; @@ -220,7 +57,8 @@ const formatted = formatCPF('12345678909'); const cnpj = generateCNPJ(); ``` -**Depois (v2):** +Depois (v2): + ```javascript import { isValidCpf, formatCpf, generateCnpj } from '@brazilian-utils/brazilian-utils'; @@ -229,176 +67,25 @@ const formatted = formatCpf('12345678909'); const cnpj = generateCnpj(); ``` -### Funções Helper Removidas - -As seguintes funções helper não são mais exportadas na API pública. Estas eram utilitários internos que não deveriam ter sido expostos. - -**Nota:** Diferentemente das funções renomeadas acima, esses helpers **NÃO** possuem aliases de compatibilidade. Você deve migrar para longe deles antes de atualizar para a v2.x. +### `generateCnpj` e a versão -#### `onlyNumbers` -Esta função foi removida da API pública. Agora é um utilitário interno chamado `sanitizeToDigits`. +`generateCnpj()` sem argumentos gera um CNPJ numérico na v2.x. Na v3.0.0 vai sortear entre numérico e alfanumérico, então passe a versão quando precisar de uma específica: -**Migração:** ```javascript -// v1 - Não use mais isso -import { onlyNumbers } from '@brazilian-utils/brazilian-utils'; -const digits = onlyNumbers('123-456'); - -// v2 - Use uma substituição simples -const digits = '123-456'.replace(/\D/g, ''); +generateCnpj(1); // sempre numérico +generateCnpj(2); // sempre alfanumérico, ex.: "Q0SLFMBD7VX439" +generateCnpj(); // numérico hoje, aleatório na v3.0.0 ``` -#### `isLastChar` -Esta função foi removida. Use uma comparação inline simples. +`isValidCnpj` valida CNPJs numéricos por padrão. Para validar alfanuméricos, passe `{ version: 2 }`: -**Migração:** ```javascript -// v1 - Não use mais isso -import { isLastChar } from '@brazilian-utils/brazilian-utils'; -if (isLastChar(index, input)) { /* ... */ } - -// v2 - Use comparação inline -if (index === input.length - 1) { /* ... */ } +isValidCnpj('12.345.678/0001-95'); // true +isValidCnpj('Q0.SLF.MBD/7VX4-39', { version: 2 }); // true +isValidCnpj('Q0.SLF.MBD/7VX4-39'); // false (só numérico sem a opção) ``` -#### `generateChecksum` -Esta função agora é interna e não é mais exportada na API pública. O pacote não exporta internals: `dist/_internals` não é publicado e não existe subpath para ele, então não há forma suportada de importar essa função na v2. Calcule o dígito verificador que você precisa no seu próprio código. - -**Migração:** -```javascript -// v1 - Não use mais isso -import { generateChecksum } from '@brazilian-utils/brazilian-utils'; -``` - -#### `generateRandomNumber` -Esta função agora é interna e não é mais exportada na API pública. - -**Migração:** -```javascript -// v1 - Não use mais isso -import { generateRandomNumber } from '@brazilian-utils/brazilian-utils'; - -// v2 - Use sua própria implementação -function generateRandomNumber(length) { - let result = ''; - for (let i = 0; i < length; i++) { - result += Math.floor(Math.random() * 10).toString(); - } - return result; -} -``` - -## Novas Funções - -As seguintes funções são novas na v2.0.0: - -### `getHolidays` - -Obtém feriados brasileiros para um determinado ano. Suporta feriados nacionais e estaduais. - -```javascript -import { getHolidays } from '@brazilian-utils/brazilian-utils'; - -// Obtém todos os feriados nacionais -const holidays = getHolidays(2024); - -// Obtém feriados para um estado específico -const spHolidays = getHolidays({ year: 2024, stateCode: 'SP' }); -``` - -### `getBoletoInfo` - -Extrai informações de um boleto (valor, data de vencimento, código do banco). - -```javascript -import { getBoletoInfo } from '@brazilian-utils/brazilian-utils'; - -const info = getBoletoInfo('00190000090114971860168524522114675860000102656'); -// { amount: 102656, expirationDate: Date, bankCode: '001' } -``` - -### `formatPhone` - -Formata números de telefone de acordo com padrões brasileiros. - -```javascript -import { formatPhone } from '@brazilian-utils/brazilian-utils'; - -formatPhone('11900000000'); // 11900-0000 (CUIDADO: a máscara padrão "sn" trunca um número com DDD) -formatPhone('11900000000', { mask: 'nanp' }); // (11) 90000-0000 -formatPhone('11900000000', { mask: 'auto' }); // (11) 90000-0000 -``` - -### `isValidRenavam` - -Valida RENAVAM (Registro Nacional de Veículos Automotores). Suporta tanto o formato antigo (9 dígitos) quanto o novo formato (11 dígitos). - -```javascript -import { isValidRenavam } from '@brazilian-utils/brazilian-utils'; - -isValidRenavam('639884962'); // true (9 dígitos, formato antigo) -isValidRenavam('00639884962'); // true (11 dígitos, formato novo) -isValidRenavam('12345678901'); // false (checksum inválido) -``` - -### `isValidBankAccount` - -Valida contas bancárias brasileiras. Suporta algoritmos de validação específicos para os principais bancos (Banco do Brasil, Itaú, Bradesco, Santander, Caixa Econômica Federal) e validação genérica mod10/mod11 para outros bancos. - -```javascript -import { isValidBankAccount } from '@brazilian-utils/brazilian-utils'; - -// Banco do Brasil -isValidBankAccount({ - bankCode: '001', - agency: '1584', - account: '00210169', - digit: '6' -}); // true - -// Itaú -isValidBankAccount({ - bankCode: '341', - agency: '2545', - account: '02366', - digit: '1' -}); // true - -// Outros bancos usam validação genérica -isValidBankAccount({ - bankCode: '246', - agency: '1234', - account: '123456', - digit: '6' -}); // true (o dígito corresponde ao mod10) -``` - -## Mudanças na API - -### `getAddressInfoByCep` - -A função `getAddressInfoByCep` agora suporta opções adicionais e melhor tratamento de erros. - -**Antes (v1):** -```javascript -const address = await getAddressInfoByCep('01310100'); -``` - -**Depois (v2):** -```javascript -// Ainda funciona da mesma forma -const address = await getAddressInfoByCep('01310100'); - -// Mas agora suporta opções -const address = await getAddressInfoByCep('01310-100', { - providers: ['viacep', 'brasilapi'] -}); - -// Também aceita números (será preenchido automaticamente com zeros à esquerda) -const address = await getAddressInfoByCep(1310100); -``` - -A função agora exporta classes de erro para melhor tratamento de erros: +### Erros de `getAddressInfoByCep` ```javascript import { @@ -412,58 +99,28 @@ try { const address = await getAddressInfoByCep('01310100'); } catch (error) { if (error instanceof GetAddressInfoByCepValidationError) { - // Tratar erro de validação + // CEP inválido } else if (error instanceof GetAddressInfoByCepNotFoundError) { - // Tratar erro de não encontrado + // nenhum endereço para este CEP } else if (error instanceof GetAddressInfoByCepServiceError) { - // Tratar erro de serviço + // os provedores falharam } } ``` -### `getCities` - -A função `getCities` agora retorna resultados ordenados alfabeticamente. - -**Antes (v1):** -```javascript -getCities(); // Retornava array não ordenado -getCities('SP'); // Retornava array não ordenado -``` - -**Depois (v2):** -```javascript -getCities(); // Retorna ordenado alfabeticamente -getCities('SP'); // Retorna ordenado alfabeticamente -``` - -**Desde a 2.4.0:** `getCities` está descontinuado. `getMunicipalities('SP')` retorna os mesmos municípios -com o código do IBGE (`{ code, name, stateCode }`), e `getMunicipalityByCode('3550308')` busca um deles -sem chamada de rede. - -## Checklist de Migração - -### Obrigatório (antes de atualizar para v2.x) -- [ ] Remover uso de funções helper (`onlyNumbers`, `isLastChar`, `generateChecksum`, `generateRandomNumber`) +## Checklist -### Opcional (recomendado antes da v3.0.0) -- [ ] Atualizar todas as importações para usar nomes de funções em camelCase -- [ ] Substituir todas as chamadas de funções com nomes em camelCase -- [ ] Trocar `getCities` por `getMunicipalities` e `getMunicipality` por `getMunicipalityByCode` (descontinuados na 2.4.0) -- [ ] Chamar `isValidIe({ value, stateCode })` em vez de `isValidIe(stateCode, ie)` (descontinuado na 2.4.0) -- [ ] Importar os tipos `*Params` em vez dos aliases `*Options` mantidos para as funções de um único argumento objeto (descontinuados na 2.4.0) -- [ ] Tirar `'widenet'` dos `providers` do `getAddressInfoByCep` (o serviço acabou; descontinuado na 2.4.0) +Obrigatório antes de atualizar: -### Revisar se aplicável -- [ ] Atualizar tratamento de erros para `getAddressInfoByCep` se necessário -- [ ] Revisar uso de `getCities` se a ordenação era importante -- [ ] Testar todas as funções de validação e formatação -- [ ] Atualizar importações de tipos TypeScript se aplicável +- [ ] Substituir `onlyNumbers`, `isLastChar`, `generateChecksum` e `generateRandomNumber`. -## Obter Ajuda +Recomendado antes da v3.0.0: -Se você encontrar problemas durante a migração, por favor: +- [ ] Renomear os imports e as chamadas da tabela acima para camelCase. +- [ ] Trocar `getCities` por `getMunicipalities` e `getMunicipality` por `getMunicipalityByCode`. +- [ ] Chamar `isValidIe({ value, stateCode })` em vez de `isValidIe(stateCode, ie)`. +- [ ] Importar os tipos `*Params` em vez dos aliases `*Options` das funções que recebem um único objeto. +- [ ] Tirar `'widenet'` dos `providers` de `getAddressInfoByCep` (o serviço não existe mais). +- [ ] Passar a versão para `generateCnpj` quando precisar de uma específica. -1. Verifique a [documentação de utilitários](/pt-br/utilities.md) para as assinaturas corretas das funções -2. Revise os exemplos neste guia de migração -3. Abra uma issue no [repositório GitHub](https://github.com/brazilian-utils/javascript) se encontrar um bug +Encontrou um bug na migração? [Abra uma issue](https://github.com/brazilian-utils/javascript/issues). diff --git a/docs/pt-br/utilities.html b/docs/pt-br/utilities.html index 71e67114f..ee67ad680 100644 --- a/docs/pt-br/utilities.html +++ b/docs/pt-br/utilities.html @@ -50,14 +50,14 @@ "@id": "https://brazilian-utils.com.br/#website", "url": "https://brazilian-utils.com.br/", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "inLanguage": ["en", "pt-BR"] }, { "@type": "SoftwareSourceCode", "@id": "https://brazilian-utils.com.br/#library", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "url": "https://brazilian-utils.com.br/", "codeRepository": "https://github.com/brazilian-utils/javascript", "programmingLanguage": "TypeScript", diff --git a/docs/utilities.html b/docs/utilities.html index 7e99567a0..3f2cb2e6d 100644 --- a/docs/utilities.html +++ b/docs/utilities.html @@ -50,14 +50,14 @@ "@id": "https://brazilian-utils.com.br/#website", "url": "https://brazilian-utils.com.br/", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "inLanguage": ["en", "pt-BR"] }, { "@type": "SoftwareSourceCode", "@id": "https://brazilian-utils.com.br/#library", "name": "Brazilian Utils", - "description": "Zero-dependency, tree-shakeable utils for Brazilian-specific businesses: CPF, CNPJ, boleto, Pix, CEP, holidays and more.", + "description": "Zero-dependency, tree-shakeable utilities for Brazilian data: validate, format, parse and generate CPF, CNPJ, CEP, boleto, Pix, holidays and more.", "url": "https://brazilian-utils.com.br/", "codeRepository": "https://github.com/brazilian-utils/javascript", "programmingLanguage": "TypeScript", diff --git a/scripts/llms.ts b/scripts/llms.ts index dc1324381..6b0ea9ce9 100644 --- a/scripts/llms.ts +++ b/scripts/llms.ts @@ -225,7 +225,7 @@ function buildLlmsTxt(utils: UtilSection[], datasetUtils: string[]): string { return `# Brazilian Utils -> Brazilian Utils is a zero-dependency JavaScript/TypeScript library of small, focused utilities for the day-to-day problems of building software for Brazilian businesses: validating, formatting, parsing and generating documents (CPF, CNPJ, CEP, Pix, boleto, NF-e, phone numbers, license plates and more). +> Brazilian Utils is a zero-dependency JavaScript/TypeScript library of small, focused utilities for the day-to-day problems of building software for Brazil: validating, formatting, parsing and generating CPF, CNPJ, CEP, Pix, boleto, NF-e, phone numbers, license plates and more. The package has **zero runtime dependencies**, is fully tree-shakeable and runs on Node.js \`^20.19.0 || >=22.12.0\`, Bun, Deno and modern browsers (including a UMD \` + diff --git a/docs/snippets/live/run.js b/docs/snippets/live/run.js index 9db4550e8..f1fef194a 100644 --- a/docs/snippets/live/run.js +++ b/docs/snippets/live/run.js @@ -100,7 +100,7 @@ .then(function (code) { // Angular's JIT compiler has to be evaluated before any other Angular package, the example // included: the partially compiled packages look for it as they load. - return /\.component\.ts$/.test(example) ? import("@angular/compiler").then(function () { return code; }) : code; + return /\.ts$/.test(example) ? import("@angular/compiler").then(function () { return code; }) : code; }) .then(function (code) { return import(toModule(transpile(code, example.replace(/\.vue$/, ".ts")))); From 057d039d02c35ce03a1289aedc3a8c52027ca05b Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:34:38 -0300 Subject: [PATCH 26/70] docs(examples): hand the CPF to the parent component in each framework --- docs/examples.md | 2 +- docs/pt-br/examples.md | 2 +- docs/snippets/cpf-field.ts | 6 ++++-- docs/snippets/cpf-field.tsx | 24 +++++++++++++++--------- docs/snippets/cpf-field.vue | 6 ++++-- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 7e40df728..a57ad8166 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,7 +6,7 @@ keywords: ["examples", "CPF input mask", "React CPF", "Angular CPF", "Vue CPF", A CPF field that formats as you type, with `formatCpf`, and validates once complete, with `isValidCpf`. Each tab runs the code below it, which you can copy as is. -`formatCpf` formats whatever has been typed so far (`"9438"` becomes `"943.8"`), drops anything that is not a digit and cuts digits past the eleventh. Replacing a field's value moves the caret to the end, though, so `maskCpf` puts it back next to the digit being edited, and turns a deleted `.` or `-` into a deleted digit, which `formatCpf` would otherwise put straight back. The field is marked `aria-invalid` when a complete CPF is not valid. +`formatCpf` formats whatever has been typed so far (`"9438"` becomes `"943.8"`), drops anything that is not a digit and cuts digits past the eleventh. Replacing a field's value moves the caret to the end, though, so `maskCpf` puts it back next to the digit being edited, and turns a deleted `.` or `-` into a deleted digit, which `formatCpf` would otherwise put straight back. The field is marked `aria-invalid` when a complete CPF is not valid, and hands the formatted CPF to its parent the way each framework does it: an `onChange` prop in React, `[(cpf)]` in Angular, `v-model` in Vue.
diff --git a/docs/pt-br/examples.md b/docs/pt-br/examples.md index 8c950f28b..f589b8c3e 100644 --- a/docs/pt-br/examples.md +++ b/docs/pt-br/examples.md @@ -6,7 +6,7 @@ keywords: ["exemplos", "máscara de CPF", "CPF React", "CPF Angular", "CPF Vue", Um campo de CPF que formata enquanto você digita, com `formatCpf`, e valida quando completo, com `isValidCpf`. Cada aba roda o código logo abaixo dela, que pode ser copiado do jeito que está. -`formatCpf` formata o que já foi digitado (`"9438"` vira `"943.8"`), descarta o que não é dígito e corta os dígitos além do décimo primeiro. Só que trocar o valor do campo joga o cursor para o fim, então `maskCpf` devolve o cursor para perto do dígito que está sendo editado, e transforma um `.` ou `-` apagado num dígito apagado, que o `formatCpf` recolocaria na hora. O campo recebe `aria-invalid` quando um CPF completo não é válido. +`formatCpf` formata o que já foi digitado (`"9438"` vira `"943.8"`), descarta o que não é dígito e corta os dígitos além do décimo primeiro. Só que trocar o valor do campo joga o cursor para o fim, então `maskCpf` devolve o cursor para perto do dígito que está sendo editado, e transforma um `.` ou `-` apagado num dígito apagado, que o `formatCpf` recolocaria na hora. O campo recebe `aria-invalid` quando um CPF completo não é válido, e entrega o CPF formatado ao componente pai do jeito de cada framework: uma prop `onChange` no React, `[(cpf)]` no Angular, `v-model` no Vue.
diff --git a/docs/snippets/cpf-field.ts b/docs/snippets/cpf-field.ts index 68a63261c..d9cd18415 100644 --- a/docs/snippets/cpf-field.ts +++ b/docs/snippets/cpf-field.ts @@ -1,4 +1,4 @@ -import { Component, computed, signal } from "@angular/core"; +import { Component, computed, model } from "@angular/core"; import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; type MaskCpfParams = { @@ -52,6 +52,7 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { @@ -62,7 +63,8 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { `, }) export class CpfField { - protected readonly cpf = signal(""); + /** The formatted CPF, bound with [(cpf)]. */ + readonly cpf = model(""); protected readonly complete = computed(() => this.cpf().length === 14); protected readonly valid = computed(() => this.complete() && isValidCpf(this.cpf())); diff --git a/docs/snippets/cpf-field.tsx b/docs/snippets/cpf-field.tsx index e4a26f287..689a7c2fc 100644 --- a/docs/snippets/cpf-field.tsx +++ b/docs/snippets/cpf-field.tsx @@ -44,7 +44,12 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { return formatted; } -export function CpfField() { +type CpfFieldProps = { + /** Called with the formatted CPF on every change. */ + onChange?: (cpf: string) => void; +}; + +export function CpfField({ onChange }: CpfFieldProps) { const [cpf, setCpf] = useState(""); const complete = cpf.length === 14; const valid = complete && isValidCpf(cpf); @@ -56,14 +61,15 @@ export function CpfField() { inputMode="numeric" placeholder="000.000.000-00" aria-invalid={complete && !valid} - onChange={(event) => - setCpf( - maskCpf({ - input: event.currentTarget, - inputType: (event.nativeEvent as InputEvent).inputType, - }), - ) - } + onChange={(event) => { + const formatted = maskCpf({ + input: event.currentTarget, + inputType: (event.nativeEvent as InputEvent).inputType, + }); + + setCpf(formatted); + onChange?.(formatted); + }} /> {complete && {valid ? "✓ Valid CPF" : "✗ Invalid CPF"}} diff --git a/docs/snippets/cpf-field.vue b/docs/snippets/cpf-field.vue index b69db048f..6de4efe23 100644 --- a/docs/snippets/cpf-field.vue +++ b/docs/snippets/cpf-field.vue @@ -1,8 +1,9 @@ + input.setAttribute("aria-invalid", String(complete && !valid)); + // A form around the field refuses to submit an invalid CPF. + input.setCustomValidity(complete && !valid ? "Invalid CPF" : ""); + result.hidden = !complete; + result.textContent = valid ? "✓ Valid CPF" : "✗ Invalid CPF"; + }); + + diff --git a/docs/snippets/cpf-field.ts b/docs/snippets/cpf-field.ts index d9cd18415..d222e7e8d 100644 --- a/docs/snippets/cpf-field.ts +++ b/docs/snippets/cpf-field.ts @@ -1,4 +1,5 @@ import { Component, computed, model } from "@angular/core"; +import type { FormValueControl } from "@angular/forms/signals"; import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; type MaskCpfParams = { @@ -52,7 +53,7 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { @@ -62,14 +63,16 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { `, }) -export class CpfField { - /** The formatted CPF, bound with [(cpf)]. */ - readonly cpf = model(""); - protected readonly complete = computed(() => this.cpf().length === 14); - protected readonly valid = computed(() => this.complete() && isValidCpf(this.cpf())); +export class CpfField implements FormValueControl { + /** The formatted CPF: bind it with [formField] (Signal Forms) or [(value)]. */ + readonly value = model(""); + protected readonly complete = computed(() => this.value().length === 14); + protected readonly valid = computed( + () => this.complete() && isValidCpf(this.value()), + ); protected onInput(event: Event) { - this.cpf.set( + this.value.set( maskCpf({ input: event.target as HTMLInputElement, inputType: (event as InputEvent).inputType, diff --git a/docs/snippets/cpf-field.tsx b/docs/snippets/cpf-field.tsx index 689a7c2fc..794c6e39d 100644 --- a/docs/snippets/cpf-field.tsx +++ b/docs/snippets/cpf-field.tsx @@ -1,4 +1,3 @@ -import { useState } from "react"; import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; type MaskCpfParams = { @@ -45,14 +44,15 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { } type CpfFieldProps = { + /** The formatted CPF. */ + value: string; /** Called with the formatted CPF on every change. */ - onChange?: (cpf: string) => void; + onChange: (cpf: string) => void; }; -export function CpfField({ onChange }: CpfFieldProps) { - const [cpf, setCpf] = useState(""); - const complete = cpf.length === 14; - const valid = complete && isValidCpf(cpf); +export function CpfField({ value, onChange }: CpfFieldProps) { + const complete = value.length === 14; + const valid = complete && isValidCpf(value); return ( diff --git a/docs/snippets/live/run.js b/docs/snippets/live/run.js index f1fef194a..087ac134b 100644 --- a/docs/snippets/live/run.js +++ b/docs/snippets/live/run.js @@ -73,8 +73,13 @@ var mount = function (module) { if (/\.tsx$/.test(example)) { return Promise.all([import("react"), import("react-dom/client")]).then(function (react) { + // The example is a controlled component: the demo is the parent that holds its value. + var Demo = function () { + var state = react[0].useState(""); + return react[0].createElement(Object.values(module)[0], { value: state[0], onChange: state[1] }); + }; var root = document.body.appendChild(document.createElement("div")); - react[1].createRoot(root).render(react[0].createElement(Object.values(module)[0])); + react[1].createRoot(root).render(react[0].createElement(Demo)); }); } diff --git a/docs/snippets/usage/signup-form.tsx b/docs/snippets/usage/signup-form.tsx new file mode 100644 index 000000000..eeb2c1833 --- /dev/null +++ b/docs/snippets/usage/signup-form.tsx @@ -0,0 +1,8 @@ +import { useState } from "react"; +import { CpfField } from "./cpf-field"; + +export function SignupForm() { + const [cpf, setCpf] = useState(""); + + return ; +} diff --git a/docs/snippets/usage/signup-form.vue b/docs/snippets/usage/signup-form.vue new file mode 100644 index 000000000..7c73fdacc --- /dev/null +++ b/docs/snippets/usage/signup-form.vue @@ -0,0 +1,10 @@ + + + diff --git a/docs/snippets/usage/signup.ts b/docs/snippets/usage/signup.ts new file mode 100644 index 000000000..911c5c975 --- /dev/null +++ b/docs/snippets/usage/signup.ts @@ -0,0 +1,17 @@ +import { Component, signal } from "@angular/core"; +import { form, FormField, validate } from "@angular/forms/signals"; +import { isValidCpf } from "@brazilian-utils/brazilian-utils"; +import { CpfField } from "./cpf-field"; + +@Component({ + selector: "app-signup", + imports: [CpfField, FormField], + template: ``, +}) +export class Signup { + protected readonly signup = form(signal({ cpf: "" }), (path) => { + validate(path.cpf, ({ value }) => + isValidCpf(value()) ? undefined : { kind: "cpf", message: "Invalid CPF" }, + ); + }); +} From c6114aec0ec0667ec3456279dc191471f4728e9b Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:56:38 -0300 Subject: [PATCH 28/70] fix(docs): build the Vercel preview when the last deployed commit is gone --- vercel.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vercel.json b/vercel.json index b659b6cb2..3c4273da0 100644 --- a/vercel.json +++ b/vercel.json @@ -5,7 +5,7 @@ "buildCommand": "", "outputDirectory": "docs", "cleanUrls": true, - "ignoreCommand": "test -n \"$VERCEL_GIT_PREVIOUS_SHA\" && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- docs vercel.json", + "ignoreCommand": "test -n \"$VERCEL_GIT_PREVIOUS_SHA\" && git diff --quiet \"$VERCEL_GIT_PREVIOUS_SHA\" HEAD -- docs vercel.json 2>/dev/null || exit 1", "git": { "deploymentEnabled": { "main": false From 2de274a9bedeac25ebe0582ab7f2b124bde1c747 Mon Sep 17 00:00:00 2001 From: Hyan Mandian <5044101+hyanmandian@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:09:07 -0300 Subject: [PATCH 29/70] docs(examples): show each field in the form library most projects use React uses react-hook-form, the field taking the input's own props so react-hook-form's field spreads into it. Angular uses Reactive Forms, the field being a ControlValueAccessor for formControlName. Vue uses VeeValidate through v-model and defineField. Each form was run in a browser: an invalid CPF shows the error and blocks the submit, a valid one submits. --- docs/examples.md | 8 ++--- docs/pt-br/examples.md | 8 ++--- docs/snippets/cpf-field.ts | 51 ++++++++++++++++++++++------- docs/snippets/cpf-field.tsx | 6 ++-- docs/snippets/live/run.js | 1 + docs/snippets/usage/signup-form.tsx | 28 ++++++++++++++-- docs/snippets/usage/signup-form.vue | 20 +++++++++-- docs/snippets/usage/signup.ts | 36 +++++++++++++++----- 8 files changed, 123 insertions(+), 35 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 187251091..a69a3eac4 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -6,13 +6,13 @@ keywords: ["examples", "CPF input mask", "React CPF", "Angular CPF", "Vue CPF", A CPF field that formats as you type, with `formatCpf`, and validates once complete, with `isValidCpf`. Each tab runs the code below it, which you can copy as is. -`formatCpf` formats whatever has been typed so far (`"9438"` becomes `"943.8"`), drops anything that is not a digit and cuts digits past the eleventh. Replacing a field's value moves the caret to the end, though, so `maskCpf` puts it back next to the digit being edited, and turns a deleted `.` or `-` into a deleted digit, which `formatCpf` would otherwise put straight back. The field is marked `aria-invalid` when a complete CPF is not valid, and hands the formatted CPF to its parent the way each framework expects: `value` and `onChange` props in React (a controlled component), a Signal Forms control in Angular (`[formField]`, or `[(value)]`), `v-model` in Vue. +`formatCpf` formats whatever has been typed so far (`"9438"` becomes `"943.8"`), drops anything that is not a digit and cuts digits past the eleventh. Replacing a field's value moves the caret to the end, though, so `maskCpf` puts it back next to the digit being edited, and turns a deleted `.` or `-` into a deleted digit, which `formatCpf` would otherwise put straight back. The field is marked `aria-invalid` when a complete CPF is not valid, and hands the formatted CPF to its parent the way each framework expects, so it drops into the form library most projects use: a controlled component in React (`value`, `onChange` and the input's own props, for react-hook-form's `field`), a `ControlValueAccessor` in Angular (`formControlName`), `v-model` in Vue (VeeValidate's `defineField`).
[cpf-field.tsx](snippets/cpf-field.tsx ':include :type=code tsx') -In a form: +With react-hook-form: [signup-form.tsx](snippets/usage/signup-form.tsx ':include :type=code tsx') @@ -22,7 +22,7 @@ In a form: [cpf-field.ts](snippets/cpf-field.ts ':include :type=code ts') -In a form: +With Reactive Forms: [signup.ts](snippets/usage/signup.ts ':include :type=code ts') @@ -32,7 +32,7 @@ In a form: [cpf-field.vue](snippets/cpf-field.vue ':include :type=code vue') -In a form: +With VeeValidate: [signup-form.vue](snippets/usage/signup-form.vue ':include :type=code vue') diff --git a/docs/pt-br/examples.md b/docs/pt-br/examples.md index 9f354a82e..eadb17f2b 100644 --- a/docs/pt-br/examples.md +++ b/docs/pt-br/examples.md @@ -6,13 +6,13 @@ keywords: ["exemplos", "máscara de CPF", "CPF React", "CPF Angular", "CPF Vue", Um campo de CPF que formata enquanto você digita, com `formatCpf`, e valida quando completo, com `isValidCpf`. Cada aba roda o código logo abaixo dela, que pode ser copiado do jeito que está. -`formatCpf` formata o que já foi digitado (`"9438"` vira `"943.8"`), descarta o que não é dígito e corta os dígitos além do décimo primeiro. Só que trocar o valor do campo joga o cursor para o fim, então `maskCpf` devolve o cursor para perto do dígito que está sendo editado, e transforma um `.` ou `-` apagado num dígito apagado, que o `formatCpf` recolocaria na hora. O campo recebe `aria-invalid` quando um CPF completo não é válido, e entrega o CPF formatado ao componente pai do jeito que cada framework espera: props `value` e `onChange` no React (um componente controlado), um controle do Signal Forms no Angular (`[formField]`, ou `[(value)]`), `v-model` no Vue. +`formatCpf` formata o que já foi digitado (`"9438"` vira `"943.8"`), descarta o que não é dígito e corta os dígitos além do décimo primeiro. Só que trocar o valor do campo joga o cursor para o fim, então `maskCpf` devolve o cursor para perto do dígito que está sendo editado, e transforma um `.` ou `-` apagado num dígito apagado, que o `formatCpf` recolocaria na hora. O campo recebe `aria-invalid` quando um CPF completo não é válido, e entrega o CPF formatado ao componente pai do jeito que cada framework espera, então encaixa na biblioteca de formulários que a maioria dos projetos usa: um componente controlado no React (`value`, `onChange` e as props do próprio input, para o `field` do react-hook-form), um `ControlValueAccessor` no Angular (`formControlName`), `v-model` no Vue (o `defineField` do VeeValidate).
[cpf-field.tsx](../snippets/cpf-field.tsx ':include :type=code tsx') -Num formulário: +Com react-hook-form: [signup-form.tsx](../snippets/usage/signup-form.tsx ':include :type=code tsx') @@ -22,7 +22,7 @@ Num formulário: [cpf-field.ts](../snippets/cpf-field.ts ':include :type=code ts') -Num formulário: +Com Reactive Forms: [signup.ts](../snippets/usage/signup.ts ':include :type=code ts') @@ -32,7 +32,7 @@ Num formulário: [cpf-field.vue](../snippets/cpf-field.vue ':include :type=code vue') -Num formulário: +Com VeeValidate: [signup-form.vue](../snippets/usage/signup-form.vue ':include :type=code vue') diff --git a/docs/snippets/cpf-field.ts b/docs/snippets/cpf-field.ts index d222e7e8d..f3f0a6602 100644 --- a/docs/snippets/cpf-field.ts +++ b/docs/snippets/cpf-field.ts @@ -1,5 +1,5 @@ -import { Component, computed, model } from "@angular/core"; -import type { FormValueControl } from "@angular/forms/signals"; +import { Component, computed, forwardRef, signal } from "@angular/core"; +import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms"; import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; type MaskCpfParams = { @@ -47,6 +47,13 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { @Component({ selector: "app-cpf-field", + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CpfField), + multi: true, + }, + ], template: ` `, }) -export class CpfField implements FormValueControl { - /** The formatted CPF: bind it with [formField] (Signal Forms) or [(value)]. */ - readonly value = model(""); +export class CpfField implements ControlValueAccessor { + protected readonly value = signal(""); + protected readonly disabled = signal(false); protected readonly complete = computed(() => this.value().length === 14); protected readonly valid = computed( () => this.complete() && isValidCpf(this.value()), ); + protected onChange: (cpf: string) => void = () => {}; + protected onTouched: () => void = () => {}; + + writeValue(cpf: string | null): void { + this.value.set(formatCpf(cpf ?? "")); + } + + registerOnChange(onChange: (cpf: string) => void): void { + this.onChange = onChange; + } + + registerOnTouched(onTouched: () => void): void { + this.onTouched = onTouched; + } + + setDisabledState(disabled: boolean): void { + this.disabled.set(disabled); + } + protected onInput(event: Event) { - this.value.set( - maskCpf({ - input: event.target as HTMLInputElement, - inputType: (event as InputEvent).inputType, - }), - ); + const cpf = maskCpf({ + input: event.target as HTMLInputElement, + inputType: (event as InputEvent).inputType, + }); + + this.value.set(cpf); + this.onChange(cpf); } } diff --git a/docs/snippets/cpf-field.tsx b/docs/snippets/cpf-field.tsx index 794c6e39d..4bc395b4a 100644 --- a/docs/snippets/cpf-field.tsx +++ b/docs/snippets/cpf-field.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from "react"; import { formatCpf, isValidCpf } from "@brazilian-utils/brazilian-utils"; type MaskCpfParams = { @@ -43,14 +44,14 @@ function maskCpf({ input, inputType = "" }: MaskCpfParams): string { return formatted; } -type CpfFieldProps = { +type CpfFieldProps = Omit, "value" | "onChange"> & { /** The formatted CPF. */ value: string; /** Called with the formatted CPF on every change. */ onChange: (cpf: string) => void; }; -export function CpfField({ value, onChange }: CpfFieldProps) { +export function CpfField({ value, onChange, ...props }: CpfFieldProps) { const complete = value.length === 14; const valid = complete && isValidCpf(value); @@ -58,6 +59,7 @@ export function CpfField({ value, onChange }: CpfFieldProps) {