diff --git a/.githooks/README.md b/.githooks/README.md new file mode 100644 index 0000000..88e577b --- /dev/null +++ b/.githooks/README.md @@ -0,0 +1,28 @@ +# .githooks — versioning enforcement + +Git-native hooks (POSIX, no dependencies). They run for **every** commit, whether made by a +person or by an agent, and enforce the team's versioning rules. + +- **`commit-msg`** — rejects the commit if the message is not a Conventional Commit + (`()(!): description`). Warns (does not block) when the type does not match the + branch prefix. +- **`pre-commit`** — blocks direct commits on `main` or `beta` (development happens on + temporary branches). Escape for release promotion: `ATENEA_ALLOW_PROTECTED=1 git commit ...`. + +## Activation + +`core.hooksPath` is a per-clone local setting (it is not versioned), so each clone enables it +once. The project automates it with a `prepare` script in `package.json`: + +```json +"scripts": { "prepare": "git config core.hooksPath .githooks || true" } +``` + +That way every `npm install` wires it up. Manually, if needed: + +```bash +git config core.hooksPath .githooks +chmod +x .githooks/* +``` + +One-off bypass of a hook (not recommended): `git commit --no-verify`. diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..f72837a --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,53 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────── +# Atenea · commit-msg · validates Conventional Commits (Venteks versioning manual) +# +# Git-native: runs for ANY commit (human or agent), with or without Claude. +# Blocks the commit if the message does not comply. One-off escape: git commit --no-verify. +# +# Required format: ()(!): +# types: feat fix docs style refactor perf test build ci chore revert +# examples: feat(auth): add session login +# fix: correct permission calculation +# feat(store)!: change the state shape (BREAKING CHANGE) +# ───────────────────────────────────────────────────────────────────────── + +msg_file="$1" +first_line="$(head -n 1 "$msg_file")" + +# Skip automatic merge/revert commits and fixup/squash. +case "$first_line" in + "Merge "*|"Revert "*|"fixup!"*|"squash!"*) exit 0 ;; +esac + +pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9._/-]+\))?(!)?: .+' + +if printf '%s' "$first_line" | grep -Eq "$pattern"; then + # Soft warning: type ↔ branch prefix consistency (does not block). + # A feat/* branch is a feature container: it legitimately accumulates intermediate + # fix/docs/refactor/chore/test commits, so it is NOT warned about. Only fix/* and + # hotfix/* are checked, since they have a single purpose. + branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo '')" + type="$(printf '%s' "$first_line" | sed -E 's/^([a-z]+).*/\1/')" + case "$branch" in + fix/*) [ "$type" = "fix" ] || printf '⚠️ Atenea: fix/* branch but commit "%s" (should it be fix?)\n' "$type" >&2 ;; + hotfix/*) [ "$type" = "fix" ] || printf '⚠️ Atenea: hotfix/* branch but commit "%s" (should it be fix?)\n' "$type" >&2 ;; + esac + exit 0 +fi + +cat >&2 <<'EOF' +✕ Atenea · commit rejected: the message does not follow Conventional Commits. + + Format: ()(!): + Types: feat fix docs style refactor perf test build ci chore revert + + Valid examples: + feat(auth): add session login + fix: correct permission calculation + feat(store)!: change the state shape + + Team versioning manual (Atenea plugin, git-flow skill). + One-off bypass (not recommended): git commit --no-verify +EOF +exit 1 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..a8d1f34 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,36 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────────────────── +# Atenea · pre-commit · protects the permanent branches (Venteks versioning manual) +# +# Rule: "never develop directly on main". Development happens on temporary branches +# (feat/ fix/ chore/ docs/) created from beta; hotfix/ from main. This hook blocks +# DIRECT commits on main or beta. +# +# Escape for release promotion/merge: ATENEA_ALLOW_PROTECTED=1 git commit ... +# One-off full bypass: git commit --no-verify +# ───────────────────────────────────────────────────────────────────────── + +[ "${ATENEA_ALLOW_PROTECTED:-}" = "1" ] && exit 0 + +branch="$(git symbolic-ref --short HEAD 2>/dev/null || echo '')" + +case "$branch" in + main|beta) + cat >&2 < · fix/ · chore/ · docs/ (from beta) + hotfix/ (from main) + + Create the branch and switch to it: + git switch -c feat/my-change + + Release promotion (merge into $branch): ATENEA_ALLOW_PROTECTED=1 git commit ... + One-off bypass (not recommended): git commit --no-verify +EOF + exit 1 + ;; +esac + +exit 0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 199ac1f..49ddfd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,10 @@ name: CI on: pull_request: - branches: [main] + branches: [beta, main] + types: [opened, synchronize, reopened, edited] push: - branches: [main] + branches: [beta, main] concurrency: group: ci-${{ github.ref }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..06b0af2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,79 @@ +# Changelog + +All notable changes to this project are documented in this file. +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +### Changed + +### Fixed + +### Removed + +## [1.3.0] - 2026-11-01 + +### Added + +- Screen reader support (VoiceOver and TalkBack): an invisible adjustable control announces the current page ("Page 3 of 12", or "Pages 3 and 4 of 12" in book mode) and turns pages with the increment/decrement actions. Headings are exposed with the `header` role. +- `accessibilityLabels` prop on `DocumentViewer` to override the spoken strings, plus the `ViewerAccessibilityLabels` type and the `DEFAULT_ACCESSIBILITY_LABELS` export. +- GitHub Actions CI for every PR and push to `beta` and `main`: typecheck, lint, formatting and tests. It also reruns when a PR is retargeted. +- Git hooks (`.githooks/`) that require Conventional Commits and block direct commits on `main` and `beta`, and a contributing guide with the branching model. + +### Changed + +- Peer dependency ranges now match what the library supports: `react-native` ≥ 0.76, `react` ≥ 18.3.1, `react-native-reanimated` ≥ 3.16 and < 4, `react-native-gesture-handler` ≥ 2.20 and < 3. Reanimated 4 and Gesture Handler 3 are not supported yet. +- The repository moved from GitLab to GitHub (`acertijosapps/react-native-md-document`): `repository` and `bugs` in `package.json` and `source` in the podspec, which now uses the `vX.Y.Z` tag. +- The public documentation (README, changelog, contributing guide) is now in English. + +### Fixed + +- The pages stacked behind the current one for the flip animation are hidden from the accessibility tree, so screen readers no longer read them. +- The error thrown when the native speech module is not linked now names the published package (`@acertijosapps/react-native-md-document`) instead of the old `react-native-document`, and is in English. + +## [1.2.0] - 2026-10-25 + +### Added + +- Book mode: on tablets and foldables in landscape, the document is shown as two facing pages with page-turning. Props `dualPage` (on by default), `dualPageMinWidth` and `spreadGap` (space for a foldable's hinge). +- `onPageChange` receives a fourth argument, `pagesPerView` (1 or 2). +- "Book mode" and "Foldables" sections in the README, with screenshots. + +### Fixed + +- Pagination on tablets: it no longer gets stuck or leaves blank pages because of late layout events. +- Book mode only turns on for tablet- or foldable-sized screens (window short side of at least 600 dp); a phone in landscape still shows a single page. + +## [1.1.2] - 2026-10-13 + +### Changed + +- `author`, email and `homepage` of AcertijosApps in `package.json` and the podspec. + +## [1.1.1] - 2026-09-19 + +### Added + +- `homepage`, `repository` and `bugs` in `package.json`, and an aligned podspec. + +## [1.1.0] - 2026-09-19 + +### Added + +- Loading the document through `source`, with loading and error states (`onLoad*`, `renderLoading`, `renderError`). + +## [1.0.1] - 2026-09-19 + +### Changed + +- README with the published package name and absolute image URLs, so they render on npm; `media/` is included in the package. +- Podspec renamed to `react-native-md-document.podspec`. +- Keywords `android`, `ios` and `zoom`. + +## [1.0.0] - 2026-09-19 + +### Added + +- First public release: Markdown-as-PDF viewer with page-turning, zoom, persistent highlights, text-to-speech (TTS) and light/dark color tokens, with injectable storage, source and speech engine. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..70dc7cf --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,82 @@ +# Contributing + +Contributions are welcome. Keep changes small, focused and traceable. + +## Development workflow + +Use the Node.js version in [`.nvmrc`](./.nvmrc) and install the dependencies from the root: + +```sh +npm install +``` + +`npm install` also wires the repository git hooks (`core.hooksPath` → `.githooks/`). + +The example app lives in `example/` (Expo) and consumes the code in `src/` directly, so TypeScript changes hot-reload there. + +### Commands + +```sh +npm run typecheck # type-check with TypeScript +npm run lint # lint with ESLint +npm run format # format with Prettier (writes) +npm run format:check # check formatting with Prettier +npm test # run the unit tests (node:test) +``` + +## Branching model + +Two permanent branches, both protected: + +- **`main`**: production. No direct pushes; changes land only through an approved Pull Request. +- **`beta`**: release candidate. + +Temporary branches, always created from `beta`, in kebab-case: + +- `feat/` · `fix/` · `chore/` · `docs/` → Pull Request **into `beta`**, deleted after merge. +- `hotfix/` is the only exception: created from `main`, PR into `main`, then back-merged into `beta`. + +One branch = one focused change. + +## Commits + +Write commit messages and Pull Requests in English, following [Conventional Commits](https://www.conventionalcommits.org/): + +``` +()(!): +``` + +Types: `feat` `fix` `docs` `style` `refactor` `perf` `test` `build` `ci` `chore` `revert`. + +[SemVer](https://semver.org/) mapping: `feat` → MINOR, `fix` → PATCH, breaking (`feat!` or `BREAKING CHANGE:`) → MAJOR. + +The git hooks in [`.githooks/`](./.githooks) run for every commit: + +- `commit-msg` rejects messages that are not Conventional Commits. +- `pre-commit` blocks direct commits on `main` and `beta`. + +One-off bypass (not recommended): `git commit --no-verify`. + +## Changelog + +Any change that affects behavior updates [`CHANGELOG.md`](./CHANGELOG.md) ([Keep a Changelog](https://keepachangelog.com/en/1.1.0/)): add the entry under `## [Unreleased]` in the right category (Added · Changed · Fixed · Removed). + +## Pull Requests + +When opening a PR: + +- Target `beta` (or `main` only for `hotfix`). +- Keep it small and focused on one change. +- Request a review when there is another maintainer. Approvals are not enforced while the repository has a single maintainer; the PR and a green CI check are always required. +- Gates must be green: `npm run typecheck`, `npm run lint`, `npm run format:check` and `npm test`. CI ([`.github/workflows/ci.yml`](./.github/workflows/ci.yml)) runs them on every PR to `beta` or `main`. +- Include the `CHANGELOG.md` entry. + +## Releasing (`beta` → `main`) + +1. Confirm the gates are green on `beta`. +2. Decide the SemVer bump from the commits since the last tag. +3. Update `version` in `package.json`. +4. Rename the `## [Unreleased]` section of the changelog to `## [X.Y.Z] - YYYY-MM-DD` and open a fresh empty one. +5. Open the `beta` → `main` PR and merge it. +6. Tag `vX.Y.Z` on `main` and push it (`git push origin vX.Y.Z`). +7. Publish from the repository root with `npm publish` (the README and `package.json` are frozen per version). diff --git a/README.md b/README.md index 72f96b8..bca475a 100644 --- a/README.md +++ b/README.md @@ -2,54 +2,55 @@ > npm: [`@acertijosapps/react-native-md-document`](https://www.npmjs.com/package/@acertijosapps/react-native-md-document) -Visor de documentos **Markdown-como-PDF** para React Native: documento inmutable, con paso -de hoja animado, zoom, subrayado persistente, text-to-speech con resaltado sincronizado y -tema claro/oscuro. Sin librerías de terceros para el contenido (parser y render propios); el -storage, el origen del documento y el motor de voz son inyectables. +**Markdown-as-PDF** document viewer for React Native: an immutable document with animated +page-turning, zoom, persistent highlights, text-to-speech with synchronized word highlighting +and a light/dark theme. No third-party libraries for the content (own parser and renderer); the +storage, the document source and the speech engine are injectable. -## Vista previa +## Preview -Documento de demostración (`example/`) en tema claro y oscuro: banner con tokens de marca, -estilos en línea, resaltado y adaptación de color por tema. +Demo document (`example/`) in light and dark themes: banner with brand tokens, inline styles, +highlights and per-theme color adaptation. -| Claro | Oscuro | +| Light | Dark | |---|---| -| Tema claro | Tema oscuro | - -## Características - -- **Render Markdown** + **HTML embebido**: encabezados, párrafos, listas, blockquote, code, - `hr`, estilos en línea (negrita, cursiva, código, tachado, enlaces), **tablas** y HTML con - estilos inline (color, fondo, centrado, tamaños, anchos de columna). -- **Paso de hoja** animado (Reanimated + Gesture Handler), un gesto = una página. -- **Zoom** por pinch + arrastre, compuesto con el flip y el tap. -- **Subrayado** por palabra, **persistente entre sesiones** (storage inyectable). -- **Text-to-speech** con resaltado de la palabra hablada (incluso en tablas), pausa/reanudar, - velocidad, y auto-avance de página siguiendo la lectura. -- **Tema claro/oscuro** (`auto` sigue el SO) con tokens de color adaptables por tema. -- **Orígenes**: URL remota, ruta local del dispositivo, ruta del proyecto. -- **Orientación** vertical y horizontal (re-pagina al rotar). -- **Modo libro**: en tablets y plegables en horizontal muestra dos páginas lado a lado, con - el volteo de hoja desde el lomo. Activo por defecto; se desactiva con `dualPage={false}`. - -## Requisitos - -| Plataforma | Mínimo | Nota | +| Light theme | Dark theme | + +## Features + +- **Markdown** + **embedded HTML**: headings, paragraphs, lists, blockquotes, code, `hr`, + inline styles (bold, italic, code, strikethrough, links), **tables** and HTML with inline + styles (color, background, centering, sizes, column widths). +- Animated **page turning** (Reanimated + Gesture Handler), one gesture = one page. +- **Zoom** with pinch + drag, composed with the flip and the tap. +- **Per-word highlights**, **persistent across sessions** (injectable storage). +- **Text-to-speech** with the spoken word highlighted (even inside tables), pause/resume, + speed, and automatic page advance that follows the reading. +- **Light/dark theme** (`auto` follows the OS) with color tokens that adapt per theme. +- **Sources**: remote URL, local device path, bundled project file. +- **Portrait and landscape** orientation (re-paginates on rotation). +- **Book mode**: on tablets and foldables in landscape it shows two pages side by side, with + the page turning from the spine. On by default; turn it off with `dualPage={false}`. +- **Accessible**: screen readers (VoiceOver, TalkBack) hear the current page, can turn pages + with a swipe up/down, and skip the pages hidden behind the flip animation. + +## Requirements + +| Platform | Minimum | Note | |---|---|---| -| **iOS** | 13.0 | Deployment target del módulo nativo (`react-native-md-document.podspec`). TTS con resaltado por palabra incluido. | -| **Android** | 7.0 (API 24) | `minSdkVersion` del módulo. El TTS habla en API 24+, pero el **resaltado por palabra** durante la lectura usa `onRangeStart`, que requiere **Android 8.0 (API 26)**. | -| **React Native** | 0.76 | Probado con RN 0.76.5 / Expo SDK 52. | -| **Peer deps** | `react-native-reanimated` ≥ 3.16 · `react-native-gesture-handler` ≥ 2.20 | Únicas dependencias visuales. | +| **iOS** | 13.0 | Deployment target of the native module (`react-native-md-document.podspec`). TTS with per-word highlighting included. | +| **Android** | 7.0 (API 24) | The module's `minSdkVersion`. TTS speaks on API 24+, but the **per-word highlighting** while reading uses `onRangeStart`, which requires **Android 8.0 (API 26)**. | +| **React Native** | 0.76 | Tested with RN 0.76.5 / Expo SDK 52. | +| **Peer deps** | `react-native-reanimated` ≥ 3.16 and < 4 · `react-native-gesture-handler` ≥ 2.20 and < 3 | The only visual dependencies. Reanimated 4 and Gesture Handler 3 are not supported yet. | ### Android 16 KB (page size) -**Compatible.** `react-native-md-document` **no incluye código nativo compilado** -(`.so`): el módulo `DocumentSpeech` es Kotlin puro y se compila al dex de la -app. Al no aportar binarios, no hay librerías desalineadas de nuestra parte — -la librería cumple el requisito de 16 KB de Android 15 (Google Play, `targetSdk -35`) por construcción, en cualquier tamaño de página. +**Compatible.** `react-native-md-document` **ships no compiled native code** (`.so`): the +`DocumentSpeech` module is plain Kotlin and is compiled into the app's dex. Since it adds no +binaries, none of our libraries can be misaligned — the library meets Android 15's 16 KB +requirement (Google Play, `targetSdk 35`) by construction, on any page size. -## Instalación +## Installation ```sh # npm @@ -59,7 +60,7 @@ npm install @acertijosapps/react-native-md-document react-native-gesture-handler yarn add @acertijosapps/react-native-md-document react-native-gesture-handler react-native-reanimated ``` -`react-native-reanimated` requiere su plugin de Babel (último en la lista): +`react-native-reanimated` requires its Babel plugin (last in the list): ```js // babel.config.js @@ -69,7 +70,7 @@ module.exports = { }; ``` -La app debe envolver la raíz en `GestureHandlerRootView`: +The app must wrap its root in `GestureHandlerRootView`: ```tsx import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -79,92 +80,91 @@ export default function App() { } ``` -Para el **text-to-speech nativo**, el módulo `DocumentSpeech` se autoenlaza como cualquier -paquete RN: +For **native text-to-speech**, the `DocumentSpeech` module is autolinked like any RN package: ```sh -npx pod-install # iOS (o: cd ios && pod install) -# Android: autolinking por gradle, sin pasos extra +npx pod-install # iOS (or: cd ios && pod install) +# Android: Gradle autolinking, no extra steps ``` -### Dependencias opcionales +### Optional dependencies -- **Almacenamiento del subrayado**: la librería **no** depende de - `@react-native-async-storage/async-storage`. El store es inyectable - (`getItem`/`setItem`, sync o async): sirve AsyncStorage, MMKV, SQLite o el - tuyo. Instálalo **solo si** quieres persistir subrayados con esa opción; para - subrayado en memoria usa `createMemoryHighlightStore()`, y sin subrayado no - necesitas nada. Ver [Subrayado persistente](#subrayado-persistente). +- **Highlight storage**: the library does **not** depend on + `@react-native-async-storage/async-storage`. The store is injectable + (`getItem`/`setItem`, sync or async): AsyncStorage, MMKV, SQLite or your own will do. Install + it **only if** you want to persist highlights with that option; for in-memory highlights use + `createMemoryHighlightStore()`, and if you don't highlight you need nothing. See + [Persistent highlights](#persistent-highlights). -Únicas dependencias obligatorias: `react-native-reanimated` y -`react-native-gesture-handler` (los peers). +The only required dependencies are `react-native-reanimated` and +`react-native-gesture-handler` (the peers). -## Inicio rápido +## Quick start ```tsx import { DocumentViewer, parse } from '@acertijosapps/react-native-md-document'; -const doc = parse('# Hola\n\nUn documento **Markdown**.'); +const doc = parse('# Hello\n\nA **Markdown** document.'); export function Viewer() { return ; } ``` -`DocumentViewer` mide el contenido, lo pagina al tamaño del viewport y habilita flip + zoom. -`parse(source)` devuelve un `MDDocument` inmutable con `plainText` (la proyección de texto que -usan highlight y TTS). +`DocumentViewer` measures the content, paginates it to the viewport size and enables flip + +zoom. `parse(source)` returns an immutable `MDDocument` with `plainText` (the text projection +that highlights and TTS rely on). -## Orígenes del documento +## Document sources -`useDocumentSource` cubre los tres orígenes: +`useDocumentSource` covers the three sources: ```tsx import { DocumentViewer, useDocumentSource } from '@acertijosapps/react-native-md-document'; function Screen() { - // Proyecto (bundled): string o { markdown } + // Project (bundled): string or { markdown } const { document, isLoading, error, reload } = useDocumentSource({ markdown: MD_STRING }); - // URL remota: fetch integrado + // Remote URL: built-in fetch // useDocumentSource({ uri: 'https://example.com/doc.md' }) - // Ruta local del dispositivo + // Local device path // useDocumentSource({ uri: 'file:///…/doc.md' }) if (!document) { - return null; // isLoading / error / reload disponibles + return null; // isLoading / error / reload are available } return ; } ``` -Detección de string: con `http(s)://`, `file:`, `content:`, `asset:` o que empiece con `/` se -trata como URI; cualquier otro string es Markdown crudo. +String detection: a string starting with `http(s)://`, `file:`, `content:`, `asset:` or `/` is +treated as a URI; any other string is raw Markdown. -### Carga gestionada por el visor +### Viewer-managed loading -Si no quieres cablear el hook a mano, pasa `source` directo a `DocumentViewer`: carga solo y -expone los estados de carga. +If you don't want to wire the hook by hand, pass `source` straight to `DocumentViewer`: it +loads on its own and exposes the loading states. ```tsx console.log('listo', doc.length)} + onLoadComplete={(doc) => console.log('ready', doc.length)} onLoadError={(err) => console.warn(err.message)} renderLoading={() => } renderError={(err, retry) => ( {err.message} -