diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index b1a1a72..16a28c0 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -39,7 +39,7 @@ export async function generateMetadata({ description: caseStudy.summary, alternates: { canonical: url }, openGraph: { - locale: "pt_BR", + locale: "pt_BR", title: caseStudy.title, description: caseStudy.summary, url, diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx index 8d7ccb7..847ca27 100644 --- a/app/components/Hero.tsx +++ b/app/components/Hero.tsx @@ -34,13 +34,19 @@ function statItems(stats?: HeroStats) { } /** Título em palavras para a entrada em cascata; o texto segue inteiro para leitores de tela e buscadores. */ -const TITLE = "Sistemas sob medida para operações que não cabem em soluções prontas.".split(" "); +const TITLE = + "Sistemas sob medida para operações que não cabem em soluções prontas.".split( + " ", + ); const ACCENT = new Set([1, 2]); /** O que cada camada da cena significa, em linguagem simples. */ const CAPTIONS = [ ["A tela", "O que sua equipe e seus clientes usam no dia a dia."], - ["As integrações", "Os programas que você já usa conversando entre si, sem retrabalho."], + [ + "As integrações", + "Os programas que você já usa conversando entre si, sem retrabalho.", + ], ["Os dados", "Suas informações guardadas, organizadas e seguras."], ] as const; @@ -151,9 +157,7 @@ export default function Hero({ stats }: { stats?: HeroStats }) { { }); it("keeps the whole sentence readable once for assistive technology", () => { - const { container } = render(); + const { container } = render( + , + ); - expect(screen.getByText("Software feito a partir do seu negócio")).toHaveClass("sr-only"); + expect( + screen.getByText("Software feito a partir do seu negócio"), + ).toHaveClass("sr-only"); const words = container.querySelectorAll(".text-reveal-word"); expect(words).toHaveLength(7); expect(words[0].closest("[aria-hidden='true']")).not.toBeNull(); }); it("marks the title as shown only after it enters the viewport", () => { - const { container, rerender } = render(); - expect(container.querySelector(".text-reveal")).not.toHaveAttribute("data-shown"); + const { container, rerender } = render( + , + ); + expect(container.querySelector(".text-reveal")).not.toHaveAttribute( + "data-shown", + ); mockInView = true; rerender(); - expect(container.querySelector(".text-reveal")).toHaveAttribute("data-shown"); + expect(container.querySelector(".text-reveal")).toHaveAttribute( + "data-shown", + ); }); }); diff --git a/app/components/animations/TextDiffuseReveal.tsx b/app/components/animations/TextDiffuseReveal.tsx index dc2866d..a484673 100644 --- a/app/components/animations/TextDiffuseReveal.tsx +++ b/app/components/animations/TextDiffuseReveal.tsx @@ -45,7 +45,11 @@ export function TextDiffuseReveal({ if (token === "\n") return
; if (/^\s+$/.test(token)) return " "; return ( - + {token} ); diff --git a/app/components/cinematic/CinematicHome.test.tsx b/app/components/cinematic/CinematicHome.test.tsx index e9c4349..bf3b108 100644 --- a/app/components/cinematic/CinematicHome.test.tsx +++ b/app/components/cinematic/CinematicHome.test.tsx @@ -51,9 +51,7 @@ describe("home fragment navigation", () => { restoredPosition = options.top ?? restoredPosition; }); render(); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect(restoredPosition).toBe(0); }); @@ -62,17 +60,13 @@ describe("home fragment navigation", () => { .spyOn(window, "scrollTo") .mockImplementation(() => {}); const firstVisit = render(); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); firstVisit.unmount(); expect(window.history.scrollRestoration).not.toBe("manual"); scrollTo.mockClear(); render(); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect(scrollTo).not.toHaveBeenCalled(); expect(window.history.scrollRestoration).not.toBe("manual"); }); @@ -87,9 +81,7 @@ describe("home fragment navigation", () => { render(); position = 600; fireEvent.wheel(window, { deltaY: 600 }); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect(position).toBe(600); }); @@ -99,14 +91,10 @@ describe("home fragment navigation", () => { const contact = screen.getByRole("region", { name: "Contato" }); expect(contact).not.toHaveFocus(); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect(contact).toHaveFocus(); screen.getByRole("button", { name: "Read biography" }).focus(); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect( screen.getByRole("button", { name: "Read biography" }), ).toHaveFocus(); @@ -118,18 +106,14 @@ describe("home fragment navigation", () => { const message = screen.getByRole("button", { name: "Send message" }); message.focus(); fireEvent.wheel(window, { deltaY: 200 }); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect(message).toHaveFocus(); }); it("keeps repeated same-fragment links native while moving focus to the destination", () => { window.history.replaceState(null, "", "/#about"); render(); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); const link = screen.getByRole("link", { name: "Sobre" }); link.focus(); expect(fireEvent.click(link)).toBe(true); @@ -151,9 +135,7 @@ describe("home fragment navigation", () => { window.history.replaceState(null, "", "/#contact"); window.dispatchEvent(new HashChangeEvent("hashchange")); }); - fireEvent.click( - screen.getByRole("button", { name: "Establish layout" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Establish layout" })); expect(message).toHaveFocus(); }); }); diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx index 88ae374..4035e13 100644 --- a/app/components/cinematic/CinematicHome.tsx +++ b/app/components/cinematic/CinematicHome.tsx @@ -1,5 +1,6 @@ "use client"; +import { useEffect, useRef } from "react"; import type { PortfolioExperience, PortfolioProjectView, @@ -15,7 +16,6 @@ import LighthouseMetrics from "../LighthouseMetrics"; import Projects from "../Projects"; import Skills from "../Skills"; import { WorkModalities } from "../WorkModalities"; -import { useEffect, useRef } from "react"; import ExperienceDisclosure from "./ExperienceDisclosure"; import { useCinematicScroll } from "./useCinematicScroll"; import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation"; diff --git a/app/components/cinematic/HeroField.tsx b/app/components/cinematic/HeroField.tsx index e0d52dc..303c4c0 100644 --- a/app/components/cinematic/HeroField.tsx +++ b/app/components/cinematic/HeroField.tsx @@ -48,7 +48,10 @@ export default function HeroField() { const update = () => { frame = 0; const rect = section.getBoundingClientRect(); - const span = Math.max(rect.height - window.innerHeight, window.innerHeight * 0.8); + const span = Math.max( + rect.height - window.innerHeight, + window.innerHeight * 0.8, + ); const p = Math.min(1, Math.max(0, -rect.top / span)); progressRef.current = p; section.style.setProperty("--hero-p", p.toFixed(4)); @@ -83,7 +86,9 @@ export default function HeroField() { if (!supportsWebGL2()) return; // Título, botões e hidratação vêm primeiro; o Three.js entra quando o navegador fica ocioso. if (typeof window.requestIdleCallback === "function") { - const id = window.requestIdleCallback(() => setMount(true), { timeout: 2500 }); + const id = window.requestIdleCallback(() => setMount(true), { + timeout: 2500, + }); return () => window.cancelIdleCallback(id); } const id = setTimeout(() => setMount(true), 1200); diff --git a/app/components/cinematic/HeroFieldScene.tsx b/app/components/cinematic/HeroFieldScene.tsx index 9c8859d..484f8a7 100644 --- a/app/components/cinematic/HeroFieldScene.tsx +++ b/app/components/cinematic/HeroFieldScene.tsx @@ -42,7 +42,13 @@ class FieldBuilder { alpha: number[] = []; constructor(private density: number) {} - point(x: number, y: number, z: number, style: PointStyle = {}, to?: [number, number, number]) { + point( + x: number, + y: number, + z: number, + style: PointStyle = {}, + to?: [number, number, number], + ) { this.base.push(x, y, z); this.to.push(...(to ?? [x, y, z])); this.move.push(to ? 1 : 0); @@ -50,36 +56,89 @@ class FieldBuilder { this.size.push(style.size ?? 0.02); this.alpha.push(style.alpha ?? 0.7); } - line(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) { + line( + y: Layer, + x0: number, + z0: number, + x1: number, + z1: number, + step: number, + style?: PointStyle, + ) { const s = step * this.density; const n = Math.max(1, Math.round(Math.hypot(x1 - x0, z1 - z0) / s)); - for (let i = 0; i <= n; i++) this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style); + for (let i = 0; i <= n; i++) + this.point(x0 + ((x1 - x0) * i) / n, y, z0 + ((z1 - z0) * i) / n, style); } - rect(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) { + rect( + y: Layer, + x0: number, + z0: number, + x1: number, + z1: number, + step: number, + style?: PointStyle, + ) { this.line(y, x0, z0, x1, z0, step, style); this.line(y, x1, z0, x1, z1, step, style); this.line(y, x1, z1, x0, z1, step, style); this.line(y, x0, z1, x0, z0, step, style); } - fill(y: Layer, x0: number, z0: number, x1: number, z1: number, step: number, style?: PointStyle) { + fill( + y: Layer, + x0: number, + z0: number, + x1: number, + z1: number, + step: number, + style?: PointStyle, + ) { const s = step * this.density; for (let x = x0; x <= x1 + 1e-6; x += s) for (let z = z0; z <= z1 + 1e-6; z += s) - this.point(x + (Math.random() - 0.5) * s * 0.3, y, z + (Math.random() - 0.5) * s * 0.3, style); + this.point( + x + (Math.random() - 0.5) * s * 0.3, + y, + z + (Math.random() - 0.5) * s * 0.3, + style, + ); } - ring(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) { - const n = Math.max(8, Math.round((Math.PI * 2 * r) / (step * this.density))); + ring( + y: Layer, + cx: number, + cz: number, + r: number, + step: number, + style?: PointStyle, + ) { + const n = Math.max( + 8, + Math.round((Math.PI * 2 * r) / (step * this.density)), + ); for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2; this.point(cx + Math.cos(a) * r, y, cz + Math.sin(a) * r, style); } } - disc(y: Layer, cx: number, cz: number, r: number, step: number, style?: PointStyle) { - for (let rr = r; rr > 0.005; rr -= step * this.density) this.ring(y, cx, cz, rr, step, style); + disc( + y: Layer, + cx: number, + cz: number, + r: number, + step: number, + style?: PointStyle, + ) { + for (let rr = r; rr > 0.005; rr -= step * this.density) + this.ring(y, cx, cz, rr, step, style); this.point(cx, y, cz, style); } /** partículas que correm de a até b (a posição é calculada no shader) */ - flow(a: [number, number, number], b: [number, number, number], count: number, style?: PointStyle) { + flow( + a: [number, number, number], + b: [number, number, number], + count: number, + style?: PointStyle, + ) { const n = Math.max(2, Math.round(count / this.density)); for (let i = 0; i < n; i++) this.point(...a, style, b); } @@ -91,12 +150,17 @@ function drawScreen(f: FieldBuilder) { f.fill(y, -2.1, -1.4, 2.1, 1.4, 0.1, { alpha: 0.1 }); // superfície da tela f.rect(y, -2.1, -1.4, 2.1, 1.4, 0.026, { alpha: 0.95 }); f.line(y, -2.1, -1.05, 2.1, -1.05, 0.03, { alpha: 0.6 }); // barra do navegador - for (const x of [-1.9, -1.72, -1.54]) f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 }); + for (const x of [-1.9, -1.72, -1.54]) + f.disc(y, x, -1.23, 0.05, 0.025, { alpha: 0.8 }); f.rect(y, -1.1, -1.32, 1.3, -1.14, 0.035, { alpha: 0.45 }); // endereço f.fill(y, -1.8, -0.82, 0.25, -0.58, 0.03, { alpha: 0.85 }); // título f.line(y, -1.8, -0.38, 0.75, -0.38, 0.04, { alpha: 0.5 }); // texto f.line(y, -1.8, -0.24, 0.35, -0.24, 0.04, { alpha: 0.5 }); - f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { tone: 1, alpha: 0.95, size: 0.022 }); // botão + f.fill(y, -1.8, -0.02, -0.75, 0.2, 0.028, { + tone: 1, + alpha: 0.95, + size: 0.022, + }); // botão f.rect(y, 0.6, -0.82, 1.8, 0.2, 0.035, { alpha: 0.6 }); // imagem f.fill(y, 0.6, -0.82, 1.8, 0.2, 0.08, { alpha: 0.25 }); for (const [x0, x1] of [ @@ -127,9 +191,18 @@ function drawIntegrations(f: FieldBuilder) { const ux = x / len; const uz = z / len; // linha da central até o sistema, e mensagens correndo nos dois sentidos - f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { alpha: 0.35 }); - f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { tone: 1, alpha: 0.95, size: 0.026 }); - f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { alpha: 0.9, size: 0.024 }); + f.line(y, ux * 0.38, uz * 0.38, x - ux * 0.24, z - uz * 0.24, 0.06, { + alpha: 0.35, + }); + f.flow([ux * 0.38, y, uz * 0.38], [x - ux * 0.24, y, z - uz * 0.24], 10, { + tone: 1, + alpha: 0.95, + size: 0.026, + }); + f.flow([x - ux * 0.24, y, z - uz * 0.24], [ux * 0.38, y, uz * 0.38], 6, { + alpha: 0.9, + size: 0.024, + }); const [nx, nz] = NODES[(k + 1) % NODES.length]; f.line(y, x, z, nx, nz, 0.08, { alpha: 0.15 }); }); @@ -144,12 +217,15 @@ function drawData(f: FieldBuilder) { const cols = [x0, -0.9, 0.2, 1.1, x1]; f.rect(y, x0, z0, x1, z1, 0.03, { alpha: 0.8 }); f.fill(y, x0, z0, x1, z0 + 0.32, 0.045, { tone: 0.6, alpha: 0.45 }); // cabeçalho da tabela - for (let z = z0 + 0.32; z < z1; z += 0.33) f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 }); - for (const x of cols.slice(1, -1)) f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 }); + for (let z = z0 + 0.32; z < z1; z += 0.33) + f.line(y, x0, z, x1, z, 0.04, { alpha: 0.4 }); + for (const x of cols.slice(1, -1)) + f.line(y, x, z0, x, z1, 0.04, { alpha: 0.4 }); for (let z = z0 + 0.48; z < z1; z += 0.33) { for (let c = 0; c < cols.length - 1; c++) { const start = cols[c] + 0.12; - const width = (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6); + const width = + (cols[c + 1] - cols[c] - 0.24) * (0.35 + Math.random() * 0.6); f.line(y, start, z, start + width, z, 0.045, { alpha: 0.55 }); // valores nas células } } @@ -173,10 +249,16 @@ function buildField(dense: boolean) { const s = Math.sqrt(1 - u * u); const d = 4 + Math.random() * 7; scatter.set([Math.cos(t) * s * d, u * d, Math.sin(t) * s * d], n * 3); - rand.set([Math.random(), Math.random(), Math.random(), Math.random()], n * 4); + rand.set( + [Math.random(), Math.random(), Math.random(), Math.random()], + n * 4, + ); } const geometry = new THREE.BufferGeometry(); - geometry.setAttribute("position", new THREE.Float32BufferAttribute(f.base, 3)); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(f.base, 3), + ); geometry.setAttribute("aTo", new THREE.Float32BufferAttribute(f.to, 3)); geometry.setAttribute("aMove", new THREE.Float32BufferAttribute(f.move, 1)); geometry.setAttribute("aTone", new THREE.Float32BufferAttribute(f.tone, 1)); @@ -314,7 +396,12 @@ const LABEL_ANCHORS: [number, Layer, number][] = [ [2, -1, 1.3], ]; -function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps) { +function Field({ + progressRef, + labelsRef, + onReady, + onError, +}: HeroFieldSceneProps) { const { gl, camera, size } = useThree(); const group = useRef(null); const dense = useMemo( @@ -397,11 +484,14 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps const time = frame.clock.elapsedTime; // rolagem amortecida: o mergulho segue o dedo com um leve atraso de câmera - s.progress += ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5)); + s.progress += + ((progressRef.current ?? 0) - s.progress) * (1 - Math.exp(-dt * 5)); const p = s.progress; const aspect = size.width / size.height; const wide = aspect > 1.1; - const tanHalf = Math.tan(THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2)); + const tanHalf = Math.tan( + THREE.MathUtils.degToRad((camera as THREE.PerspectiveCamera).fov / 2), + ); // enquadramento pela largura: a camada (4,2 de largura) com margem cabe na tela, inclusive em pé const frameDist = Math.max(FRAME, 2.35 / (tanHalf * aspect)); const spreadMax = Math.max(SPREAD, frameDist + 0.6); @@ -416,7 +506,8 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps u.uFade.value = 1 - smooth(0.93, 1, p); // com a câmera mais longe (celular), os pontos crescem um pouco para o desenho não sumir u.uScale.value = - ((gl.getPixelRatio() * size.height * 0.5) / tanHalf) * Math.sqrt(frameDist / FRAME); + ((gl.getPixelRatio() * size.height * 0.5) / tanHalf) * + Math.sqrt(frameDist / FRAME); // sem mouse (toque, ou mouse parado), um cursor fantasma percorre a tela const mouseActive = time - s.lastMove < 2.5 && s.lastMove > 0 ? 1 : 0; @@ -425,16 +516,22 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps // em repouso, vista de três quartos que balança devagar; ao mergulhar, as camadas ficam de frente e retas g.rotation.set( 0.42 + (Math.PI / 2 - 0.42) * face - s.tilt.y * 0.08, - (-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + s.tilt.x * 0.15 * (1 - face), + (-0.45 + Math.sin(time * 0.2) * 0.08) * (1 - face) + + s.tilt.x * 0.15 * (1 - face), 0, ); const settle = 1 - smooth(0, 0.28, p); // em repouso: à direita do texto na tela larga; no celular, mais abaixo, atrás dos botões - g.position.set(wide ? 1.7 * settle : 0, (wide ? -0.1 : -rest * tanHalf * 0.38) * settle, 0); + g.position.set( + wide ? 1.7 * settle : 0, + (wide ? -0.1 : -rest * tanHalf * 0.38) * settle, + 0, + ); camera.position.z = keyframes(cameraFrames(rest, spreadMax, frameDist), p); // foco na próxima camada à frente da câmera (as de trás desfocam como em uma lente) const spread = u.uSpread.value; - const ahead = [spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread; + const ahead = + [spread, 0, -spread].find((z) => z < camera.position.z - 0.2) ?? -spread; u.uFocus.value = Math.max(1.5, camera.position.z - ahead); g.updateMatrixWorld(); @@ -445,17 +542,24 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps .applyMatrix4(g.matrixWorld); if (helpers.ray.ray.intersectPlane(helpers.plane, helpers.hit)) { g.worldToLocal(helpers.hit); - s.mouse.lerp(helpers.target.set(helpers.hit.x, helpers.hit.z), 1 - Math.exp(-dt * 10)); + s.mouse.lerp( + helpers.target.set(helpers.hit.x, helpers.hit.z), + 1 - Math.exp(-dt * 10), + ); } } else { s.mouse.lerp( - helpers.target.set(Math.sin(time * 0.31) * 1.5, Math.sin(time * 0.23 + 1.3) * 1.1), + helpers.target.set( + Math.sin(time * 0.31) * 1.5, + Math.sin(time * 0.23 + 1.3) * 1.1, + ), 1 - Math.exp(-dt * 1.5), ); } u.uMouse.value.copy(s.mouse); const targetAmt = (mouseActive ? 0.8 : 0.4) * (1 - smooth(0.05, 0.3, p)); - u.uMouseAmt.value += (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4)); + u.uMouseAmt.value += + (targetAmt - u.uMouseAmt.value) * (1 - Math.exp(-dt * 4)); // etiquetas: projeta o canto de cada camada na tela e as esconde quando o mergulho começa const labels = labelsRef.current ?? []; @@ -463,7 +567,10 @@ function Field({ progressRef, labelsRef, onReady, onError }: HeroFieldSceneProps LABEL_ANCHORS.forEach(([x, layer, z], index) => { const label = labels[index]; if (!label) return; - helpers.anchor.set(x, layer * u.uSpread.value, z).applyMatrix4(g.matrixWorld).project(camera); + helpers.anchor + .set(x, layer * u.uSpread.value, z) + .applyMatrix4(g.matrixWorld) + .project(camera); const px = ((helpers.anchor.x + 1) / 2) * size.width; const py = ((1 - helpers.anchor.y) / 2) * size.height; label.style.transform = `translate3d(${px.toFixed(1)}px, ${py.toFixed(1)}px, 0)`; @@ -505,7 +612,11 @@ export default function HeroFieldScene(props: HeroFieldSceneProps) { camera={{ fov: 35, position: [0, 0, 9], near: 0.05, far: 120 }} dpr={[1, 1.75]} frameloop={props.visible ? "always" : "never"} - gl={{ alpha: true, antialias: false, powerPreference: "high-performance" }} + gl={{ + alpha: true, + antialias: false, + powerPreference: "high-performance", + }} > diff --git a/app/components/cinematic/useCinematicScroll.ts b/app/components/cinematic/useCinematicScroll.ts index 2a032d4..b401e27 100644 --- a/app/components/cinematic/useCinematicScroll.ts +++ b/app/components/cinematic/useCinematicScroll.ts @@ -2,7 +2,11 @@ import { type RefObject, useEffect } from "react"; -type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) => void; +type Transition = ( + tl: gsap.core.Timeline, + prev: HTMLElement, + next: HTMLElement, +) => void; /* * Rolagem como um vídeo: o Lenis suaviza a roda do mouse e o GSAP liga cada passagem de seção à posição @@ -20,12 +24,17 @@ type Transition = (tl: gsap.core.Timeline, prev: HTMLElement, next: HTMLElement) const vh = () => window.innerHeight; // origem no meio da parte visível da seção que sai (a última altura de tela dela) -const visibleCenter = (el: HTMLElement) => () => `50% ${el.offsetHeight - vh() / 2}px`; +const visibleCenter = (el: HTMLElement) => () => + `50% ${el.offsetHeight - vh() / 2}px`; /** Topo do elemento na página pelo layout (offsetTop ignora os transforms das transições, getBoundingClientRect não). */ export function pageTop(el: HTMLElement) { let top = 0; - for (let node: HTMLElement | null = el; node; node = node.offsetParent as HTMLElement | null) + for ( + let node: HTMLElement | null = el; + node; + node = node.offsetParent as HTMLElement | null + ) top += node.offsetTop; return top; } @@ -45,7 +54,12 @@ const TRANSITIONS: Transition[] = [ holdNext(tl, next); const band = (open: number) => () => `inset(${(vh() * (1 - open)) / 2}px 0px ${next.offsetHeight - vh() + (vh() * (1 - open)) / 2}px 0px)`; - tl.fromTo(prev, { autoAlpha: 1 }, { autoAlpha: 0, duration: 0.4 }, 0).fromTo( + tl.fromTo( + prev, + { autoAlpha: 1 }, + { autoAlpha: 0, duration: 0.4 }, + 0, + ).fromTo( next, { opacity: 0, clipPath: band(0.36) }, { opacity: 1, clipPath: band(1), duration: 0.65 }, @@ -68,7 +82,8 @@ const TRANSITIONS: Transition[] = [ next, { clipPath: () => `circle(0px ${center()})` }, { - clipPath: () => `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`, + clipPath: () => + `circle(${Math.hypot(window.innerWidth / 2, vh() / 2)}px ${center()})`, ease: "power1.in", }, 0, @@ -78,12 +93,12 @@ const TRANSITIONS: Transition[] = [ (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); - tl.fromTo(prev, { xPercent: 0, opacity: 1 }, { xPercent: -35, opacity: 0.3 }, 0).fromTo( - next, - { xPercent: 100 }, - { xPercent: 0, ease: "power2.out" }, + tl.fromTo( + prev, + { xPercent: 0, opacity: 1 }, + { xPercent: -35, opacity: 0.3 }, 0, - ); + ).fromTo(next, { xPercent: 100 }, { xPercent: 0, ease: "power2.out" }, 0); }, // IA → qualidade: a seção sobe mais rápido que a rolagem, como um cartão, sobre a anterior que escurece. // Só translate/opacity: recorte animado, escala (o Chrome rasteriza de novo a cada novo tamanho) e o @@ -102,7 +117,12 @@ const TRANSITIONS: Transition[] = [ (tl, prev, next) => { holdPrev(tl, prev); holdNext(tl, next); - tl.fromTo(prev, { opacity: 1, xPercent: 0 }, { opacity: 0.25, xPercent: 20 }, 0).fromTo( + tl.fromTo( + prev, + { opacity: 1, xPercent: 0 }, + { opacity: 0.25, xPercent: 20 }, + 0, + ).fromTo( next, { xPercent: -100, rotation: -7, transformOrigin: "0% 0%" }, { xPercent: 0, rotation: 0, ease: "power2.out" }, @@ -133,7 +153,8 @@ const TRANSITIONS: Transition[] = [ }, ]; -const easeInOutCubic = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); +const easeInOutCubic = (t: number) => + t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; export function useCinematicScroll(mainRef: RefObject) { useEffect(() => { @@ -144,11 +165,12 @@ export function useCinematicScroll(mainRef: RefObject) { let cleanup = () => {}; (async () => { - const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = await Promise.all([ - import("gsap"), - import("gsap/ScrollTrigger"), - import("lenis"), - ]); + const [{ gsap }, { ScrollTrigger }, { default: Lenis }] = + await Promise.all([ + import("gsap"), + import("gsap/ScrollTrigger"), + import("lenis"), + ]); if (cancelled) return; gsap.registerPlugin(ScrollTrigger); @@ -162,7 +184,10 @@ export function useCinematicScroll(mainRef: RefObject) { const context = gsap.context(() => { scenes.forEach((scene, index) => { // quem entra fica por cima; o hero, que sai por dissolução, fica por cima da cena seguinte - gsap.set(scene, { position: "relative", zIndex: index === 0 ? 30 : index + 1 }); + gsap.set(scene, { + position: "relative", + zIndex: index === 0 ? 30 : index + 1, + }); }); TRANSITIONS.forEach((transition, index) => { const prev = scenes[index]; @@ -189,23 +214,34 @@ export function useCinematicScroll(mainRef: RefObject) { // Visibilidade pela posição da rolagem, não pelo estado inicial dos tweens: cada cena só existe na // tela da transição em que entra até a transição em que sai. Antes ela não vaza por cima da anterior; // depois não aparece atrás de transparências, e o navegador não pinta o que está coberto. - let tops: number[] = []; + // As posições são lidas a cada quadro (offsetTop é barato com o layout já calculado): guardá-las + // deixava a visibilidade errada quando fontes, imagens ou o bloco "Experiência" mudavam a altura. + let frame = 0; const syncVisibility = () => { + frame = 0; const y = window.scrollY; + const tops = scenes.map(pageTop); scenes.forEach((scene, index) => { const from = index === 0 ? -Infinity : tops[index] - vh(); const to = index === scenes.length - 1 ? Infinity : tops[index + 1]; const visibility = y >= from && y <= to ? "" : "hidden"; - if (scene.style.visibility !== visibility) scene.style.visibility = visibility; + if (scene.style.visibility !== visibility) + scene.style.visibility = visibility; }); }; - const measure = () => { - tops = scenes.map(pageTop); - syncVisibility(); + const queueSync = () => { + if (!frame) frame = requestAnimationFrame(syncVisibility); }; - measure(); - ScrollTrigger.addEventListener("refresh", measure); - window.addEventListener("scroll", syncVisibility, { passive: true }); + syncVisibility(); + ScrollTrigger.addEventListener("refresh", queueSync); + window.addEventListener("scroll", queueSync, { passive: true }); + // a página mudou de altura (fontes, imagens, "Experiência" aberta ou fechada): o GSAP remede as passagens + let refreshTimer: ReturnType | undefined; + const resizeObserver = new ResizeObserver(() => { + clearTimeout(refreshTimer); + refreshTimer = setTimeout(() => ScrollTrigger.refresh(), 150); + }); + resizeObserver.observe(main); // botões e links internos: em vez do salto, a página "avança o vídeo" até o destino const onClick = (event: MouseEvent) => { @@ -218,14 +254,27 @@ export function useCinematicScroll(mainRef: RefObject) { event.altKey ) return; - const link = event.target instanceof Element ? event.target.closest("a[href]") : null; - if (!(link instanceof HTMLAnchorElement) || (link.target && link.target !== "_self")) return; + const link = + event.target instanceof Element + ? event.target.closest("a[href]") + : null; + if ( + !(link instanceof HTMLAnchorElement) || + (link.target && link.target !== "_self") + ) + return; const url = new URL(link.href); - if (url.origin !== location.origin || url.pathname !== location.pathname || !url.hash) + if ( + url.origin !== location.origin || + url.pathname !== location.pathname || + !url.hash + ) return; let target: HTMLElement | null = null; try { - target = document.getElementById(decodeURIComponent(url.hash.slice(1))); + target = document.getElementById( + decodeURIComponent(url.hash.slice(1)), + ); } catch {} if (!target) return; event.preventDefault(); @@ -233,11 +282,16 @@ export function useCinematicScroll(mainRef: RefObject) { // alvos internos descontam o cabeçalho fixo const scene = scenes.find((candidate) => candidate.contains(target)); const offset = - Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 0; + Number.parseFloat( + getComputedStyle(document.documentElement).scrollPaddingTop, + ) || 0; const top = !scene || scene === scenes[0] ? 0 - : Math.max(pageTop(scene), scene === target ? 0 : pageTop(target) - offset); + : Math.max( + pageTop(scene), + scene === target ? 0 : pageTop(target) - offset, + ); if (location.hash !== url.hash) history.pushState(null, "", url.hash); lenis.scrollTo(top, { duration: Math.min(2.8, 1 + Math.abs(top - window.scrollY) / 8000), @@ -253,8 +307,11 @@ export function useCinematicScroll(mainRef: RefObject) { cleanup = () => { document.removeEventListener("click", onClick, true); - ScrollTrigger.removeEventListener("refresh", measure); - window.removeEventListener("scroll", syncVisibility); + ScrollTrigger.removeEventListener("refresh", queueSync); + window.removeEventListener("scroll", queueSync); + cancelAnimationFrame(frame); + clearTimeout(refreshTimer); + resizeObserver.disconnect(); context.revert(); for (const scene of scenes) scene.style.visibility = ""; gsap.ticker.remove(tick); diff --git a/app/globals.css b/app/globals.css index 9336549..b8fb70b 100644 --- a/app/globals.css +++ b/app/globals.css @@ -290,9 +290,11 @@ textarea { flex: 1 1 auto; min-width: 0; max-width: 760px; +} - /* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */ - @container (min-width: 880px) { +/* Largo: título à esquerda, descrição à direita, alinhados pela base (some a faixa vazia ao lado do título). */ +@container (min-width: 880px) { + .ds-section-meta { max-width: none; display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); @@ -983,7 +985,11 @@ textarea { inset: 0; pointer-events: none; background: - radial-gradient(55% 65% at 78% 38%, rgba(204, 255, 0, 0.11), transparent 70%), + radial-gradient( + 55% 65% at 78% 38%, + rgba(204, 255, 0, 0.11), + transparent 70% + ), radial-gradient(40% 50% at 6% 92%, rgba(204, 255, 0, 0.05), transparent 70%); } @@ -1007,7 +1013,9 @@ textarea { content: ""; position: absolute; inset: 0; - background: radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0 0 / 22px 22px; + background: + radial-gradient(circle, rgba(244, 242, 237, 0.22) 1px, transparent 1.5px) 0 + 0 / 22px 22px; mask: radial-gradient(38% 46% at 74% 50%, #000, transparent 75%); transition: opacity 1.2s var(--ease-out); } @@ -1032,11 +1040,20 @@ textarea { inset: 0; pointer-events: none; background: - linear-gradient(90deg, rgba(10, 10, 10, 0.86) 0%, rgba(10, 10, 10, 0.55) 36%, transparent 62%), - linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%); + linear-gradient( + 90deg, + rgba(10, 10, 10, 0.86) 0%, + rgba(10, 10, 10, 0.55) 36%, + transparent 62% + ), linear-gradient(0deg, rgba(10, 10, 10, 0.9), transparent 28%); @media (max-width: 1023px) { - background: linear-gradient(0deg, rgba(10, 10, 10, 0.92) 0 30%, rgba(10, 10, 10, 0.5) 65%, rgba(10, 10, 10, 0.7)); + background: linear-gradient( + 0deg, + rgba(10, 10, 10, 0.92) 0 30%, + rgba(10, 10, 10, 0.5) 65%, + rgba(10, 10, 10, 0.7) + ); } } @@ -1158,7 +1175,11 @@ textarea { pointer-events: none; /* faixa escura atrás da legenda, como em legenda de cinema, sem cobrir o centro da cena */ background: linear-gradient(0deg, rgba(10, 10, 10, 0.75), transparent 38%); - opacity: clamp(0, min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14), 1); + opacity: clamp( + 0, + min((var(--hero-p) - 0.24) * 14, (0.94 - var(--hero-p)) * 14), + 1 + ); & li { --mid: calc((var(--from) + var(--to)) / 2); @@ -1169,7 +1190,16 @@ textarea { letter-spacing: -0.035em; text-align: center; text-shadow: 0 2px 40px rgba(10, 10, 10, 0.9); - opacity: clamp(0, min((var(--hero-p) - var(--from)) * 14, (var(--to) - var(--hero-p)) * 14), 1); + opacity: clamp( + 0, + min( + (var(--hero-p) - var(--from)) * + 14, + (var(--to) - var(--hero-p)) * + 14 + ), + 1 + ); translate: 0 calc((var(--hero-p) - var(--mid)) * -160px); } @@ -1203,7 +1233,8 @@ textarea { /* o texto sai de cena enquanto a câmera mergulha: sobe, encolhe e perde o foco */ .hero-content { - transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) scale(calc(1 - var(--hero-p) * 0.08)); + transform: translate3d(0, calc(var(--hero-p) * -16vh), 0) + scale(calc(1 - var(--hero-p) * 0.08)); transform-origin: 0 50%; opacity: calc(1 - var(--hero-p) * 4.2); filter: blur(calc(var(--hero-p) * 18px)); @@ -1277,8 +1308,10 @@ html.lenis { color: var(--paper); background: radial-gradient(120% 90% at 100% 0%, var(--slash-dim), transparent 60%), - linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100% 32px, - linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 32px 100%, + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 100% + 32px, + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / + 32px 100%, var(--ink); & .ds-cover-mark { @@ -1486,16 +1519,25 @@ html.lenis { aparecem uma única vez, sem acompanhar cada quadro da rolagem */ @media (prefers-reduced-motion: no-preference) { /* só com as transições ativas (html.lenis): sem o script, o diagrama aparece normalmente */ - html.lenis .quality-lab:not(.is-drawing) :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) { + html.lenis .quality-lab:not(.is-drawing) + :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) { opacity: 0; } - .quality-lab.is-drawing .quality-blueprint :is(.quality-dimensions path, .quality-browser-frame, .quality-wire, .quality-browser-button) { + .quality-lab.is-drawing + .quality-blueprint + :is( + .quality-dimensions path, + .quality-browser-frame, + .quality-wire, + .quality-browser-button + ) { stroke-dasharray: 1800; /* maior que o maior contorno (moldura: ~1600) */ animation: quality-draw 1.1s var(--ease-out) both; } - .quality-lab.is-drawing :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) { + .quality-lab.is-drawing + :is(.quality-blueprint, .quality-visual-label, .quality-visual figcaption) { animation: quality-fade 0.6s var(--ease-out) both; } } @@ -1786,14 +1828,17 @@ html.lenis { animation: none; } +/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */ ::view-transition-group(page) { animation: none; } +/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */ ::view-transition-old(page) { animation: page-out 280ms cubic-bezier(0.7, 0, 0.84, 0) both; } +/* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */ ::view-transition-new(page) { animation: page-in 640ms var(--ease-out) 160ms both; } @@ -1815,7 +1860,12 @@ html.lenis { } @media (prefers-reduced-motion: reduce) { - ::view-transition-old(page), + /* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */ + ::view-transition-old(page) { + animation: none; + } + + /* biome-ignore lint/correctness/noUnknownTypeSelector: page é um nome de view-transition, não um elemento */ ::view-transition-new(page) { animation: none; } diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx index 7ce805a..559bcdb 100644 --- a/app/servicos/[slug]/page.tsx +++ b/app/servicos/[slug]/page.tsx @@ -45,7 +45,7 @@ export async function generateMetadata({ canonical: url, }, openGraph: { - locale: "pt_BR", + locale: "pt_BR", title: service.seoTitle, description: service.metaDescription, url, diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index 617cdad..f7e93f8 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -75,7 +75,9 @@ it("requires sources for factual statements and resolves every source ID", () => for (const id of ["reddit-explorer", "gcc-installer"] as const) { const { boundaries } = getDetail(id); expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0); - expect(boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0)).toBe(true); + expect( + boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0), + ).toBe(true); } }); diff --git a/package.json b/package.json index 502c139..90c5a90 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,11 @@ "@tailwindcss/oxide", "sharp", "unrs-resolver" - ] + ], + "overrides": { + "brace-expansion@<1.1.21": "1.1.21", + "brace-expansion@>=2.0.0 <2.1.7": "2.1.7" + } }, "scripts": { "dev": "next dev --turbopack", @@ -32,7 +36,7 @@ "lenis": "^1.3.26", "lucide-react": "0.542.0", "motion": "12.23.12", - "next": "16.3.3", + "next": "16.3.8", "react": "19.1.0", "react-dom": "19.1.0", "tailwind-merge": "3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6efd97b..8b14317 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + brace-expansion@<1.1.21: 1.1.21 + brace-expansion@>=2.0.0 <2.1.7: 2.1.7 + importers: .: @@ -30,8 +34,8 @@ importers: specifier: 12.23.12 version: 12.23.12(react-dom@19.1.0(react@19.1.0))(react@19.1.0) next: - specifier: 16.3.3 - version: 16.3.3(@babel/core@7.29.7)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + specifier: 16.3.8 + version: 16.3.8(@babel/core@7.29.7)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) react: specifier: 19.1.0 version: 19.1.0 @@ -706,53 +710,53 @@ packages: '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 - '@next/env@16.3.3': - resolution: {integrity: sha512-U2eYQRwXj+dsqxV79zFqExDdatnNY/ZWc2nsJU1p/OgT7fd3dXwlF6OjYaFQCfMoeTA19PWq+wVmYgimVA+V+g==} + '@next/env@16.3.8': + resolution: {integrity: sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==} - '@next/swc-darwin-arm64@16.3.3': - resolution: {integrity: sha512-8Hiv32QJPwdV6KYJ8meR9SBA061tQqnIKTJDocvOXlEQqib0xMFpzArosuffFUUc0sslbh7QQ8a3Yey1QV8EIw==} + '@next/swc-darwin-arm64@16.3.8': + resolution: {integrity: sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.3.3': - resolution: {integrity: sha512-A1lgKgwVchRYmSe467zdwhxT9040dd8lH+o65sL5Jet8fjB4kegw/rDyPIpYVRb6jAqwXFOJpjIXJLxQKLiE3A==} + '@next/swc-darwin-x64@16.3.8': + resolution: {integrity: sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.3.3': - resolution: {integrity: sha512-bf0FIssMFueU2dm7vQEWWxk0c8UjKTdW0yzuh0sQsD8pf1+KCLDdaqhYZNMYGmXwEOiHAUzgBKudovIlcvvBjg==} + '@next/swc-linux-arm64-gnu@16.3.8': + resolution: {integrity: sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.3.3': - resolution: {integrity: sha512-W7viwCk9JY/cAkdz/A273rd5bb3RgT/IHwR7Upv90tunjBWNtAAhGhoecHh+teRNRSinuAFmE+l7fwZ4YKkrXg==} + '@next/swc-linux-arm64-musl@16.3.8': + resolution: {integrity: sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.3.3': - resolution: {integrity: sha512-0W46zw1N3ODpI6n0GeivHvvob1pooozgZVqy65k0mh4/7vr+FbY9+WpHzNVXjHipJf/A3FDheBG19H1s5A25rA==} + '@next/swc-linux-x64-gnu@16.3.8': + resolution: {integrity: sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.3.3': - resolution: {integrity: sha512-H4mBso8ZTMBPtdT0PN0pBx2ayTvQuTuvS6qT13d77yVFJXAPCxkyIhLTmdMaGTJs0krQYI/qpzdHijCeihXhbg==} + '@next/swc-linux-x64-musl@16.3.8': + resolution: {integrity: sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.3.3': - resolution: {integrity: sha512-cTMUJpcEGmeywofCUfhR+rSsoE33+rVPnPEYNTNdLNlsOeEg/vktOsKUSTb28vUGqD2jkm4Zaskcwn7OCI6FQg==} + '@next/swc-win32-arm64-msvc@16.3.8': + resolution: {integrity: sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.3.3': - resolution: {integrity: sha512-2VR4cTBzHXaBjnGsuH6GyJjENzQOmHeAh11uY1iUhjm3j5dEUrVJuUj+VL78jaGi/Dik8xS76zEj18BsFhlVZQ==} + '@next/swc-win32-x64-msvc@16.3.8': + resolution: {integrity: sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -1254,11 +1258,11 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@1.1.18: - resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@1.1.21: + resolution: {integrity: sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==} - brace-expansion@2.1.4: - resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@2.1.7: + resolution: {integrity: sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==} browserslist@4.28.9: resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} @@ -2094,8 +2098,8 @@ packages: neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} - next@16.3.3: - resolution: {integrity: sha512-tuRTx1nQ/yVw83cwJBo9F+njGUgMn3UHQycreWHB8XsStvvAh1AthbI8/4IpKnFaF58F+iSiHejYOlMQ/eq83g==} + next@16.3.8: + resolution: {integrity: sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -3433,30 +3437,30 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true - '@next/env@16.3.3': {} + '@next/env@16.3.8': {} - '@next/swc-darwin-arm64@16.3.3': + '@next/swc-darwin-arm64@16.3.8': optional: true - '@next/swc-darwin-x64@16.3.3': + '@next/swc-darwin-x64@16.3.8': optional: true - '@next/swc-linux-arm64-gnu@16.3.3': + '@next/swc-linux-arm64-gnu@16.3.8': optional: true - '@next/swc-linux-arm64-musl@16.3.3': + '@next/swc-linux-arm64-musl@16.3.8': optional: true - '@next/swc-linux-x64-gnu@16.3.3': + '@next/swc-linux-x64-gnu@16.3.8': optional: true - '@next/swc-linux-x64-musl@16.3.3': + '@next/swc-linux-x64-musl@16.3.8': optional: true - '@next/swc-win32-arm64-msvc@16.3.3': + '@next/swc-win32-arm64-msvc@16.3.8': optional: true - '@next/swc-win32-x64-msvc@16.3.3': + '@next/swc-win32-x64-msvc@16.3.8': optional: true '@pkgjs/parseargs@0.11.0': @@ -3950,12 +3954,12 @@ snapshots: dependencies: require-from-string: 2.0.2 - brace-expansion@1.1.18: + brace-expansion@1.1.21: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.1.4: + brace-expansion@2.1.7: dependencies: balanced-match: 1.0.2 @@ -4887,11 +4891,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.18 + brace-expansion: 1.1.21 minimatch@9.0.9: dependencies: - brace-expansion: 2.1.4 + brace-expansion: 2.1.7 minimist@1.2.8: {} @@ -4925,9 +4929,9 @@ snapshots: neo-async@2.6.2: {} - next@16.3.3(@babel/core@7.29.7)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + next@16.3.8(@babel/core@7.29.7)(@types/node@22.20.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: - '@next/env': 16.3.3 + '@next/env': 16.3.8 '@swc/helpers': 0.5.23 baseline-browser-mapping: 2.11.21 caniuse-lite: 1.0.30001810 @@ -4936,14 +4940,14 @@ snapshots: react-dom: 19.1.0(react@19.1.0) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.1.0) optionalDependencies: - '@next/swc-darwin-arm64': 16.3.3 - '@next/swc-darwin-x64': 16.3.3 - '@next/swc-linux-arm64-gnu': 16.3.3 - '@next/swc-linux-arm64-musl': 16.3.3 - '@next/swc-linux-x64-gnu': 16.3.3 - '@next/swc-linux-x64-musl': 16.3.3 - '@next/swc-win32-arm64-msvc': 16.3.3 - '@next/swc-win32-x64-msvc': 16.3.3 + '@next/swc-darwin-arm64': 16.3.8 + '@next/swc-darwin-x64': 16.3.8 + '@next/swc-linux-arm64-gnu': 16.3.8 + '@next/swc-linux-arm64-musl': 16.3.8 + '@next/swc-linux-x64-gnu': 16.3.8 + '@next/swc-linux-x64-musl': 16.3.8 + '@next/swc-win32-arm64-msvc': 16.3.8 + '@next/swc-win32-x64-msvc': 16.3.8 sharp: 0.35.4(@types/node@22.20.1) transitivePeerDependencies: - '@babel/core' diff --git a/public/portfolio-data.json b/public/portfolio-data.json index 9117ab7..15ad6b1 100644 --- a/public/portfolio-data.json +++ b/public/portfolio-data.json @@ -155,7 +155,13 @@ "subtitle": "Posts do r/artificial em MongoDB, filtrados por período", "description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.", "longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.", - "technologies": ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"], + "technologies": [ + "Astro", + "MongoDB", + "Mongoose", + "TypeScript", + "Reddit API" + ], "features": [ "Coleta dos posts em alta do r/artificial", "Upsert por id no MongoDB", @@ -181,7 +187,12 @@ "subtitle": "Landing page institucional em Next.js 15", "description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.", "longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.", - "technologies": ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"], + "technologies": [ + "Next.js 15", + "React 19", + "Tailwind CSS 4", + "TypeScript" + ], "features": [ "Landing page institucional", "Metadados e URL canônica", diff --git a/scripts/seo-audit.mjs b/scripts/seo-audit.mjs index b348a19..1465ef7 100644 --- a/scripts/seo-audit.mjs +++ b/scripts/seo-audit.mjs @@ -21,30 +21,68 @@ const BASE = (args.base ?? "https://evandro.dev.br").replace(/\/$/, ""); const SITE = "https://evandro.dev.br"; // origem canônica declarada no código const UA = "Mozilla/5.0 (compatible; seo-audit/1.0)"; const AI_BOTS = [ - ["Googlebot", "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"], - ["bingbot", "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"], - ["OAI-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot"], - ["GPTBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)"], - ["ChatGPT-User", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)"], - ["Claude-SearchBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; +searchbot@anthropic.com)"], - ["ClaudeBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)"], - ["PerplexityBot", "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)"], + [ + "Googlebot", + "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)", + ], + [ + "bingbot", + "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)", + ], + [ + "OAI-SearchBot", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OAI-SearchBot/1.0; +https://openai.com/searchbot", + ], + [ + "GPTBot", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; GPTBot/1.1; +https://openai.com/gptbot)", + ], + [ + "ChatGPT-User", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ChatGPT-User/1.0; +https://openai.com/bot)", + ], + [ + "Claude-SearchBot", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; Claude-SearchBot/1.0; +searchbot@anthropic.com)", + ], + [ + "ClaudeBot", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ClaudeBot/1.0; +claudebot@anthropic.com)", + ], + [ + "PerplexityBot", + "Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; PerplexityBot/1.0; +https://perplexity.ai/perplexitybot)", + ], ]; const local = (url) => url.replace(SITE, BASE); // permite auditar localhost com URLs canônicas de produção const get = async (url, ua = UA, init = {}) => { const t0 = performance.now(); - const res = await fetch(url, { headers: { "user-agent": ua, "accept-encoding": "gzip, br" }, redirect: "manual", ...init }); + const res = await fetch(url, { + headers: { "user-agent": ua, "accept-encoding": "gzip, br" }, + redirect: "manual", + ...init, + }); const body = init.method === "HEAD" ? "" : await res.text(); return { res, body, ms: Math.round(performance.now() - t0) }; }; const attrs = (tag) => { const out = {}; - for (const m of tag.matchAll(/([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1].toLowerCase()] = m[2] ?? m[3] ?? ""; + for (const m of tag.matchAll( + /([a-zA-Z_:][\w:.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g, + )) + out[m[1].toLowerCase()] = m[2] ?? m[3] ?? ""; return out; }; -const decode = (s) => s.replace(/&/g, "&").replace(/"/g, '"').replace(/'|'/g, "'").replace(/</g, "<").replace(/>/g, ">").trim(); +const decode = (s) => + s + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'|'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .trim(); const strip = (html) => decode( html @@ -57,16 +95,25 @@ const words = (s) => (s.match(/[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu) ?? []).length; function parsePage(html) { const metas = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); - const meta = (key, val = "name") => metas.find((m) => m[val]?.toLowerCase() === key)?.content; + const meta = (key, val = "name") => + metas.find((m) => m[val]?.toLowerCase() === key)?.content; const links = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); - const headings = [...html.matchAll(/]*>([\s\S]*?)<\/h\1>/gi)].map((m) => ({ level: +m[1], text: strip(m[2]) })); + const headings = [ + ...html.matchAll(/]*>([\s\S]*?)<\/h\1>/gi), + ].map((m) => ({ level: +m[1], text: strip(m[2]) })); const imgs = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); const anchors = [...html.matchAll(/]*>/gi)].map((m) => attrs(m[0])); - const ld = [...html.matchAll(/]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]); + const ld = [ + ...html.matchAll( + /]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi, + ), + ].map((m) => m[1]); const main = html.match(//i)?.[0] ?? html; const firstH1 = main.search(/= 0 ? main.slice(firstH1) : main; - const firstP = [...afterH1.matchAll(/]*>([\s\S]*?)<\/p>/gi)].map((m) => strip(m[1])).find((t) => words(t) >= 8); + const firstP = [...afterH1.matchAll(/]*>([\s\S]*?)<\/p>/gi)] + .map((m) => strip(m[1])) + .find((t) => words(t) >= 8); return { title: decode(html.match(/]*>([\s\S]*?)<\/title>/i)?.[1] ?? ""), description: meta("description"), @@ -74,9 +121,19 @@ function parsePage(html) { viewport: meta("viewport"), lang: html.match(/]*\blang=["']([^"']+)/i)?.[1], canonical: links.find((l) => l.rel === "canonical")?.href, - alternates: links.filter((l) => l.rel === "alternate" && l.hreflang).map((l) => l.hreflang), - og: Object.fromEntries(metas.filter((m) => m.property?.startsWith("og:")).map((m) => [m.property, m.content])), - tw: Object.fromEntries(metas.filter((m) => m.name?.startsWith("twitter:")).map((m) => [m.name, m.content])), + alternates: links + .filter((l) => l.rel === "alternate" && l.hreflang) + .map((l) => l.hreflang), + og: Object.fromEntries( + metas + .filter((m) => m.property?.startsWith("og:")) + .map((m) => [m.property, m.content]), + ), + tw: Object.fromEntries( + metas + .filter((m) => m.name?.startsWith("twitter:")) + .map((m) => [m.name, m.content]), + ), headings, imgs, anchors, @@ -105,7 +162,13 @@ function jsonLdFacts(blocks) { if (Array.isArray(o)) return o.forEach(walk); if (o && typeof o === "object") { const keys = Object.keys(o); - if (keys.length === 1 && keys[0] === "@id" && !ids.has(o["@id"]) && !String(o["@id"]).startsWith(SITE)) dangling.push(o["@id"]); + if ( + keys.length === 1 && + keys[0] === "@id" && + !ids.has(o["@id"]) && + !String(o["@id"]).startsWith(SITE) + ) + dangling.push(o["@id"]); Object.values(o).forEach(walk); } }; @@ -114,7 +177,8 @@ function jsonLdFacts(blocks) { } const checks = []; // {scope, id, group, ok, detail} -const add = (scope, group, id, ok, detail = "") => checks.push({ scope, group, id, ok: Boolean(ok), detail }); +const add = (scope, group, id, ok, detail = "") => + checks.push({ scope, group, id, ok: Boolean(ok), detail }); async function auditUrl(url, siteTitles, siteDescs) { const target = local(url); @@ -122,20 +186,70 @@ async function auditUrl(url, siteTitles, siteDescs) { const scope = url.replace(SITE, "") || "/"; add(scope, "técnico", "status-200", res.status === 200, `HTTP ${res.status}`); if (res.status !== 200) return null; - add(scope, "técnico", "html-comprimido", /br|gzip/.test(res.headers.get("content-encoding") ?? "") || BASE.startsWith("http://localhost"), res.headers.get("content-encoding") ?? "sem compressão"); - add(scope, "técnico", "ttfb-menor-800ms", ms < 800, `${ms} ms (HTML completo)`); + add( + scope, + "técnico", + "html-comprimido", + /br|gzip/.test(res.headers.get("content-encoding") ?? "") || + BASE.startsWith("http://localhost"), + res.headers.get("content-encoding") ?? "sem compressão", + ); + add( + scope, + "técnico", + "ttfb-menor-800ms", + ms < 800, + `${ms} ms (HTML completo)`, + ); const p = parsePage(body); - add(scope, "on-page", "lang-pt-BR", p.lang?.toLowerCase() === "pt-br", p.lang ?? "ausente"); + add( + scope, + "on-page", + "lang-pt-BR", + p.lang?.toLowerCase() === "pt-br", + p.lang ?? "ausente", + ); add(scope, "on-page", "viewport", Boolean(p.viewport)); - add(scope, "on-page", "title-30-65", p.title.length >= 30 && p.title.length <= 65, `${p.title.length} car.: ${p.title}`); + add( + scope, + "on-page", + "title-30-65", + p.title.length >= 30 && p.title.length <= 65, + `${p.title.length} car.: ${p.title}`, + ); add(scope, "on-page", "title-unico", !siteTitles.has(p.title), p.title); siteTitles.add(p.title); - add(scope, "on-page", "description-70-165", !!p.description && p.description.length >= 70 && p.description.length <= 165, `${p.description?.length ?? 0} car.`); - add(scope, "on-page", "description-unica", !!p.description && !siteDescs.has(p.description)); + add( + scope, + "on-page", + "description-70-165", + !!p.description && + p.description.length >= 70 && + p.description.length <= 165, + `${p.description?.length ?? 0} car.`, + ); + add( + scope, + "on-page", + "description-unica", + !!p.description && !siteDescs.has(p.description), + ); if (p.description) siteDescs.add(p.description); - add(scope, "on-page", "canonical-propria", p.canonical === url || p.canonical === `${url}/`, p.canonical ?? "ausente"); - add(scope, "on-page", "indexavel", !/noindex/i.test(p.robots ?? "") && !/noindex/i.test(res.headers.get("x-robots-tag") ?? "")); + add( + scope, + "on-page", + "canonical-propria", + p.canonical === url || p.canonical === `${url}/`, + p.canonical ?? "ausente", + ); + add( + scope, + "on-page", + "indexavel", + !/noindex/i.test(p.robots ?? "") && + !/noindex/i.test(res.headers.get("x-robots-tag") ?? ""), + ); const h1 = p.headings.filter((h) => h.level === 1); add(scope, "on-page", "um-h1", h1.length === 1, `${h1.length} h1`); let skipped = false; @@ -144,57 +258,205 @@ async function auditUrl(url, siteTitles, siteDescs) { return h.level; }, 0); add(scope, "on-page", "hierarquia-de-titulos", !skipped); - add(scope, "on-page", "imagens-com-alt", p.imgs.every((i) => i.alt !== undefined), `${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`); - const internal = p.anchors.filter((a) => a.href && (a.href.startsWith("/") || a.href.startsWith(SITE)) && !a.href.startsWith("//")); - add(scope, "on-page", "links-internos-5+", new Set(internal.map((a) => a.href.split("#")[0])).size >= 5, `${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`); + add( + scope, + "on-page", + "imagens-com-alt", + p.imgs.every((i) => i.alt !== undefined), + `${p.imgs.filter((i) => i.alt === undefined).length}/${p.imgs.length} sem alt`, + ); + const internal = p.anchors.filter( + (a) => + a.href && + (a.href.startsWith("/") || a.href.startsWith(SITE)) && + !a.href.startsWith("//"), + ); + add( + scope, + "on-page", + "links-internos-5+", + new Set(internal.map((a) => a.href.split("#")[0])).size >= 5, + `${new Set(internal.map((a) => a.href.split("#")[0])).size} destinos`, + ); const external = p.anchors.filter((a) => a.target === "_blank"); // noreferrer também implica noopener - add(scope, "on-page", "externos-com-noopener", external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")), `${external.length} links _blank; sem rel: ${external.filter((a) => !/noopener|noreferrer/.test(a.rel ?? "")).map((a) => a.href).slice(0, 2).join(" ")}`); + add( + scope, + "on-page", + "externos-com-noopener", + external.every((a) => /noopener|noreferrer/.test(a.rel ?? "")), + `${external.length} links _blank; sem rel: ${external + .filter((a) => !/noopener|noreferrer/.test(a.rel ?? "")) + .map((a) => a.href) + .slice(0, 2) + .join(" ")}`, + ); - const og = p.og, tw = p.tw; - const missingOg = ["og:title", "og:description", "og:url", "og:image", "og:type", "og:site_name", "og:locale"].filter((k) => !og[k]); - add(scope, "social", "og-completo", missingOg.length === 0, missingOg.length ? `faltam ${missingOg.join(",")}` : ""); - add(scope, "social", "twitter-card", tw["twitter:card"] === "summary_large_image" && !!tw["twitter:title"] && !!tw["twitter:image"]); + const og = p.og, + tw = p.tw; + const missingOg = [ + "og:title", + "og:description", + "og:url", + "og:image", + "og:type", + "og:site_name", + "og:locale", + ].filter((k) => !og[k]); + add( + scope, + "social", + "og-completo", + missingOg.length === 0, + missingOg.length ? `faltam ${missingOg.join(",")}` : "", + ); + add( + scope, + "social", + "twitter-card", + tw["twitter:card"] === "summary_large_image" && + !!tw["twitter:title"] && + !!tw["twitter:image"], + ); if (og["og:image"]) { - const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch(() => null); - add(scope, "social", "og-image-acessivel", img?.res.status === 200 && /^image\//.test(img.res.headers.get("content-type") ?? ""), `${img?.res.status} ${img?.res.headers.get("content-type")}`); + const img = await get(local(og["og:image"]), UA, { method: "GET" }).catch( + () => null, + ); + add( + scope, + "social", + "og-image-acessivel", + img?.res.status === 200 && + /^image\//.test(img.res.headers.get("content-type") ?? ""), + `${img?.res.status} ${img?.res.headers.get("content-type")}`, + ); } const ld = jsonLdFacts(p.ld); - add(scope, "dados-estruturados", "json-ld-valido", p.ld.length > 0 && ld.errors.length === 0, ld.errors.join("; ") || `${p.ld.length} blocos`); - add(scope, "dados-estruturados", "referencias-resolvem", ld.dangling.length === 0, ld.dangling.join(", ")); - add(scope, "dados-estruturados", "sem-faqpage-nem-localbusiness", !ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)), ld.types.join(",")); - if (scope !== "/") add(scope, "dados-estruturados", "breadcrumb", ld.types.includes("BreadcrumbList")); - add(scope, "dados-estruturados", "person-com-sameas", !ld.types.includes("Person") || ld.nodes.some((n) => n["@type"] === "Person" && Array.isArray(n.sameAs) && n.sameAs.length >= 2)); + add( + scope, + "dados-estruturados", + "json-ld-valido", + p.ld.length > 0 && ld.errors.length === 0, + ld.errors.join("; ") || `${p.ld.length} blocos`, + ); + add( + scope, + "dados-estruturados", + "referencias-resolvem", + ld.dangling.length === 0, + ld.dangling.join(", "), + ); + add( + scope, + "dados-estruturados", + "sem-faqpage-nem-localbusiness", + !ld.types.some((t) => /FAQPage|LocalBusiness|ProfessionalService/.test(t)), + ld.types.join(","), + ); + if (scope !== "/") + add( + scope, + "dados-estruturados", + "breadcrumb", + ld.types.includes("BreadcrumbList"), + ); + add( + scope, + "dados-estruturados", + "person-com-sameas", + !ld.types.includes("Person") || + ld.nodes.some( + (n) => + n["@type"] === "Person" && + Array.isArray(n.sameAs) && + n.sameAs.length >= 2, + ), + ); - add(scope, "aeo-geo", "conteudo-no-html-sem-js", p.wordCount >= 250, `${p.wordCount} palavras no
do HTML`); - add(scope, "aeo-geo", "resposta-direta-cedo", p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70, `${p.firstAnswerWords} palavras no 1º parágrafo após o H1`); - const names = new Set(ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name)); - add(scope, "aeo-geo", "entidade-autor-consistente", names.size <= 1 && (scope === "/" ? names.size === 1 : true), [...names].join(",")); + add( + scope, + "aeo-geo", + "conteudo-no-html-sem-js", + p.wordCount >= 250, + `${p.wordCount} palavras no
do HTML`, + ); + add( + scope, + "aeo-geo", + "resposta-direta-cedo", + p.firstAnswerWords >= 15 && p.firstAnswerWords <= 70, + `${p.firstAnswerWords} palavras no 1º parágrafo após o H1`, + ); + const names = new Set( + ld.nodes.filter((n) => n["@type"] === "Person").map((n) => n.name), + ); + add( + scope, + "aeo-geo", + "entidade-autor-consistente", + names.size <= 1 && (scope === "/" ? names.size === 1 : true), + [...names].join(","), + ); return { scope, p, ld }; } async function main() { const sm = await get(`${BASE}/sitemap.xml`); const urls = [...sm.body.matchAll(/([^<]+)<\/loc>/g)].map((m) => m[1]); - add("site", "técnico", "sitemap-200", sm.res.status === 200 && urls.length > 0, `${urls.length} URLs`); + add( + "site", + "técnico", + "sitemap-200", + sm.res.status === 200 && urls.length > 0, + `${urls.length} URLs`, + ); const robots = await get(`${BASE}/robots.txt`); - add("site", "técnico", "robots-com-sitemap", /sitemap:\s*https?:\/\//i.test(robots.body)); - add("site", "técnico", "robots-nao-bloqueia-tudo", !/disallow:\s*\/\s*$/im.test(robots.body)); - const home = await get(`${BASE}/`); + add( + "site", + "técnico", + "robots-com-sitemap", + /sitemap:\s*https?:\/\//i.test(robots.body), + ); + add( + "site", + "técnico", + "robots-nao-bloqueia-tudo", + !/disallow:\s*\/\s*$/im.test(robots.body), + ); add("site", "técnico", "http-para-https-ou-local", true); for (const [name, ua] of AI_BOTS) { const r = await get(`${BASE}/`, ua); - add("site", "aeo-geo", `acesso-${name}`, r.res.status === 200, `HTTP ${r.res.status}`); + add( + "site", + "aeo-geo", + `acesso-${name}`, + r.res.status === 200, + `HTTP ${r.res.status}`, + ); } // A Cloudflare (Email Address Obfuscation) troca e-mails por /cdn-cgi/l/email-protection para quem é robô: buscadores e IA não veem o contato const botHome = await get(`${BASE}/`, AI_BOTS[0][1]); - add("site", "aeo-geo", "email-visivel-para-robos", !/email-protection|\[email( |\s)protected\]/.test(botHome.body), /email-protection/.test(botHome.body) ? "HTML servido a robôs tem e-mail ofuscado" : ""); + add( + "site", + "aeo-geo", + "email-visivel-para-robos", + !/email-protection|\[email( |\s)protected\]/.test(botHome.body), + /email-protection/.test(botHome.body) + ? "HTML servido a robôs tem e-mail ofuscado" + : "", + ); const llms = await get(`${BASE}/llms.txt`); // informativo: a política do projeto exclui llms.txt (nenhum sistema de IA o usa, segundo o Google); ausência é o esperado - add("site", "aeo-geo", "llms-txt-ausente-por-politica", llms.res.status === 404, `HTTP ${llms.res.status}`); + add( + "site", + "aeo-geo", + "llms-txt-ausente-por-politica", + llms.res.status === 404, + `HTTP ${llms.res.status}`, + ); const titles = new Set(); const descs = new Set(); @@ -206,52 +468,100 @@ async function main() { for (const pg of pages.filter(Boolean)) for (const a of pg.p.anchors) { const h = a.href?.split("#")[0]; - if (h && (h.startsWith("/") && !h.startsWith("//"))) targets.add(h); + if (h?.startsWith("/") && !h.startsWith("//")) targets.add(h); } let broken = 0; const brokenList = []; for (const t of targets) { - const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch(() => null); + const r = await get(`${BASE}${t}`, UA, { method: "HEAD" }).catch( + () => null, + ); if (!r || r.res.status >= 400) { broken++; brokenList.push(`${t} (${r?.res.status ?? "erro"})`); } } - add("site", "técnico", "sem-links-internos-quebrados", broken === 0, `${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`); + add( + "site", + "técnico", + "sem-links-internos-quebrados", + broken === 0, + `${broken}/${targets.size} quebrados: ${brokenList.join(", ")}`, + ); const inSitemap = new Set(urls.map((u) => u.replace(/\/$/, ""))); const orphanCandidates = [...inSitemap].filter((u) => { const path = u.replace(SITE, "") || "/"; - return path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path); + return ( + path !== "/" && ![...targets].some((t) => t.replace(/\/$/, "") === path) + ); }); - add("site", "técnico", "sem-paginas-orfas-no-sitemap", orphanCandidates.length === 0, orphanCandidates.join(", ")); + add( + "site", + "técnico", + "sem-paginas-orfas-no-sitemap", + orphanCandidates.length === 0, + orphanCandidates.join(", "), + ); // links externos para os repositórios do autor: 404 = projeto com "Ver código" quebrado (repositório privado) const ghLinks = new Set(); for (const pg of pages.filter(Boolean)) - for (const a of pg.p.anchors) if (/^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "")) ghLinks.add(a.href.replace(/\/$/, "")); + for (const a of pg.p.anchors) + if ( + /^https:\/\/github\.com\/evandrodevbr\/[\w.-]+\/?$/.test(a.href ?? "") + ) + ghLinks.add(a.href.replace(/\/$/, "")); const ghBroken = []; for (const url of ghLinks) { const r = await get(url, UA, { method: "HEAD" }).catch(() => null); - if (!r || r.res.status === 404) ghBroken.push(url.replace("https://github.com/evandrodevbr/", "")); + if (!r || r.res.status === 404) + ghBroken.push(url.replace("https://github.com/evandrodevbr/", "")); } - add("site", "técnico", "links-github-acessiveis", ghBroken.length === 0, ghBroken.length ? `404 para visitantes: ${ghBroken.join(", ")}` : `${ghLinks.size} repositórios`); + add( + "site", + "técnico", + "links-github-acessiveis", + ghBroken.length === 0, + ghBroken.length + ? `404 para visitantes: ${ghBroken.join(", ")}` + : `${ghLinks.size} repositórios`, + ); const groups = {}; for (const c of checks) { - const g = (groups[c.group] ??= { ok: 0, total: 0 }); + groups[c.group] ??= { ok: 0, total: 0 }; + const g = groups[c.group]; g.total++; if (c.ok) g.ok++; } - const out = { base: BASE, at: new Date().toISOString(), urls: urls.length, groups, checks }; + const out = { + base: BASE, + at: new Date().toISOString(), + urls: urls.length, + groups, + checks, + }; if (args.out) writeFileSync(args.out, JSON.stringify(out, null, 2)); console.log(`\nAuditoria ${BASE} — ${urls.length} URLs`); - for (const [g, v] of Object.entries(groups)) console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`); + for (const [g, v] of Object.entries(groups)) + console.log(` ${g.padEnd(20)} ${v.ok}/${v.total}`); const failing = checks.filter((c) => !c.ok); console.log(`\nFalhas (${failing.length}):`); const byId = {}; - for (const f of failing) (byId[f.id] ??= []).push(f); - for (const [id, list] of Object.entries(byId)) console.log(` - ${id} (${list.length}): ${list.slice(0, 4).map((f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`).join(" | ")}`); + for (const f of failing) { + byId[f.id] ??= []; + byId[f.id].push(f); + } + for (const [id, list] of Object.entries(byId)) + console.log( + ` - ${id} (${list.length}): ${list + .slice(0, 4) + .map( + (f) => `${f.scope}${f.detail ? ` [${f.detail.slice(0, 70)}]` : ""}`, + ) + .join(" | ")}`, + ); } main().catch((e) => { diff --git a/scripts/seo-compare.mjs b/scripts/seo-compare.mjs index c68889f..4e54ac9 100644 --- a/scripts/seo-compare.mjs +++ b/scripts/seo-compare.mjs @@ -13,23 +13,32 @@ const key = (c) => `${c.scope}::${c.id}`; const prev = new Map(before.checks.map((c) => [key(c), c])); const next = new Map(after.checks.map((c) => [key(c), c])); -console.log(`\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`); +console.log( + `\nAntes: ${before.base} (${before.urls} URLs) Depois: ${after.base} (${after.urls} URLs)\n`, +); console.log("grupo antes depois"); -for (const g of new Set([...Object.keys(before.groups), ...Object.keys(after.groups)])) { +for (const g of new Set([ + ...Object.keys(before.groups), + ...Object.keys(after.groups), +])) { const a = before.groups[g] ?? { ok: 0, total: 0 }; const b = after.groups[g] ?? { ok: 0, total: 0 }; - console.log(`${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`); + console.log( + `${g.padEnd(20)} ${`${a.ok}/${a.total}`.padEnd(9)} ${b.ok}/${b.total}`, + ); } const fixed = []; const regressed = []; for (const [k, c] of next) { const p = prev.get(k); if (p && !p.ok && c.ok) fixed.push(c); - if (p && p.ok && !c.ok) regressed.push(c); - if (!p && !c.ok) regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` }); + if (p?.ok && !c.ok) regressed.push(c); + if (!p && !c.ok) + regressed.push({ ...c, detail: `novo e falhando: ${c.detail}` }); } console.log(`\nCorrigidos (${fixed.length}):`); for (const c of fixed) console.log(` + ${c.scope} ${c.id}`); console.log(`\nPiorou (${regressed.length}):`); -for (const c of regressed) console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`); +for (const c of regressed) + console.log(` - ${c.scope} ${c.id} ${c.detail ?? ""}`); process.exit(regressed.length ? 2 : 0);