@maccesar/aiskills 1.17.1 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +41 -0
  2. package/lib/commands/list.js +109 -22
  3. package/lib/commands/skills.js +1 -3
  4. package/lib/config.js +1 -0
  5. package/lib/prompts/checkboxCancel.js +0 -6
  6. package/lib/symlink.js +1 -1
  7. package/package.json +4 -2
  8. package/skills/seo-launch/SKILL.md +91 -0
  9. package/skills/seo-launch/assets/head.php +85 -0
  10. package/skills/seo-launch/assets/htaccess-static +86 -0
  11. package/skills/seo-launch/assets/robots.txt +17 -0
  12. package/skills/seo-launch/assets/social-meta.blade.php +90 -0
  13. package/skills/seo-launch/references/head-tags.md +109 -0
  14. package/skills/seo-launch/references/images.md +86 -0
  15. package/skills/seo-launch/references/search-engines.md +77 -0
  16. package/skills/seo-launch/references/server-files.md +168 -0
  17. package/skills/seo-launch/references/structured-data.md +139 -0
  18. package/skills/seo-launch/scripts/__pycache__/auditar_seo.cpython-312.pyc +0 -0
  19. package/skills/seo-launch/scripts/auditar_seo.py +539 -0
  20. package/skills/stitch-showcase/references/12-video-embedding.md +11 -28
  21. package/skills/stitch-showcase/references/13-language-detection.md +13 -38
  22. package/skills/stitch-showcase/scripts/__pycache__/build_showcase.cpython-314.pyc +0 -0
  23. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-313.pyc +0 -0
  24. package/skills/stitch-showcase/scripts/__pycache__/component_utils.cpython-314.pyc +0 -0
  25. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-313.pyc +0 -0
  26. package/skills/stitch-showcase/scripts/__pycache__/detect_components.cpython-314.pyc +0 -0
  27. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-313.pyc +0 -0
  28. package/skills/stitch-showcase/scripts/__pycache__/extract_catalog.cpython-314.pyc +0 -0
  29. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-313.pyc +0 -0
  30. package/skills/stitch-showcase/scripts/__pycache__/extract_text.cpython-314.pyc +0 -0
  31. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-313.pyc +0 -0
  32. package/skills/stitch-showcase/scripts/__pycache__/extract_zips.cpython-314.pyc +0 -0
  33. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-313.pyc +0 -0
  34. package/skills/stitch-showcase/scripts/__pycache__/parse_design_md.cpython-314.pyc +0 -0
  35. package/skills/stitch-showcase/scripts/__pycache__/slug_demangle.cpython-314.pyc +0 -0
package/README.md CHANGED
@@ -73,6 +73,7 @@ All three platforms use the same Agent Skills format: a `SKILL.md` file with YAM
73
73
  | vscode-extension-dev | VS Code | VS Code Extension API docs | 14 files |
74
74
  | stitch-showcase | Design Tools | Google Stitch export workflow | 16 files |
75
75
  | session-log | Project | Convention + 3 A/B rounds | 2 files |
76
+ | seo-launch | Web / SEO | Head tags, share cards, server files | 5 files |
76
77
 
77
78
  Use `aiskills list` to see available skills from the command line. Pull requests are welcome.
78
79
 
@@ -452,6 +453,46 @@ Anti-patterns `refactoring-ui` guards against:
452
453
 
453
454
  ---
454
455
 
456
+ ### seo-launch
457
+
458
+ Takes a site from "it's online" to "search engines can find it and the link looks right when someone shares it". It covers the part that is configuration rather than content: the `<head>` tags, the images the platforms actually fetch, the server files, the JSON-LD, and the handover to Search Console.
459
+
460
+ It runs in two stages, separated by your approval — the same shape as `audit-codebase`, and for the same reason: a tool that edits while it looks hands you a list of things it already changed instead of a diagnosis.
461
+
462
+ **Stage 1** runs `scripts/auditar_seo.py` against the live URL and reports what is missing, with a severity per finding and the consequence spelled out. The script is standard-library Python, so there is nothing to install. It checks the `<head>` tags and their lengths, the seven Open Graph tags, the Twitter card, the icons, the JSON-LD, `robots.txt`, `sitemap.xml`, the `http→https` and `www→apex` redirects, the response headers, and whether the `og:image` exists — reading its **real dimensions from the file header**, which is how it catches an image declared as 1200×630 that is not.
463
+
464
+ ```bash
465
+ python3 <SKILL_DIR>/scripts/auditar_seo.py https://example.com
466
+ python3 <SKILL_DIR>/scripts/auditar_seo.py https://mysite.test --local # self-signed Herd certificate
467
+ ```
468
+
469
+ `<SKILL_DIR>` is wherever the skill got installed — `~/.agents/skills/seo-launch` for an npm install, a versioned path under `~/.claude/plugins/cache/` for a marketplace one. The skill reads it from the system message rather than assuming, because the working directory during an audit is your project, not the skill.
470
+
471
+ Certificate verification is on by default; `--local` is the explicit opt-out for local `.test` domains, because an unverified response is not evidence of anything.
472
+
473
+ **Stage 2**, once you approve, installs the tags from a parameterized template, generates the images with ImageMagick, writes `robots.txt` / `sitemap.xml` / `.htaccess`, and re-runs the audit against the live site to verify.
474
+
475
+ | Reference file | Covers |
476
+ | --- | --- |
477
+ | `head-tags.md` | title, description, canonical, robots, the Open Graph block, Twitter card, and why the URLs must be absolute |
478
+ | `images.md` | og:image 1200×630, SVG favicon, apple-touch-icon, and the ImageMagick commands with the decision behind each flag |
479
+ | `server-files.md` | robots.txt, sitemap.xml, .htaccess: canonical domain, compression, split caching, security headers |
480
+ | `structured-data.md` | JSON-LD for LocalBusiness, Organization, Article and BreadcrumbList |
481
+ | `search-engines.md` | Search Console (Domain vs URL prefix), Bing, submitting the sitemap, validators, busting Facebook's cache |
482
+
483
+ Templates in `assets/`: a parameterized `head.php` for static sites, a `social-meta.blade.php` component for Laravel, a commented `.htaccess`, and a `robots.txt`.
484
+
485
+ How to invoke it — in whatever words you'd use anyway:
486
+
487
+ ```
488
+ "el enlace sale como cuadro gris cuando lo mando por WhatsApp"
489
+ "why doesn't Google find this site?"
490
+ "vamos a poner el dominio en producción, ¿qué falta?"
491
+ "review the meta tags on this site"
492
+ ```
493
+
494
+ ---
495
+
455
496
  ## CLI reference
456
497
 
457
498
  ### aiskills list
@@ -16,10 +16,104 @@ import {
16
16
  const CHECK = chalk.green('✓');
17
17
  const CROSS = chalk.red('✗');
18
18
 
19
+ // Two lines of description per skill: enough to tell what a skill is for,
20
+ // short enough that the list stays a list. Everything past that is the
21
+ // skill's trigger text, which is written for the agent, not for this screen.
22
+ const MAX_DESCRIPTION_LINES = 2;
23
+
24
+ // Descriptions still need room to say something when the window is narrow.
25
+ const MIN_DESCRIPTION_WIDTH = 24;
26
+
27
+ /**
28
+ * Unquote a YAML scalar. The frontmatter of every skill here is single-quoted
29
+ * because the descriptions contain colons and apostrophes; without this, the
30
+ * quote is printed as part of the text.
31
+ * @param {string} value - Raw scalar as it appears after `description:`
32
+ * @returns {string} The value with its wrapping quotes and escapes resolved
33
+ */
34
+ function unquote(value) {
35
+ const text = value.trim();
36
+ const quote = text[0];
37
+ if ((quote !== "'" && quote !== '"') || !text.endsWith(quote) || text.length < 2) {
38
+ return text;
39
+ }
40
+ const inner = text.slice(1, -1);
41
+ // In single-quoted YAML an apostrophe is escaped by doubling it.
42
+ return quote === "'" ? inner.replace(/''/g, "'") : inner.replace(/\\"/g, '"');
43
+ }
44
+
19
45
  /**
20
- * Extract the skill's human-readable name and short description from SKILL.md.
21
- * Returns { description, installed } where description is the first sentence of
22
- * the frontmatter description, trimmed for terminal display.
46
+ * Wrap text into aligned lines, capped at maxLines with an ellipsis when it
47
+ * does not fit.
48
+ * @param {string} text - Text to wrap
49
+ * @param {number} width - Columns available for the text itself
50
+ * @param {number} maxLines - Hard cap on the number of lines
51
+ * @returns {string[]} Lines, none longer than width
52
+ */
53
+ export function wrapDescription(text, width, maxLines = MAX_DESCRIPTION_LINES) {
54
+ const columns = Math.max(width, MIN_DESCRIPTION_WIDTH);
55
+ const lines = [];
56
+ let current = '';
57
+
58
+ for (let word of text.split(/\s+/).filter(Boolean)) {
59
+ // A single word wider than the column (a long URL) is broken by hand;
60
+ // otherwise the loop below could never place it and would spin.
61
+ while (word.length > columns) {
62
+ if (current) {
63
+ lines.push(current);
64
+ current = '';
65
+ }
66
+ lines.push(word.slice(0, columns));
67
+ word = word.slice(columns);
68
+ }
69
+
70
+ if (!current) {
71
+ current = word;
72
+ } else if (current.length + 1 + word.length <= columns) {
73
+ current += ` ${word}`;
74
+ } else {
75
+ lines.push(current);
76
+ current = word;
77
+ }
78
+ }
79
+ if (current) lines.push(current);
80
+
81
+ if (lines.length <= maxLines) return lines;
82
+
83
+ const kept = lines.slice(0, maxLines);
84
+ let last = kept[maxLines - 1];
85
+ if (last.length + 1 > columns) {
86
+ last = last.slice(0, columns - 1).replace(/\s+\S*$/, '');
87
+ }
88
+ kept[maxLines - 1] = `${last.replace(/[,;:]$/, '')}…`;
89
+ return kept;
90
+ }
91
+
92
+ /**
93
+ * Extract the skill's short description from SKILL.md — the first sentence of
94
+ * the frontmatter description, unquoted and collapsed to a single line.
95
+ * @param {string} frontmatter - The YAML block between the --- fences
96
+ * @returns {string|null} The description, or null when there is none
97
+ */
98
+ export function parseDescription(frontmatter) {
99
+ // The lookahead ends the value at the next top-level key, or at the end of
100
+ // the block. `(?![\s\S])` rather than `$`, which under the /m flag would
101
+ // match the first newline and cut a description written across two lines.
102
+ const descMatch = frontmatter.match(/^description:\s*([\s\S]+?)(?=\n[A-Za-z][\w-]*:|(?![\s\S]))/m);
103
+ if (!descMatch) return null;
104
+
105
+ const full = unquote(descMatch[1]).replace(/\s+/g, ' ').trim();
106
+ if (!full) return null;
107
+
108
+ const firstSentence = full.match(/^([^]*?\.)\s/);
109
+ if (firstSentence) return firstSentence[1].trim();
110
+ return full;
111
+ }
112
+
113
+ /**
114
+ * Read a skill's description from disk.
115
+ * @param {string} skillDir - Directory holding the skill's SKILL.md
116
+ * @returns {{description: string|null, installed: boolean}}
23
117
  */
24
118
  function readSkillMetadata(skillDir) {
25
119
  const skillMd = join(skillDir, 'SKILL.md');
@@ -32,21 +126,7 @@ function readSkillMetadata(skillDir) {
32
126
  const frontmatter = content.match(/^---\n([\s\S]*?)\n---/);
33
127
  if (!frontmatter) return { description: null, installed: true };
34
128
 
35
- const descMatch = frontmatter[1].match(/description:\s*"([^"]+)"|description:\s*(.+)/);
36
- if (!descMatch) return { description: null, installed: true };
37
-
38
- const full = descMatch[1] || descMatch[2] || '';
39
- let short;
40
- const firstSentence = full.match(/^([^]*?\.)\s/);
41
- if (firstSentence) {
42
- short = firstSentence[1];
43
- } else if (full.length > 80) {
44
- const cut = full.slice(0, 80);
45
- short = cut.slice(0, cut.lastIndexOf(' ')) + '…';
46
- } else {
47
- short = full;
48
- }
49
- return { description: short.trim(), installed: true };
129
+ return { description: parseDescription(frontmatter[1]), installed: true };
50
130
  } catch {
51
131
  return { description: null, installed: true };
52
132
  }
@@ -81,14 +161,21 @@ export async function listCommand() {
81
161
  rows.push({ name, description, installed });
82
162
  }
83
163
 
164
+ // Where the description column starts: ' ' + mark + ' ' + padded name + ' '.
165
+ // Measured on the plain text, since chalk's colour codes take no columns.
166
+ const descriptionColumn = 2 + 1 + 1 + (maxNameLen + 2) + 1;
167
+ const available = (process.stdout.columns || 80) - descriptionColumn;
168
+
84
169
  for (const row of rows) {
85
170
  const mark = row.installed ? CHECK : CROSS;
86
171
  const paddedName = row.name.padEnd(maxNameLen + 2);
87
- const desc = row.description
88
- ? chalk.gray(row.description)
89
- : chalk.gray(row.installed ? '(no description)' : 'not installed');
172
+ const text = row.description || (row.installed ? '(no description)' : 'not installed');
173
+ const [first, ...rest] = wrapDescription(text, available);
90
174
 
91
- console.log(` ${mark} ${chalk.cyan(paddedName)} ${desc}`);
175
+ console.log(` ${mark} ${chalk.cyan(paddedName)} ${chalk.gray(first)}`);
176
+ for (const line of rest) {
177
+ console.log(`${' '.repeat(descriptionColumn)}${chalk.gray(line)}`);
178
+ }
92
179
  }
93
180
 
94
181
  console.log('');
@@ -8,9 +8,7 @@ import ora from 'ora';
8
8
  import {
9
9
  SKILLS,
10
10
  getPlatforms,
11
- getAgentsSkillsDir,
12
11
  } from '../config.js';
13
- import select from '../prompts/selectCancel.js';
14
12
  import checkbox, { Separator } from '../prompts/checkboxCancel.js';
15
13
  import {
16
14
  detectPlatforms,
@@ -256,7 +254,7 @@ export async function skillsCommand(options) {
256
254
  if (!removeOnly) {
257
255
  // Install skills
258
256
  spinner.start('Installing skills...');
259
- const skillsResult = await installSkills(repoDir, baseDir, skillsToInstall);
257
+ await installSkills(repoDir, baseDir, skillsToInstall);
260
258
  spinner.succeed(`${skillsToInstall.length} skill${skillsToInstall.length !== 1 ? 's' : ''} installed`);
261
259
 
262
260
  // If the user deselected some skills, sweep any previously-installed
package/lib/config.js CHANGED
@@ -30,6 +30,7 @@ export const SKILLS = [
30
30
  'audit-codebase',
31
31
  'humaniza',
32
32
  'refactoring-ui',
33
+ 'seo-launch',
33
34
  'session-log',
34
35
  'stitch-showcase',
35
36
  'vscode-extension-dev',
@@ -49,12 +49,6 @@ function toggle(item) {
49
49
  return isSelectable(item) ? { ...item, checked: !item.checked } : item;
50
50
  }
51
51
 
52
- function check(checked) {
53
- return function (item) {
54
- return isSelectable(item) ? { ...item, checked } : item;
55
- };
56
- }
57
-
58
52
  function normalizeChoices(choices) {
59
53
  return choices.map((choice) => {
60
54
  if (Separator.isSeparator(choice)) return choice;
package/lib/symlink.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { mkdirSync, existsSync } from 'fs';
7
- import { symlink, readlink, unlink, lstat } from 'fs/promises';
7
+ import { symlink, readlink } from 'fs/promises';
8
8
  import { join, dirname, relative } from 'path';
9
9
  import { copy, remove } from 'fs-extra';
10
10
  import { isWindows } from './platform.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maccesar/aiskills",
3
- "version": "1.17.1",
3
+ "version": "1.18.0",
4
4
  "description": "AI coding assistant skills for Claude Code, Gemini CLI, and Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "test": "node --test test/*.test.js",
11
- "lint": "eslint lib/**/*.js",
11
+ "lint": "eslint bin lib test",
12
12
  "format": "prettier --write lib/**/*.js"
13
13
  },
14
14
  "keywords": [
@@ -47,7 +47,9 @@
47
47
  "tar": "^7.5.7"
48
48
  },
49
49
  "devDependencies": {
50
+ "@eslint/js": "^9.39.5",
50
51
  "eslint": "^9.39.2",
52
+ "globals": "^16.5.0",
51
53
  "prettier": "^3.8.1"
52
54
  },
53
55
  "files": [
@@ -0,0 +1,91 @@
1
+ ---
2
+ name: seo-launch
3
+ description: 'Audit and then install everything a site needs to be indexed and to render a proper card when its link is shared: head tags, Open Graph and Twitter card, the 1200x630 og:image, favicon and apple-touch-icon, robots.txt, sitemap.xml, an .htaccess with one canonical domain, JSON-LD, and the Search Console handover. Works on static sites and on Laravel or plain PHP projects. Use when the user says the link shows a grey box with no preview in WhatsApp, asks why Google cannot find the site, is putting a new domain live, or asks for an SEO review, meta tags, og:image, sitemap or robots.txt — even when they never say "SEO". Not for: keyword research, writing the content itself, backlinks, paid ads, or analytics dashboards.'
4
+ allowed-tools: Read, Grep, Glob, Bash, Edit, Write, AskUserQuestion
5
+ ---
6
+
7
+ # SEO Launch
8
+
9
+ Get a site indexable and shareable: the `<head>` tags, the images the platforms actually fetch, the server files, the structured data, and the handover to Search Console.
10
+
11
+ The work has two stages separated by an explicit authorization. The separation exists because the report is the deliverable: an audit that edits while it looks produces a list of things you already changed, which is not a diagnosis and takes the decision away from the owner. Stage 1 writes nothing.
12
+
13
+ Respond in the user's language. This skill is written in English for portability; the report should match whatever language the user is writing in.
14
+
15
+ ## Read the terrain first
16
+
17
+ Three questions, answered before anything else, because each one changes what you apply and where:
18
+
19
+ 1. **What kind of project is this?** `artisan` in the root means Laravel — the tags belong in a Blade component or the layout's `<head>`, not in each view. A tree of `.html` files means a static site; look for a `partials/` directory or an SSI/PHP include before writing the same block into every page. Anything else (Astro, Next, Hugo, WordPress), find the one template that owns the `<head>` and edit that.
20
+ 2. **Is the site live?** A reachable URL means the audit can be measured instead of guessed — run the script. A domain that does not resolve yet means the audit is a read of local files only, and the verification step moves to after the deploy.
21
+ 3. **Where is the document root?** The `.htaccess`, `robots.txt` and `sitemap.xml` go where Apache serves from, which in a Laravel project is `public/` and in a static project is often `public/` too. Writing them one level up is the most common way this work silently does nothing.
22
+
23
+ Ask only what you cannot read from the repo. The domain, the business name and the phone number are the user's to give; the framework is yours to detect.
24
+
25
+ ## Stage 1 — Audit, no modifications
26
+
27
+ Do not create or edit files in this stage, including images.
28
+
29
+ 1. **Measure the live site**, if there is one:
30
+
31
+ ```bash
32
+ python3 <SKILL_DIR>/scripts/auditar_seo.py https://example.com
33
+ python3 <SKILL_DIR>/scripts/auditar_seo.py https://mysite.test --local # self-signed Herd cert
34
+ ```
35
+
36
+ Replace `<SKILL_DIR>` with the absolute "Base directory for this skill" from the system message that loaded this skill — the working directory here is the user's project, not this one, so a relative `scripts/…` resolves to nothing. The path also differs by install type (`~/.claude/plugins/cache/<plugin>/<version>/skills/seo-launch` for a plugin install, `~/.claude/skills/seo-launch` standalone), so read it rather than assuming it.
37
+
38
+ It reads the page once and checks the `<head>` tags, `robots.txt`, `sitemap.xml`, the `http→https` and `www→apex` redirects, the response headers, and whether the `og:image` exists — including its **real dimensions**, read from the file header, which is how you catch an image declared as 1200×630 that is not.
39
+
40
+ `--no-network` limits it to the markup. `--local` relaxes certificate verification and exists only for local `.test` domains; never point it at a site on the public internet, since an unverified response is not evidence of anything.
41
+
42
+ 2. **Read the local files** that own what the script found missing: the template holding the `<head>`, the document root, `.htaccess`, `robots.txt`, `sitemap.xml`.
43
+
44
+ 3. **Report findings as a table** — item, severity, what breaks because of it:
45
+
46
+ | Finding | Severity | Consequence |
47
+ | --- | --- | --- |
48
+ | No `og:image` | High | Every shared link renders as a grey rectangle |
49
+ | `<title>` is 74 characters | Medium | Google truncates it at ~60 and the tail is lost |
50
+
51
+ Severity is what breaks, not how hard it is to fix. Anything that makes the site invisible (no `sitemap.xml`, `noindex` in production, two domains serving 200) or unshareable (no `og:image`, relative image URLs) is High. Nice-to-haves (`twitter:site`, `theme-color`) are Low, and say so rather than padding the list.
52
+
53
+ 4. **Stop.** Present the table and what you would do about it. Do not start fixing.
54
+
55
+ If the user asked up front to "audit and fix", still show the table first — approving it takes seconds, and the content decisions inside it (the description text, which pages go in the sitemap, whether keywords are wanted) are not yours to invent.
56
+
57
+ ## Stage 2 — Authorized implementation
58
+
59
+ Start only once the user approves. Work in this order, because each step depends on the previous one being real:
60
+
61
+ 1. **Tags.** Install the `<head>` block from the right template in `assets/`, parameterized — one place holds the domain, one place holds the fallback image. Never paste the same literal block into five pages.
62
+ 2. **Images.** Generate the `og:image`, the favicon and the `apple-touch-icon` per `references/images.md`. Verify each file exists and measures what the tags declare.
63
+ 3. **Server files.** `robots.txt` with the `Sitemap:` line, `sitemap.xml` with the real pages, `.htaccess` with the canonical domain, caching and security headers.
64
+ 4. **Structured data.** The JSON-LD block, with confirmed data only.
65
+ 5. **Verify against the live site**, not against the repo. Re-run the script and expect the findings you fixed to be gone. If the site deploys by SFTP-on-save, the files may already be up; if it deploys by git, they are not until it ships — check, do not infer.
66
+ 6. **Search engines.** The Search Console steps in `references/search-engines.md` need a browser and the domain's DNS. Hand the user the exact sequence; you cannot do this part for them.
67
+
68
+ ## Where to look
69
+
70
+ | What the work touches | Read |
71
+ | --- | --- |
72
+ | `<title>`, description, canonical, Open Graph, Twitter card | `references/head-tags.md` |
73
+ | `og:image`, favicon, `apple-touch-icon`, ImageMagick commands | `references/images.md` |
74
+ | `robots.txt`, `sitemap.xml`, `.htaccess`, caching, headers | `references/server-files.md` |
75
+ | JSON-LD: `LocalBusiness`, `Organization`, `Article`, breadcrumbs | `references/structured-data.md` |
76
+ | Search Console, Bing, submitting the sitemap, validators | `references/search-engines.md` |
77
+
78
+ Templates in `assets/`: `head.php` (static site, parameterized include), `social-meta.blade.php` (Laravel component), `htaccess-static`, `robots.txt`.
79
+
80
+ ## Hard rules
81
+
82
+ These are the ones that break on their own, each with the reason it matters:
83
+
84
+ - **`og:image` and `canonical` carry absolute URLs.** Facebook, WhatsApp and X discard an image with a relative path — the tag is present, valid, and produces no thumbnail at all.
85
+ - **The `og:image` is a JPEG, not WebP.** Several preview clients still cannot decode WebP and fall back to no image; the 30 KB you save are not worth an invisible link.
86
+ - **The `apple-touch-icon` is a flat PNG with a white background and no alpha.** iOS does not honour the alpha channel here, it fills transparency with black, and a dark logo disappears into it.
87
+ - **The `<title>` stays under ~60 characters.** Past that Google truncates it in the results, and what gets cut is the tail — which is where the brand usually sits.
88
+ - **`og:image:width` and `og:image:height` must match the file.** They exist so the client can lay out the card before downloading; wrong values render a cropped or stretched card and nothing reports an error.
89
+ - **The canonical redirects go in the server config, not only in the tag.** `rel=canonical` is a hint Google may ignore; the 301 is the one that settles it.
90
+ - **JSON-LD carries confirmed data only.** Google cross-checks it against other sources — an invented address or opening hours costs you trust in the whole block, and there is no warning when that happens.
91
+ - **Changing the `og:image` requires busting Facebook's cache.** It caches the card for days; without the debugger refresh, the "fixed" preview keeps showing the old image and it looks like the fix failed.
@@ -0,0 +1,85 @@
1
+ <?php
2
+ /**
3
+ * The full <head>, shared by every page of a static site.
4
+ *
5
+ * Include it once per page: <?php $titulo = '…'; $descripcion = '…'; $ruta = ''; include '../partials/head.php'; ?>
6
+ *
7
+ * Variables it expects before the include:
8
+ * $titulo (required) the <title>
9
+ * $descripcion (required) the meta description
10
+ * $ruta (required) path from the root, '' for the home page
11
+ * $og_titulo (optional) defaults to $titulo — the card usually drops the brand,
12
+ * because og:site_name already prints it right below
13
+ * $og_desc (optional) defaults to $descripcion
14
+ * $og_img (optional) absolute URL, overrides the site-wide card image
15
+ * $og_img_alt (optional) alt text for the card image
16
+ * $twitter_desc (optional) X truncates earlier than Facebook; sometimes a
17
+ * shorter version is worth writing
18
+ * $keywords (optional) Google has ignored these since 2009
19
+ * $jsonld (optional) an already-formatted structured data block
20
+ *
21
+ * The URLs are absolute on purpose: Facebook, WhatsApp and X discard images
22
+ * with a relative path. The base lives here, in one place.
23
+ */
24
+
25
+ const BASE = 'https://example.com/';
26
+ const OG_IMAGEN = BASE . 'images/og-image.jpg';
27
+ const OG_IMAGEN_ALT = 'Describe the card image in one sentence.';
28
+ const SITIO = 'Site Name';
29
+
30
+ $url = BASE . ($ruta ?? '');
31
+ $ogTitulo = $og_titulo ?? $titulo;
32
+ $ogDesc = $og_desc ?? $descripcion;
33
+ $ogImagen = $og_img ?? OG_IMAGEN;
34
+ $ogImgAlt = $og_img_alt ?? OG_IMAGEN_ALT;
35
+ $twitterDesc = $twitter_desc ?? $ogDesc;
36
+
37
+ function attr(string $valor): string
38
+ {
39
+ return htmlspecialchars($valor, ENT_QUOTES, 'UTF-8');
40
+ }
41
+ ?>
42
+ <meta charset="utf-8"/>
43
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
44
+ <title><?= attr($titulo) ?></title>
45
+ <meta content="<?= attr($descripcion) ?>" name="description"/>
46
+ <link href="<?= attr($url) ?>" rel="canonical"/>
47
+ <meta content="index, follow" name="robots"/>
48
+ <meta content="#0f2d52" name="theme-color"/>
49
+ <?php if (! empty($keywords)): ?>
50
+ <meta content="<?= attr($keywords) ?>" name="keywords"/>
51
+ <?php endif; ?>
52
+ <!-- An SVG favicon has no intrinsic size, so it draws sharp in the tab, in the
53
+ bookmarks bar and at any density. iOS ignores it for the home screen — that
54
+ is what the PNG below is for. -->
55
+ <link href="<?= BASE ?>images/logo-symbol.svg" rel="icon" type="image/svg+xml"/>
56
+ <link href="<?= BASE ?>images/apple-touch-icon.png" rel="apple-touch-icon" sizes="180x180"/>
57
+ <!-- Without this, iOS puts the whole <title> under the icon and cuts it. -->
58
+ <meta content="<?= SITIO ?>" name="apple-mobile-web-app-title"/>
59
+ <!-- Open Graph: Facebook, WhatsApp, LinkedIn -->
60
+ <meta content="website" property="og:type"/>
61
+ <meta content="es_MX" property="og:locale"/>
62
+ <meta content="<?= SITIO ?>" property="og:site_name"/>
63
+ <meta content="<?= attr($url) ?>" property="og:url"/>
64
+ <meta content="<?= attr($ogTitulo) ?>" property="og:title"/>
65
+ <meta content="<?= attr($ogDesc) ?>" property="og:description"/>
66
+ <meta content="<?= attr($ogImagen) ?>" property="og:image"/>
67
+ <meta content="<?= attr($ogImagen) ?>" property="og:image:secure_url"/>
68
+ <meta content="image/jpeg" property="og:image:type"/>
69
+ <!-- These must match the real file. A wrong value renders a cropped card and
70
+ nothing reports an error. -->
71
+ <meta content="1200" property="og:image:width"/>
72
+ <meta content="630" property="og:image:height"/>
73
+ <meta content="<?= attr($ogImgAlt) ?>" property="og:image:alt"/>
74
+ <!-- X. summary_large_image is the one that shows the photo full width. -->
75
+ <meta content="summary_large_image" name="twitter:card"/>
76
+ <meta content="<?= attr($ogTitulo) ?>" name="twitter:title"/>
77
+ <meta content="<?= attr($twitterDesc) ?>" name="twitter:description"/>
78
+ <meta content="<?= attr($ogImagen) ?>" name="twitter:image"/>
79
+ <meta content="<?= attr($ogImgAlt) ?>" name="twitter:image:alt"/>
80
+ <?php if (! empty($jsonld)): ?>
81
+ <script type="application/ld+json">
82
+ <?= $jsonld ?>
83
+ </script>
84
+ <?php endif; ?>
85
+ <link href="<?= BASE ?>css/app.css" rel="stylesheet"/>
@@ -0,0 +1,86 @@
1
+ # .htaccess for a static site served directly by Apache.
2
+ #
3
+ # Goes in the document root. A Laravel project already ships an .htaccess with
4
+ # the front-controller rewrite: add these blocks to that file, do not replace it.
5
+ #
6
+ # Replace example.com with the real domain in the two RewriteRules below.
7
+
8
+ # ---------------------------------------------------------------- hidden files
9
+ # Any path starting with a dot returns 404. Defence in depth for the day a
10
+ # deploy tool uploads .vscode/ or .git/ into the document root.
11
+ #
12
+ # The (?!well-known/) exception is NOT optional: AutoSSL renews the certificate
13
+ # by placing a file in /.well-known/acme-challenge/. Without it, validation
14
+ # fails silently and you find out when the certificate expires.
15
+ RedirectMatch 404 /\.(?!well-known/)
16
+
17
+ # Formats that only exist to work on the site, never to be served.
18
+ <FilesMatch "\.(md|json|lock|yml|yaml|sh|sql|bak|log)$">
19
+ Require all denied
20
+ </FilesMatch>
21
+
22
+ # ------------------------------------------------------------ canonical domain
23
+ # Without this, http://, https://www. and https:// all serve the same content
24
+ # with a 200 — duplicate content, with the authority split three ways.
25
+ <IfModule mod_rewrite.c>
26
+ RewriteEngine On
27
+
28
+ # The double condition is not redundant: behind a proxy or CDN, %{HTTPS} can
29
+ # arrive off even though the request was secure. Testing only that variable
30
+ # produces an infinite redirect loop.
31
+ RewriteCond %{HTTPS} !=on
32
+ RewriteCond %{HTTP:X-Forwarded-Proto} !=https
33
+ RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
34
+
35
+ # Pick apex or www and stay consistent with the canonical tag and the sitemap.
36
+ RewriteCond %{HTTP_HOST} ^www\.example\.com$ [NC]
37
+ RewriteRule ^ https://example.com%{REQUEST_URI} [R=301,L]
38
+ </IfModule>
39
+
40
+ # ------------------------------------------------------------------ compression
41
+ # image/svg+xml is the one usually missing from a default config. An SVG is
42
+ # text and compresses enormously.
43
+ <IfModule mod_deflate.c>
44
+ AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml \
45
+ application/javascript application/json image/svg+xml
46
+ </IfModule>
47
+
48
+ # ----------------------------------------------------------------------- cache
49
+ <IfModule mod_headers.c>
50
+ # Images and fonts: a year, immutable — the browser will not even ask whether
51
+ # they changed. What makes this safe is renaming a file when its content does.
52
+ <FilesMatch "\.(webp|jpe?g|png|gif|svg|ico|woff2)$">
53
+ Header set Cache-Control "public, max-age=31536000, immutable"
54
+ </FilesMatch>
55
+
56
+ # HTML, CSS and JS: revalidate every time. A stylesheet always named app.css
57
+ # cannot be cached long — recompile it and returning visitors keep the old one
58
+ # until it expires, with new classes silently missing and no console error.
59
+ # This flips only when the build fingerprints filenames (app.4f3a1c.css).
60
+ <FilesMatch "\.(html|css|js)$">
61
+ Header set Cache-Control "public, max-age=0, must-revalidate"
62
+ </FilesMatch>
63
+
64
+ # ------------------------------------------------------------------ security
65
+ Header always set X-Content-Type-Options "nosniff"
66
+ Header always set Referrer-Policy "strict-origin-when-cross-origin"
67
+ Header always set X-Frame-Options "SAMEORIGIN"
68
+ Header always set Permissions-Policy "geolocation=(), microphone=(), camera=()"
69
+
70
+ # Content-Security-Policy is deliberately absent. A miscalibrated CSP strips
71
+ # the site of its styles, and any page with an inline <script> or a
72
+ # third-party font needs it tuned by hand. Add it once those are self-hosted
73
+ # and you can test it.
74
+ </IfModule>
75
+
76
+ # ------------------------------------------------------------------ MIME types
77
+ # An SVG served as text/plain renders blank — including the favicon.
78
+ <IfModule mod_mime.c>
79
+ AddType image/svg+xml .svg
80
+ AddType image/webp .webp
81
+ AddType application/xml .xml
82
+ </IfModule>
83
+
84
+ # --------------------------------------------------------------- default files
85
+ DirectoryIndex index.html index.php
86
+ Options -Indexes
@@ -0,0 +1,17 @@
1
+ # robots.txt — goes in the document root, reachable at https://example.com/robots.txt
2
+ #
3
+ # The point of this file here is the Sitemap: line: it is the only standard
4
+ # place to point at the sitemap without submitting it by hand to each engine.
5
+ #
6
+ # It is public. Do not list paths you would rather nobody found — a Disallow
7
+ # reads as a directory of interesting URLs. It keeps polite crawlers out; it is
8
+ # not access control.
9
+
10
+ User-agent: *
11
+ Allow: /
12
+
13
+ # Endpoints that are not pages: form handlers, internal APIs, debug routes.
14
+ # Disallow: /send.php
15
+
16
+ # The URL must be absolute, and must match the canonical domain.
17
+ Sitemap: https://example.com/sitemap.xml