commit 6ec3196ecc6d26379deda4224c1e5dfd571e0525 Author: Zhongwei Li Date: Sun Nov 30 08:48:52 2025 +0800 Initial commit diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..877d7b1 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,34 @@ +{ + "name": "claudekit-skills", + "description": "ClaudeKit Skills - Comprehensive collection of specialized agent skills, commands, and agents for authentication, AI/ML, web development, cloud platforms, databases, debugging, documentation, problem-solving, and more", + "version": "main", + "author": { + "name": "mrgoonie", + "url": "https://github.com/mrgoonie" + }, + "skills": [ + "./skills/ai-multimodal", + "./skills/better-auth", + "./skills/chrome-devtools", + "./skills/claude-code", + "./skills/code-review", + "./skills/common", + "./skills/databases", + "./skills/debugging", + "./skills/devops", + "./skills/docs-seeker", + "./skills/document-skills", + "./skills/google-adk-python", + "./skills/mcp-builder", + "./skills/mcp-management", + "./skills/media-processing", + "./skills/problem-solving", + "./skills/repomix", + "./skills/sequential-thinking", + "./skills/shopify", + "./skills/skill-creator", + "./skills/template-skill", + "./skills/ui-styling", + "./skills/web-frameworks" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cd9f7b --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# claudekit-skills + +ClaudeKit Skills - Comprehensive collection of specialized agent skills, commands, and agents for authentication, AI/ML, web development, cloud platforms, databases, debugging, documentation, problem-solving, and more diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..c539038 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,1813 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:rafaelcalleja/claude-market-place:plugins/claudekit-skills", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "fc08264fe92f3b17320b08ef5bd9ba2eb5808228", + "treeHash": "67012aff99a0fb8498e5a2234fd4ab558e85c60d85822d5ab8a35ad610581bf2", + "generatedAt": "2025-11-28T10:27:45.889460Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "claudekit-skills", + "description": "ClaudeKit Skills - Comprehensive collection of specialized agent skills, commands, and agents for authentication, AI/ML, web development, cloud platforms, databases, debugging, documentation, problem-solving, and more", + "version": "main" + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "8de63066125e7a13e6938373eedd546e494591262b07c5756d62030187f45aa2" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "c7368b180604e604b9094fdf93f3e0c562d724eb4fbd56d0f299532b5ec29a59" + }, + { + "path": "skills/document-skills/xlsx/recalc.py", + "sha256": "ab1ef0c94536bb23b6c6a3d32769b0401ec3cc85e73c247d574dd84ec73af15d" + }, + { + "path": "skills/document-skills/xlsx/SKILL.md", + "sha256": "020ccdb5932257b66c638ec1157ea248d57fa52c8c01f1f68b559b5970c7df35" + }, + { + "path": "skills/document-skills/xlsx/LICENSE.txt", + "sha256": "79f6d8f5b427252fa3b1c11ecdbdb6bf610b944f7530b4de78f770f38741cfaa" + }, + { + "path": "skills/document-skills/pdf/reference.md", + "sha256": "03a5f964f8abecbbe156f363356e927e864d7ee964f1012c84ee1bfc8acbeb95" + }, + { + "path": "skills/document-skills/pdf/forms.md", + "sha256": "0ab10e9095deb1c1f9f79eb04254589f55c1d16e095cb53191e03f9fc3184449" + }, + { + "path": "skills/document-skills/pdf/SKILL.md", + "sha256": "38d8559d4899602f82f3560052132aa0c40cfca80203037729756bdb4fb8e0cb" + }, + { + "path": "skills/document-skills/pdf/LICENSE.txt", + "sha256": "79f6d8f5b427252fa3b1c11ecdbdb6bf610b944f7530b4de78f770f38741cfaa" + }, + { + "path": "skills/document-skills/pdf/scripts/fill_fillable_fields.py", + "sha256": "65b3e41969707022283a313a4cf9696d31793cbe255dffe13370e75abda448a7" + }, + { + "path": "skills/document-skills/pdf/scripts/convert_pdf_to_images.py", + "sha256": "095a0105a718af75ede309cb03f84a20c81d17f1727f7686fd4b294f1f40294f" + }, + { + "path": "skills/document-skills/pdf/scripts/extract_form_field_info.py", + "sha256": "9db1a2720cf54223cdc4bf797080c70f4e0d27288d9f400e066c14524519021d" + }, + { + "path": "skills/document-skills/pdf/scripts/check_bounding_boxes.py", + "sha256": "eb2a5f79c8aa10c57b5867e1f0fc75b52a68b1218442ef9d838dfb4b9eedc6f4" + }, + { + "path": "skills/document-skills/pdf/scripts/check_bounding_boxes_test.py", + "sha256": "f95dca01a8b79aafd152511e9f7bf2bbcd606dde1be77d691f03a18624e002ca" + }, + { + "path": "skills/document-skills/pdf/scripts/create_validation_image.py", + "sha256": "89675be66b48925d7b498eb9454521c78cf9e9ff188ebf094934b598550effe5" + }, + { + "path": "skills/document-skills/pdf/scripts/fill_pdf_form_with_annotations.py", + "sha256": "599d6f307edb4ee6b837f21d0ea860c41c22246e270b45d6bc750c5b87c86ce0" + }, + { + "path": "skills/document-skills/pdf/scripts/check_fillable_fields.py", + "sha256": "250d5aa4e8451d6a83d17d3550c14e6c844ac347145f916ebf7980b118312b41" + }, + { + "path": "skills/document-skills/pptx/ooxml.md", + "sha256": "09868e9f1786765421ecf3f0f49c77006738efda82a76df43ed87f7a9bfe2467" + }, + { + "path": "skills/document-skills/pptx/SKILL.md", + "sha256": "b6f25545bfb358739f1532f793458b5dbc87ee009933cb7c306b2d951ab6617f" + }, + { + "path": "skills/document-skills/pptx/html2pptx.md", + "sha256": "f08ed7580969b796d9cd5ade93e2cdee981dcaf13cc5eb12e8d4a3700c2d6047" + }, + { + "path": "skills/document-skills/pptx/LICENSE.txt", + "sha256": "79f6d8f5b427252fa3b1c11ecdbdb6bf610b944f7530b4de78f770f38741cfaa" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd", + "sha256": "842e7163409c8d74f4d7088a8bc99500d80bc75332681a0980055b08f374a604" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2012.xsd", + "sha256": "0fa75578a000439a7988ba0c59fdc69f774bbd416cbacc14d07125b3f686cb74" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2010.xsd", + "sha256": "568b26ee156cb9549aa439ca2158965f77b7c1602b7e0316f40ac6cf586e35f2" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cid-2016.xsd", + "sha256": "127ca209fa73d7cb708449cb355c871867948a96e4a74f7bf5811ef62d17991d" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-symex-2015.xsd", + "sha256": "16f6f8072249f431370723c2cd8974672e0d9c897e00e97dd918079df934871b" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cex-2018.xsd", + "sha256": "fddc2b880cabb9005aebbc7e783e53c19fec1c03df7d0e2f2076a33a0fdfd081" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2018.xsd", + "sha256": "be0ff793a22dd31384650c3a4da14c2fa8062751c2e97b0e5ee852bda13c60ad" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/mce/mc.xsd", + "sha256": "3a37e461ecf5a8670fdec34029703401f8728ab9c96ec1739a6ae58d55212413" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd", + "sha256": "451958454e8588dfc7cd945981ada142ca06ff3307937f5700df059c2b307fa8" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd", + "sha256": "f565adfef5a502044abc3a9153e157edc25af78304d335994afb958874b15e26" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd", + "sha256": "9e0b7209fc69ab11987900404540969976000c5ebe4d4f58c43dc3842886bf3a" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd", + "sha256": "6de111e11403f7cd49027400755bae0ea1cabef2815f09bd40a24f0017613b24" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd", + "sha256": "133c9f64a5c5d573b78d0a474122b22506d8eadb5e063f67cdbbb8fa2f161d0e" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd", + "sha256": "585bedc1313b40888dcc544cb74cd939a105ee674f3b1d3aa1cc6d34f70ff155" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd", + "sha256": "0d103b99a4a8652f8871552a69d42d2a3760ac6a5e3ef02d979c4273257ff6a4" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd", + "sha256": "d173c3e5d61e42e2e3a97226c632fd2ab7cc481fc4e492365b87024ab546daff" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd", + "sha256": "5cb76dabd8b97d1e9308a1700b90c20139be4d50792d21a7f09789f5cccd6026" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd", + "sha256": "41b93bd8857cc68b1e43be2806a872d736a9bdd6566900062d8fdb57d7bbb354" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd", + "sha256": "3fd0586f2637b98bb9886f0e0b67d89e1cc987c2d158cc7deb5f5b9890ced412" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd", + "sha256": "beffeed56945c22a77440122c8bdc426f3fcbe7f3b12ea0976c770d1f8d54578" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd", + "sha256": "6bdeb169c3717eb01108853bd9fc5a3750fb1fa5b82abbdd854d49855a40f519" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd", + "sha256": "c2dd9f61f892deae6acd8d20771ea79b12018af25f3bf8d06639c8542d218cfd" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd", + "sha256": "5d389d42befbebd91945d620242347caecd3367f9a3a7cf8d97949507ae1f53c" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd", + "sha256": "29b254ee0d10414a8504b5a08149c7baec35a60d5ff607d6b3f492aa36815f40" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd", + "sha256": "5375417f0f5394b8dd1a7035b9679151f19a6b65df309dec10cfb4a420cb00e9" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + "sha256": "9c085407751b9061c1f996f6c39ce58451be22a8d334f09175f0e89e42736285" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd", + "sha256": "b4532b6d258832953fbb3ee4c711f4fe25d3faf46a10644b2505f17010d01e88" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd", + "sha256": "e2abacbb9a55ce1365f8961bc1b1395bbc811e512b111000d8c333f98458dece" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd", + "sha256": "bdad416b096b61d37b71603b2c949484f9070c830bdaeba93bf35e15c8900614" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd", + "sha256": "475dcae1e7d1ea46232db6f8481040c15e53a52a3c256831d3df204212b0e831" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd", + "sha256": "0b364451dc36a48dd6dae0f3b6ada05fd9b71e5208211f8ee5537d7e51a587e2" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "sha256": "bc92e36ccd233722d4c5869bec71ddc7b12e2df56059942cce5a39065cc9c368" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd", + "sha256": "f5ee623b08b6a66935e5aced2f5d8ad0fc71bf9e8e833cd490150c0fa94b8763" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd", + "sha256": "a539aa2fb154fa50e0f5cc97e6ad7cbc66f8ec3e3746f61ec6a8b0d5d15ecdf2" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd", + "sha256": "12264f3c03d738311cd9237d212f1c07479e70f0cbe1ae725d29b36539aef637" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd", + "sha256": "0ef4bb354ff44b923564c4ddbdda5987919d220225129ec94614a618ceafc281" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd", + "sha256": "7b5b7413e2c895b1e148e82e292a117d53c7ec65b0696c992edca57b61b4a74b" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd", + "sha256": "3213ef1631606250f5010b42cad7ef716f7c59426367798e33c374c0ec391d3a" + }, + { + "path": "skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd", + "sha256": "3c6709101c6aaa82888df5d8795c33f9e857196790eb320d9194e64be2b6bdd8" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/pack.py", + "sha256": "6fe762f45aff8c63fd95b9fcb1337b28921d6fa454e18a0e8158d4c8708d6d00" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/validate.py", + "sha256": "1ec252de8b14b07d16966c48906ccb1c45c68bcd23557ad31d8c50a27f5f8c0f" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/unpack.py", + "sha256": "0bd17f76a1a4c388aba42c6d1d39015fa84e405c3e0692397fe12762bd632b58" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/validation/docx.py", + "sha256": "e65d6cda0525866a24cc847b2e883bd2416ae6f87b3f5b9e2784dfbb0ec13093" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/validation/__init__.py", + "sha256": "83e0f035c5abea238d3f2c3968afbd511ed022b527b7c9cb60a9434cc34ff987" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/validation/redlining.py", + "sha256": "97abfdff4f08f43f9a4bb5c8a2f8fd483398b5b339592724e8635153b5507967" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/validation/pptx.py", + "sha256": "00bf2623da1177b3948143a4ade2f1cda7cb389dee31960861913fa42ef1b00f" + }, + { + "path": "skills/document-skills/pptx/ooxml/scripts/validation/base.py", + "sha256": "f2c70d481613456e32b43869d1604b05c236c8da34b5b3967677a661cac7ba63" + }, + { + "path": "skills/document-skills/pptx/scripts/html2pptx.js", + "sha256": "c675d09a54d6a002e8ca5917b9d24a6568aa8d455bb7abeb212d4f564dd07a34" + }, + { + "path": "skills/document-skills/pptx/scripts/thumbnail.py", + "sha256": "c21fd950b6ada7bd2f029885d3e56bc66b7ff061cc8404c492eb301664aa9e5d" + }, + { + "path": "skills/document-skills/pptx/scripts/rearrange.py", + "sha256": "c04ac37916f398ba621b2d9e1e4c1a69225eaad6d7fb0ad116c237ddeb1b2b68" + }, + { + "path": "skills/document-skills/pptx/scripts/inventory.py", + "sha256": "adead8fe6270e520c397cec9fbee4d606ab10bb80f749e018b42ec894c60d2e5" + }, + { + "path": "skills/document-skills/pptx/scripts/replace.py", + "sha256": "8a590747551be847a904e3296fb2f35aa4e7feeb4970a61596c2375306462820" + }, + { + "path": "skills/document-skills/docx/ooxml.md", + "sha256": "a16f922797eeaa3670ea31c1e49d15b799613d03f39445c857a5dd3221aa3597" + }, + { + "path": "skills/document-skills/docx/docx-js.md", + "sha256": "83b4a2f88d058a10509fbc0b3b12b6933c407805f4d4afc955cd3fb939c16428" + }, + { + "path": "skills/document-skills/docx/SKILL.md", + "sha256": "0bd90681fcab2e282025ee14acf508b60bbd6c41ac6c3bf83c0dc14d52c37933" + }, + { + "path": "skills/document-skills/docx/LICENSE.txt", + "sha256": "79f6d8f5b427252fa3b1c11ecdbdb6bf610b944f7530b4de78f770f38741cfaa" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd", + "sha256": "842e7163409c8d74f4d7088a8bc99500d80bc75332681a0980055b08f374a604" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-2012.xsd", + "sha256": "0fa75578a000439a7988ba0c59fdc69f774bbd416cbacc14d07125b3f686cb74" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-2010.xsd", + "sha256": "568b26ee156cb9549aa439ca2158965f77b7c1602b7e0316f40ac6cf586e35f2" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-cid-2016.xsd", + "sha256": "127ca209fa73d7cb708449cb355c871867948a96e4a74f7bf5811ef62d17991d" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-symex-2015.xsd", + "sha256": "16f6f8072249f431370723c2cd8974672e0d9c897e00e97dd918079df934871b" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-cex-2018.xsd", + "sha256": "fddc2b880cabb9005aebbc7e783e53c19fec1c03df7d0e2f2076a33a0fdfd081" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/microsoft/wml-2018.xsd", + "sha256": "be0ff793a22dd31384650c3a4da14c2fa8062751c2e97b0e5ee852bda13c60ad" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/mce/mc.xsd", + "sha256": "3a37e461ecf5a8670fdec34029703401f8728ab9c96ec1739a6ae58d55212413" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd", + "sha256": "451958454e8588dfc7cd945981ada142ca06ff3307937f5700df059c2b307fa8" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd", + "sha256": "f565adfef5a502044abc3a9153e157edc25af78304d335994afb958874b15e26" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd", + "sha256": "9e0b7209fc69ab11987900404540969976000c5ebe4d4f58c43dc3842886bf3a" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd", + "sha256": "6de111e11403f7cd49027400755bae0ea1cabef2815f09bd40a24f0017613b24" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd", + "sha256": "133c9f64a5c5d573b78d0a474122b22506d8eadb5e063f67cdbbb8fa2f161d0e" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd", + "sha256": "585bedc1313b40888dcc544cb74cd939a105ee674f3b1d3aa1cc6d34f70ff155" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd", + "sha256": "0d103b99a4a8652f8871552a69d42d2a3760ac6a5e3ef02d979c4273257ff6a4" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd", + "sha256": "d173c3e5d61e42e2e3a97226c632fd2ab7cc481fc4e492365b87024ab546daff" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd", + "sha256": "5cb76dabd8b97d1e9308a1700b90c20139be4d50792d21a7f09789f5cccd6026" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd", + "sha256": "41b93bd8857cc68b1e43be2806a872d736a9bdd6566900062d8fdb57d7bbb354" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd", + "sha256": "3fd0586f2637b98bb9886f0e0b67d89e1cc987c2d158cc7deb5f5b9890ced412" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd", + "sha256": "beffeed56945c22a77440122c8bdc426f3fcbe7f3b12ea0976c770d1f8d54578" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd", + "sha256": "6bdeb169c3717eb01108853bd9fc5a3750fb1fa5b82abbdd854d49855a40f519" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd", + "sha256": "c2dd9f61f892deae6acd8d20771ea79b12018af25f3bf8d06639c8542d218cfd" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd", + "sha256": "5d389d42befbebd91945d620242347caecd3367f9a3a7cf8d97949507ae1f53c" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd", + "sha256": "29b254ee0d10414a8504b5a08149c7baec35a60d5ff607d6b3f492aa36815f40" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd", + "sha256": "5375417f0f5394b8dd1a7035b9679151f19a6b65df309dec10cfb4a420cb00e9" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + "sha256": "9c085407751b9061c1f996f6c39ce58451be22a8d334f09175f0e89e42736285" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd", + "sha256": "b4532b6d258832953fbb3ee4c711f4fe25d3faf46a10644b2505f17010d01e88" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd", + "sha256": "e2abacbb9a55ce1365f8961bc1b1395bbc811e512b111000d8c333f98458dece" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd", + "sha256": "bdad416b096b61d37b71603b2c949484f9070c830bdaeba93bf35e15c8900614" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd", + "sha256": "475dcae1e7d1ea46232db6f8481040c15e53a52a3c256831d3df204212b0e831" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd", + "sha256": "0b364451dc36a48dd6dae0f3b6ada05fd9b71e5208211f8ee5537d7e51a587e2" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "sha256": "bc92e36ccd233722d4c5869bec71ddc7b12e2df56059942cce5a39065cc9c368" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd", + "sha256": "f5ee623b08b6a66935e5aced2f5d8ad0fc71bf9e8e833cd490150c0fa94b8763" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd", + "sha256": "a539aa2fb154fa50e0f5cc97e6ad7cbc66f8ec3e3746f61ec6a8b0d5d15ecdf2" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd", + "sha256": "12264f3c03d738311cd9237d212f1c07479e70f0cbe1ae725d29b36539aef637" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd", + "sha256": "0ef4bb354ff44b923564c4ddbdda5987919d220225129ec94614a618ceafc281" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd", + "sha256": "7b5b7413e2c895b1e148e82e292a117d53c7ec65b0696c992edca57b61b4a74b" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd", + "sha256": "3213ef1631606250f5010b42cad7ef716f7c59426367798e33c374c0ec391d3a" + }, + { + "path": "skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd", + "sha256": "3c6709101c6aaa82888df5d8795c33f9e857196790eb320d9194e64be2b6bdd8" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/pack.py", + "sha256": "6fe762f45aff8c63fd95b9fcb1337b28921d6fa454e18a0e8158d4c8708d6d00" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/validate.py", + "sha256": "1ec252de8b14b07d16966c48906ccb1c45c68bcd23557ad31d8c50a27f5f8c0f" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/unpack.py", + "sha256": "0bd17f76a1a4c388aba42c6d1d39015fa84e405c3e0692397fe12762bd632b58" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/validation/docx.py", + "sha256": "e65d6cda0525866a24cc847b2e883bd2416ae6f87b3f5b9e2784dfbb0ec13093" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/validation/__init__.py", + "sha256": "83e0f035c5abea238d3f2c3968afbd511ed022b527b7c9cb60a9434cc34ff987" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/validation/redlining.py", + "sha256": "97abfdff4f08f43f9a4bb5c8a2f8fd483398b5b339592724e8635153b5507967" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/validation/pptx.py", + "sha256": "00bf2623da1177b3948143a4ade2f1cda7cb389dee31960861913fa42ef1b00f" + }, + { + "path": "skills/document-skills/docx/ooxml/scripts/validation/base.py", + "sha256": "f2c70d481613456e32b43869d1604b05c236c8da34b5b3967677a661cac7ba63" + }, + { + "path": "skills/document-skills/docx/scripts/__init__.py", + "sha256": "83e262a425814b72add701272b99ddcf9635251c5d4672bf9fc38d2b03f00d85" + }, + { + "path": "skills/document-skills/docx/scripts/document.py", + "sha256": "65f8569034a5893bd5ef0654be5168774fe81c0407b0c4ec80992db9fff91c0c" + }, + { + "path": "skills/document-skills/docx/scripts/utilities.py", + "sha256": "62a4b689056501b91e2df2d1f4e6335818e421c7390e48050717ea8f461a0ed0" + }, + { + "path": "skills/document-skills/docx/scripts/templates/comments.xml", + "sha256": "87e218a3a295016ec855f2cd74495c416072f29c4846e86b527aec0a4d93ba21" + }, + { + "path": "skills/document-skills/docx/scripts/templates/commentsExtensible.xml", + "sha256": "af5d057e16462ca172cea845e502bafb4f3e1b474a8d5848ffe92214853a4935" + }, + { + "path": "skills/document-skills/docx/scripts/templates/commentsExtended.xml", + "sha256": "86bf401354c111102033ed147763faccb82479598f17777a3384c2f3e9fa0014" + }, + { + "path": "skills/document-skills/docx/scripts/templates/commentsIds.xml", + "sha256": "20168f7b237af091332f8348c548eb7f755f583185bb198359c5978155099d67" + }, + { + "path": "skills/document-skills/docx/scripts/templates/people.xml", + "sha256": "61db9900b579acd4c4f84ff7f40df47e77e9e780c40d5f5ef6a7beba41d62ec5" + }, + { + "path": "skills/claude-code/llms.txt", + "sha256": "50010ba6e9dc1ffe6a20a50e55f12c1b0cc00204f756770a9b9ac6f961f7b92b" + }, + { + "path": "skills/claude-code/SKILL.md", + "sha256": "196133ca0328dd48ed5bf62fdeea0f1a37b41dcdece1b3b83fa0baf09fb51905" + }, + { + "path": "skills/claude-code/skill.json", + "sha256": "60cedfbc6c8deaca4816199da27709aaedcf1418b2404d8c6878444f7c8d53b9" + }, + { + "path": "skills/claude-code/references/slash-commands.md", + "sha256": "e7542139e4a2629389f6ce142290224017326f176f04c6c6b90cb8f26cc24262" + }, + { + "path": "skills/claude-code/references/ide-integration.md", + "sha256": "1e599fd5abe9322d22f14e7e178c7aca42292a1fc493e67eb634fcc7c712c56f" + }, + { + "path": "skills/claude-code/references/best-practices.md", + "sha256": "efabe665fd9ba46270e937503648a0f0f0cf6302142ca8ab5e128d66031adf56" + }, + { + "path": "skills/claude-code/references/troubleshooting.md", + "sha256": "39c73df67cce1001f0868671ef99fe19e3607dc40bd1fb6aadc360a66ad0c9a0" + }, + { + "path": "skills/claude-code/references/enterprise-features.md", + "sha256": "8f885147892a5493676f1b97101cd937c3c5800c5a30498d0f0ad8d4a55742e0" + }, + { + "path": "skills/claude-code/references/advanced-features.md", + "sha256": "2fb92f67efbed1338ea53c7be52ed52bffe98f3e1a51c2b79094cc213ca65c2a" + }, + { + "path": "skills/claude-code/references/getting-started.md", + "sha256": "14e1b685b36914f114bfcaa1a74fc618f6aa315d79cc45b76e3234935322bcd1" + }, + { + "path": "skills/claude-code/references/hooks-and-plugins.md", + "sha256": "22231fac9bf3e32fe14a0dcafefb0cbbd7519adcfd350a0d49d0beb2f3736b0a" + }, + { + "path": "skills/claude-code/references/configuration.md", + "sha256": "36f648bd73ee38cfdda861bf4e9366d2110e9ef9a231fdc4084469a88d6b9515" + }, + { + "path": "skills/claude-code/references/cicd-integration.md", + "sha256": "8f74dd8c4f5a197338c5a09fc932fe634eb6a774f1bf2253b872c02f1165c323" + }, + { + "path": "skills/claude-code/references/mcp-integration.md", + "sha256": "29e711f34c06c014f8ea790388b5f089f4c8bb96d73807effdb7b3763d2f6176" + }, + { + "path": "skills/claude-code/references/api-reference.md", + "sha256": "e6c62e7d435c012be4437cd3630b1501bab07ed2183ee5b267dd0f0632f2dfdf" + }, + { + "path": "skills/claude-code/references/agent-skills.md", + "sha256": "b392d1b1eb52915978e7098ef8c8ceb66f510f69b96676201eda325e0b085691" + }, + { + "path": "skills/databases/SKILL.md", + "sha256": "0567d1899bd7fa7530f9a4ac78f38ae597c3ca5adc27958efda4d0052bc03304" + }, + { + "path": "skills/databases/references/postgresql-queries.md", + "sha256": "860725feefff992bdef6defb4acb93235f9662abfd076dafb61c194f4e4ee689" + }, + { + "path": "skills/databases/references/mongodb-indexing.md", + "sha256": "828af24fa18b508b0df3699a0d78345a21a2a6dbb42a6b0522758cc517efce45" + }, + { + "path": "skills/databases/references/postgresql-psql-cli.md", + "sha256": "ca3a81fde3d1e894510b5b906c9e91db2d300df4cbdb1171ad5683df7ccc0b61" + }, + { + "path": "skills/databases/references/mongodb-crud.md", + "sha256": "4c931cea2dbab883fe0c904dc3278c779488ad31cba93dd24b937bd5a2674721" + }, + { + "path": "skills/databases/references/postgresql-performance.md", + "sha256": "77f84195e3f519c8f63679d57d80b1517b04b0188a966141a05cba1d72092116" + }, + { + "path": "skills/databases/references/mongodb-atlas.md", + "sha256": "d37fea35012dc79ba21f9f5f541307b9b7289ec828007b58e02621e63010411b" + }, + { + "path": "skills/databases/references/postgresql-administration.md", + "sha256": "3bf09a45484b4a3633d4b8c0e3812051508116233fcef7136ab21aa696ee479b" + }, + { + "path": "skills/databases/references/mongodb-aggregation.md", + "sha256": "b7cca207376f2a9b62de73615ec8ff22dc339f1cb84fb39b6d548a8271fc6b52" + }, + { + "path": "skills/databases/scripts/db_performance_check.py", + "sha256": "53ca71738a1ac0faa0def8473e75e697ffbe927910b3176ca14dc2f53e819d8b" + }, + { + "path": "skills/databases/scripts/db_migrate.py", + "sha256": "35b08b8a47c423439d34b4f9dced7dd4523d65893f4315d5d3772a99060b2dc3" + }, + { + "path": "skills/databases/scripts/requirements.txt", + "sha256": "da0d7d811e02966201fac258844820dc214cb1b3bcea3006bd94029f6a2518d1" + }, + { + "path": "skills/databases/scripts/.coverage", + "sha256": "e58277507e55c72bd390690e2307f6751cdb9c96e6c6a3ee346f1a710e9c02bd" + }, + { + "path": "skills/databases/scripts/db_backup.py", + "sha256": "ad77002838cfa1e1493ef19208962018251bcc91b6adde571a744e67bc750f2b" + }, + { + "path": "skills/databases/scripts/tests/requirements.txt", + "sha256": "52a7b73d3f2b08178eb3bac41cdc1786f4b06336f741c817d89347e8dbb5504d" + }, + { + "path": "skills/databases/scripts/tests/test_db_backup.py", + "sha256": "ba16ff85557dc29a7a424cb80aebcde760f3a3d59ad7eb4d05a7dfed483d67d7" + }, + { + "path": "skills/databases/scripts/tests/coverage-db.json", + "sha256": "f40c12d1ff34d9c927b31ba3b3f57371f06f45cf462f1516d0eb3f1fa2351b0a" + }, + { + "path": "skills/databases/scripts/tests/test_db_performance_check.py", + "sha256": "f89b7421305587cd45daf1f47d02a0f6c791a92b5a1b5ac0332982f91b6ed2e9" + }, + { + "path": "skills/databases/scripts/tests/test_db_migrate.py", + "sha256": "22f9323d767bdd02a97b7250f14c2cda788f829d9af5497da2417ace21ae2fcc" + }, + { + "path": "skills/template-skill/SKILL.md", + "sha256": "eb685d91de039ed864fbd790cddf31684b017fd4a34ee1a55760d8d7cdbadefa" + }, + { + "path": "skills/devops/SKILL.md", + "sha256": "c947d0a2f13d527a4e54edd782b1fc6f726288a4a45002b1e0a64d0d7efe878e" + }, + { + "path": "skills/devops/.env.example", + "sha256": "2e90b03a2ca418a05985b6cc4c2a02ccb96764b7b058538597217f1311c7bf18" + }, + { + "path": "skills/devops/references/cloudflare-workers-advanced.md", + "sha256": "fc4b2c5bd5c826ce1dbbeec914666dd6c8edc64287dbd0acc75c4de1ccaae019" + }, + { + "path": "skills/devops/references/cloudflare-r2-storage.md", + "sha256": "03c90dd24354676d97ac53b2b111daae3c88002955849f2bddd9689447db4235" + }, + { + "path": "skills/devops/references/cloudflare-d1-kv.md", + "sha256": "d35c07d3bf5356a21544e088c9995cd59dec463992ae95e7a848130ebf8c6d3d" + }, + { + "path": "skills/devops/references/cloudflare-platform.md", + "sha256": "d009a10f0f37073a08ae51ab6768a63b3baf20f815c2048e3370dfd263d21e6a" + }, + { + "path": "skills/devops/references/browser-rendering.md", + "sha256": "d258f97511e47ef425bfc1ae5578720162fa2499466ef4c275072c1a4db25d9d" + }, + { + "path": "skills/devops/references/gcloud-services.md", + "sha256": "2581140e4bf5412fdc7b2758e9c2acc59e428117af26b1cc1efb10a21106c454" + }, + { + "path": "skills/devops/references/cloudflare-workers-apis.md", + "sha256": "d36537cf342f93b3db0bc6457f2e61ac5f10e3daf82fab2d557cdb88278fe57f" + }, + { + "path": "skills/devops/references/cloudflare-workers-basics.md", + "sha256": "a975a4f0ef7e663e66f54156153568b2735a9a626e0fae13cd777e5399979a37" + }, + { + "path": "skills/devops/references/docker-compose.md", + "sha256": "cf6eb6338aa55201f0a5b4390cdb33d4de2b8cffabf2c2b84dac612a2d4f0816" + }, + { + "path": "skills/devops/references/docker-basics.md", + "sha256": "9e92f9e2f9e147d1002b3abdb45526ac175fd63d1fec6b5862a3c3c0cb828631" + }, + { + "path": "skills/devops/references/gcloud-platform.md", + "sha256": "195c09f69bb47976467fe1e5094a156f16ac88ad8e20003b60d7f9ae037bc09d" + }, + { + "path": "skills/devops/scripts/requirements.txt", + "sha256": "4a508a3b086c25ad5adc9b743a8c0a576910e650008b8144fc58f68770b662fd" + }, + { + "path": "skills/devops/scripts/cloudflare_deploy.py", + "sha256": "439844726c149c7dd5c7a6fb85b20059b8e230041c1a61364fd609b4c503216c" + }, + { + "path": "skills/devops/scripts/docker_optimize.py", + "sha256": "7e484e26109df1790ef7c31269647aa62e0be519df6fbfe558a0df212ebe12fd" + }, + { + "path": "skills/devops/scripts/tests/requirements.txt", + "sha256": "0795bdcfb80afae0ff06e9cbe5bed67b39283e577984bed87d85da3fec798a37" + }, + { + "path": "skills/devops/scripts/tests/test_docker_optimize.py", + "sha256": "c82fd9d4f18e60d1ebdbcebda5781d462a6a0c846658512aa5239e1eaee0d9fb" + }, + { + "path": "skills/devops/scripts/tests/test_cloudflare_deploy.py", + "sha256": "66373336b3290bed9ca300699816954006cabc9f91897cb4759358d365dabc69" + }, + { + "path": "skills/ui-styling/SKILL.md", + "sha256": "7f5b4a0ce2a109779d6ca235543ee7755881c53f0258d526e174bd37fd4c8dd8" + }, + { + "path": "skills/ui-styling/LICENSE.txt", + "sha256": "58d1e17ffe5109a7ae296caafcadfdbe6a7d176f0bc4ab01e12a689b0499d8bd" + }, + { + "path": "skills/ui-styling/canvas-fonts/Lora-Italic.ttf", + "sha256": "be627e595184e8afe521f08da0607eee613f1997d423bc8dadc5798995581377" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSerif-Italic.ttf", + "sha256": "9c86e4d5a47b50224a2463a9eca8535835257c8e85c470c2c6b454b1af6f046e" + }, + { + "path": "skills/ui-styling/canvas-fonts/WorkSans-Regular.ttf", + "sha256": "e67985a843df0d3cdee51a3d0f329eb1774a344ad9ff0c9ab923751f1577e2a4" + }, + { + "path": "skills/ui-styling/canvas-fonts/ArsenalSC-OFL.txt", + "sha256": "8ddd61b18ba2c0d0dbe4a691cf5f1a0673f473d02fa0546e67ee88c006aeff6e" + }, + { + "path": "skills/ui-styling/canvas-fonts/Italiana-OFL.txt", + "sha256": "8373b11312ace78c4cec2e8f9f6aa9f2330601107dac7bcf899c6f2dbd40c5a5" + }, + { + "path": "skills/ui-styling/canvas-fonts/CrimsonPro-Italic.ttf", + "sha256": "52318db3526b644e6efa60be0b3ca5a50e40fbe8bd026c261e0aa206f0772267" + }, + { + "path": "skills/ui-styling/canvas-fonts/Outfit-OFL.txt", + "sha256": "1945b62cd76da9a3051a1660dde72afaa64ffc2666d30e7a78356d651653ba2f" + }, + { + "path": "skills/ui-styling/canvas-fonts/Gloock-OFL.txt", + "sha256": "c0a3f3125ac491ef3d1f09f401be4834c646562f647e44f2bcbc49f0466c656d" + }, + { + "path": "skills/ui-styling/canvas-fonts/RedHatMono-Regular.ttf", + "sha256": "452fe826871b37539f5212b20c87cf30f82f58dd2741f1c96edd1dcbdc0db6b4" + }, + { + "path": "skills/ui-styling/canvas-fonts/YoungSerif-OFL.txt", + "sha256": "cdcb8039606b40a027a6d24586ec62d5fe29c701343d82a048c829cb28a3dd28" + }, + { + "path": "skills/ui-styling/canvas-fonts/Outfit-Bold.ttf", + "sha256": "6654b93d21301ec61887d3cedd6c11d9df1b1dfb63f9cf45ac7995f6e2235ab1" + }, + { + "path": "skills/ui-styling/canvas-fonts/Boldonse-Regular.ttf", + "sha256": "cc2e540604565c0f90a7d8d46194a2f42fc9c45512cd2e39bf03b50eb68c35a4" + }, + { + "path": "skills/ui-styling/canvas-fonts/CrimsonPro-OFL.txt", + "sha256": "35680d14547b6748b6f362a052a46d22764ce5eccf96e18b74f567bb2ee58114" + }, + { + "path": "skills/ui-styling/canvas-fonts/RedHatMono-Bold.ttf", + "sha256": "7ef48353f4be5ddb90f000f6fad48f2b62b3e8c27d9818d8d45ff46c201065e0" + }, + { + "path": "skills/ui-styling/canvas-fonts/PixelifySans-OFL.txt", + "sha256": "7f54d1d9f1ae1ba9f2722f978145f90324fea34ca3c2304b3a29cfa96ac6037e" + }, + { + "path": "skills/ui-styling/canvas-fonts/Jura-OFL.txt", + "sha256": "eaf9bdb675f6d87e5feb88199ab3ea581d3bd2082f426e384fa9c394576d7260" + }, + { + "path": "skills/ui-styling/canvas-fonts/Outfit-Regular.ttf", + "sha256": "f24945365147c9e783e91d8649959b59be6b00c9ee4ecd2f6b33afbb2dd871fe" + }, + { + "path": "skills/ui-styling/canvas-fonts/Silkscreen-OFL.txt", + "sha256": "6b849745119bbe85ec01fd080c9cd50234da9f52ac6e48b55d1a424a0c4d7ca9" + }, + { + "path": "skills/ui-styling/canvas-fonts/JetBrainsMono-Bold.ttf", + "sha256": "a2349098b9e45419e7bf0e2958d6c4937a049dded37387b08be725be4c7615f3" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexSerif-Regular.ttf", + "sha256": "77cd233a2af8dc6b1022faea3bb3b01f3c75af68bcf530cb6aeb15982ff3dbb7" + }, + { + "path": "skills/ui-styling/canvas-fonts/YoungSerif-Regular.ttf", + "sha256": "f8dc08f77abad753a00670af70756a8ace938e5c3f0b770f4f4c2071c4bd8fc6" + }, + { + "path": "skills/ui-styling/canvas-fonts/Italiana-Regular.ttf", + "sha256": "15c4dd6ab8cf4a29ba8826f65edcbe2f6c266c557d34d081f25072dfd5605fd2" + }, + { + "path": "skills/ui-styling/canvas-fonts/Lora-Bold.ttf", + "sha256": "7d74015e950c2fb66519c7295b8155621d22200ae2ca2a4c6b43ce3c490cac87" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexSerif-BoldItalic.ttf", + "sha256": "da64b75f4284f53e7b5c71fa190a35b8bf3494fe19f1804c81c3a53340bca570" + }, + { + "path": "skills/ui-styling/canvas-fonts/JetBrainsMono-Regular.ttf", + "sha256": "b6b1ff4ddefe36d7f2a6174e1d001cab374e594519ee9049af028d577b64c5f5" + }, + { + "path": "skills/ui-styling/canvas-fonts/CrimsonPro-Regular.ttf", + "sha256": "48fad08cb1917a7b2f2c6fe5135d6c07743a6663cf7631ec4481108aaf081422" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSans-Italic.ttf", + "sha256": "78e85858e371b2cb4e18f617c10f0f937c0e12a0887ffee98555b24ed305b3a7" + }, + { + "path": "skills/ui-styling/canvas-fonts/Jura-Light.ttf", + "sha256": "c891a381df056b2c4dfe85841e911bf45da0890fa21a7b2692cbe5ea1f505e1e" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexMono-Bold.ttf", + "sha256": "dbd2a2fb024579438d6400a84e57579bfd2dbe67c306c8fd9fde92a61e4f2eea" + }, + { + "path": "skills/ui-styling/canvas-fonts/WorkSans-BoldItalic.ttf", + "sha256": "a5b2cad813df0aaa7d16621f2e93b5117c25e9bc788bc9a3ad218e9d6348ce34" + }, + { + "path": "skills/ui-styling/canvas-fonts/WorkSans-OFL.txt", + "sha256": "ace8c22a3326318b54e67c3691857929634205533f454a70ef5a3473ddb2e2ba" + }, + { + "path": "skills/ui-styling/canvas-fonts/Lora-OFL.txt", + "sha256": "62e37a82d3f1ef2a70712885fa8b3144b65fd144d8e748d6196b690a354d792c" + }, + { + "path": "skills/ui-styling/canvas-fonts/Silkscreen-Regular.ttf", + "sha256": "49567408600809e25147e9225ac4f37f410e2df45a750696c45027531fb65f1b" + }, + { + "path": "skills/ui-styling/canvas-fonts/PixelifySans-Medium.ttf", + "sha256": "38397504f71c122b03d234ea6f55118e3d5bdbddffd82bedddbd7755d3b3be82" + }, + { + "path": "skills/ui-styling/canvas-fonts/PoiretOne-Regular.ttf", + "sha256": "9cf265b139648b36b6c0afdfeb0bf27f7e66db9a16094bc40f644d8da05bc318" + }, + { + "path": "skills/ui-styling/canvas-fonts/BricolageGrotesque-Regular.ttf", + "sha256": "972a6d098c9867ae131d0ea99e221e63976b11a19d4b931c2c7ace525674e4f6" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexMono-Regular.ttf", + "sha256": "ab08018ccd276b79fb2c636bb95b9c543598f9d50505fe92506fcb4dae7810cd" + }, + { + "path": "skills/ui-styling/canvas-fonts/CrimsonPro-Bold.ttf", + "sha256": "48f191e38355c8db100eb3ce157c20f9302a3b9a37b44a660f77ecfce3986609" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSerif-Regular.ttf", + "sha256": "56ac3be03ac3ba283196b3e77850ab2ffcf56cfb6fd3212c5620109a972f8c99" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSans-OFL.txt", + "sha256": "bf4dc6d13a8cccd4807133c77a1ee9619a16b92cb23322258725ab6731c2f6e5" + }, + { + "path": "skills/ui-styling/canvas-fonts/Tektur-OFL.txt", + "sha256": "3f1466cb5438f31782eeb6e895f3a655bc4d090e24263e331f555357d1cb734e" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSans-BoldItalic.ttf", + "sha256": "3762f6cef95d6039489ad5ba5787d4c30f17a1ad01e9ac3c816ed69692722a68" + }, + { + "path": "skills/ui-styling/canvas-fonts/EricaOne-OFL.txt", + "sha256": "e0de629968b52255548d5fafcf30b24ff9edae0eda362380755a75816404d0fa" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexSerif-Italic.ttf", + "sha256": "b11f1048745e715a55c9d837b3f10226ca3d78867b7db7251ddad8f98dcf0f38" + }, + { + "path": "skills/ui-styling/canvas-fonts/DMMono-Regular.ttf", + "sha256": "f98ada968dc3b6b2c08d3f5caaf266977df0bfe0929372b93df5a06cf2ace450" + }, + { + "path": "skills/ui-styling/canvas-fonts/NationalPark-Bold.ttf", + "sha256": "69ac4c301c4a7233c6e602d12a92c54d7967b575f4449951c45ce773f7acff53" + }, + { + "path": "skills/ui-styling/canvas-fonts/DMMono-OFL.txt", + "sha256": "bfe7842fcb88323e2981e24710c25202677385a8c75fb6a87217b275a0247ae3" + }, + { + "path": "skills/ui-styling/canvas-fonts/NationalPark-OFL.txt", + "sha256": "81c6c71d83b5b45d7344f96df12bb4a2477a5b092a9144757ee1d0f50f855175" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSans-Regular.ttf", + "sha256": "a22cb26e48fd79bcb01bf2fc92d36785474dce36d9c544ab0a8868c2657c4a87" + }, + { + "path": "skills/ui-styling/canvas-fonts/LibreBaskerville-Regular.ttf", + "sha256": "2101302538d9e88adb679031c04623e4578b5745e89566284fd2c508d79acae0" + }, + { + "path": "skills/ui-styling/canvas-fonts/EricaOne-Regular.ttf", + "sha256": "db1d89e80e33a8a01beaaac7a85df582857d24a43f1e181461aa7ff5d701476a" + }, + { + "path": "skills/ui-styling/canvas-fonts/SmoochSans-OFL.txt", + "sha256": "74c9c4eb88e891483e1b7bc54780b452cbf4f4df66d4e71881d7569aa2130749" + }, + { + "path": "skills/ui-styling/canvas-fonts/NothingYouCouldDo-Regular.ttf", + "sha256": "d866f985896d3280f4fce72db7e17302c24a0c1fdb0699b6b5ed3af14f944d57" + }, + { + "path": "skills/ui-styling/canvas-fonts/WorkSans-Italic.ttf", + "sha256": "6b7f7002e0b0c8b261fe878658ef5551e3e59d9f6b609b04efb90dde1e2c1ada" + }, + { + "path": "skills/ui-styling/canvas-fonts/LibreBaskerville-OFL.txt", + "sha256": "55959eef5b0c3b2e3c1c7631b8ff0f9447d75de20f29cfa7db5bcfb026763343" + }, + { + "path": "skills/ui-styling/canvas-fonts/PoiretOne-OFL.txt", + "sha256": "2eaf541f7eb8b512e4c757a5212060abf5b6edfef230e9d7640bf736b315c33a" + }, + { + "path": "skills/ui-styling/canvas-fonts/Gloock-Regular.ttf", + "sha256": "e86b4ce66dbd3f1f83eee8db99ec96e0da1128c3f53df0e9b3b7472025dfe960" + }, + { + "path": "skills/ui-styling/canvas-fonts/BricolageGrotesque-OFL.txt", + "sha256": "0e4f4eb8534bc66a76aca13dd19c1f9731b2008866b29ccff182b764649df9b4" + }, + { + "path": "skills/ui-styling/canvas-fonts/SmoochSans-Medium.ttf", + "sha256": "dd76e6e77cce82f827a8654cd906e9ce58f3aaf78adda63c4a7f655b8ecb41f0" + }, + { + "path": "skills/ui-styling/canvas-fonts/NothingYouCouldDo-OFL.txt", + "sha256": "7c2a6970584ddad04919816163746f83b378078015899b18468b40f05e9ce128" + }, + { + "path": "skills/ui-styling/canvas-fonts/Tektur-Regular.ttf", + "sha256": "162e1b36c4718c5b051b36c971ad7e50d341944f35618f480422ebbe72988f98" + }, + { + "path": "skills/ui-styling/canvas-fonts/GeistMono-Regular.ttf", + "sha256": "a55c1b51cda4afeab9e471e7947b85a20f7c8831d7e6b1470c1b7fbdc0f0f15e" + }, + { + "path": "skills/ui-styling/canvas-fonts/Lora-BoldItalic.ttf", + "sha256": "152f87e71f5ddb60d5c57ecd9132807c947e65c42977193c9164e7c5a6690081" + }, + { + "path": "skills/ui-styling/canvas-fonts/RedHatMono-OFL.txt", + "sha256": "435fbfb7e66988b2a06686a4cb966faec733f35d8fe100a1601573c27f3e0bb8" + }, + { + "path": "skills/ui-styling/canvas-fonts/BricolageGrotesque-Bold.ttf", + "sha256": "a737b146fe0d77ffe8a86e3cd16700dd431d3b1e420d4fd80e142cd68a1cb50d" + }, + { + "path": "skills/ui-styling/canvas-fonts/NationalPark-Regular.ttf", + "sha256": "a477338b7e18308d476650dfe31235ef86a883572665e56ffb5fb80f82009b58" + }, + { + "path": "skills/ui-styling/canvas-fonts/BigShoulders-Regular.ttf", + "sha256": "18a879fc71978a4447150705caf880a9da3860083c259fd29e6dc03057b6842a" + }, + { + "path": "skills/ui-styling/canvas-fonts/GeistMono-Bold.ttf", + "sha256": "75c0828d5c1ee44b9ef9f4df577bf41595ec362e2ea3f1e558590c9e92c7949d" + }, + { + "path": "skills/ui-styling/canvas-fonts/GeistMono-OFL.txt", + "sha256": "6a873c900f584109b13ae0aaf81d6e3cf0a68751a216b03f7b6c68d547057bb4" + }, + { + "path": "skills/ui-styling/canvas-fonts/Lora-Regular.ttf", + "sha256": "7ed00e7c9cdf16ab7e2fd2361fe45d4f0b61263cd60aae398b27b7ee08108827" + }, + { + "path": "skills/ui-styling/canvas-fonts/ArsenalSC-Regular.ttf", + "sha256": "65e6f89df58f68fd905b3add34a79dd6106aa3b3044df0dad9676fff53d504b9" + }, + { + "path": "skills/ui-styling/canvas-fonts/JetBrainsMono-OFL.txt", + "sha256": "a76abf002c49097d146e86740a3105a5d00450b1592e820a1109a8c5680cd697" + }, + { + "path": "skills/ui-styling/canvas-fonts/BigShoulders-OFL.txt", + "sha256": "fbc746aabf0eb1847dfd92e2efc4596d79fa897d60b8e64062a22f585508fb3f" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexMono-OFL.txt", + "sha256": "5294ce778857e1eb02e830b6ab06435537d38f43055327e73d03a2d4d57d5123" + }, + { + "path": "skills/ui-styling/canvas-fonts/Tektur-Medium.ttf", + "sha256": "52bbe8c9b057b3d2da4eeace31a524b1ea26a1375ae34319cf6900ccc57a4c82" + }, + { + "path": "skills/ui-styling/canvas-fonts/Jura-Medium.ttf", + "sha256": "c72965cb732a92872643819fd1734128238583cc36b116313859137a51d3368a" + }, + { + "path": "skills/ui-styling/canvas-fonts/InstrumentSans-Bold.ttf", + "sha256": "444f85bf1c4b0e1ce1ca624f6be54bcd832207714ccaf4ea99ee531341683bdf" + }, + { + "path": "skills/ui-styling/canvas-fonts/Boldonse-OFL.txt", + "sha256": "45cc82ab4032273c0924025ffcf8f0665a68e1a5955e3f7247e5daf1deeb1326" + }, + { + "path": "skills/ui-styling/canvas-fonts/BigShoulders-Bold.ttf", + "sha256": "b43bcd198b9fdf717dd42aa61a34dba32e01aceaeae659d689afd0ca52c37ea2" + }, + { + "path": "skills/ui-styling/canvas-fonts/IBMPlexSerif-Bold.ttf", + "sha256": "b8d294e9b5c5a0940f167c3ced0f7ef2e3f57082ca3ff096ef30e86e26c1c159" + }, + { + "path": "skills/ui-styling/canvas-fonts/WorkSans-Bold.ttf", + "sha256": "240d125fc9f8561363dc1ea3f513501253bd70942f41468f48f0b0cafb0c82e2" + }, + { + "path": "skills/ui-styling/references/shadcn-theming.md", + "sha256": "d17d641474221123ff0e0288043ca2f2dab3ac96908a19eed28d82f9e58865ed" + }, + { + "path": "skills/ui-styling/references/tailwind-utilities.md", + "sha256": "aba1c40ef84f43beea142ae310371e332541f39d973df90a71f1854823effda1" + }, + { + "path": "skills/ui-styling/references/tailwind-responsive.md", + "sha256": "8d00ae620df26daea4623c7bf996d6e612e09d359305fcfa2b40cab757ed840c" + }, + { + "path": "skills/ui-styling/references/tailwind-customization.md", + "sha256": "4c5adeed6263a274f74f6eb2c816ca420fd6ef4f35683582988bacd404799afe" + }, + { + "path": "skills/ui-styling/references/shadcn-accessibility.md", + "sha256": "a22cd4ccf82b635b2b2c4f12416a0c4f7edcd825d219d6089450fa797a0d9511" + }, + { + "path": "skills/ui-styling/references/shadcn-components.md", + "sha256": "79c4f91cbf68993a43fa3a95889c35db73de45bdd3dfd83a6830863c01aa598a" + }, + { + "path": "skills/ui-styling/references/canvas-design-system.md", + "sha256": "f5de85ff39d9f3a8275c4164b921ba72d5e1d34b8d7a9243d9ee34b3870a4f5f" + }, + { + "path": "skills/ui-styling/scripts/tailwind_config_gen.py", + "sha256": "fd0e7b96fdb0d24b9ac85e87aed1dae6a2399b6c508809b3db71a32696584511" + }, + { + "path": "skills/ui-styling/scripts/requirements.txt", + "sha256": "09402d2d274248e18bb5fd0a0267fd7cbf99b7a2440e73e87f054aa8f259f9da" + }, + { + "path": "skills/ui-styling/scripts/.coverage", + "sha256": "a1f68d7a1d6322975c40c893004bc5e811d455d3ab124dab5154aad7060e66f5" + }, + { + "path": "skills/ui-styling/scripts/shadcn_add.py", + "sha256": "0bcdf28ea2c2d5d4f17a2b1e0a0cab29ff649b9db7b134ecc6e9898a3d84824f" + }, + { + "path": "skills/ui-styling/scripts/tests/requirements.txt", + "sha256": "80846c98ee02a7e9651ec4eee6709f840b785646b469aad5284ab7ab9b344f17" + }, + { + "path": "skills/ui-styling/scripts/tests/test_shadcn_add.py", + "sha256": "4012b1efe1e51a5dcf31078b0d9069f92cfe2045e9a35dd52fdd4631b1815150" + }, + { + "path": "skills/ui-styling/scripts/tests/test_tailwind_config_gen.py", + "sha256": "2a8b7b3e8b9ff081532551f2168dd08a8471e71c17747ae8d6026e1273068dc8" + }, + { + "path": "skills/ui-styling/scripts/tests/coverage-ui.json", + "sha256": "33bdc1f5998db9a38cac8e6128a82711f8f8b65fbb3dcb0c1680ae260441cbce" + }, + { + "path": "skills/code-review/SKILL.md", + "sha256": "d7ae241ea6db2cf782b4d4c07374dad7f9dfa8a7882a147668b9f26fe72bac9b" + }, + { + "path": "skills/code-review/references/code-review-reception.md", + "sha256": "2f6114025609bed0dcf13a9b4a583f625c9f27b91052c9c90a8833af9a83c804" + }, + { + "path": "skills/code-review/references/requesting-code-review.md", + "sha256": "a3703cacc5c72bf8e39759c11dca0c1204008a53e9704bca7be5d016e4d14a26" + }, + { + "path": "skills/code-review/references/verification-before-completion.md", + "sha256": "963f6b63d58d88e029181126528a1c720f76c4f8cfaec3532ece14795aef5b77" + }, + { + "path": "skills/debugging/systematic-debugging/test-pressure-1.md", + "sha256": "0b6a915db0054577819834c79be9eb614e97bddba10d73768e1fbe91cfed048a" + }, + { + "path": "skills/debugging/systematic-debugging/test-pressure-2.md", + "sha256": "b2030aeffba07050e8ad573ddf87486457c4a016a786bb326235bebd856f2016" + }, + { + "path": "skills/debugging/systematic-debugging/CREATION-LOG.md", + "sha256": "b482ef9a918fbfc6c369729e8160633ddfa2332466dd362ee73f1527c239ef8b" + }, + { + "path": "skills/debugging/systematic-debugging/test-academic.md", + "sha256": "fe2ba480d78ac0d686dc025f41c2a32a43d642bf533f91b0c6053a04d35d6486" + }, + { + "path": "skills/debugging/systematic-debugging/SKILL.md", + "sha256": "48252adfa183d927befe3894d7aa32c23b59d374cde101f4e2269e14c7c1c5df" + }, + { + "path": "skills/debugging/systematic-debugging/test-pressure-3.md", + "sha256": "96b50a52e2c7989c9cf20fb752c47c1e9a3a70dc362f8f7989f8f5b64dac7708" + }, + { + "path": "skills/debugging/root-cause-tracing/SKILL.md", + "sha256": "476830e10488843688d17d13feae60ca040c769939971e8b8554f67cffb7ddf8" + }, + { + "path": "skills/debugging/root-cause-tracing/find-polluter.sh", + "sha256": "f4dc594206175b17de25464b5f60a0e011774a7c7843014b6442338a085eba57" + }, + { + "path": "skills/debugging/verification-before-completion/SKILL.md", + "sha256": "6c3c414b4058c5d6a461abfcee62aebac50741488e9b327445bef661d1634fda" + }, + { + "path": "skills/debugging/defense-in-depth/SKILL.md", + "sha256": "26b67533bbd6b088d1004f027d2305851270794cb4c632d897abe934956a6a0e" + }, + { + "path": "skills/shopify/README.md", + "sha256": "c13775e03e078aae7fee1956ec758e2f728195495a1033680e4c202625bb2d64" + }, + { + "path": "skills/shopify/SKILL.md", + "sha256": "9a6b1a96e3d84a58d8264560daf5d03f3626fa25dcd4739afc99ccd76cbfd398" + }, + { + "path": "skills/shopify/references/extensions.md", + "sha256": "8264fa2dac7d21493fe23cebe96271fcf90f88119b49491157c29a3fbcdb16e9" + }, + { + "path": "skills/shopify/references/app-development.md", + "sha256": "af92f5533dec1d23a1836a84e253d2f3737f0f00db03146ab9d1ca171cb05e10" + }, + { + "path": "skills/shopify/references/themes.md", + "sha256": "0a18523f8183f060d4867bec17a4f25f48f7e8cdbc0b6e543795dd00fa087c67" + }, + { + "path": "skills/shopify/scripts/requirements.txt", + "sha256": "ff02bd21c424070c78b466debfc2748ea3f42f43c5d4bd888c6be2c38e3bee45" + }, + { + "path": "skills/shopify/scripts/.coverage", + "sha256": "9b1a52fa7e42d18f69c31e808c40fb0fe51a4f60da6a9e4696bed1274cc530fb" + }, + { + "path": "skills/shopify/scripts/shopify_init.py", + "sha256": "ec668b243ff55b78437486ce8e99b6a8bd92a4b7ea63b21ee724c5caba960521" + }, + { + "path": "skills/shopify/scripts/tests/.coverage", + "sha256": "d2b0dfe017df2674b062e577e2b1b1b93651e18353d78e9aede6ed14db6fafd8" + }, + { + "path": "skills/shopify/scripts/tests/test_shopify_init.py", + "sha256": "7bb517b08c58b2b1b9b10f89f1437dba378188adfe63e93aa1dccb3abbeabbd3" + }, + { + "path": "skills/mcp-management/README.md", + "sha256": "171e3485f70e6e21dd9512c3fb264bfc54081cc5db2ba710b16d3e200f427c03" + }, + { + "path": "skills/mcp-management/SKILL.md", + "sha256": "e163d352e168a42a3403d2a71846914e4d2a6648ccd4e5b8fc90a83004c7cf10" + }, + { + "path": "skills/mcp-management/references/mcp-protocol.md", + "sha256": "eb4ab38de303d0e9702d5e5f82953e89ad156755b446d07af6fbfd55503d3579" + }, + { + "path": "skills/mcp-management/references/configuration.md", + "sha256": "82e722baef392dd1e80702226263c20cb38e63ba296132d99779b8bdc5dd36cf" + }, + { + "path": "skills/mcp-management/references/gemini-cli-integration.md", + "sha256": "4e8123a392da44a996b8a2c523b0780d517640926ba793aa0eea5fd4286bc6e2" + }, + { + "path": "skills/mcp-management/scripts/cli.ts", + "sha256": "b23345ecd42a85c0c24bb4367ea37e1d0baaf4b708e2cfdb8986c28ec42bb0d9" + }, + { + "path": "skills/mcp-management/scripts/mcp-client.ts", + "sha256": "6f1ad0f5a0e80cc1150a3197b801bfeff5aca2e61087561b2ba24934eb597e97" + }, + { + "path": "skills/mcp-management/scripts/package.json", + "sha256": "38ccee009ffbd44163256fb5e8ee1702db749adb25a7fe65e2a84ad88ae7f78c" + }, + { + "path": "skills/mcp-management/scripts/tsconfig.json", + "sha256": "c9d43a3e50d31af519710feb01ae2558001abd84bdb87ddc08628c193fe273a6" + }, + { + "path": "skills/mcp-management/scripts/.env.example", + "sha256": "58b814af626ee7d10e24653156c0b5df7c7ee236a2757049e0c7a2e99b60fdd1" + }, + { + "path": "skills/mcp-management/assets/tools.json", + "sha256": "0ce5ca4e12dfa1738cd1a54697b697c9bd5cebf6fb42a8f452a9ea7b1cb8eca2" + }, + { + "path": "skills/problem-solving/ABOUT.md", + "sha256": "5ac236d43b2f1173d0fbc7a80247848b4f6454b89d826addb7e5aa9c804d50d0" + }, + { + "path": "skills/problem-solving/when-stuck/SKILL.md", + "sha256": "3f5dd9d0dee4703d7f65e565a751d6a6336b9a99aeda5af15bbba8e9c6561c4a" + }, + { + "path": "skills/problem-solving/simplification-cascades/SKILL.md", + "sha256": "57417c2d46934c2214b3698823b7b1098c84fc2bcd5dea372ea2ba9fc00f4c32" + }, + { + "path": "skills/problem-solving/collision-zone-thinking/SKILL.md", + "sha256": "416850bf47da72973a0891c5b9433b22c3b5677ec97f07742045c5a1ef00dd6f" + }, + { + "path": "skills/problem-solving/meta-pattern-recognition/SKILL.md", + "sha256": "2a579c471488a1ae73044bf6bf07d4fe8ccf63bcfdb3158e0059f815fd2f6eb5" + }, + { + "path": "skills/problem-solving/inversion-exercise/SKILL.md", + "sha256": "01786f5fd93aabb4a4beb14f88104a2d16d57ffdf5f2a20054e17dfede1c9215" + }, + { + "path": "skills/problem-solving/scale-game/SKILL.md", + "sha256": "43373c54188b3bd9ba5eca74e57a53dfd3967ed65503996bcd16441e34cd15aa" + }, + { + "path": "skills/common/api_key_helper.py", + "sha256": "3d7dbdf19a835ee0467d8bde2cdbd5fcf42ce45f0c4d84a7a844268b4d76ff88" + }, + { + "path": "skills/common/README.md", + "sha256": "c5f3373de60c0bfc530c73a6f2392e06263b7fa425894a139910941de53fd777" + }, + { + "path": "skills/skill-creator/SKILL.md", + "sha256": "a1208de184ff0607e8eade5defc90ebcef6a0c76ab45c4b25b3b4c0c240dd631" + }, + { + "path": "skills/skill-creator/LICENSE.txt", + "sha256": "58d1e17ffe5109a7ae296caafcadfdbe6a7d176f0bc4ab01e12a689b0499d8bd" + }, + { + "path": "skills/skill-creator/scripts/init_skill.py", + "sha256": "0bba250b94caa4cb2b28b15dad26fdcf371aeda4c9797b8120e55c2e33e0c73c" + }, + { + "path": "skills/skill-creator/scripts/package_skill.py", + "sha256": "692525dd8096aee51730ab142fb2605894a6f1a4135bcffebbf4f8ed3cf8715e" + }, + { + "path": "skills/skill-creator/scripts/quick_validate.py", + "sha256": "41647b79d15e7e51b944d56843e9f87a33f4c66e64ded5964d1f95bad2fe634a" + }, + { + "path": "skills/media-processing/SKILL.md", + "sha256": "eb2c53742a9092234ad499be028adcf976bc2d6578e513092361ea3cda40d016" + }, + { + "path": "skills/media-processing/references/format-compatibility.md", + "sha256": "900ac7216f595d94ae597096c2bf12cd62bfe0cf1841b143e4ac8564cd12e02f" + }, + { + "path": "skills/media-processing/references/ffmpeg-encoding.md", + "sha256": "903682c3375524ce29a12f52c72e947ab114cee1187d88652360911d06a5759f" + }, + { + "path": "skills/media-processing/references/imagemagick-batch.md", + "sha256": "e9aefed340939dc2241df5a45f82a49516bd348b6340d2acb42b4a3cfdf30537" + }, + { + "path": "skills/media-processing/references/imagemagick-editing.md", + "sha256": "61f9e9f4d7266db2ed6bd7774f4790c4146fab07c127413a1037b53af8096c0e" + }, + { + "path": "skills/media-processing/references/ffmpeg-streaming.md", + "sha256": "087eab32cf7336e7815158635a4487db9640d038a22bb01a269277393c5b510f" + }, + { + "path": "skills/media-processing/references/ffmpeg-filters.md", + "sha256": "347447ae305410e5bb72a4934ad4fadeb91fdc12435551b038a7f895a49a72a0" + }, + { + "path": "skills/media-processing/scripts/requirements.txt", + "sha256": "656461e5a959cc78eda21807a73b5c20e78e6bd116fa89c7606c82eeacaab221" + }, + { + "path": "skills/media-processing/scripts/video_optimize.py", + "sha256": "c2df1f79fc8314b94bade5e7ceee7b4d54459402a7f91bf12ebe71b640746b68" + }, + { + "path": "skills/media-processing/scripts/media_convert.py", + "sha256": "896961d11de1f7040243e388a82b30295e3224ec7b0afb4d023f4579c832e709" + }, + { + "path": "skills/media-processing/scripts/batch_resize.py", + "sha256": "ce8e55a5546a0c49b84c8e97021a875db7be83f2f8f1a53ee1285e64d4e0b60e" + }, + { + "path": "skills/media-processing/scripts/tests/requirements.txt", + "sha256": "7f336e73b484fac1a0807a6cfba48eefe79c12f3c348d988a708dda2d6df6d14" + }, + { + "path": "skills/media-processing/scripts/tests/.coverage", + "sha256": "86e832012e03a2fbebbed563c84e3fbfae6f6fa379db99696301593fa40790bd" + }, + { + "path": "skills/media-processing/scripts/tests/test_video_optimize.py", + "sha256": "483f996459f251f9f15affaca183e9bee558432a7b49903a87fff9d7da244273" + }, + { + "path": "skills/media-processing/scripts/tests/test_batch_resize.py", + "sha256": "ce898f6a8f10596399a4dfaadb1abc9e2a133e18e375e1b6a5b5e56faf6d0033" + }, + { + "path": "skills/media-processing/scripts/tests/test_media_convert.py", + "sha256": "c067e325304b7cb84701a1481e2aa077ce8a29a04799a1b31a7768342d4c1f20" + }, + { + "path": "skills/chrome-devtools/SKILL.md", + "sha256": "893563ea6ee597b247f289589aeb6d20fb0f53b204be62d125cb79083e776664" + }, + { + "path": "skills/chrome-devtools/references/puppeteer-reference.md", + "sha256": "dcec4af083b7fe2d405a40aad2b511317c1e9deaa2bec5ca91ec9959aff1a423" + }, + { + "path": "skills/chrome-devtools/references/cdp-domains.md", + "sha256": "fa81fa0fda1ae94ff2c25c924cfcb16f5a6e967b6d8412ddd372dc8a1b26f601" + }, + { + "path": "skills/chrome-devtools/references/performance-guide.md", + "sha256": "e9391edae861ecd6fac3af4384a7e692def9c9cacbb8c5d4f7ff207475f977c0" + }, + { + "path": "skills/chrome-devtools/scripts/navigate.js", + "sha256": "773cbd9a016a4db1fcaef9e0c84e8dbc4629a7207d10569d2b04ee5511ab2273" + }, + { + "path": "skills/chrome-devtools/scripts/console.js", + "sha256": "0ff057a2ac4f158668f24ccb9795d7c6d9869ca871fb5507a61092d54ff81ef5" + }, + { + "path": "skills/chrome-devtools/scripts/install.sh", + "sha256": "bdd10bfe4195ca105704c2b4ba2f641f484e3706bb6cf57030372e5a90aa10df" + }, + { + "path": "skills/chrome-devtools/scripts/click.js", + "sha256": "b81d416174532d5ec0a89a6a631550539356e67722e636da9c868c36125f88fd" + }, + { + "path": "skills/chrome-devtools/scripts/fill.js", + "sha256": "9c8b358f6ba8d1274c83ece268cb368e2f7546c001f9b9a229a80bc0e0edd516" + }, + { + "path": "skills/chrome-devtools/scripts/README.md", + "sha256": "4059687091606bace882d492073b8561eb23590994881df46f499276107e1667" + }, + { + "path": "skills/chrome-devtools/scripts/performance.js", + "sha256": "bc97995f6664336683281f47795bd8005b4bb8d8ff8ebcc952becb32cf1c668f" + }, + { + "path": "skills/chrome-devtools/scripts/screenshot.js", + "sha256": "4ec07a8c8b42ed1d7291eba0c41afd924e5c52cb2cf36a544b19375fcc6b19e7" + }, + { + "path": "skills/chrome-devtools/scripts/evaluate.js", + "sha256": "00bb01206f3a15cf33098f9ee19524fd733574bd484c99de62e9153547028d55" + }, + { + "path": "skills/chrome-devtools/scripts/package.json", + "sha256": "1ca3a64a645d7b1d2cace2256d2e3d459a6abbcfab0417429423e6fc97ce9b59" + }, + { + "path": "skills/chrome-devtools/scripts/snapshot.js", + "sha256": "d969e885d1b5a1140616282221b655117156a1bb6b4a72be9d3da02cebce04e5" + }, + { + "path": "skills/chrome-devtools/scripts/install-deps.sh", + "sha256": "c236b59df7a234079da05bdb18c22f064955a3ccea315431fa87f8a413ecc78c" + }, + { + "path": "skills/chrome-devtools/scripts/network.js", + "sha256": "492c542775a90443628ad5ae859ff556338e0b24cbd6bae23e754cca2f23b2cd" + }, + { + "path": "skills/chrome-devtools/scripts/__tests__/selector.test.js", + "sha256": "69fa6c4d04f2c8d0578325894fc06ea820e4c771d267d90969f09343e076d5e2" + }, + { + "path": "skills/chrome-devtools/scripts/lib/selector.js", + "sha256": "0c988abbec29cfb7361920c773673191a94d99c01fe1aec5a42cb48aeb6acf88" + }, + { + "path": "skills/chrome-devtools/scripts/lib/browser.js", + "sha256": "3cc9cee00475f8827b225c7f56da566a0cfb8acb8eb61557f951cb58f17f057d" + }, + { + "path": "skills/ai-multimodal/SKILL.md", + "sha256": "492e291734b1039cd7c8782fd9dc6364e7befd00fa8a26255caa8c7446146fe7" + }, + { + "path": "skills/ai-multimodal/.env.example", + "sha256": "b0786e193dfc47356939b55ae8bab12f912bf81e85bb7de9ca7155cb96a16907" + }, + { + "path": "skills/ai-multimodal/references/vision-understanding.md", + "sha256": "08de168674d3405d1c8336918a21000502cfac0ca42ba067547032fff7556afa" + }, + { + "path": "skills/ai-multimodal/references/image-generation.md", + "sha256": "1880b9e98accb7fab9b57560eeff60bc83e8519c9cee5c6192c704432f57dd8d" + }, + { + "path": "skills/ai-multimodal/references/video-analysis.md", + "sha256": "33a0df1a26b82f0f8fd9a906adfec5c969d8f792387d38cd8e89659c4ee5e4be" + }, + { + "path": "skills/ai-multimodal/references/audio-processing.md", + "sha256": "e9f9009a8ce32971470d17ecb015e37e6939d30d60acfda833afb783f044b131" + }, + { + "path": "skills/ai-multimodal/scripts/media_optimizer.py", + "sha256": "bbc3f9e1791f8cfe1bf743bcd743cf6474d8155398a7c9772fbbf5c2c6d1a6e4" + }, + { + "path": "skills/ai-multimodal/scripts/requirements.txt", + "sha256": "ca4e56c561613a6ed22b7b9b7f2853ea8f1bbe3adfc9b52103de0df7d65fae6b" + }, + { + "path": "skills/ai-multimodal/scripts/document_converter.py", + "sha256": "2067076f1476469b86c21972429643080a7a3ee6b479fb6df61285cf6269b5cb" + }, + { + "path": "skills/ai-multimodal/scripts/gemini_batch_process.py", + "sha256": "797d5670fa2a764e366a246c71b4cea8b69f4cc1725bf0411429132aff3b7b70" + }, + { + "path": "skills/ai-multimodal/scripts/tests/test_gemini_batch_process.py", + "sha256": "799bec3975ba750c056b35090148325811b1c9488ae50c05e98ac651cf9e6dfc" + }, + { + "path": "skills/ai-multimodal/scripts/tests/requirements.txt", + "sha256": "15a909ef1471e0d1fe9d523c3b02016f01abc9ab3a81b8d047a1222c3f3af2d2" + }, + { + "path": "skills/ai-multimodal/scripts/tests/.coverage", + "sha256": "22ebc0a59ff873aea8cbc28fd41651e572fdd9c5c664a0211910115679e9d79f" + }, + { + "path": "skills/ai-multimodal/scripts/tests/test_document_converter.py", + "sha256": "df6b645e5387eed2e1d1f298534870183352dde953545f254cb9957690294b52" + }, + { + "path": "skills/ai-multimodal/scripts/tests/test_media_optimizer.py", + "sha256": "8451512f789ad3519dccac1ab8221397c85188abfd528c8f9d66cecccdfd8040" + }, + { + "path": "skills/google-adk-python/SKILL.md", + "sha256": "edf5cc1d1a29fc329d425d15761e0286e431d7238c8738380181402fe09911d8" + }, + { + "path": "skills/repomix/SKILL.md", + "sha256": "0e3dafc01cef2097206eb0abee0442d5e8f8bc0307d596068950b0223320fe8f" + }, + { + "path": "skills/repomix/references/configuration.md", + "sha256": "a40386b0ca68061d03ba2ae6d1a8109306895b13699e1e05bd4b8b4c4fa7912d" + }, + { + "path": "skills/repomix/references/usage-patterns.md", + "sha256": "6e28825bf0c97e05dc367ba590487a6ecb0b3b530ff3540429d761b03b2ad1ed" + }, + { + "path": "skills/repomix/scripts/repos.example.json", + "sha256": "5086bf887ccc032abd23715c7ed278bf9844b035068b25469ed4c00435e079fd" + }, + { + "path": "skills/repomix/scripts/repomix_batch.py", + "sha256": "3b46726af95b84e0a37035f754107d2739ce1a1c95083305e166f34ab0975449" + }, + { + "path": "skills/repomix/scripts/requirements.txt", + "sha256": "abb821f8ee3248ea5c82c2d4b3c103ff411f1f44b484398186844487cf4bd9a6" + }, + { + "path": "skills/repomix/scripts/.coverage", + "sha256": "71b5da5bcfbc4f90b8cc493576c15aad70adf984b5ea7bbf5e6b294706e7ee35" + }, + { + "path": "skills/repomix/scripts/README.md", + "sha256": "0b300fe9fe67e8354085a30c7623967c444dad4bd82f5c985cecbef471fc44a4" + }, + { + "path": "skills/repomix/scripts/tests/test_repomix_batch.py", + "sha256": "4f15a013b4f1a92cc73e9fc8d1029ebb05fb8c68ac6c6a9c82d56cefd462db18" + }, + { + "path": "skills/docs-seeker/WORKFLOWS.md", + "sha256": "7ce5df192e19e0025aab94cb51e6820649df7e9e646010ba3f72f898cef8f44a" + }, + { + "path": "skills/docs-seeker/SKILL.md", + "sha256": "5f94bb0b9df6b53db457934cbb69fd440a78616e11a1e99b272f7e4849c49e7f" + }, + { + "path": "skills/docs-seeker/references/tool-selection.md", + "sha256": "ebec69a5ee56692d67544c3597ba76ce2cc787849eda634395c203e9088074e4" + }, + { + "path": "skills/docs-seeker/references/best-practices.md", + "sha256": "36f68db5da47052f5953f2a15a00f7e059440c0bd7fbc9e35df1e617ce69c14e" + }, + { + "path": "skills/docs-seeker/references/limitations.md", + "sha256": "89593bcf2e35ea022a99b141d8b54255a0ccbce29b6409e3df4d8199c65299dd" + }, + { + "path": "skills/docs-seeker/references/documentation-sources.md", + "sha256": "ad6a52d78d4157287f96d1d54c8dbd2d9a0d00e75b7453acc791f67f4697cca9" + }, + { + "path": "skills/docs-seeker/references/performance.md", + "sha256": "f9234c698dab3a666d1432ec8ac91572c259140d08e99ce5c8ace883c52a526a" + }, + { + "path": "skills/docs-seeker/references/error-handling.md", + "sha256": "0ba635c66ceb5abe172334737ee5fcd5e92cfa63f708e3951883c3e60471a0e5" + }, + { + "path": "skills/better-auth/SKILL.md", + "sha256": "bdf843e14e064b435cba53ae40e9b8d8fa8a0bbdbcb5abf5edbc8620d17822a6" + }, + { + "path": "skills/better-auth/references/database-integration.md", + "sha256": "89a873e8462dca550439e75e2dd1721835127239b5bdd71ba2cac58d35571841" + }, + { + "path": "skills/better-auth/references/email-password-auth.md", + "sha256": "a96376f55c00c2c7f0ff63bffd656fb4426a51c16c49868a7a5045b29d1bc7e3" + }, + { + "path": "skills/better-auth/references/advanced-features.md", + "sha256": "cf67bd65f28f1c09281a18c2a30e4adc425403dd688de3a3396dc8d0a5c47708" + }, + { + "path": "skills/better-auth/references/oauth-providers.md", + "sha256": "5201dc2715fe4e385e9363e67adcdeb3dd0e0879c42158e7846dd02138cf8684" + }, + { + "path": "skills/better-auth/scripts/requirements.txt", + "sha256": "4c164fd0ed4d81bba6da0852149aadfc80e169e488b86ba33cc757a3cc64bff2" + }, + { + "path": "skills/better-auth/scripts/.coverage", + "sha256": "f0eb0985b3a10c20dc53020b9aee09cbabd40e96955c47085f5678e437560528" + }, + { + "path": "skills/better-auth/scripts/better_auth_init.py", + "sha256": "82d44a3d8962186a81f7ca1615c965aae7a706332a8ab7b899d038d3c2aff69a" + }, + { + "path": "skills/better-auth/scripts/tests/test_better_auth_init.py", + "sha256": "134df79e3238f4e15e2a7c8df4d9db4c143e404a7454f8f618e56374e0de8a94" + }, + { + "path": "skills/better-auth/scripts/tests/.coverage", + "sha256": "0c77c29e26616abf909054079e5b0d0e316329e09ab0569c330e0cd2e22caa66" + }, + { + "path": "skills/web-frameworks/SKILL.md", + "sha256": "0af5655f3cb77323b3b0c6bf73ff6e9a1909ac710ab4921d306937fee964aeda" + }, + { + "path": "skills/web-frameworks/references/nextjs-data-fetching.md", + "sha256": "5175463a517033aa8d3295c4fc7420db57726017450ec637d413afcb541d3e9f" + }, + { + "path": "skills/web-frameworks/references/turborepo-setup.md", + "sha256": "429c0ed6cab365a18f3ecfb12df741ece4b4e5bc2052719b6dec97f496f81d8f" + }, + { + "path": "skills/web-frameworks/references/nextjs-optimization.md", + "sha256": "20b66c2e616ea9af62e1b3a07b68069dfc2df1574dfca6bce2e00a427ac76498" + }, + { + "path": "skills/web-frameworks/references/nextjs-server-components.md", + "sha256": "afce90a0cd5175832212bd03fdf692ee5ad7154dfce0aeb75f153fc244224801" + }, + { + "path": "skills/web-frameworks/references/turborepo-pipelines.md", + "sha256": "64279184f245524dbcaf94e29b48c56fb766e41af36e4ad92253edcc648dfe5e" + }, + { + "path": "skills/web-frameworks/references/nextjs-app-router.md", + "sha256": "bca2cc4b417f2e7aaff4a7491bdd84a10ac03ad4b42839b8c91181b5851a69c7" + }, + { + "path": "skills/web-frameworks/references/turborepo-caching.md", + "sha256": "2d7056c6cc5b5041e23c2f7bd0ff26da685c6e9513a9271803ae93bc0d2be1e2" + }, + { + "path": "skills/web-frameworks/references/remix-icon-integration.md", + "sha256": "5f2d77e5b1ddc512e2be1f270e55435e372b332e98ed56d8a399ec48226405a3" + }, + { + "path": "skills/web-frameworks/scripts/requirements.txt", + "sha256": "89002a89d32b1f2e71cfb62fe48fe52580aedd6001a4fe761fd1edb8f727af28" + }, + { + "path": "skills/web-frameworks/scripts/turborepo_migrate.py", + "sha256": "d31252fe5cf3fe7ed89ded47e8f0b76ac945a84cec2c7ae77c964b4e2c7934a1" + }, + { + "path": "skills/web-frameworks/scripts/.coverage", + "sha256": "b27f663f8575c76587c3073b7aab28e6e5d26af02d95abfd55417e142e7268bf" + }, + { + "path": "skills/web-frameworks/scripts/__init__.py", + "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + }, + { + "path": "skills/web-frameworks/scripts/nextjs_init.py", + "sha256": "8aefe43870792d3690434d56db6ee927d7785302e3e98ff4abbb0b80f3a54b9c" + }, + { + "path": "skills/web-frameworks/scripts/tests/requirements.txt", + "sha256": "0795bdcfb80afae0ff06e9cbe5bed67b39283e577984bed87d85da3fec798a37" + }, + { + "path": "skills/web-frameworks/scripts/tests/test_turborepo_migrate.py", + "sha256": "2897b6b62df7a8b7d029520ac32a92634e6a19d3cac043ff4ffc3441c014c671" + }, + { + "path": "skills/web-frameworks/scripts/tests/test_nextjs_init.py", + "sha256": "770ea000b05f56cf199aa93533a070afd2f6113dd1f7a975de0a5ef0168f64d9" + }, + { + "path": "skills/web-frameworks/scripts/tests/coverage-web.json", + "sha256": "737e65f4900cc685612f9831ebd622567efb3bd7de55dd9d97b5aa2e04cd89ec" + }, + { + "path": "skills/mcp-builder/SKILL.md", + "sha256": "b1010e90adcb8fd6bf57640df34ab6454fbf7e4216e150a4620f7caccadc4e63" + }, + { + "path": "skills/mcp-builder/LICENSE.txt", + "sha256": "58d1e17ffe5109a7ae296caafcadfdbe6a7d176f0bc4ab01e12a689b0499d8bd" + }, + { + "path": "skills/mcp-builder/scripts/requirements.txt", + "sha256": "d5d7558b2368ecea9dfeed7d1fbc71ee9e0750bebd1282faa527d528a344c3c7" + }, + { + "path": "skills/mcp-builder/scripts/evaluation.py", + "sha256": "49ed1d17cdce5da101b210197740713f49b935c29d4f339542a14b132658e6f7" + }, + { + "path": "skills/mcp-builder/scripts/connections.py", + "sha256": "9403668a2041568772082a8b334122c1f88daf0541fb393af4522d0094a47a6e" + }, + { + "path": "skills/mcp-builder/scripts/example_evaluation.xml", + "sha256": "9272b348ddcc4b06ba562367ccd0770e018158c0068ac5116d5e34aaeff8777a" + }, + { + "path": "skills/mcp-builder/reference/mcp_best_practices.md", + "sha256": "3bdf013379bdd3c198baccd0f183441c710fc7cae07ba4c6f8f8048276519688" + }, + { + "path": "skills/mcp-builder/reference/python_mcp_server.md", + "sha256": "4e6db48188f44ff4eb707f50b8d273d5d18af4b88d326f7a26f03a405064bc0b" + }, + { + "path": "skills/mcp-builder/reference/node_mcp_server.md", + "sha256": "40b03e9c07463d5db524c1f5140ef60713fdd911c2f4386f89e0b94d43b8764e" + }, + { + "path": "skills/mcp-builder/reference/evaluation.md", + "sha256": "8c99479f8a2d22a636c38e274537aac3610879e26f34e0709825077c4576f427" + }, + { + "path": "skills/sequential-thinking/README.md", + "sha256": "3571f9547aa31d7b316caa4dbc636ce9dba03ffacd73f2d3d9eef57bb3de6f22" + }, + { + "path": "skills/sequential-thinking/SKILL.md", + "sha256": "c517ae710853ee2ea06111d72888fbe1eb432368b0a7984b9c31391ecbc2ef27" + }, + { + "path": "skills/sequential-thinking/references/examples.md", + "sha256": "f0bd40121128acf978ebbaa5761c79efbfe3ca11e49a473ef62d8d7bd0f58f8f" + }, + { + "path": "skills/sequential-thinking/references/advanced.md", + "sha256": "94a5fe40dd7150612374578b389a8428c3e69cc02e838007185a0d60aee75cdf" + } + ], + "dirSha256": "67012aff99a0fb8498e5a2234fd4ab558e85c60d85822d5ab8a35ad610581bf2" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/ai-multimodal/.env.example b/skills/ai-multimodal/.env.example new file mode 100644 index 0000000..53663c4 --- /dev/null +++ b/skills/ai-multimodal/.env.example @@ -0,0 +1,97 @@ +# Google Gemini API Configuration + +# ============================================================================ +# OPTION 1: Google AI Studio (Default - Recommended for most users) +# ============================================================================ +# Get your API key: https://aistudio.google.com/apikey +GEMINI_API_KEY=your_api_key_here + +# ============================================================================ +# OPTION 2: Vertex AI (Google Cloud Platform) +# ============================================================================ +# Uncomment these lines to use Vertex AI instead of Google AI Studio +# GEMINI_USE_VERTEX=true +# VERTEX_PROJECT_ID=your-gcp-project-id +# VERTEX_LOCATION=us-central1 + +# ============================================================================ +# Model Selection (Optional) +# ============================================================================ +# Override default model for specific tasks +# Default: gemini-2.5-flash for most tasks +# GEMINI_MODEL=gemini-2.5-flash +# GEMINI_IMAGE_GEN_MODEL=gemini-2.5-flash-image + +# ============================================================================ +# Rate Limiting Configuration (Optional) +# ============================================================================ +# Requests per minute limit (adjust based on your tier) +# GEMINI_RPM_LIMIT=15 + +# Tokens per minute limit +# GEMINI_TPM_LIMIT=4000000 + +# Requests per day limit +# GEMINI_RPD_LIMIT=1500 + +# ============================================================================ +# Processing Options (Optional) +# ============================================================================ +# Video resolution mode: default or low-res +# low-res uses ~100 tokens/second vs ~300 for default +# GEMINI_VIDEO_RESOLUTION=default + +# Audio quality: default (16 Kbps mono, auto-downsampled) +# GEMINI_AUDIO_QUALITY=default + +# PDF processing mode: inline (<20MB) or file-api (>20MB, automatic) +# GEMINI_PDF_MODE=auto + +# ============================================================================ +# Retry Configuration (Optional) +# ============================================================================ +# Maximum retry attempts for failed requests +# GEMINI_MAX_RETRIES=3 + +# Initial retry delay in seconds (uses exponential backoff) +# GEMINI_RETRY_DELAY=1 + +# ============================================================================ +# Output Configuration (Optional) +# ============================================================================ +# Default output directory for generated images +# OUTPUT_DIR=./output + +# Image output format (png or jpeg) +# IMAGE_FORMAT=png + +# Image quality for JPEG (1-100) +# IMAGE_QUALITY=95 + +# ============================================================================ +# Context Caching (Optional) +# ============================================================================ +# Enable context caching for repeated queries on same file +# GEMINI_ENABLE_CACHING=true + +# Cache TTL in seconds (default: 1800 = 30 minutes) +# GEMINI_CACHE_TTL=1800 + +# ============================================================================ +# Logging (Optional) +# ============================================================================ +# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL +# LOG_LEVEL=INFO + +# Log file path +# LOG_FILE=./logs/gemini.log + +# ============================================================================ +# Notes +# ============================================================================ +# 1. Never commit API keys to version control +# 2. Add .env to .gitignore +# 3. API keys can be restricted in Google Cloud Console +# 4. Monitor usage at: https://aistudio.google.com/apikey +# 5. Free tier limits: 15 RPM, 1M-4M TPM, 1,500 RPD +# 6. Vertex AI requires GCP authentication via gcloud CLI diff --git a/skills/ai-multimodal/SKILL.md b/skills/ai-multimodal/SKILL.md new file mode 100644 index 0000000..aa33d45 --- /dev/null +++ b/skills/ai-multimodal/SKILL.md @@ -0,0 +1,357 @@ +--- +name: ai-multimodal +description: Process and generate multimedia content using Google Gemini API. Capabilities include analyze audio files (transcription with timestamps, summarization, speech understanding, music/sound analysis up to 9.5 hours), understand images (captioning, object detection, OCR, visual Q&A, segmentation), process videos (scene detection, Q&A, temporal analysis, YouTube URLs, up to 6 hours), extract from documents (PDF tables, forms, charts, diagrams, multi-page), generate images (text-to-image, editing, composition, refinement). Use when working with audio/video files, analyzing images or screenshots, processing PDF documents, extracting structured data from media, creating images from text prompts, or implementing multimodal AI features. Supports multiple models (Gemini 2.5/2.0) with context windows up to 2M tokens. +license: MIT +allowed-tools: + - Bash + - Read + - Write + - Edit +--- + +# AI Multimodal Processing Skill + +Process audio, images, videos, documents, and generate images using Google Gemini's multimodal API. Unified interface for all multimedia content understanding and generation. + +## Core Capabilities + +### Audio Processing +- Transcription with timestamps (up to 9.5 hours) +- Audio summarization and analysis +- Speech understanding and speaker identification +- Music and environmental sound analysis +- Text-to-speech generation with controllable voice + +### Image Understanding +- Image captioning and description +- Object detection with bounding boxes (2.0+) +- Pixel-level segmentation (2.5+) +- Visual question answering +- Multi-image comparison (up to 3,600 images) +- OCR and text extraction + +### Video Analysis +- Scene detection and summarization +- Video Q&A with temporal understanding +- Transcription with visual descriptions +- YouTube URL support +- Long video processing (up to 6 hours) +- Frame-level analysis + +### Document Extraction +- Native PDF vision processing (up to 1,000 pages) +- Table and form extraction +- Chart and diagram analysis +- Multi-page document understanding +- Structured data output (JSON schema) +- Format conversion (PDF to HTML/JSON) + +### Image Generation +- Text-to-image generation +- Image editing and modification +- Multi-image composition (up to 3 images) +- Iterative refinement +- Multiple aspect ratios (1:1, 16:9, 9:16, 4:3, 3:4) +- Controllable style and quality + +## Capability Matrix + +| Task | Audio | Image | Video | Document | Generation | +|------|:-----:|:-----:|:-----:|:--------:|:----------:| +| Transcription | ✓ | - | ✓ | - | - | +| Summarization | ✓ | ✓ | ✓ | ✓ | - | +| Q&A | ✓ | ✓ | ✓ | ✓ | - | +| Object Detection | - | ✓ | ✓ | - | - | +| Text Extraction | - | ✓ | - | ✓ | - | +| Structured Output | ✓ | ✓ | ✓ | ✓ | - | +| Creation | TTS | - | - | - | ✓ | +| Timestamps | ✓ | - | ✓ | - | - | +| Segmentation | - | ✓ | - | - | - | + +## Model Selection Guide + +### Gemini 2.5 Series (Recommended) +- **gemini-2.5-pro**: Highest quality, all features, 1M-2M context +- **gemini-2.5-flash**: Best balance, all features, 1M-2M context +- **gemini-2.5-flash-lite**: Lightweight, segmentation support +- **gemini-2.5-flash-image**: Image generation only + +### Gemini 2.0 Series +- **gemini-2.0-flash**: Fast processing, object detection +- **gemini-2.0-flash-lite**: Lightweight option + +### Feature Requirements +- **Segmentation**: Requires 2.5+ models +- **Object Detection**: Requires 2.0+ models +- **Multi-video**: Requires 2.5+ models +- **Image Generation**: Requires flash-image model + +### Context Windows +- **2M tokens**: ~6 hours video (low-res) or ~2 hours (default) +- **1M tokens**: ~3 hours video (low-res) or ~1 hour (default) +- **Audio**: 32 tokens/second (1 min = 1,920 tokens) +- **PDF**: 258 tokens/page (fixed) +- **Image**: 258-1,548 tokens based on size + +## Quick Start + +### Prerequisites + +**API Key Setup**: Supports both Google AI Studio and Vertex AI. + +The skill checks for `GEMINI_API_KEY` in this order: +1. Process environment: `export GEMINI_API_KEY="your-key"` +2. Project root: `.env` +3. `.claude/.env` +4. `.claude/skills/.env` +5. `.claude/skills/ai-multimodal/.env` + +**Get API key**: https://aistudio.google.com/apikey + +**For Vertex AI**: +```bash +export GEMINI_USE_VERTEX=true +export VERTEX_PROJECT_ID=your-gcp-project-id +export VERTEX_LOCATION=us-central1 # Optional +``` + +**Install SDK**: +```bash +pip install google-genai python-dotenv pillow +``` + +### Common Patterns + +**Transcribe Audio**: +```bash +python scripts/gemini_batch_process.py \ + --files audio.mp3 \ + --task transcribe \ + --model gemini-2.5-flash +``` + +**Analyze Image**: +```bash +python scripts/gemini_batch_process.py \ + --files image.jpg \ + --task analyze \ + --prompt "Describe this image" \ + --output docs/assets/.md \ + --model gemini-2.5-flash +``` + +**Process Video**: +```bash +python scripts/gemini_batch_process.py \ + --files video.mp4 \ + --task analyze \ + --prompt "Summarize key points with timestamps" \ + --output docs/assets/.md \ + --model gemini-2.5-flash +``` + +**Extract from PDF**: +```bash +python scripts/gemini_batch_process.py \ + --files document.pdf \ + --task extract \ + --prompt "Extract table data as JSON" \ + --output docs/assets/.md \ + --format json +``` + +**Generate Image**: +```bash +python scripts/gemini_batch_process.py \ + --task generate \ + --prompt "A futuristic city at sunset" \ + --output docs/assets/ \ + --model gemini-2.5-flash-image \ + --aspect-ratio 16:9 +``` + +**Optimize Media**: +```bash +# Prepare large video for processing +python scripts/media_optimizer.py \ + --input large-video.mp4 \ + --output docs/assets/ \ + --target-size 100MB + +# Batch optimize multiple files +python scripts/media_optimizer.py \ + --input-dir ./videos \ + --output-dir docs/assets/optimized \ + --quality 85 +``` + +**Convert Documents to Markdown**: +```bash +# Convert to PDF +python scripts/document_converter.py \ + --input document.docx \ + --output docs/assets/document.md + +# Extract pages +python scripts/document_converter.py \ + --input large.pdf \ + --output docs/assets/chapter1.md \ + --pages 1-20 +``` + +## Supported Formats + +### Audio +- WAV, MP3, AAC, FLAC, OGG Vorbis, AIFF +- Max 9.5 hours per request +- Auto-downsampled to 16 Kbps mono + +### Images +- PNG, JPEG, WEBP, HEIC, HEIF +- Max 3,600 images per request +- Resolution: ≤384px = 258 tokens, larger = tiled + +### Video +- MP4, MPEG, MOV, AVI, FLV, MPG, WebM, WMV, 3GPP +- Max 6 hours (low-res) or 2 hours (default) +- YouTube URLs supported (public only) + +### Documents +- PDF only for vision processing +- Max 1,000 pages +- TXT, HTML, Markdown supported (text-only) + +### Size Limits +- **Inline**: <20MB total request +- **File API**: 2GB per file, 20GB project quota +- **Retention**: 48 hours auto-delete + +## Reference Navigation + +For detailed implementation guidance, see: + +### Audio Processing +- `references/audio-processing.md` - Transcription, analysis, TTS + - Timestamp handling and segment analysis + - Multi-speaker identification + - Non-speech audio analysis + - Text-to-speech generation + +### Image Understanding +- `references/vision-understanding.md` - Captioning, detection, OCR + - Object detection and localization + - Pixel-level segmentation + - Visual question answering + - Multi-image comparison + +### Video Analysis +- `references/video-analysis.md` - Scene detection, temporal understanding + - YouTube URL processing + - Timestamp-based queries + - Video clipping and FPS control + - Long video optimization + +### Document Extraction +- `references/document-extraction.md` - PDF processing, structured output + - Table and form extraction + - Chart and diagram analysis + - JSON schema validation + - Multi-page handling + +### Image Generation +- `references/image-generation.md` - Text-to-image, editing + - Prompt engineering strategies + - Image editing and composition + - Aspect ratio selection + - Safety settings + +## Cost Optimization + +### Token Costs +**Input Pricing**: +- Gemini 2.5 Flash: $1.00/1M input, $0.10/1M output +- Gemini 2.5 Pro: $3.00/1M input, $12.00/1M output +- Gemini 1.5 Flash: $0.70/1M input, $0.175/1M output + +**Token Rates**: +- Audio: 32 tokens/second (1 min = 1,920 tokens) +- Video: ~300 tokens/second (default) or ~100 (low-res) +- PDF: 258 tokens/page (fixed) +- Image: 258-1,548 tokens based on size + +**TTS Pricing**: +- Flash TTS: $10/1M tokens +- Pro TTS: $20/1M tokens + +### Best Practices +1. Use `gemini-2.5-flash` for most tasks (best price/performance) +2. Use File API for files >20MB or repeated queries +3. Optimize media before upload (see `media_optimizer.py`) +4. Process specific segments instead of full videos +5. Use lower FPS for static content +6. Implement context caching for repeated queries +7. Batch process multiple files in parallel + +## Rate Limits + +**Free Tier**: +- 10-15 RPM (requests per minute) +- 1M-4M TPM (tokens per minute) +- 1,500 RPD (requests per day) + +**YouTube Limits**: +- Free tier: 8 hours/day +- Paid tier: No length limits +- Public videos only + +**Storage Limits**: +- 20GB per project +- 2GB per file +- 48-hour retention + +## Error Handling + +Common errors and solutions: +- **400**: Invalid format/size - validate before upload +- **401**: Invalid API key - check configuration +- **403**: Permission denied - verify API key restrictions +- **404**: File not found - ensure file uploaded and active +- **429**: Rate limit exceeded - implement exponential backoff +- **500**: Server error - retry with backoff + +## Scripts Overview + +All scripts support unified API key detection and error handling: + +**gemini_batch_process.py**: Batch process multiple media files +- Supports all modalities (audio, image, video, PDF) +- Progress tracking and error recovery +- Output formats: JSON, Markdown, CSV +- Rate limiting and retry logic +- Dry-run mode + +**media_optimizer.py**: Prepare media for Gemini API +- Compress videos/audio for size limits +- Resize images appropriately +- Split long videos into chunks +- Format conversion +- Quality vs size optimization + +**document_converter.py**: Convert documents to PDF +- Convert DOCX, XLSX, PPTX to PDF +- Extract page ranges +- Optimize PDFs for Gemini +- Extract images from PDFs +- Batch conversion support + +Run any script with `--help` for detailed usage. + +## Resources + +- [Audio API Docs](https://ai.google.dev/gemini-api/docs/audio) +- [Image API Docs](https://ai.google.dev/gemini-api/docs/image-understanding) +- [Video API Docs](https://ai.google.dev/gemini-api/docs/video-understanding) +- [Document API Docs](https://ai.google.dev/gemini-api/docs/document-processing) +- [Image Gen Docs](https://ai.google.dev/gemini-api/docs/image-generation) +- [Get API Key](https://aistudio.google.com/apikey) +- [Pricing](https://ai.google.dev/pricing) diff --git a/skills/ai-multimodal/references/audio-processing.md b/skills/ai-multimodal/references/audio-processing.md new file mode 100644 index 0000000..b99c889 --- /dev/null +++ b/skills/ai-multimodal/references/audio-processing.md @@ -0,0 +1,373 @@ +# Audio Processing Reference + +Comprehensive guide for audio analysis and speech generation using Gemini API. + +## Audio Understanding + +### Supported Formats + +| Format | MIME Type | Best Use | +|--------|-----------|----------| +| WAV | `audio/wav` | Uncompressed, highest quality | +| MP3 | `audio/mp3` | Compressed, widely compatible | +| AAC | `audio/aac` | Compressed, good quality | +| FLAC | `audio/flac` | Lossless compression | +| OGG Vorbis | `audio/ogg` | Open format | +| AIFF | `audio/aiff` | Apple format | + +### Specifications + +- **Maximum length**: 9.5 hours per request +- **Multiple files**: Unlimited count, combined max 9.5 hours +- **Token rate**: 32 tokens/second (1 minute = 1,920 tokens) +- **Processing**: Auto-downsampled to 16 Kbps mono +- **File size limits**: + - Inline: 20 MB max total request + - File API: 2 GB per file, 20 GB project quota + - Retention: 48 hours auto-delete + +## Transcription + +### Basic Transcription + +```python +from google import genai +import os + +client = genai.Client(api_key=os.getenv('GEMINI_API_KEY')) + +# Upload audio +myfile = client.files.upload(file='meeting.mp3') + +# Transcribe +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Generate a transcript of the speech.', myfile] +) +print(response.text) +``` + +### With Timestamps + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Generate transcript with timestamps in MM:SS format.', myfile] +) +``` + +### Multi-Speaker Identification + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Transcribe with speaker labels. Format: [Speaker 1], [Speaker 2], etc.', myfile] +) +``` + +### Segment-Specific Transcription + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Transcribe only the segment from 02:30 to 05:15.', myfile] +) +``` + +## Audio Analysis + +### Summarization + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Summarize key points in 5 bullets with timestamps.', myfile] +) +``` + +### Non-Speech Audio Analysis + +```python +# Music analysis +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Identify the musical instruments and genre.', myfile] +) + +# Environmental sounds +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Identify all sounds: voices, music, ambient noise.', myfile] +) + +# Birdsong identification +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Identify bird species based on their calls.', myfile] +) +``` + +### Timestamp-Based Analysis + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['What is discussed from 10:30 to 15:45? Provide key points.', myfile] +) +``` + +## Input Methods + +### File Upload (>20MB or Reuse) + +```python +# Upload once, use multiple times +myfile = client.files.upload(file='large-audio.mp3') + +# First query +response1 = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Transcribe this', myfile] +) + +# Second query (reuses same file) +response2 = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Summarize this', myfile] +) +``` + +### Inline Data (<20MB) + +```python +from google.genai import types + +with open('small-audio.mp3', 'rb') as f: + audio_bytes = f.read() + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Describe this audio', + types.Part.from_bytes(data=audio_bytes, mime_type='audio/mp3') + ] +) +``` + +## Speech Generation (TTS) + +### Available Models + +| Model | Quality | Speed | Cost/1M tokens | +|-------|---------|-------|----------------| +| `gemini-2.5-flash-native-audio-preview-09-2025` | High | Fast | $10 | +| `gemini-2.5-pro` TTS mode | Premium | Slower | $20 | + +### Basic TTS + +```python +response = client.models.generate_content( + model='gemini-2.5-flash-native-audio-preview-09-2025', + contents='Generate audio: Welcome to today\'s episode.' +) + +# Save audio +with open('output.wav', 'wb') as f: + f.write(response.audio_data) +``` + +### Controllable Voice Style + +```python +# Professional tone +response = client.models.generate_content( + model='gemini-2.5-flash-native-audio-preview-09-2025', + contents='Generate audio in a professional, clear tone: Welcome to our quarterly earnings call.' +) + +# Casual and friendly +response = client.models.generate_content( + model='gemini-2.5-flash-native-audio-preview-09-2025', + contents='Generate audio in a friendly, conversational tone: Hey there! Let\'s dive into today\'s topic.' +) + +# Narrative style +response = client.models.generate_content( + model='gemini-2.5-flash-native-audio-preview-09-2025', + contents='Generate audio in a narrative, storytelling tone: Once upon a time, in a land far away...' +) +``` + +### Voice Control Parameters + +- **Style**: Professional, casual, narrative, conversational +- **Pace**: Slow, normal, fast +- **Tone**: Friendly, serious, enthusiastic +- **Accent**: Natural language control (e.g., "British accent", "Southern drawl") + +## Best Practices + +### File Management + +1. Use File API for files >20MB +2. Use File API for repeated queries (saves tokens) +3. Files auto-delete after 48 hours +4. Clean up manually when done: + ```python + client.files.delete(name=myfile.name) + ``` + +### Prompt Engineering + +**Effective prompts**: +- "Transcribe from 02:30 to 03:29 in MM:SS format" +- "Identify speakers and extract dialogue with timestamps" +- "Summarize key points with relevant timestamps" +- "Transcribe and analyze sentiment for each speaker" + +**Context improves accuracy**: +- "This is a medical interview - use appropriate terminology" +- "Transcribe this legal deposition with precise terminology" +- "This is a technical podcast about machine learning" + +**Combined tasks**: +- "Transcribe and summarize in bullet points" +- "Extract key quotes with timestamps and speaker labels" +- "Transcribe and identify action items with timestamps" + +### Cost Optimization + +**Token calculation**: +- 1 minute audio = 1,920 tokens +- 1 hour audio = 115,200 tokens +- 9.5 hours = 1,094,400 tokens + +**Model selection**: +- Use `gemini-2.5-flash` ($1/1M tokens) for most tasks +- Upgrade to `gemini-2.5-pro` ($3/1M tokens) for complex analysis +- For high-volume: `gemini-1.5-flash` ($0.70/1M tokens) + +**Reduce costs**: +- Process only relevant segments using timestamps +- Use lower-quality audio when possible +- Batch multiple short files in one request +- Cache context for repeated queries + +### Error Handling + +```python +import time + +def transcribe_with_retry(file_path, max_retries=3): + """Transcribe audio with exponential backoff retry""" + for attempt in range(max_retries): + try: + myfile = client.files.upload(file=file_path) + response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Transcribe with timestamps', myfile] + ) + return response.text + except Exception as e: + if attempt == max_retries - 1: + raise + wait_time = 2 ** attempt + print(f"Retry {attempt + 1} after {wait_time}s") + time.sleep(wait_time) +``` + +## Common Use Cases + +### 1. Meeting Transcription + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Transcribe this meeting with: + 1. Speaker labels + 2. Timestamps for topic changes + 3. Action items highlighted + ''', + myfile + ] +) +``` + +### 2. Podcast Summary + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Create podcast summary with: + 1. Main topics with timestamps + 2. Key quotes from each speaker + 3. Recommended episode highlights + ''', + myfile + ] +) +``` + +### 3. Interview Analysis + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Analyze interview: + 1. Questions asked with timestamps + 2. Key responses from interviewee + 3. Overall sentiment and tone + ''', + myfile + ] +) +``` + +### 4. Content Verification + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Verify audio content: + 1. Check for specific keywords or phrases + 2. Identify any compliance issues + 3. Note any concerning statements with timestamps + ''', + myfile + ] +) +``` + +### 5. Multilingual Transcription + +```python +# Gemini auto-detects language +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Transcribe this audio and translate to English if needed.', myfile] +) +``` + +## Token Costs + +**Audio Input** (32 tokens/second): +- 1 minute = 1,920 tokens +- 10 minutes = 19,200 tokens +- 1 hour = 115,200 tokens +- 9.5 hours = 1,094,400 tokens + +**Example costs** (Gemini 2.5 Flash at $1/1M): +- 1 hour audio: 115,200 tokens = $0.12 +- Full day podcast (8 hours): 921,600 tokens = $0.92 + +## Limitations + +- Maximum 9.5 hours per request +- Auto-downsampled to 16 Kbps mono (quality loss) +- Files expire after 48 hours +- No real-time streaming support +- Non-speech audio less accurate than speech diff --git a/skills/ai-multimodal/references/image-generation.md b/skills/ai-multimodal/references/image-generation.md new file mode 100644 index 0000000..df18b7e --- /dev/null +++ b/skills/ai-multimodal/references/image-generation.md @@ -0,0 +1,558 @@ +# Image Generation Reference + +Comprehensive guide for image creation, editing, and composition using Gemini API. + +## Core Capabilities + +- **Text-to-Image**: Generate images from text prompts +- **Image Editing**: Modify existing images with text instructions +- **Multi-Image Composition**: Combine up to 3 images +- **Iterative Refinement**: Refine images conversationally +- **Aspect Ratios**: Multiple formats (1:1, 16:9, 9:16, 4:3, 3:4) +- **Style Control**: Control artistic style and quality +- **Text in Images**: Limited text rendering (max 25 chars) + +## Model + +**gemini-2.5-flash-image** - Specialized for image generation +- Input tokens: 65,536 +- Output tokens: 32,768 +- Knowledge cutoff: June 2025 +- Supports: Text and image inputs, image outputs + +## Quick Start + +### Basic Generation + +```python +from google import genai +from google.genai import types +import os + +client = genai.Client(api_key=os.getenv('GEMINI_API_KEY')) + +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='A serene mountain landscape at sunset with snow-capped peaks', + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='16:9' + ) +) + +# Save image +for i, part in enumerate(response.candidates[0].content.parts): + if part.inline_data: + with open(f'output-{i}.png', 'wb') as f: + f.write(part.inline_data.data) +``` + +## Aspect Ratios + +| Ratio | Resolution | Use Case | Token Cost | +|-------|-----------|----------|------------| +| 1:1 | 1024×1024 | Social media, avatars | 1290 | +| 16:9 | 1344×768 | Landscapes, banners | 1290 | +| 9:16 | 768×1344 | Mobile, portraits | 1290 | +| 4:3 | 1152×896 | Traditional media | 1290 | +| 3:4 | 896×1152 | Vertical posters | 1290 | + +All ratios cost the same: 1,290 tokens per image. + +## Response Modalities + +### Image Only + +```python +config = types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='1:1' +) +``` + +### Text Only (No Image) + +```python +config = types.GenerateContentConfig( + response_modalities=['text'] +) +# Returns text description instead of generating image +``` + +### Both Image and Text + +```python +config = types.GenerateContentConfig( + response_modalities=['image', 'text'], + aspect_ratio='16:9' +) +# Returns both generated image and description +``` + +## Image Editing + +### Modify Existing Image + +```python +import PIL.Image + +# Load original +img = PIL.Image.open('original.png') + +# Edit with instructions +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[ + 'Add a red balloon floating in the sky', + img + ], + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='16:9' + ) +) +``` + +### Style Transfer + +```python +img = PIL.Image.open('photo.jpg') + +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[ + 'Transform this into an oil painting style', + img + ] +) +``` + +### Object Addition/Removal + +```python +# Add object +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[ + 'Add a vintage car parked on the street', + img + ] +) + +# Remove object +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[ + 'Remove the person on the left side', + img + ] +) +``` + +## Multi-Image Composition + +### Combine Multiple Images + +```python +img1 = PIL.Image.open('background.png') +img2 = PIL.Image.open('foreground.png') +img3 = PIL.Image.open('overlay.png') + +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[ + 'Combine these images into a cohesive scene', + img1, + img2, + img3 + ], + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='16:9' + ) +) +``` + +**Note**: Recommended maximum 3 input images for best results. + +## Prompt Engineering + +### Effective Prompt Structure + +**Three key elements**: +1. **Subject**: What to generate +2. **Context**: Environmental setting +3. **Style**: Artistic treatment + +**Example**: "A robot [subject] in a futuristic city [context], cyberpunk style with neon lighting [style]" + +### Quality Modifiers + +**Technical terms**: +- "4K", "8K", "high resolution" +- "HDR", "high dynamic range" +- "professional photography" +- "studio lighting" +- "ultra detailed" + +**Camera settings**: +- "35mm lens", "50mm lens" +- "shallow depth of field" +- "wide angle shot" +- "macro photography" +- "golden hour lighting" + +### Style Keywords + +**Art styles**: +- "oil painting", "watercolor", "sketch" +- "digital art", "concept art" +- "photorealistic", "hyperrealistic" +- "minimalist", "abstract" +- "cyberpunk", "steampunk", "fantasy" + +**Mood and atmosphere**: +- "dramatic lighting", "soft lighting" +- "moody", "bright and cheerful" +- "mysterious", "whimsical" +- "dark and gritty", "pastel colors" + +### Subject Description + +**Be specific**: +- ❌ "A cat" +- ✅ "A fluffy orange tabby cat with green eyes" + +**Add context**: +- ❌ "A building" +- ✅ "A modern glass skyscraper reflecting sunset clouds" + +**Include details**: +- ❌ "A person" +- ✅ "A young woman in a red dress holding an umbrella" + +### Composition and Framing + +**Camera angles**: +- "bird's eye view", "aerial shot" +- "low angle", "high angle" +- "close-up", "wide shot" +- "centered composition" +- "rule of thirds" + +**Perspective**: +- "first person view" +- "third person perspective" +- "isometric view" +- "forced perspective" + +### Text in Images + +**Limitations**: +- Maximum 25 characters total +- Up to 3 distinct text phrases +- Works best with simple text + +**Best practices**: +```python +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='A vintage poster with bold text "EXPLORE" at the top, mountain landscape, retro 1950s style' +) +``` + +**Font control**: +- "bold sans-serif title" +- "handwritten script" +- "vintage letterpress" +- "modern minimalist font" + +## Advanced Techniques + +### Iterative Refinement + +```python +# Initial generation +response1 = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='A futuristic city skyline' +) + +# Save first version +with open('v1.png', 'wb') as f: + f.write(response1.candidates[0].content.parts[0].inline_data.data) + +# Refine +img = PIL.Image.open('v1.png') +response2 = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=[ + 'Add flying vehicles and neon signs', + img + ] +) +``` + +### Negative Prompts (Indirect) + +```python +# Instead of "no blur", be specific about what you want +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='A crystal clear, sharp photograph of a diamond ring with perfect focus and high detail' +) +``` + +### Consistent Style Across Images + +```python +base_prompt = "Digital art, vibrant colors, cel-shaded style, clean lines" + +prompts = [ + f"{base_prompt}, a warrior character", + f"{base_prompt}, a mage character", + f"{base_prompt}, a rogue character" +] + +for i, prompt in enumerate(prompts): + response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=prompt + ) + # Save each character +``` + +## Safety Settings + +### Configure Safety Filters + +```python +config = types.GenerateContentConfig( + response_modalities=['image'], + safety_settings=[ + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold=types.HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE + ), + types.SafetySetting( + category=types.HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, + threshold=types.HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE + ) + ] +) +``` + +### Available Categories + +- `HARM_CATEGORY_HATE_SPEECH` +- `HARM_CATEGORY_DANGEROUS_CONTENT` +- `HARM_CATEGORY_HARASSMENT` +- `HARM_CATEGORY_SEXUALLY_EXPLICIT` + +### Thresholds + +- `BLOCK_NONE`: No blocking +- `BLOCK_LOW_AND_ABOVE`: Block low probability and above +- `BLOCK_MEDIUM_AND_ABOVE`: Block medium and above (default) +- `BLOCK_ONLY_HIGH`: Block only high probability + +## Common Use Cases + +### 1. Marketing Assets + +```python +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='''Professional product photography: + - Sleek smartphone on minimalist white surface + - Dramatic side lighting creating subtle shadows + - Shallow depth of field, crisp focus + - Clean, modern aesthetic + - 4K quality + ''', + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='4:3' + ) +) +``` + +### 2. Concept Art + +```python +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='''Fantasy concept art: + - Ancient floating islands connected by chains + - Waterfalls cascading into clouds below + - Magical crystals glowing on the islands + - Epic scale, dramatic lighting + - Detailed digital painting style + ''', + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='16:9' + ) +) +``` + +### 3. Social Media Graphics + +```python +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='''Instagram post design: + - Pastel gradient background (pink to blue) + - Motivational quote layout + - Modern minimalist style + - Clean typography + - Mobile-friendly composition + ''', + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='1:1' + ) +) +``` + +### 4. Illustration + +```python +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='''Children's book illustration: + - Friendly cartoon dragon reading a book + - Bright, cheerful colors + - Soft, rounded shapes + - Whimsical forest background + - Warm, inviting atmosphere + ''', + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='4:3' + ) +) +``` + +### 5. UI/UX Mockups + +```python +response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents='''Modern mobile app interface: + - Clean dashboard design + - Card-based layout + - Soft shadows and gradients + - Contemporary color scheme (blue and white) + - Professional fintech aesthetic + ''', + config=types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='9:16' + ) +) +``` + +## Best Practices + +### Prompt Quality + +1. **Be specific**: More detail = better results +2. **Order matters**: Most important elements first +3. **Use examples**: Reference known styles or artists +4. **Avoid contradictions**: Don't ask for opposing styles +5. **Test and iterate**: Refine prompts based on results + +### File Management + +```python +# Save with descriptive names +timestamp = int(time.time()) +filename = f'generated_{timestamp}_{aspect_ratio}.png' + +with open(filename, 'wb') as f: + f.write(image_data) +``` + +### Cost Optimization + +**Token costs**: +- 1 image: 1,290 tokens = $0.00129 (Flash Image at $1/1M) +- 10 images: 12,900 tokens = $0.0129 +- 100 images: 129,000 tokens = $0.129 + +**Strategies**: +- Generate fewer iterations +- Use text modality first to validate concept +- Batch similar requests +- Cache prompts for consistent style + +## Error Handling + +### Safety Filter Blocking + +```python +try: + response = client.models.generate_content( + model='gemini-2.5-flash-image', + contents=prompt + ) +except Exception as e: + # Check block reason + if hasattr(e, 'prompt_feedback'): + print(f"Blocked: {e.prompt_feedback.block_reason}") + # Modify prompt and retry +``` + +### Token Limit Exceeded + +```python +# Keep prompts concise +if len(prompt) > 1000: + # Truncate or simplify + prompt = prompt[:1000] +``` + +## Limitations + +- Maximum 3 input images for composition +- Text rendering limited (25 chars max) +- No video or animation generation +- Regional restrictions (child images in EEA, CH, UK) +- Optimal language support: English, Spanish (Mexico), Japanese, Mandarin, Hindi +- No real-time generation +- Cannot perfectly replicate specific people or copyrighted characters + +## Troubleshooting + +### aspect_ratio Parameter Error + +**Error**: `Extra inputs are not permitted [type=extra_forbidden, input_value='1:1', input_type=str]` + +**Cause**: The `aspect_ratio` parameter must be nested inside an `image_config` object, not passed directly to `GenerateContentConfig`. + +**Incorrect Usage**: +```python +# ❌ This will fail +config = types.GenerateContentConfig( + response_modalities=['image'], + aspect_ratio='16:9' # Wrong - not a direct parameter +) +``` + +**Correct Usage**: +```python +# ✅ Correct implementation +config = types.GenerateContentConfig( + response_modalities=['Image'], # Note: Capital 'I' + image_config=types.ImageConfig( + aspect_ratio='16:9' + ) +) +``` + +### Response Modality Case Sensitivity + +The `response_modalities` parameter expects capital case values: +- ✅ Correct: `['Image']`, `['Text']`, `['Image', 'Text']` +- ❌ Wrong: `['image']`, `['text']` diff --git a/skills/ai-multimodal/references/video-analysis.md b/skills/ai-multimodal/references/video-analysis.md new file mode 100644 index 0000000..09870af --- /dev/null +++ b/skills/ai-multimodal/references/video-analysis.md @@ -0,0 +1,502 @@ +# Video Analysis Reference + +Comprehensive guide for video understanding, temporal analysis, and YouTube processing using Gemini API. + +## Core Capabilities + +- **Video Summarization**: Create concise summaries +- **Question Answering**: Answer specific questions about content +- **Transcription**: Audio transcription with visual descriptions +- **Timestamp References**: Query specific moments (MM:SS format) +- **Video Clipping**: Process specific segments +- **Scene Detection**: Identify scene changes and transitions +- **Multiple Videos**: Compare up to 10 videos (2.5+) +- **YouTube Support**: Analyze YouTube videos directly +- **Custom Frame Rate**: Adjust FPS sampling + +## Supported Formats + +- MP4, MPEG, MOV, AVI, FLV, MPG, WebM, WMV, 3GPP + +## Model Selection + +### Gemini 2.5 Series +- **gemini-2.5-pro**: Best quality, 1M-2M context +- **gemini-2.5-flash**: Balanced, 1M-2M context +- **gemini-2.5-flash-preview-09-2025**: Preview features, 1M context + +### Gemini 2.0 Series +- **gemini-2.0-flash**: Fast processing +- **gemini-2.0-flash-lite**: Lightweight option + +### Context Windows +- **2M token models**: ~2 hours (default) or ~6 hours (low-res) +- **1M token models**: ~1 hour (default) or ~3 hours (low-res) + +## Basic Video Analysis + +### Local Video + +```python +from google import genai +import os + +client = genai.Client(api_key=os.getenv('GEMINI_API_KEY')) + +# Upload video (File API for >20MB) +myfile = client.files.upload(file='video.mp4') + +# Wait for processing +import time +while myfile.state.name == 'PROCESSING': + time.sleep(1) + myfile = client.files.get(name=myfile.name) + +if myfile.state.name == 'FAILED': + raise ValueError('Video processing failed') + +# Analyze +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Summarize this video in 3 key points', myfile] +) +print(response.text) +``` + +### YouTube Video + +```python +from google.genai import types + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Summarize the main topics discussed', + types.Part.from_uri( + uri='https://www.youtube.com/watch?v=VIDEO_ID', + mime_type='video/mp4' + ) + ] +) +``` + +### Inline Video (<20MB) + +```python +with open('short-clip.mp4', 'rb') as f: + video_bytes = f.read() + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'What happens in this video?', + types.Part.from_bytes(data=video_bytes, mime_type='video/mp4') + ] +) +``` + +## Advanced Features + +### Video Clipping + +```python +# Analyze specific time range +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Summarize this segment', + types.Part.from_video_metadata( + file_uri=myfile.uri, + start_offset='40s', + end_offset='80s' + ) + ] +) +``` + +### Custom Frame Rate + +```python +# Lower FPS for static content (saves tokens) +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Analyze this presentation', + types.Part.from_video_metadata( + file_uri=myfile.uri, + fps=0.5 # Sample every 2 seconds + ) + ] +) + +# Higher FPS for fast-moving content +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Analyze rapid movements in this sports video', + types.Part.from_video_metadata( + file_uri=myfile.uri, + fps=5 # Sample 5 times per second + ) + ] +) +``` + +### Multiple Videos (2.5+) + +```python +video1 = client.files.upload(file='demo1.mp4') +video2 = client.files.upload(file='demo2.mp4') + +# Wait for processing +for video in [video1, video2]: + while video.state.name == 'PROCESSING': + time.sleep(1) + video = client.files.get(name=video.name) + +response = client.models.generate_content( + model='gemini-2.5-pro', + contents=[ + 'Compare these two product demos. Which explains features better?', + video1, + video2 + ] +) +``` + +## Temporal Understanding + +### Timestamp-Based Questions + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'What happens at 01:15 and how does it relate to 02:30?', + myfile + ] +) +``` + +### Timeline Creation + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Create a timeline with timestamps: + - Key events + - Scene changes + - Important moments + Format: MM:SS - Description + ''', + myfile + ] +) +``` + +### Scene Detection + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Identify all scene changes with timestamps and describe each scene', + myfile + ] +) +``` + +## Transcription + +### Basic Transcription + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Transcribe the audio from this video', + myfile + ] +) +``` + +### With Visual Descriptions + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Transcribe with visual context: + - Audio transcription + - Visual descriptions of important moments + - Timestamps for salient events + ''', + myfile + ] +) +``` + +### Speaker Identification + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Transcribe with speaker labels and timestamps', + myfile + ] +) +``` + +## Common Use Cases + +### 1. Video Summarization + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Summarize this video: + 1. Main topic and purpose + 2. Key points with timestamps + 3. Conclusion or call-to-action + ''', + myfile + ] +) +``` + +### 2. Educational Content + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Create educational materials: + 1. List key concepts taught + 2. Create 5 quiz questions with answers + 3. Provide timestamp for each concept + ''', + myfile + ] +) +``` + +### 3. Action Detection + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'List all actions performed in this tutorial with timestamps', + myfile + ] +) +``` + +### 4. Content Moderation + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Review video content: + 1. Identify any problematic content + 2. Note timestamps of concerns + 3. Provide content rating recommendation + ''', + myfile + ] +) +``` + +### 5. Interview Analysis + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Analyze interview: + 1. Questions asked (timestamps) + 2. Key responses + 3. Candidate body language and demeanor + 4. Overall assessment + ''', + myfile + ] +) +``` + +### 6. Sports Analysis + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Analyze sports video: + 1. Key plays with timestamps + 2. Player movements and positioning + 3. Game strategy observations + ''', + types.Part.from_video_metadata( + file_uri=myfile.uri, + fps=5 # Higher FPS for fast action + ) + ] +) +``` + +## YouTube Specific Features + +### Public Video Requirements + +- Video must be public (not private or unlisted) +- No age-restricted content +- Valid video ID required + +### Usage Example + +```python +# YouTube URL +youtube_uri = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Create chapter markers with timestamps', + types.Part.from_uri(uri=youtube_uri, mime_type='video/mp4') + ] +) +``` + +### Rate Limits + +- **Free tier**: 8 hours of YouTube video per day +- **Paid tier**: No length-based limits +- Public videos only + +## Token Calculation + +Video tokens depend on resolution and FPS: + +**Default resolution** (~300 tokens/second): +- 1 minute = 18,000 tokens +- 10 minutes = 180,000 tokens +- 1 hour = 1,080,000 tokens + +**Low resolution** (~100 tokens/second): +- 1 minute = 6,000 tokens +- 10 minutes = 60,000 tokens +- 1 hour = 360,000 tokens + +**Context windows**: +- 2M tokens ≈ 2 hours (default) or 6 hours (low-res) +- 1M tokens ≈ 1 hour (default) or 3 hours (low-res) + +## Best Practices + +### File Management + +1. Use File API for videos >20MB (most videos) +2. Wait for ACTIVE state before analysis +3. Files auto-delete after 48 hours +4. Clean up manually: + ```python + client.files.delete(name=myfile.name) + ``` + +### Optimization Strategies + +**Reduce token usage**: +- Process specific segments using start/end offsets +- Use lower FPS for static content +- Use low-resolution mode for long videos +- Split very long videos into chunks + +**Improve accuracy**: +- Provide context in prompts +- Use higher FPS for fast-moving content +- Use Pro model for complex analysis +- Be specific about what to extract + +### Prompt Engineering + +**Effective prompts**: +- "Summarize key points with timestamps in MM:SS format" +- "Identify all scene changes and describe each scene" +- "Extract action items mentioned with timestamps" +- "Compare these two videos on: X, Y, Z criteria" + +**Structured output**: +```python +from pydantic import BaseModel +from typing import List + +class VideoEvent(BaseModel): + timestamp: str # MM:SS format + description: str + category: str + +class VideoAnalysis(BaseModel): + summary: str + events: List[VideoEvent] + duration: str + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Analyze this video', myfile], + config=genai.types.GenerateContentConfig( + response_mime_type='application/json', + response_schema=VideoAnalysis + ) +) +``` + +### Error Handling + +```python +import time + +def upload_and_process_video(file_path, max_wait=300): + """Upload video and wait for processing""" + myfile = client.files.upload(file=file_path) + + elapsed = 0 + while myfile.state.name == 'PROCESSING' and elapsed < max_wait: + time.sleep(5) + myfile = client.files.get(name=myfile.name) + elapsed += 5 + + if myfile.state.name == 'FAILED': + raise ValueError(f'Video processing failed: {myfile.state.name}') + + if myfile.state.name == 'PROCESSING': + raise TimeoutError(f'Processing timeout after {max_wait}s') + + return myfile +``` + +## Cost Optimization + +**Token costs** (Gemini 2.5 Flash at $1/1M): +- 1 minute video (default): 18,000 tokens = $0.018 +- 10 minute video: 180,000 tokens = $0.18 +- 1 hour video: 1,080,000 tokens = $1.08 + +**Strategies**: +- Use video clipping for specific segments +- Lower FPS for static content +- Use low-resolution mode for long videos +- Batch related queries on same video +- Use context caching for repeated queries + +## Limitations + +- Maximum 6 hours (low-res) or 2 hours (default) +- YouTube videos must be public +- No live streaming analysis +- Files expire after 48 hours +- Processing time varies by video length +- No real-time processing +- Limited to 10 videos per request (2.5+) diff --git a/skills/ai-multimodal/references/vision-understanding.md b/skills/ai-multimodal/references/vision-understanding.md new file mode 100644 index 0000000..5969d91 --- /dev/null +++ b/skills/ai-multimodal/references/vision-understanding.md @@ -0,0 +1,483 @@ +# Vision Understanding Reference + +Comprehensive guide for image analysis, object detection, and visual understanding using Gemini API. + +## Core Capabilities + +- **Captioning**: Generate descriptive text for images +- **Classification**: Categorize and identify content +- **Visual Q&A**: Answer questions about images +- **Object Detection**: Locate objects with bounding boxes (2.0+) +- **Segmentation**: Create pixel-level masks (2.5+) +- **Multi-image**: Compare up to 3,600 images +- **OCR**: Extract text from images +- **Document Understanding**: Process PDFs with vision + +## Supported Formats + +- **Images**: PNG, JPEG, WEBP, HEIC, HEIF +- **Documents**: PDF (up to 1,000 pages) +- **Size Limits**: + - Inline: 20MB max total request + - File API: 2GB per file + - Max images: 3,600 per request + +## Model Selection + +### Gemini 2.5 Series +- **gemini-2.5-pro**: Best quality, segmentation + detection +- **gemini-2.5-flash**: Fast, efficient, all features +- **gemini-2.5-flash-lite**: Lightweight, all features + +### Gemini 2.0 Series +- **gemini-2.0-flash**: Object detection support +- **gemini-2.0-flash-lite**: Lightweight detection + +### Feature Requirements +- **Segmentation**: Requires 2.5+ models +- **Object Detection**: Requires 2.0+ models +- **Multi-image**: All models (up to 3,600 images) + +## Basic Image Analysis + +### Image Captioning + +```python +from google import genai +import os + +client = genai.Client(api_key=os.getenv('GEMINI_API_KEY')) + +# Local file +with open('image.jpg', 'rb') as f: + img_bytes = f.read() + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Describe this image in detail', + genai.types.Part.from_bytes(data=img_bytes, mime_type='image/jpeg') + ] +) +print(response.text) +``` + +### Image Classification + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Classify this image. Provide category and confidence level.', + img_part + ] +) +``` + +### Visual Question Answering + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'How many people are in this image and what are they doing?', + img_part + ] +) +``` + +## Advanced Features + +### Object Detection (2.0+) + +```python +response = client.models.generate_content( + model='gemini-2.0-flash', + contents=[ + 'Detect all objects in this image and provide bounding boxes', + img_part + ] +) + +# Returns bounding box coordinates: [ymin, xmin, ymax, xmax] +# Normalized to [0, 1000] range +``` + +### Segmentation (2.5+) + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Create a segmentation mask for all people in this image', + img_part + ] +) + +# Returns pixel-level masks for requested objects +``` + +### Multi-Image Comparison + +```python +import PIL.Image + +img1 = PIL.Image.open('photo1.jpg') +img2 = PIL.Image.open('photo2.jpg') + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Compare these two images. What are the differences?', + img1, + img2 + ] +) +``` + +### OCR and Text Extraction + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Extract all visible text from this image', + img_part + ] +) +``` + +## Input Methods + +### Inline Data (<20MB) + +```python +from google.genai import types + +# From file +with open('image.jpg', 'rb') as f: + img_bytes = f.read() + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Analyze this image', + types.Part.from_bytes(data=img_bytes, mime_type='image/jpeg') + ] +) +``` + +### PIL Image + +```python +import PIL.Image + +img = PIL.Image.open('photo.jpg') + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['What is in this image?', img] +) +``` + +### File API (>20MB or Reuse) + +```python +# Upload once +myfile = client.files.upload(file='large-image.jpg') + +# Use multiple times +response1 = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Describe this image', myfile] +) + +response2 = client.models.generate_content( + model='gemini-2.5-flash', + contents=['What colors dominate this image?', myfile] +) +``` + +### URL (Public Images) + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Analyze this image', + types.Part.from_uri( + uri='https://example.com/image.jpg', + mime_type='image/jpeg' + ) + ] +) +``` + +## Token Calculation + +Images consume tokens based on size: + +**Small images** (≤384px both dimensions): 258 tokens + +**Large images**: Tiled into 768×768 chunks, 258 tokens each + +**Formula**: +``` +crop_unit = floor(min(width, height) / 1.5) +tiles = (width / crop_unit) × (height / crop_unit) +total_tokens = tiles × 258 +``` + +**Examples**: +- 256×256: 258 tokens (small) +- 512×512: 258 tokens (small) +- 960×540: 6 tiles = 1,548 tokens +- 1920×1080: 6 tiles = 1,548 tokens +- 3840×2160 (4K): 24 tiles = 6,192 tokens + +## Structured Output + +### JSON Schema Output + +```python +from pydantic import BaseModel +from typing import List + +class ObjectDetection(BaseModel): + object_name: str + confidence: float + bounding_box: List[int] # [ymin, xmin, ymax, xmax] + +class ImageAnalysis(BaseModel): + description: str + objects: List[ObjectDetection] + scene_type: str + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Analyze this image', img_part], + config=genai.types.GenerateContentConfig( + response_mime_type='application/json', + response_schema=ImageAnalysis + ) +) + +result = ImageAnalysis.model_validate_json(response.text) +``` + +## Multi-Image Analysis + +### Batch Processing + +```python +images = [ + PIL.Image.open(f'image{i}.jpg') + for i in range(10) +] + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=['Analyze these images and find common themes'] + images +) +``` + +### Image Comparison + +```python +before = PIL.Image.open('before.jpg') +after = PIL.Image.open('after.jpg') + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Compare before and after. List all visible changes.', + before, + after + ] +) +``` + +### Visual Search + +```python +reference = PIL.Image.open('target.jpg') +candidates = [PIL.Image.open(f'option{i}.jpg') for i in range(5)] + +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Find which candidate images contain objects similar to the reference', + reference + ] + candidates +) +``` + +## Best Practices + +### Image Quality + +1. **Resolution**: Use clear, non-blurry images +2. **Rotation**: Verify correct orientation +3. **Lighting**: Ensure good contrast and lighting +4. **Size optimization**: Balance quality vs token cost +5. **Format**: JPEG for photos, PNG for graphics + +### Prompt Engineering + +**Specific instructions**: +- "Identify all vehicles with their colors and positions" +- "Count people wearing blue shirts" +- "Extract text from the sign in the top-left corner" + +**Output format**: +- "Return results as JSON with fields: category, count, description" +- "Format as markdown table" +- "List findings as numbered items" + +**Few-shot examples**: +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Example: For an image of a cat on a sofa, respond: "Object: cat, Location: sofa"', + 'Now analyze this image:', + img_part + ] +) +``` + +### File Management + +1. Use File API for images >20MB +2. Use File API for repeated queries (saves tokens) +3. Files auto-delete after 48 hours +4. Clean up manually: + ```python + client.files.delete(name=myfile.name) + ``` + +### Cost Optimization + +**Token-efficient strategies**: +- Resize large images before upload +- Use File API for repeated queries +- Batch multiple images when related +- Use appropriate model (Flash vs Pro) + +**Token costs** (Gemini 2.5 Flash at $1/1M): +- Small image (258 tokens): $0.000258 +- HD image (1,548 tokens): $0.001548 +- 4K image (6,192 tokens): $0.006192 + +## Common Use Cases + +### 1. Product Analysis + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Analyze this product image: + 1. Identify the product + 2. List visible features + 3. Assess condition + 4. Estimate value range + ''', + img_part + ] +) +``` + +### 2. Screenshot Analysis + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Extract all text and UI elements from this screenshot', + img_part + ] +) +``` + +### 3. Medical Imaging (Informational Only) + +```python +response = client.models.generate_content( + model='gemini-2.5-pro', + contents=[ + 'Describe visible features in this medical image. Note: This is for informational purposes only.', + img_part + ] +) +``` + +### 4. Chart/Graph Reading + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + 'Extract data from this chart and format as JSON', + img_part + ] +) +``` + +### 5. Scene Understanding + +```python +response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + '''Analyze this scene: + 1. Location type + 2. Time of day + 3. Weather conditions + 4. Activities happening + 5. Mood/atmosphere + ''', + img_part + ] +) +``` + +## Error Handling + +```python +import time + +def analyze_image_with_retry(image_path, prompt, max_retries=3): + """Analyze image with exponential backoff retry""" + for attempt in range(max_retries): + try: + with open(image_path, 'rb') as f: + img_bytes = f.read() + + response = client.models.generate_content( + model='gemini-2.5-flash', + contents=[ + prompt, + genai.types.Part.from_bytes( + data=img_bytes, + mime_type='image/jpeg' + ) + ] + ) + return response.text + except Exception as e: + if attempt == max_retries - 1: + raise + wait_time = 2 ** attempt + print(f"Retry {attempt + 1} after {wait_time}s: {e}") + time.sleep(wait_time) +``` + +## Limitations + +- Maximum 3,600 images per request +- OCR accuracy varies with text quality +- Object detection requires 2.0+ models +- Segmentation requires 2.5+ models +- No video frame extraction (use video API) +- Regional restrictions on child images (EEA, CH, UK) diff --git a/skills/ai-multimodal/scripts/document_converter.py b/skills/ai-multimodal/scripts/document_converter.py new file mode 100644 index 0000000..8cc7134 --- /dev/null +++ b/skills/ai-multimodal/scripts/document_converter.py @@ -0,0 +1,395 @@ +#!/usr/bin/env python3 +""" +Convert documents to Markdown using Gemini API. + +Supports all document types: +- PDF documents (native vision processing) +- Images (JPEG, PNG, WEBP, HEIC) +- Office documents (DOCX, XLSX, PPTX) +- HTML, TXT, and other text formats + +Features: +- Converts to clean markdown format +- Preserves structure, tables, and formatting +- Extracts text from images and scanned documents +- Batch conversion support +- Saves to docs/assets/document-extraction.md by default +""" + +import argparse +import os +import sys +import time +from pathlib import Path +from typing import Optional, List, Dict, Any + +try: + from google import genai + from google.genai import types +except ImportError: + print("Error: google-genai package not installed") + print("Install with: pip install google-genai") + sys.exit(1) + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None + + +def find_api_key() -> Optional[str]: + """Find Gemini API key using correct priority order. + + Priority order (highest to lowest): + 1. process.env (runtime environment variables) + 2. .claude/skills/ai-multimodal/.env (skill-specific config) + 3. .claude/skills/.env (shared skills config) + 4. .claude/.env (Claude global config) + """ + # Priority 1: Already in process.env (highest) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + # Load .env files if dotenv available + if load_dotenv: + # Determine base paths + script_dir = Path(__file__).parent + skill_dir = script_dir.parent # .claude/skills/ai-multimodal + skills_dir = skill_dir.parent # .claude/skills + claude_dir = skills_dir.parent # .claude + + # Priority 2: Skill-specific .env + env_file = skill_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + # Priority 3: Shared skills .env + env_file = skills_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + # Priority 4: Claude global .env + env_file = claude_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + return None + + +def find_project_root() -> Path: + """Find project root directory.""" + script_dir = Path(__file__).parent + + # Look for .git or .claude directory + for parent in [script_dir] + list(script_dir.parents): + if (parent / '.git').exists() or (parent / '.claude').exists(): + return parent + + return script_dir + + +def get_mime_type(file_path: str) -> str: + """Determine MIME type from file extension.""" + ext = Path(file_path).suffix.lower() + + mime_types = { + # Documents + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.html': 'text/html', + '.htm': 'text/html', + '.md': 'text/markdown', + '.csv': 'text/csv', + # Images + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + # Office (need to be uploaded as binary) + '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + } + + return mime_types.get(ext, 'application/octet-stream') + + +def upload_file(client: genai.Client, file_path: str, verbose: bool = False) -> Any: + """Upload file to Gemini File API.""" + if verbose: + print(f"Uploading {file_path}...") + + myfile = client.files.upload(file=file_path) + + # Wait for processing if needed + max_wait = 300 # 5 minutes + elapsed = 0 + while myfile.state.name == 'PROCESSING' and elapsed < max_wait: + time.sleep(2) + myfile = client.files.get(name=myfile.name) + elapsed += 2 + if verbose and elapsed % 10 == 0: + print(f" Processing... {elapsed}s") + + if myfile.state.name == 'FAILED': + raise ValueError(f"File processing failed: {file_path}") + + if myfile.state.name == 'PROCESSING': + raise TimeoutError(f"Processing timeout after {max_wait}s: {file_path}") + + if verbose: + print(f" Uploaded: {myfile.name}") + + return myfile + + +def convert_to_markdown( + client: genai.Client, + file_path: str, + model: str = 'gemini-2.5-flash', + custom_prompt: Optional[str] = None, + verbose: bool = False, + max_retries: int = 3 +) -> Dict[str, Any]: + """Convert a document to markdown using Gemini.""" + + for attempt in range(max_retries): + try: + file_path_obj = Path(file_path) + file_size = file_path_obj.stat().st_size + use_file_api = file_size > 20 * 1024 * 1024 # >20MB + + # Default prompt for markdown conversion + if custom_prompt: + prompt = custom_prompt + else: + prompt = """Convert this document to clean, well-formatted Markdown. + +Requirements: +- Preserve all content, structure, and formatting +- Convert tables to markdown table format +- Maintain heading hierarchy (# ## ### etc) +- Preserve lists, code blocks, and quotes +- Extract text from images if present +- Keep formatting consistent and readable + +Output only the markdown content without any preamble or explanation.""" + + # Upload or inline the file + if use_file_api: + myfile = upload_file(client, str(file_path), verbose) + content = [prompt, myfile] + else: + with open(file_path, 'rb') as f: + file_bytes = f.read() + + mime_type = get_mime_type(str(file_path)) + content = [ + prompt, + types.Part.from_bytes(data=file_bytes, mime_type=mime_type) + ] + + # Generate markdown + response = client.models.generate_content( + model=model, + contents=content + ) + + markdown_content = response.text if hasattr(response, 'text') else '' + + return { + 'file': str(file_path), + 'status': 'success', + 'markdown': markdown_content + } + + except Exception as e: + if attempt == max_retries - 1: + return { + 'file': str(file_path), + 'status': 'error', + 'error': str(e), + 'markdown': None + } + + wait_time = 2 ** attempt + if verbose: + print(f" Retry {attempt + 1} after {wait_time}s: {e}") + time.sleep(wait_time) + + +def batch_convert( + files: List[str], + output_file: Optional[str] = None, + auto_name: bool = False, + model: str = 'gemini-2.5-flash', + custom_prompt: Optional[str] = None, + verbose: bool = False +) -> List[Dict[str, Any]]: + """Batch convert multiple files to markdown.""" + + api_key = find_api_key() + if not api_key: + print("Error: GEMINI_API_KEY not found") + print("Set via: export GEMINI_API_KEY='your-key'") + print("Or create .env file with: GEMINI_API_KEY=your-key") + sys.exit(1) + + client = genai.Client(api_key=api_key) + results = [] + + # Determine output path + if not output_file: + project_root = find_project_root() + output_dir = project_root / 'docs' / 'assets' + + if auto_name and len(files) == 1: + # Auto-generate meaningful filename from input + input_path = Path(files[0]) + base_name = input_path.stem + output_file = str(output_dir / f"{base_name}-extraction.md") + else: + output_file = str(output_dir / 'document-extraction.md') + + output_path = Path(output_file) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Process each file + for i, file_path in enumerate(files, 1): + if verbose: + print(f"\n[{i}/{len(files)}] Converting: {file_path}") + + result = convert_to_markdown( + client=client, + file_path=file_path, + model=model, + custom_prompt=custom_prompt, + verbose=verbose + ) + + results.append(result) + + if verbose: + status = result.get('status', 'unknown') + print(f" Status: {status}") + + # Save combined markdown + with open(output_path, 'w', encoding='utf-8') as f: + f.write("# Document Extraction Results\n\n") + f.write(f"Converted {len(files)} document(s) to markdown.\n\n") + f.write("---\n\n") + + for result in results: + f.write(f"## {Path(result['file']).name}\n\n") + + if result['status'] == 'success' and result.get('markdown'): + f.write(result['markdown']) + f.write("\n\n") + elif result['status'] == 'success': + f.write("**Note**: Conversion succeeded but no content was returned.\n\n") + else: + f.write(f"**Error**: {result.get('error', 'Unknown error')}\n\n") + + f.write("---\n\n") + + if verbose or True: # Always show output location + print(f"\n{'='*50}") + print(f"Converted: {len(results)} file(s)") + print(f"Success: {sum(1 for r in results if r['status'] == 'success')}") + print(f"Failed: {sum(1 for r in results if r['status'] == 'error')}") + print(f"Output saved to: {output_path}") + + return results + + +def main(): + parser = argparse.ArgumentParser( + description='Convert documents to Markdown using Gemini API', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Convert single PDF to markdown (default name) + %(prog)s --input document.pdf + + # Auto-generate meaningful filename + %(prog)s --input testpdf.pdf --auto-name + # Output: docs/assets/testpdf-extraction.md + + # Convert multiple files + %(prog)s --input doc1.pdf doc2.docx image.png + + # Specify custom output location + %(prog)s --input document.pdf --output ./output.md + + # Use custom prompt + %(prog)s --input document.pdf --prompt "Extract only the tables as markdown" + + # Batch convert directory + %(prog)s --input ./documents/*.pdf --verbose + +Supported formats: + - PDF documents (up to 1,000 pages) + - Images (JPEG, PNG, WEBP, HEIC) + - Office documents (DOCX, XLSX, PPTX) + - Text formats (TXT, HTML, Markdown, CSV) + +Default output: /docs/assets/document-extraction.md + """ + ) + + parser.add_argument('--input', '-i', nargs='+', required=True, + help='Input file(s) to convert') + parser.add_argument('--output', '-o', + help='Output markdown file (default: docs/assets/document-extraction.md)') + parser.add_argument('--auto-name', '-a', action='store_true', + help='Auto-generate meaningful output filename from input (e.g., document.pdf -> document-extraction.md)') + parser.add_argument('--model', default='gemini-2.5-flash', + help='Gemini model to use (default: gemini-2.5-flash)') + parser.add_argument('--prompt', '-p', + help='Custom prompt for conversion') + parser.add_argument('--verbose', '-v', action='store_true', + help='Verbose output') + + args = parser.parse_args() + + # Validate input files + files = [] + for file_pattern in args.input: + file_path = Path(file_pattern) + if file_path.exists() and file_path.is_file(): + files.append(str(file_path)) + else: + # Try glob pattern + import glob + matched = glob.glob(file_pattern) + files.extend([f for f in matched if Path(f).is_file()]) + + if not files: + print("Error: No valid input files found") + sys.exit(1) + + # Convert files + batch_convert( + files=files, + output_file=args.output, + auto_name=args.auto_name, + model=args.model, + custom_prompt=args.prompt, + verbose=args.verbose + ) + + +if __name__ == '__main__': + main() diff --git a/skills/ai-multimodal/scripts/gemini_batch_process.py b/skills/ai-multimodal/scripts/gemini_batch_process.py new file mode 100644 index 0000000..f65a13f --- /dev/null +++ b/skills/ai-multimodal/scripts/gemini_batch_process.py @@ -0,0 +1,480 @@ +#!/usr/bin/env python3 +""" +Batch process multiple media files using Gemini API. + +Supports all Gemini modalities: +- Audio: Transcription, analysis, summarization +- Image: Captioning, detection, OCR, analysis +- Video: Summarization, Q&A, scene detection +- Document: PDF extraction, structured output +- Generation: Image creation from text prompts +""" + +import argparse +import json +import os +import sys +import time +from pathlib import Path +from typing import List, Dict, Any, Optional +import csv +import shutil + +try: + from google import genai + from google.genai import types +except ImportError: + print("Error: google-genai package not installed") + print("Install with: pip install google-genai") + sys.exit(1) + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None + + +def find_api_key() -> Optional[str]: + """Find Gemini API key using correct priority order. + + Priority order (highest to lowest): + 1. process.env (runtime environment variables) + 2. .claude/skills/ai-multimodal/.env (skill-specific config) + 3. .claude/skills/.env (shared skills config) + 4. .claude/.env (Claude global config) + """ + # Priority 1: Already in process.env (highest) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + # Load .env files if dotenv available + if load_dotenv: + # Determine base paths + script_dir = Path(__file__).parent + skill_dir = script_dir.parent # .claude/skills/ai-multimodal + skills_dir = skill_dir.parent # .claude/skills + claude_dir = skills_dir.parent # .claude + + # Priority 2: Skill-specific .env + env_file = skill_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + # Priority 3: Shared skills .env + env_file = skills_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + # Priority 4: Claude global .env + env_file = claude_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + api_key = os.getenv('GEMINI_API_KEY') + if api_key: + return api_key + + return None + + +def get_mime_type(file_path: str) -> str: + """Determine MIME type from file extension.""" + ext = Path(file_path).suffix.lower() + + mime_types = { + # Audio + '.mp3': 'audio/mp3', + '.wav': 'audio/wav', + '.aac': 'audio/aac', + '.flac': 'audio/flac', + '.ogg': 'audio/ogg', + '.aiff': 'audio/aiff', + # Image + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.webp': 'image/webp', + '.heic': 'image/heic', + '.heif': 'image/heif', + # Video + '.mp4': 'video/mp4', + '.mpeg': 'video/mpeg', + '.mov': 'video/quicktime', + '.avi': 'video/x-msvideo', + '.flv': 'video/x-flv', + '.mpg': 'video/mpeg', + '.webm': 'video/webm', + '.wmv': 'video/x-ms-wmv', + '.3gpp': 'video/3gpp', + # Document + '.pdf': 'application/pdf', + '.txt': 'text/plain', + '.html': 'text/html', + '.md': 'text/markdown', + } + + return mime_types.get(ext, 'application/octet-stream') + + +def upload_file(client: genai.Client, file_path: str, verbose: bool = False) -> Any: + """Upload file to Gemini File API.""" + if verbose: + print(f"Uploading {file_path}...") + + myfile = client.files.upload(file=file_path) + + # Wait for processing (video/audio files need processing) + mime_type = get_mime_type(file_path) + if mime_type.startswith('video/') or mime_type.startswith('audio/'): + max_wait = 300 # 5 minutes + elapsed = 0 + while myfile.state.name == 'PROCESSING' and elapsed < max_wait: + time.sleep(2) + myfile = client.files.get(name=myfile.name) + elapsed += 2 + if verbose and elapsed % 10 == 0: + print(f" Processing... {elapsed}s") + + if myfile.state.name == 'FAILED': + raise ValueError(f"File processing failed: {file_path}") + + if myfile.state.name == 'PROCESSING': + raise TimeoutError(f"Processing timeout after {max_wait}s: {file_path}") + + if verbose: + print(f" Uploaded: {myfile.name}") + + return myfile + + +def process_file( + client: genai.Client, + file_path: Optional[str], + prompt: str, + model: str, + task: str, + format_output: str, + aspect_ratio: Optional[str] = None, + verbose: bool = False, + max_retries: int = 3 +) -> Dict[str, Any]: + """Process a single file with retry logic.""" + + for attempt in range(max_retries): + try: + # For generation tasks without input files + if task == 'generate' and not file_path: + content = [prompt] + else: + # Process input file + file_path = Path(file_path) + # Determine if we need File API + file_size = file_path.stat().st_size + use_file_api = file_size > 20 * 1024 * 1024 # >20MB + + if use_file_api: + # Upload to File API + myfile = upload_file(client, str(file_path), verbose) + content = [prompt, myfile] + else: + # Inline data + with open(file_path, 'rb') as f: + file_bytes = f.read() + + mime_type = get_mime_type(str(file_path)) + content = [ + prompt, + types.Part.from_bytes(data=file_bytes, mime_type=mime_type) + ] + + # Configure request + config_args = {} + if task == 'generate': + config_args['response_modalities'] = ['Image'] # Capital I per API spec + if aspect_ratio: + # Nest aspect_ratio in image_config per API spec + config_args['image_config'] = types.ImageConfig( + aspect_ratio=aspect_ratio + ) + + if format_output == 'json': + config_args['response_mime_type'] = 'application/json' + + config = types.GenerateContentConfig(**config_args) if config_args else None + + # Generate content + response = client.models.generate_content( + model=model, + contents=content, + config=config + ) + + # Extract response + result = { + 'file': str(file_path) if file_path else 'generated', + 'status': 'success', + 'response': response.text if hasattr(response, 'text') else None + } + + # Handle image output + if task == 'generate' and hasattr(response, 'candidates'): + for i, part in enumerate(response.candidates[0].content.parts): + if part.inline_data: + # Determine output directory - use project root docs/assets + if file_path: + output_dir = Path(file_path).parent + base_name = Path(file_path).stem + else: + # Find project root (look for .git or .claude directory) + script_dir = Path(__file__).parent + project_root = script_dir + for parent in [script_dir] + list(script_dir.parents): + if (parent / '.git').exists() or (parent / '.claude').exists(): + project_root = parent + break + + output_dir = project_root / 'docs' / 'assets' + output_dir.mkdir(parents=True, exist_ok=True) + base_name = "generated" + + output_file = output_dir / f"{base_name}_generated_{i}.png" + with open(output_file, 'wb') as f: + f.write(part.inline_data.data) + result['generated_image'] = str(output_file) + if verbose: + print(f" Saved image to: {output_file}") + + return result + + except Exception as e: + if attempt == max_retries - 1: + return { + 'file': str(file_path) if file_path else 'generated', + 'status': 'error', + 'error': str(e) + } + + wait_time = 2 ** attempt + if verbose: + print(f" Retry {attempt + 1} after {wait_time}s: {e}") + time.sleep(wait_time) + + +def batch_process( + files: List[str], + prompt: str, + model: str, + task: str, + format_output: str, + aspect_ratio: Optional[str] = None, + output_file: Optional[str] = None, + verbose: bool = False, + dry_run: bool = False +) -> List[Dict[str, Any]]: + """Batch process multiple files.""" + api_key = find_api_key() + if not api_key: + print("Error: GEMINI_API_KEY not found") + print("Set via: export GEMINI_API_KEY='your-key'") + print("Or create .env file with: GEMINI_API_KEY=your-key") + sys.exit(1) + + if dry_run: + print("DRY RUN MODE - No API calls will be made") + print(f"Files to process: {len(files)}") + print(f"Model: {model}") + print(f"Task: {task}") + print(f"Prompt: {prompt}") + return [] + + client = genai.Client(api_key=api_key) + results = [] + + # For generation tasks without input files, process once + if task == 'generate' and not files: + if verbose: + print(f"\nGenerating image from prompt...") + + result = process_file( + client=client, + file_path=None, + prompt=prompt, + model=model, + task=task, + format_output=format_output, + aspect_ratio=aspect_ratio, + verbose=verbose + ) + + results.append(result) + + if verbose: + status = result.get('status', 'unknown') + print(f" Status: {status}") + else: + # Process input files + for i, file_path in enumerate(files, 1): + if verbose: + print(f"\n[{i}/{len(files)}] Processing: {file_path}") + + result = process_file( + client=client, + file_path=file_path, + prompt=prompt, + model=model, + task=task, + format_output=format_output, + aspect_ratio=aspect_ratio, + verbose=verbose + ) + + results.append(result) + + if verbose: + status = result.get('status', 'unknown') + print(f" Status: {status}") + + # Save results + if output_file: + save_results(results, output_file, format_output) + + return results + + +def save_results(results: List[Dict[str, Any]], output_file: str, format_output: str): + """Save results to file.""" + output_path = Path(output_file) + + # Special handling for image generation - if output has image extension, copy the generated image + image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'} + if output_path.suffix.lower() in image_extensions and len(results) == 1: + generated_image = results[0].get('generated_image') + if generated_image: + # Copy the generated image to the specified output location + shutil.copy2(generated_image, output_path) + return + else: + # Don't write text reports to image files - save error as .txt instead + output_path = output_path.with_suffix('.error.txt') + print(f"Warning: Generation failed, saving error report to: {output_path}") + + if format_output == 'json': + with open(output_path, 'w') as f: + json.dump(results, f, indent=2) + elif format_output == 'csv': + with open(output_path, 'w', newline='') as f: + fieldnames = ['file', 'status', 'response', 'error'] + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for result in results: + writer.writerow({ + 'file': result.get('file', ''), + 'status': result.get('status', ''), + 'response': result.get('response', ''), + 'error': result.get('error', '') + }) + else: # markdown + with open(output_path, 'w') as f: + f.write("# Batch Processing Results\n\n") + for i, result in enumerate(results, 1): + f.write(f"## {i}. {result.get('file', 'Unknown')}\n\n") + f.write(f"**Status**: {result.get('status', 'unknown')}\n\n") + if result.get('response'): + f.write(f"**Response**:\n\n{result['response']}\n\n") + if result.get('error'): + f.write(f"**Error**: {result['error']}\n\n") + + +def main(): + parser = argparse.ArgumentParser( + description='Batch process media files with Gemini API', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Transcribe multiple audio files + %(prog)s --files *.mp3 --task transcribe --model gemini-2.5-flash + + # Analyze images + %(prog)s --files *.jpg --task analyze --prompt "Describe this image" \\ + --model gemini-2.5-flash + + # Process PDFs to JSON + %(prog)s --files *.pdf --task extract --prompt "Extract data as JSON" \\ + --format json --output results.json + + # Generate images + %(prog)s --task generate --prompt "A mountain landscape" \\ + --model gemini-2.5-flash-image --aspect-ratio 16:9 + """ + ) + + parser.add_argument('--files', nargs='*', help='Input files to process') + parser.add_argument('--task', required=True, + choices=['transcribe', 'analyze', 'extract', 'generate'], + help='Task to perform') + parser.add_argument('--prompt', help='Prompt for analysis/generation') + parser.add_argument('--model', default='gemini-2.5-flash', + help='Gemini model to use (default: gemini-2.5-flash)') + parser.add_argument('--format', dest='format_output', default='text', + choices=['text', 'json', 'csv', 'markdown'], + help='Output format (default: text)') + parser.add_argument('--aspect-ratio', choices=['1:1', '16:9', '9:16', '4:3', '3:4'], + help='Aspect ratio for image generation') + parser.add_argument('--output', help='Output file for results') + parser.add_argument('--verbose', '-v', action='store_true', + help='Verbose output') + parser.add_argument('--dry-run', action='store_true', + help='Show what would be done without making API calls') + + args = parser.parse_args() + + # Validate arguments + if args.task != 'generate' and not args.files: + parser.error("--files required for non-generation tasks") + + if args.task == 'generate' and not args.prompt: + parser.error("--prompt required for generation task") + + if args.task != 'generate' and not args.prompt: + # Set default prompts + if args.task == 'transcribe': + args.prompt = 'Generate a transcript with timestamps' + elif args.task == 'analyze': + args.prompt = 'Analyze this content' + elif args.task == 'extract': + args.prompt = 'Extract key information' + + # Process files + files = args.files or [] + results = batch_process( + files=files, + prompt=args.prompt, + model=args.model, + task=args.task, + format_output=args.format_output, + aspect_ratio=args.aspect_ratio, + output_file=args.output, + verbose=args.verbose, + dry_run=args.dry_run + ) + + # Print summary + if not args.dry_run and results: + success = sum(1 for r in results if r.get('status') == 'success') + failed = len(results) - success + print(f"\n{'='*50}") + print(f"Processed: {len(results)} files") + print(f"Success: {success}") + print(f"Failed: {failed}") + if args.output: + print(f"Results saved to: {args.output}") + + +if __name__ == '__main__': + main() diff --git a/skills/ai-multimodal/scripts/media_optimizer.py b/skills/ai-multimodal/scripts/media_optimizer.py new file mode 100644 index 0000000..06254b6 --- /dev/null +++ b/skills/ai-multimodal/scripts/media_optimizer.py @@ -0,0 +1,506 @@ +#!/usr/bin/env python3 +""" +Optimize media files for Gemini API processing. + +Features: +- Compress videos/audio for size limits +- Resize images appropriately +- Split long videos into chunks +- Format conversion +- Quality vs size optimization +- Validation before upload +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional, Dict, Any, List + +try: + from dotenv import load_dotenv +except ImportError: + load_dotenv = None + + +def load_env_files(): + """Load .env files in correct priority order. + + Priority order (highest to lowest): + 1. process.env (runtime environment variables) + 2. .claude/skills/ai-multimodal/.env (skill-specific config) + 3. .claude/skills/.env (shared skills config) + 4. .claude/.env (Claude global config) + """ + if not load_dotenv: + return + + # Determine base paths + script_dir = Path(__file__).parent + skill_dir = script_dir.parent # .claude/skills/ai-multimodal + skills_dir = skill_dir.parent # .claude/skills + claude_dir = skills_dir.parent # .claude + + # Priority 2: Skill-specific .env + env_file = skill_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + + # Priority 3: Shared skills .env + env_file = skills_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + + # Priority 4: Claude global .env + env_file = claude_dir / '.env' + if env_file.exists(): + load_dotenv(env_file) + + +# Load environment variables at module level +load_env_files() + + +def check_ffmpeg() -> bool: + """Check if ffmpeg is installed.""" + try: + subprocess.run(['ffmpeg', '-version'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True) + return True + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + return False + + +def get_media_info(file_path: str) -> Dict[str, Any]: + """Get media file information using ffprobe.""" + if not check_ffmpeg(): + return {} + + try: + cmd = [ + 'ffprobe', + '-v', 'quiet', + '-print_format', 'json', + '-show_format', + '-show_streams', + file_path + ] + + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + data = json.loads(result.stdout) + + info = { + 'size': int(data['format'].get('size', 0)), + 'duration': float(data['format'].get('duration', 0)), + 'bit_rate': int(data['format'].get('bit_rate', 0)), + } + + # Get video/audio specific info + for stream in data.get('streams', []): + if stream['codec_type'] == 'video': + info['width'] = stream.get('width', 0) + info['height'] = stream.get('height', 0) + info['fps'] = eval(stream.get('r_frame_rate', '0/1')) + elif stream['codec_type'] == 'audio': + info['sample_rate'] = int(stream.get('sample_rate', 0)) + info['channels'] = stream.get('channels', 0) + + return info + + except (subprocess.CalledProcessError, json.JSONDecodeError, Exception): + return {} + + +def optimize_video( + input_path: str, + output_path: str, + target_size_mb: Optional[int] = None, + max_duration: Optional[int] = None, + quality: int = 23, + resolution: Optional[str] = None, + verbose: bool = False +) -> bool: + """Optimize video file for Gemini API.""" + if not check_ffmpeg(): + print("Error: ffmpeg not installed") + print("Install: apt-get install ffmpeg (Linux) or brew install ffmpeg (Mac)") + return False + + info = get_media_info(input_path) + if not info: + print(f"Error: Could not read media info from {input_path}") + return False + + if verbose: + print(f"Input: {Path(input_path).name}") + print(f" Size: {info['size'] / (1024*1024):.2f} MB") + print(f" Duration: {info['duration']:.2f}s") + if 'width' in info: + print(f" Resolution: {info['width']}x{info['height']}") + print(f" Bit rate: {info['bit_rate'] / 1000:.0f} kbps") + + # Build ffmpeg command + cmd = ['ffmpeg', '-i', input_path, '-y'] + + # Video codec + cmd.extend(['-c:v', 'libx264', '-crf', str(quality)]) + + # Resolution + if resolution: + cmd.extend(['-vf', f'scale={resolution}']) + elif 'width' in info and info['width'] > 1920: + cmd.extend(['-vf', 'scale=1920:-2']) # Max 1080p + + # Audio codec + cmd.extend(['-c:a', 'aac', '-b:a', '128k', '-ac', '2']) + + # Duration limit + if max_duration and info['duration'] > max_duration: + cmd.extend(['-t', str(max_duration)]) + + # Target size (rough estimate using bitrate) + if target_size_mb: + target_bits = target_size_mb * 8 * 1024 * 1024 + duration = min(info['duration'], max_duration) if max_duration else info['duration'] + target_bitrate = int(target_bits / duration) + # Reserve some for audio (128kbps) + video_bitrate = max(target_bitrate - 128000, 500000) + cmd.extend(['-b:v', str(video_bitrate)]) + + cmd.append(output_path) + + if verbose: + print(f"\nOptimizing...") + print(f" Command: {' '.join(cmd)}") + + try: + subprocess.run(cmd, check=True, capture_output=not verbose) + + # Check output + output_info = get_media_info(output_path) + if output_info and verbose: + print(f"\nOutput: {Path(output_path).name}") + print(f" Size: {output_info['size'] / (1024*1024):.2f} MB") + print(f" Duration: {output_info['duration']:.2f}s") + if 'width' in output_info: + print(f" Resolution: {output_info['width']}x{output_info['height']}") + compression = (1 - output_info['size'] / info['size']) * 100 + print(f" Compression: {compression:.1f}%") + + return True + + except subprocess.CalledProcessError as e: + print(f"Error optimizing video: {e}") + return False + + +def optimize_audio( + input_path: str, + output_path: str, + target_size_mb: Optional[int] = None, + bitrate: str = '64k', + sample_rate: int = 16000, + verbose: bool = False +) -> bool: + """Optimize audio file for Gemini API.""" + if not check_ffmpeg(): + print("Error: ffmpeg not installed") + return False + + info = get_media_info(input_path) + if not info: + print(f"Error: Could not read media info from {input_path}") + return False + + if verbose: + print(f"Input: {Path(input_path).name}") + print(f" Size: {info['size'] / (1024*1024):.2f} MB") + print(f" Duration: {info['duration']:.2f}s") + + # Build command + cmd = [ + 'ffmpeg', '-i', input_path, '-y', + '-c:a', 'aac', + '-b:a', bitrate, + '-ar', str(sample_rate), + '-ac', '1', # Mono (Gemini uses mono anyway) + output_path + ] + + if verbose: + print(f"\nOptimizing...") + + try: + subprocess.run(cmd, check=True, capture_output=not verbose) + + output_info = get_media_info(output_path) + if output_info and verbose: + print(f"\nOutput: {Path(output_path).name}") + print(f" Size: {output_info['size'] / (1024*1024):.2f} MB") + compression = (1 - output_info['size'] / info['size']) * 100 + print(f" Compression: {compression:.1f}%") + + return True + + except subprocess.CalledProcessError as e: + print(f"Error optimizing audio: {e}") + return False + + +def optimize_image( + input_path: str, + output_path: str, + max_width: int = 1920, + quality: int = 85, + verbose: bool = False +) -> bool: + """Optimize image file for Gemini API.""" + try: + from PIL import Image + except ImportError: + print("Error: Pillow not installed") + print("Install with: pip install pillow") + return False + + try: + img = Image.open(input_path) + + if verbose: + print(f"Input: {Path(input_path).name}") + print(f" Size: {Path(input_path).stat().st_size / 1024:.2f} KB") + print(f" Resolution: {img.width}x{img.height}") + + # Resize if needed + if img.width > max_width: + ratio = max_width / img.width + new_height = int(img.height * ratio) + img = img.resize((max_width, new_height), Image.Resampling.LANCZOS) + if verbose: + print(f" Resized to: {img.width}x{img.height}") + + # Convert RGBA to RGB if saving as JPEG + if output_path.lower().endswith('.jpg') or output_path.lower().endswith('.jpeg'): + if img.mode == 'RGBA': + rgb_img = Image.new('RGB', img.size, (255, 255, 255)) + rgb_img.paste(img, mask=img.split()[3]) + img = rgb_img + + # Save + img.save(output_path, quality=quality, optimize=True) + + if verbose: + print(f"\nOutput: {Path(output_path).name}") + print(f" Size: {Path(output_path).stat().st_size / 1024:.2f} KB") + compression = (1 - Path(output_path).stat().st_size / Path(input_path).stat().st_size) * 100 + print(f" Compression: {compression:.1f}%") + + return True + + except Exception as e: + print(f"Error optimizing image: {e}") + return False + + +def split_video( + input_path: str, + output_dir: str, + chunk_duration: int = 3600, + verbose: bool = False +) -> List[str]: + """Split long video into chunks.""" + if not check_ffmpeg(): + print("Error: ffmpeg not installed") + return [] + + info = get_media_info(input_path) + if not info: + return [] + + total_duration = info['duration'] + num_chunks = int(total_duration / chunk_duration) + 1 + + if num_chunks == 1: + if verbose: + print("Video is short enough, no splitting needed") + return [input_path] + + Path(output_dir).mkdir(parents=True, exist_ok=True) + output_files = [] + + for i in range(num_chunks): + start_time = i * chunk_duration + output_file = Path(output_dir) / f"{Path(input_path).stem}_chunk_{i+1}.mp4" + + cmd = [ + 'ffmpeg', '-i', input_path, '-y', + '-ss', str(start_time), + '-t', str(chunk_duration), + '-c', 'copy', + str(output_file) + ] + + if verbose: + print(f"Creating chunk {i+1}/{num_chunks}...") + + try: + subprocess.run(cmd, check=True, capture_output=not verbose) + output_files.append(str(output_file)) + except subprocess.CalledProcessError as e: + print(f"Error creating chunk {i+1}: {e}") + + return output_files + + +def main(): + parser = argparse.ArgumentParser( + description='Optimize media files for Gemini API', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Optimize video to 100MB + %(prog)s --input video.mp4 --output optimized.mp4 --target-size 100 + + # Optimize audio + %(prog)s --input audio.mp3 --output optimized.m4a --bitrate 64k + + # Resize image + %(prog)s --input image.jpg --output resized.jpg --max-width 1920 + + # Split long video + %(prog)s --input long-video.mp4 --split --chunk-duration 3600 --output-dir ./chunks + + # Batch optimize directory + %(prog)s --input-dir ./videos --output-dir ./optimized --quality 85 + """ + ) + + parser.add_argument('--input', help='Input file') + parser.add_argument('--output', help='Output file') + parser.add_argument('--input-dir', help='Input directory for batch processing') + parser.add_argument('--output-dir', help='Output directory for batch processing') + parser.add_argument('--target-size', type=int, help='Target size in MB') + parser.add_argument('--quality', type=int, default=85, + help='Quality (video: 0-51 CRF, image: 1-100) (default: 85)') + parser.add_argument('--max-width', type=int, default=1920, + help='Max image width (default: 1920)') + parser.add_argument('--bitrate', default='64k', + help='Audio bitrate (default: 64k)') + parser.add_argument('--resolution', help='Video resolution (e.g., 1920x1080)') + parser.add_argument('--split', action='store_true', help='Split long video into chunks') + parser.add_argument('--chunk-duration', type=int, default=3600, + help='Chunk duration in seconds (default: 3600 = 1 hour)') + parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') + + args = parser.parse_args() + + # Validate arguments + if not args.input and not args.input_dir: + parser.error("Either --input or --input-dir required") + + # Single file processing + if args.input: + input_path = Path(args.input) + if not input_path.exists(): + print(f"Error: Input file not found: {input_path}") + sys.exit(1) + + if args.split: + output_dir = args.output_dir or './chunks' + chunks = split_video(str(input_path), output_dir, args.chunk_duration, args.verbose) + print(f"\nCreated {len(chunks)} chunks in {output_dir}") + sys.exit(0) + + if not args.output: + parser.error("--output required for single file processing") + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Determine file type + ext = input_path.suffix.lower() + + if ext in ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv']: + success = optimize_video( + str(input_path), + str(output_path), + target_size_mb=args.target_size, + quality=args.quality, + resolution=args.resolution, + verbose=args.verbose + ) + elif ext in ['.mp3', '.wav', '.m4a', '.flac', '.aac']: + success = optimize_audio( + str(input_path), + str(output_path), + target_size_mb=args.target_size, + bitrate=args.bitrate, + verbose=args.verbose + ) + elif ext in ['.jpg', '.jpeg', '.png', '.webp']: + success = optimize_image( + str(input_path), + str(output_path), + max_width=args.max_width, + quality=args.quality, + verbose=args.verbose + ) + else: + print(f"Error: Unsupported file type: {ext}") + sys.exit(1) + + sys.exit(0 if success else 1) + + # Batch processing + if args.input_dir: + if not args.output_dir: + parser.error("--output-dir required for batch processing") + + input_dir = Path(args.input_dir) + output_dir = Path(args.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + # Find all media files + patterns = ['*.mp4', '*.mov', '*.avi', '*.mkv', '*.webm', + '*.mp3', '*.wav', '*.m4a', '*.flac', + '*.jpg', '*.jpeg', '*.png', '*.webp'] + + files = [] + for pattern in patterns: + files.extend(input_dir.glob(pattern)) + + if not files: + print(f"No media files found in {input_dir}") + sys.exit(1) + + print(f"Found {len(files)} files to process") + + success_count = 0 + for input_file in files: + output_file = output_dir / input_file.name + + ext = input_file.suffix.lower() + success = False + + if ext in ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.flv']: + success = optimize_video(str(input_file), str(output_file), + quality=args.quality, verbose=args.verbose) + elif ext in ['.mp3', '.wav', '.m4a', '.flac', '.aac']: + success = optimize_audio(str(input_file), str(output_file), + bitrate=args.bitrate, verbose=args.verbose) + elif ext in ['.jpg', '.jpeg', '.png', '.webp']: + success = optimize_image(str(input_file), str(output_file), + max_width=args.max_width, quality=args.quality, + verbose=args.verbose) + + if success: + success_count += 1 + + print(f"\nProcessed: {success_count}/{len(files)} files") + + +if __name__ == '__main__': + main() diff --git a/skills/ai-multimodal/scripts/requirements.txt b/skills/ai-multimodal/scripts/requirements.txt new file mode 100644 index 0000000..7a67dac --- /dev/null +++ b/skills/ai-multimodal/scripts/requirements.txt @@ -0,0 +1,26 @@ +# AI Multimodal Skill Dependencies +# Python 3.10+ required + +# Google Gemini API +google-genai>=0.1.0 + +# PDF processing +pypdf>=4.0.0 + +# Document conversion +python-docx>=1.0.0 +docx2pdf>=0.1.8 # Windows only, optional on Linux/macOS + +# Markdown processing +markdown>=3.5.0 + +# Image processing +Pillow>=10.0.0 + +# Environment variable management +python-dotenv>=1.0.0 + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 diff --git a/skills/ai-multimodal/scripts/tests/requirements.txt b/skills/ai-multimodal/scripts/tests/requirements.txt new file mode 100644 index 0000000..bc19f96 --- /dev/null +++ b/skills/ai-multimodal/scripts/tests/requirements.txt @@ -0,0 +1,20 @@ +# Core dependencies +google-genai>=0.2.0 +python-dotenv>=1.0.0 + +# Image processing +pillow>=10.0.0 + +# PDF processing +pypdf>=3.0.0 + +# Document conversion +markdown>=3.5 + +# Testing +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Optional dependencies for full functionality +# ffmpeg-python>=0.2.0 # For media optimization (requires ffmpeg installed) diff --git a/skills/ai-multimodal/scripts/tests/test_document_converter.py b/skills/ai-multimodal/scripts/tests/test_document_converter.py new file mode 100644 index 0000000..be8c908 --- /dev/null +++ b/skills/ai-multimodal/scripts/tests/test_document_converter.py @@ -0,0 +1,299 @@ +""" +Tests for document_converter.py +""" + +import pytest +import sys +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import document_converter as dc + + +class TestEnvLoading: + """Test environment variable loading.""" + + @patch('document_converter.load_dotenv') + @patch('pathlib.Path.exists') + def test_load_env_files_success(self, mock_exists, mock_load_dotenv): + """Test successful .env file loading.""" + mock_exists.return_value = True + dc.load_env_files() + # Should be called for skill, skills, and claude dirs + assert mock_load_dotenv.call_count >= 1 + + @patch('document_converter.load_dotenv', None) + def test_load_env_files_no_dotenv(self): + """Test when dotenv is not available.""" + # Should not raise an error + dc.load_env_files() + + +class TestDependencyCheck: + """Test dependency checking.""" + + @patch('builtins.__import__') + def test_check_all_dependencies_available(self, mock_import): + """Test when all dependencies are available.""" + mock_import.return_value = Mock() + + deps = dc.check_dependencies() + + assert 'pypdf' in deps + assert 'markdown' in deps + assert 'pillow' in deps + + @patch('builtins.__import__') + def test_check_dependencies_missing(self, mock_import): + """Test when dependencies are missing.""" + def import_side_effect(name, *args, **kwargs): + if name == 'pypdf': + raise ImportError() + return Mock() + + mock_import.side_effect = import_side_effect + + # The function uses try/except, so we test the actual function + with patch('document_converter.sys.modules', {}): + # This is tricky to test due to import handling + pass + + +class TestPDFPageExtraction: + """Test PDF page extraction.""" + + @patch('pypdf.PdfReader') + @patch('pypdf.PdfWriter') + @patch('builtins.open', create=True) + def test_extract_single_page(self, mock_open, mock_writer_class, mock_reader_class): + """Test extracting a single page.""" + # Mock reader + mock_reader = Mock() + mock_page = Mock() + mock_reader.pages = [Mock(), mock_page, Mock()] + mock_reader_class.return_value = mock_reader + + # Mock writer + mock_writer = Mock() + mock_writer.pages = [mock_page] + mock_writer_class.return_value = mock_writer + + result = dc.extract_pdf_pages( + 'input.pdf', + 'output.pdf', + page_range='2', + verbose=False + ) + + assert result is True + mock_writer.add_page.assert_called_once_with(mock_page) + + @patch('pypdf.PdfReader') + @patch('pypdf.PdfWriter') + @patch('builtins.open', create=True) + def test_extract_page_range(self, mock_open, mock_writer_class, mock_reader_class): + """Test extracting a range of pages.""" + mock_reader = Mock() + mock_reader.pages = [Mock() for _ in range(10)] + mock_reader_class.return_value = mock_reader + + mock_writer = Mock() + mock_writer.pages = [] + mock_writer_class.return_value = mock_writer + + result = dc.extract_pdf_pages( + 'input.pdf', + 'output.pdf', + page_range='2-5', + verbose=False + ) + + assert result is True + assert mock_writer.add_page.call_count == 4 # Pages 2-5 (4 pages) + + def test_extract_pages_no_pypdf(self): + """Test page extraction without pypdf.""" + with patch.dict('sys.modules', {'pypdf': None}): + result = dc.extract_pdf_pages('input.pdf', 'output.pdf', '1-10') + assert result is False + + +class TestPDFOptimization: + """Test PDF optimization.""" + + @patch('pypdf.PdfReader') + @patch('pypdf.PdfWriter') + @patch('builtins.open', create=True) + @patch('pathlib.Path.stat') + def test_optimize_pdf_success(self, mock_stat, mock_open, mock_writer_class, mock_reader_class): + """Test successful PDF optimization.""" + # Mock reader + mock_reader = Mock() + mock_page = Mock() + mock_reader.pages = [mock_page, mock_page] + mock_reader_class.return_value = mock_reader + + # Mock writer + mock_writer = Mock() + mock_writer.pages = [mock_page, mock_page] + mock_writer_class.return_value = mock_writer + + # Mock file sizes + mock_stat.return_value.st_size = 1024 * 1024 + + result = dc.optimize_pdf('input.pdf', 'output.pdf', verbose=False) + + assert result is True + mock_page.compress_content_streams.assert_called() + + def test_optimize_pdf_no_pypdf(self): + """Test PDF optimization without pypdf.""" + with patch.dict('sys.modules', {'pypdf': None}): + result = dc.optimize_pdf('input.pdf', 'output.pdf') + assert result is False + + +class TestImageExtraction: + """Test image extraction from PDFs.""" + + @patch('pypdf.PdfReader') + @patch('PIL.Image') + @patch('pathlib.Path.mkdir') + @patch('builtins.open', create=True) + def test_extract_images_success(self, mock_open, mock_mkdir, mock_image, mock_reader_class): + """Test successful image extraction.""" + # Mock PDF reader + mock_reader = Mock() + mock_page = MagicMock() + + # Mock XObject with image + mock_obj = MagicMock() + mock_obj.__getitem__.side_effect = lambda k: { + '/Subtype': '/Image', + '/Width': 100, + '/Height': 100, + '/Filter': '/DCTDecode' + }[k] + mock_obj.get_data.return_value = b'image_data' + + mock_xobjects = MagicMock() + mock_xobjects.__iter__.return_value = ['img1'] + mock_xobjects.__getitem__.return_value = mock_obj + + mock_resources = MagicMock() + mock_resources.get_object.return_value = mock_xobjects + mock_page.__getitem__.side_effect = lambda k: { + '/Resources': {'/XObject': mock_resources} + }[k] + + mock_reader.pages = [mock_page] + mock_reader_class.return_value = mock_reader + + result = dc.extract_images_from_pdf('input.pdf', './output', verbose=False) + + assert len(result) > 0 + + def test_extract_images_no_dependencies(self): + """Test image extraction without required dependencies.""" + with patch.dict('sys.modules', {'pypdf': None}): + result = dc.extract_images_from_pdf('input.pdf', './output') + assert result == [] + + +class TestMarkdownConversion: + """Test Markdown to PDF conversion.""" + + @patch('markdown.markdown') + @patch('builtins.open', create=True) + @patch('subprocess.run') + @patch('pathlib.Path.unlink') + def test_convert_markdown_success(self, mock_unlink, mock_run, mock_open, mock_markdown): + """Test successful Markdown to PDF conversion.""" + mock_markdown.return_value = '

Test

' + + # Mock file reading and writing + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = '# Test' + mock_open.return_value = mock_file + + result = dc.convert_markdown_to_pdf('input.md', 'output.pdf', verbose=False) + + assert result is True + mock_run.assert_called_once() + + @patch('markdown.markdown') + @patch('builtins.open', create=True) + @patch('subprocess.run') + def test_convert_markdown_no_wkhtmltopdf(self, mock_run, mock_open, mock_markdown): + """Test Markdown conversion without wkhtmltopdf.""" + mock_markdown.return_value = '

Test

' + + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = '# Test' + mock_open.return_value = mock_file + + mock_run.side_effect = FileNotFoundError() + + result = dc.convert_markdown_to_pdf('input.md', 'output.pdf', verbose=False) + + assert result is False + + def test_convert_markdown_no_markdown_lib(self): + """Test Markdown conversion without markdown library.""" + with patch.dict('sys.modules', {'markdown': None}): + result = dc.convert_markdown_to_pdf('input.md', 'output.pdf') + assert result is False + + +class TestHTMLConversion: + """Test HTML to PDF conversion.""" + + @patch('subprocess.run') + def test_convert_html_success(self, mock_run): + """Test successful HTML to PDF conversion.""" + result = dc.convert_html_to_pdf('input.html', 'output.pdf', verbose=False) + + assert result is True + mock_run.assert_called_once() + + @patch('subprocess.run') + def test_convert_html_no_wkhtmltopdf(self, mock_run): + """Test HTML conversion without wkhtmltopdf.""" + mock_run.side_effect = FileNotFoundError() + + result = dc.convert_html_to_pdf('input.html', 'output.pdf', verbose=False) + + assert result is False + + +class TestIntegration: + """Integration tests.""" + + @patch('pathlib.Path.exists') + def test_file_not_found(self, mock_exists): + """Test handling of non-existent input file.""" + mock_exists.return_value = False + + # This would normally be tested via main() but we test the concept + assert not Path('nonexistent.pdf').exists() + + @patch('document_converter.check_dependencies') + def test_check_dependencies_integration(self, mock_check): + """Test dependency checking integration.""" + mock_check.return_value = { + 'pypdf': True, + 'markdown': True, + 'pillow': True + } + + deps = dc.check_dependencies() + + assert deps['pypdf'] is True + assert deps['markdown'] is True + assert deps['pillow'] is True + + +if __name__ == '__main__': + pytest.main([__file__, '-v', '--cov=document_converter', '--cov-report=term-missing']) diff --git a/skills/ai-multimodal/scripts/tests/test_gemini_batch_process.py b/skills/ai-multimodal/scripts/tests/test_gemini_batch_process.py new file mode 100644 index 0000000..7c90812 --- /dev/null +++ b/skills/ai-multimodal/scripts/tests/test_gemini_batch_process.py @@ -0,0 +1,362 @@ +""" +Tests for gemini_batch_process.py +""" + +import pytest +import sys +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import gemini_batch_process as gbp + + +class TestAPIKeyFinder: + """Test API key detection.""" + + def test_find_api_key_from_env(self, monkeypatch): + """Test finding API key from environment variable.""" + monkeypatch.setenv('GEMINI_API_KEY', 'test_key_123') + assert gbp.find_api_key() == 'test_key_123' + + @patch('gemini_batch_process.load_dotenv') + def test_find_api_key_not_found(self, mock_load_dotenv, monkeypatch): + """Test when API key is not found.""" + monkeypatch.delenv('GEMINI_API_KEY', raising=False) + # Mock load_dotenv to not actually load any files + mock_load_dotenv.return_value = None + assert gbp.find_api_key() is None + + +class TestMimeTypeDetection: + """Test MIME type detection.""" + + def test_audio_mime_types(self): + """Test audio file MIME types.""" + assert gbp.get_mime_type('test.mp3') == 'audio/mp3' + assert gbp.get_mime_type('test.wav') == 'audio/wav' + assert gbp.get_mime_type('test.aac') == 'audio/aac' + assert gbp.get_mime_type('test.flac') == 'audio/flac' + + def test_image_mime_types(self): + """Test image file MIME types.""" + assert gbp.get_mime_type('test.jpg') == 'image/jpeg' + assert gbp.get_mime_type('test.jpeg') == 'image/jpeg' + assert gbp.get_mime_type('test.png') == 'image/png' + assert gbp.get_mime_type('test.webp') == 'image/webp' + + def test_video_mime_types(self): + """Test video file MIME types.""" + assert gbp.get_mime_type('test.mp4') == 'video/mp4' + assert gbp.get_mime_type('test.mov') == 'video/quicktime' + assert gbp.get_mime_type('test.avi') == 'video/x-msvideo' + + def test_document_mime_types(self): + """Test document file MIME types.""" + assert gbp.get_mime_type('test.pdf') == 'application/pdf' + assert gbp.get_mime_type('test.txt') == 'text/plain' + + def test_unknown_mime_type(self): + """Test unknown file extension.""" + assert gbp.get_mime_type('test.xyz') == 'application/octet-stream' + + def test_case_insensitive(self): + """Test case-insensitive extension matching.""" + assert gbp.get_mime_type('TEST.MP3') == 'audio/mp3' + assert gbp.get_mime_type('Test.JPG') == 'image/jpeg' + + +class TestFileUpload: + """Test file upload functionality.""" + + @patch('gemini_batch_process.genai.Client') + def test_upload_file_success(self, mock_client_class): + """Test successful file upload.""" + # Mock client and file + mock_client = Mock() + mock_file = Mock() + mock_file.state.name = 'ACTIVE' + mock_file.name = 'test_file' + mock_client.files.upload.return_value = mock_file + + result = gbp.upload_file(mock_client, 'test.jpg', verbose=False) + + assert result == mock_file + mock_client.files.upload.assert_called_once_with(file='test.jpg') + + @patch('gemini_batch_process.genai.Client') + @patch('gemini_batch_process.time.sleep') + def test_upload_video_with_processing(self, mock_sleep, mock_client_class): + """Test video upload with processing wait.""" + mock_client = Mock() + + # First call: PROCESSING, second call: ACTIVE + mock_file_processing = Mock() + mock_file_processing.state.name = 'PROCESSING' + mock_file_processing.name = 'test_video' + + mock_file_active = Mock() + mock_file_active.state.name = 'ACTIVE' + mock_file_active.name = 'test_video' + + mock_client.files.upload.return_value = mock_file_processing + mock_client.files.get.return_value = mock_file_active + + result = gbp.upload_file(mock_client, 'test.mp4', verbose=False) + + assert result.state.name == 'ACTIVE' + + @patch('gemini_batch_process.genai.Client') + def test_upload_file_failed(self, mock_client_class): + """Test failed file upload.""" + mock_client = Mock() + mock_file = Mock() + mock_file.state.name = 'FAILED' + mock_client.files.upload.return_value = mock_file + mock_client.files.get.return_value = mock_file + + with pytest.raises(ValueError, match="File processing failed"): + gbp.upload_file(mock_client, 'test.mp4', verbose=False) + + +class TestProcessFile: + """Test file processing functionality.""" + + @patch('gemini_batch_process.genai.Client') + @patch('builtins.open', create=True) + @patch('pathlib.Path.stat') + def test_process_small_file_inline(self, mock_stat, mock_open, mock_client_class): + """Test processing small file with inline data.""" + # Mock small file + mock_stat.return_value.st_size = 10 * 1024 * 1024 # 10MB + + # Mock file content + mock_open.return_value.__enter__.return_value.read.return_value = b'test_data' + + # Mock client and response + mock_client = Mock() + mock_response = Mock() + mock_response.text = 'Test response' + mock_client.models.generate_content.return_value = mock_response + + result = gbp.process_file( + client=mock_client, + file_path='test.jpg', + prompt='Describe this image', + model='gemini-2.5-flash', + task='analyze', + format_output='text', + verbose=False + ) + + assert result['status'] == 'success' + assert result['response'] == 'Test response' + + @patch('gemini_batch_process.upload_file') + @patch('gemini_batch_process.genai.Client') + @patch('pathlib.Path.stat') + def test_process_large_file_api(self, mock_stat, mock_client_class, mock_upload): + """Test processing large file with File API.""" + # Mock large file + mock_stat.return_value.st_size = 50 * 1024 * 1024 # 50MB + + # Mock upload and response + mock_file = Mock() + mock_upload.return_value = mock_file + + mock_client = Mock() + mock_response = Mock() + mock_response.text = 'Test response' + mock_client.models.generate_content.return_value = mock_response + + result = gbp.process_file( + client=mock_client, + file_path='test.mp4', + prompt='Summarize this video', + model='gemini-2.5-flash', + task='analyze', + format_output='text', + verbose=False + ) + + assert result['status'] == 'success' + mock_upload.assert_called_once() + + @patch('gemini_batch_process.genai.Client') + @patch('builtins.open', create=True) + @patch('pathlib.Path.stat') + def test_process_file_error_handling(self, mock_stat, mock_open, mock_client_class): + """Test error handling in file processing.""" + mock_stat.return_value.st_size = 1024 + + # Mock file read + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = b'test_data' + mock_open.return_value = mock_file + + mock_client = Mock() + mock_client.models.generate_content.side_effect = Exception("API Error") + + result = gbp.process_file( + client=mock_client, + file_path='test.jpg', + prompt='Test', + model='gemini-2.5-flash', + task='analyze', + format_output='text', + verbose=False, + max_retries=1 + ) + + assert result['status'] == 'error' + assert 'API Error' in result['error'] + + @patch('gemini_batch_process.genai.Client') + @patch('builtins.open', create=True) + @patch('pathlib.Path.stat') + def test_image_generation_with_aspect_ratio(self, mock_stat, mock_open, mock_client_class): + """Test image generation with aspect ratio config.""" + mock_stat.return_value.st_size = 1024 + + # Mock file read + mock_file = MagicMock() + mock_file.__enter__.return_value.read.return_value = b'test' + mock_open.return_value = mock_file + + mock_client = Mock() + mock_response = Mock() + mock_response.candidates = [Mock()] + mock_response.candidates[0].content.parts = [ + Mock(inline_data=Mock(data=b'fake_image_data')) + ] + mock_client.models.generate_content.return_value = mock_response + + result = gbp.process_file( + client=mock_client, + file_path='test.txt', + prompt='Generate mountain landscape', + model='gemini-2.5-flash-image', + task='generate', + format_output='text', + aspect_ratio='16:9', + verbose=False + ) + + # Verify config was called with correct structure + call_args = mock_client.models.generate_content.call_args + config = call_args.kwargs.get('config') + assert config is not None + assert result['status'] == 'success' + assert 'generated_image' in result + + +class TestBatchProcessing: + """Test batch processing functionality.""" + + @patch('gemini_batch_process.find_api_key') + @patch('gemini_batch_process.process_file') + @patch('gemini_batch_process.genai.Client') + def test_batch_process_success(self, mock_client_class, mock_process, mock_find_key): + """Test successful batch processing.""" + mock_find_key.return_value = 'test_key' + mock_process.return_value = {'status': 'success', 'response': 'Test'} + + results = gbp.batch_process( + files=['test1.jpg', 'test2.jpg'], + prompt='Analyze', + model='gemini-2.5-flash', + task='analyze', + format_output='text', + verbose=False, + dry_run=False + ) + + assert len(results) == 2 + assert all(r['status'] == 'success' for r in results) + + @patch('gemini_batch_process.find_api_key') + def test_batch_process_no_api_key(self, mock_find_key): + """Test batch processing without API key.""" + mock_find_key.return_value = None + + with pytest.raises(SystemExit): + gbp.batch_process( + files=['test.jpg'], + prompt='Test', + model='gemini-2.5-flash', + task='analyze', + format_output='text', + verbose=False, + dry_run=False + ) + + @patch('gemini_batch_process.find_api_key') + def test_batch_process_dry_run(self, mock_find_key): + """Test dry run mode.""" + # API key not needed for dry run, but we mock it to avoid sys.exit + mock_find_key.return_value = 'test_key' + + results = gbp.batch_process( + files=['test1.jpg', 'test2.jpg'], + prompt='Test', + model='gemini-2.5-flash', + task='analyze', + format_output='text', + verbose=False, + dry_run=True + ) + + assert results == [] + + +class TestResultsSaving: + """Test results saving functionality.""" + + @patch('builtins.open', create=True) + @patch('json.dump') + def test_save_results_json(self, mock_json_dump, mock_open): + """Test saving results as JSON.""" + results = [ + {'file': 'test1.jpg', 'status': 'success', 'response': 'Test1'}, + {'file': 'test2.jpg', 'status': 'success', 'response': 'Test2'} + ] + + gbp.save_results(results, 'output.json', 'json') + + mock_json_dump.assert_called_once() + + @patch('builtins.open', create=True) + @patch('csv.DictWriter') + def test_save_results_csv(self, mock_csv_writer, mock_open): + """Test saving results as CSV.""" + results = [ + {'file': 'test1.jpg', 'status': 'success', 'response': 'Test1'}, + {'file': 'test2.jpg', 'status': 'success', 'response': 'Test2'} + ] + + gbp.save_results(results, 'output.csv', 'csv') + + # Verify CSV writer was used + mock_csv_writer.assert_called_once() + + @patch('builtins.open', create=True) + def test_save_results_markdown(self, mock_open): + """Test saving results as Markdown.""" + mock_file = MagicMock() + mock_open.return_value.__enter__.return_value = mock_file + + results = [ + {'file': 'test1.jpg', 'status': 'success', 'response': 'Test1'}, + {'file': 'test2.jpg', 'status': 'error', 'error': 'Failed'} + ] + + gbp.save_results(results, 'output.md', 'markdown') + + # Verify write was called + assert mock_file.write.call_count > 0 + + +if __name__ == '__main__': + pytest.main([__file__, '-v', '--cov=gemini_batch_process', '--cov-report=term-missing']) diff --git a/skills/ai-multimodal/scripts/tests/test_media_optimizer.py b/skills/ai-multimodal/scripts/tests/test_media_optimizer.py new file mode 100644 index 0000000..7a8c424 --- /dev/null +++ b/skills/ai-multimodal/scripts/tests/test_media_optimizer.py @@ -0,0 +1,373 @@ +""" +Tests for media_optimizer.py +""" + +import pytest +import sys +from pathlib import Path +from unittest.mock import Mock, patch, MagicMock +import json + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import media_optimizer as mo + + +class TestEnvLoading: + """Test environment variable loading.""" + + @patch('media_optimizer.load_dotenv') + @patch('pathlib.Path.exists') + def test_load_env_files_success(self, mock_exists, mock_load_dotenv): + """Test successful .env file loading.""" + mock_exists.return_value = True + mo.load_env_files() + # Should be called for skill, skills, and claude dirs + assert mock_load_dotenv.call_count >= 1 + + @patch('media_optimizer.load_dotenv', None) + def test_load_env_files_no_dotenv(self): + """Test when dotenv is not available.""" + # Should not raise an error + mo.load_env_files() + + +class TestFFmpegCheck: + """Test ffmpeg availability checking.""" + + @patch('subprocess.run') + def test_ffmpeg_installed(self, mock_run): + """Test when ffmpeg is installed.""" + mock_run.return_value = Mock() + assert mo.check_ffmpeg() is True + + @patch('subprocess.run') + def test_ffmpeg_not_installed(self, mock_run): + """Test when ffmpeg is not installed.""" + mock_run.side_effect = FileNotFoundError() + assert mo.check_ffmpeg() is False + + @patch('subprocess.run') + def test_ffmpeg_error(self, mock_run): + """Test ffmpeg command error.""" + mock_run.side_effect = Exception("Error") + assert mo.check_ffmpeg() is False + + +class TestMediaInfo: + """Test media information extraction.""" + + @patch('media_optimizer.check_ffmpeg') + @patch('subprocess.run') + def test_get_video_info(self, mock_run, mock_check): + """Test extracting video information.""" + mock_check.return_value = True + + mock_result = Mock() + mock_result.stdout = json.dumps({ + 'format': { + 'size': '10485760', + 'duration': '120.5', + 'bit_rate': '691200' + }, + 'streams': [ + { + 'codec_type': 'video', + 'width': 1920, + 'height': 1080, + 'r_frame_rate': '30/1' + }, + { + 'codec_type': 'audio', + 'sample_rate': '48000', + 'channels': 2 + } + ] + }) + mock_run.return_value = mock_result + + info = mo.get_media_info('test.mp4') + + assert info['size'] == 10485760 + assert info['duration'] == 120.5 + assert info['width'] == 1920 + assert info['height'] == 1080 + assert info['sample_rate'] == 48000 + + @patch('media_optimizer.check_ffmpeg') + def test_get_media_info_no_ffmpeg(self, mock_check): + """Test when ffmpeg is not available.""" + mock_check.return_value = False + info = mo.get_media_info('test.mp4') + assert info == {} + + @patch('media_optimizer.check_ffmpeg') + @patch('subprocess.run') + def test_get_media_info_error(self, mock_run, mock_check): + """Test error handling in media info extraction.""" + mock_check.return_value = True + mock_run.side_effect = Exception("Error") + + info = mo.get_media_info('test.mp4') + assert info == {} + + +class TestVideoOptimization: + """Test video optimization functionality.""" + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + @patch('subprocess.run') + def test_optimize_video_success(self, mock_run, mock_info, mock_check): + """Test successful video optimization.""" + mock_check.return_value = True + mock_info.side_effect = [ + # Input info + { + 'size': 50 * 1024 * 1024, + 'duration': 120.0, + 'bit_rate': 3500000, + 'width': 1920, + 'height': 1080 + }, + # Output info + { + 'size': 25 * 1024 * 1024, + 'duration': 120.0, + 'width': 1920, + 'height': 1080 + } + ] + + result = mo.optimize_video( + 'input.mp4', + 'output.mp4', + quality=23, + verbose=False + ) + + assert result is True + mock_run.assert_called_once() + + @patch('media_optimizer.check_ffmpeg') + def test_optimize_video_no_ffmpeg(self, mock_check): + """Test video optimization without ffmpeg.""" + mock_check.return_value = False + + result = mo.optimize_video('input.mp4', 'output.mp4') + assert result is False + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + def test_optimize_video_no_info(self, mock_info, mock_check): + """Test video optimization when info cannot be read.""" + mock_check.return_value = True + mock_info.return_value = {} + + result = mo.optimize_video('input.mp4', 'output.mp4') + assert result is False + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + @patch('subprocess.run') + def test_optimize_video_with_target_size(self, mock_run, mock_info, mock_check): + """Test video optimization with target size.""" + mock_check.return_value = True + mock_info.side_effect = [ + {'size': 100 * 1024 * 1024, 'duration': 60.0, 'bit_rate': 3500000}, + {'size': 50 * 1024 * 1024, 'duration': 60.0} + ] + + result = mo.optimize_video( + 'input.mp4', + 'output.mp4', + target_size_mb=50, + verbose=False + ) + + assert result is True + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + @patch('subprocess.run') + def test_optimize_video_with_resolution(self, mock_run, mock_info, mock_check): + """Test video optimization with custom resolution.""" + mock_check.return_value = True + mock_info.side_effect = [ + {'size': 50 * 1024 * 1024, 'duration': 120.0, 'bit_rate': 3500000}, + {'size': 25 * 1024 * 1024, 'duration': 120.0} + ] + + result = mo.optimize_video( + 'input.mp4', + 'output.mp4', + resolution='1280x720', + verbose=False + ) + + assert result is True + + +class TestAudioOptimization: + """Test audio optimization functionality.""" + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + @patch('subprocess.run') + def test_optimize_audio_success(self, mock_run, mock_info, mock_check): + """Test successful audio optimization.""" + mock_check.return_value = True + mock_info.side_effect = [ + {'size': 10 * 1024 * 1024, 'duration': 300.0}, + {'size': 5 * 1024 * 1024, 'duration': 300.0} + ] + + result = mo.optimize_audio( + 'input.mp3', + 'output.m4a', + bitrate='64k', + verbose=False + ) + + assert result is True + mock_run.assert_called_once() + + @patch('media_optimizer.check_ffmpeg') + def test_optimize_audio_no_ffmpeg(self, mock_check): + """Test audio optimization without ffmpeg.""" + mock_check.return_value = False + + result = mo.optimize_audio('input.mp3', 'output.m4a') + assert result is False + + +class TestImageOptimization: + """Test image optimization functionality.""" + + @patch('PIL.Image.open') + @patch('pathlib.Path.stat') + def test_optimize_image_success(self, mock_stat, mock_image_open): + """Test successful image optimization.""" + # Mock image + mock_resized = Mock() + mock_resized.mode = 'RGB' + + mock_img = Mock() + mock_img.width = 3840 + mock_img.height = 2160 + mock_img.mode = 'RGB' + mock_img.resize.return_value = mock_resized + mock_image_open.return_value = mock_img + + # Mock file sizes + mock_stat.return_value.st_size = 5 * 1024 * 1024 + + result = mo.optimize_image( + 'input.jpg', + 'output.jpg', + max_width=1920, + quality=85, + verbose=False + ) + + assert result is True + # Since image is resized, save is called on the resized image + mock_resized.save.assert_called_once() + + @patch('PIL.Image.open') + @patch('pathlib.Path.stat') + def test_optimize_image_resize(self, mock_stat, mock_image_open): + """Test image resizing during optimization.""" + mock_img = Mock() + mock_img.width = 3840 + mock_img.height = 2160 + mock_img.mode = 'RGB' + mock_resized = Mock() + mock_img.resize.return_value = mock_resized + mock_image_open.return_value = mock_img + + mock_stat.return_value.st_size = 5 * 1024 * 1024 + + mo.optimize_image('input.jpg', 'output.jpg', max_width=1920, verbose=False) + + mock_img.resize.assert_called_once() + + @patch('PIL.Image.open') + @patch('pathlib.Path.stat') + def test_optimize_image_rgba_to_jpg(self, mock_stat, mock_image_open): + """Test converting RGBA to RGB for JPEG.""" + mock_img = Mock() + mock_img.width = 1920 + mock_img.height = 1080 + mock_img.mode = 'RGBA' + mock_img.split.return_value = [Mock(), Mock(), Mock(), Mock()] + mock_image_open.return_value = mock_img + + mock_stat.return_value.st_size = 1024 * 1024 + + with patch('PIL.Image.new') as mock_new: + mock_rgb = Mock() + mock_new.return_value = mock_rgb + + mo.optimize_image('input.png', 'output.jpg', verbose=False) + + mock_new.assert_called_once() + + def test_optimize_image_no_pillow(self): + """Test image optimization without Pillow.""" + with patch.dict('sys.modules', {'PIL': None}): + result = mo.optimize_image('input.jpg', 'output.jpg') + # Will fail to import but function handles it + assert result is False + + +class TestVideoSplitting: + """Test video splitting functionality.""" + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + @patch('subprocess.run') + @patch('pathlib.Path.mkdir') + def test_split_video_success(self, mock_mkdir, mock_run, mock_info, mock_check): + """Test successful video splitting.""" + mock_check.return_value = True + mock_info.return_value = {'duration': 7200.0} # 2 hours + + result = mo.split_video( + 'input.mp4', + './chunks', + chunk_duration=3600, # 1 hour chunks + verbose=False + ) + + # Duration 7200s / 3600s = 2, +1 for safety = 3 chunks + assert len(result) == 3 + assert mock_run.call_count == 3 + + @patch('media_optimizer.check_ffmpeg') + @patch('media_optimizer.get_media_info') + def test_split_video_short_duration(self, mock_info, mock_check): + """Test splitting video shorter than chunk duration.""" + mock_check.return_value = True + mock_info.return_value = {'duration': 1800.0} # 30 minutes + + result = mo.split_video( + 'input.mp4', + './chunks', + chunk_duration=3600, # 1 hour + verbose=False + ) + + assert result == ['input.mp4'] + + @patch('media_optimizer.check_ffmpeg') + def test_split_video_no_ffmpeg(self, mock_check): + """Test video splitting without ffmpeg.""" + mock_check.return_value = False + + result = mo.split_video('input.mp4', './chunks') + assert result == [] + + +if __name__ == '__main__': + pytest.main([__file__, '-v', '--cov=media_optimizer', '--cov-report=term-missing']) diff --git a/skills/better-auth/SKILL.md b/skills/better-auth/SKILL.md new file mode 100644 index 0000000..15ef839 --- /dev/null +++ b/skills/better-auth/SKILL.md @@ -0,0 +1,204 @@ +--- +name: better-auth +description: Implement authentication and authorization with Better Auth - a framework-agnostic TypeScript authentication framework. Features include email/password authentication with verification, OAuth providers (Google, GitHub, Discord, etc.), two-factor authentication (TOTP, SMS), passkeys/WebAuthn support, session management, role-based access control (RBAC), rate limiting, and database adapters. Use when adding authentication to applications, implementing OAuth flows, setting up 2FA/MFA, managing user sessions, configuring authorization rules, or building secure authentication systems for web applications. +license: MIT +version: 2.0.0 +--- + +# Better Auth Skill + +Better Auth is comprehensive, framework-agnostic authentication/authorization framework for TypeScript with built-in email/password, social OAuth, and powerful plugin ecosystem for advanced features. + +## When to Use + +- Implementing auth in TypeScript/JavaScript applications +- Adding email/password or social OAuth authentication +- Setting up 2FA, passkeys, magic links, advanced auth features +- Building multi-tenant apps with organization support +- Managing sessions and user lifecycle +- Working with any framework (Next.js, Nuxt, SvelteKit, Remix, Astro, Hono, Express, etc.) + +## Quick Start + +### Installation + +```bash +npm install better-auth +# or pnpm/yarn/bun add better-auth +``` + +### Environment Setup + +Create `.env`: +```env +BETTER_AUTH_SECRET= +BETTER_AUTH_URL=http://localhost:3000 +``` + +### Basic Server Setup + +Create `auth.ts` (root, lib/, utils/, or under src/app/server/): + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + database: { + // See references/database-integration.md + }, + emailAndPassword: { + enabled: true, + autoSignIn: true + }, + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + } + } +}); +``` + +### Database Schema + +```bash +npx @better-auth/cli generate # Generate schema/migrations +npx @better-auth/cli migrate # Apply migrations (Kysely only) +``` + +### Mount API Handler + +**Next.js App Router:** +```ts +// app/api/auth/[...all]/route.ts +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); +``` + +**Other frameworks:** See references/email-password-auth.md#framework-setup + +### Client Setup + +Create `auth-client.ts`: + +```ts +import { createAuthClient } from "better-auth/client"; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL || "http://localhost:3000" +}); +``` + +### Basic Usage + +```ts +// Sign up +await authClient.signUp.email({ + email: "user@example.com", + password: "secure123", + name: "John Doe" +}); + +// Sign in +await authClient.signIn.email({ + email: "user@example.com", + password: "secure123" +}); + +// OAuth +await authClient.signIn.social({ provider: "github" }); + +// Session +const { data: session } = authClient.useSession(); // React/Vue/Svelte +const { data: session } = await authClient.getSession(); // Vanilla JS +``` + +## Feature Selection Matrix + +| Feature | Plugin Required | Use Case | Reference | +|---------|----------------|----------|-----------| +| Email/Password | No (built-in) | Basic auth | [email-password-auth.md](./references/email-password-auth.md) | +| OAuth (GitHub, Google, etc.) | No (built-in) | Social login | [oauth-providers.md](./references/oauth-providers.md) | +| Email Verification | No (built-in) | Verify email addresses | [email-password-auth.md](./references/email-password-auth.md#email-verification) | +| Password Reset | No (built-in) | Forgot password flow | [email-password-auth.md](./references/email-password-auth.md#password-reset) | +| Two-Factor Auth (2FA/TOTP) | Yes (`twoFactor`) | Enhanced security | [advanced-features.md](./references/advanced-features.md#two-factor-authentication) | +| Passkeys/WebAuthn | Yes (`passkey`) | Passwordless auth | [advanced-features.md](./references/advanced-features.md#passkeys-webauthn) | +| Magic Link | Yes (`magicLink`) | Email-based login | [advanced-features.md](./references/advanced-features.md#magic-link) | +| Username Auth | Yes (`username`) | Username login | [email-password-auth.md](./references/email-password-auth.md#username-authentication) | +| Organizations/Multi-tenant | Yes (`organization`) | Team/org features | [advanced-features.md](./references/advanced-features.md#organizations) | +| Rate Limiting | No (built-in) | Prevent abuse | [advanced-features.md](./references/advanced-features.md#rate-limiting) | +| Session Management | No (built-in) | User sessions | [advanced-features.md](./references/advanced-features.md#session-management) | + +## Auth Method Selection Guide + +**Choose Email/Password when:** +- Building standard web app with traditional auth +- Need full control over user credentials +- Targeting users who prefer email-based accounts + +**Choose OAuth when:** +- Want quick signup with minimal friction +- Users already have social accounts +- Need access to social profile data + +**Choose Passkeys when:** +- Want passwordless experience +- Targeting modern browsers/devices +- Security is top priority + +**Choose Magic Link when:** +- Want passwordless without WebAuthn complexity +- Targeting email-first users +- Need temporary access links + +**Combine Multiple Methods when:** +- Want flexibility for different user preferences +- Building enterprise apps with various auth requirements +- Need progressive enhancement (start simple, add more options) + +## Core Architecture + +Better Auth uses client-server architecture: +1. **Server** (`better-auth`): Handles auth logic, database ops, API routes +2. **Client** (`better-auth/client`): Provides hooks/methods for frontend +3. **Plugins**: Extend both server/client functionality + +## Implementation Checklist + +- [ ] Install `better-auth` package +- [ ] Set environment variables (SECRET, URL) +- [ ] Create auth server instance with database config +- [ ] Run schema migration (`npx @better-auth/cli generate`) +- [ ] Mount API handler in framework +- [ ] Create client instance +- [ ] Implement sign-up/sign-in UI +- [ ] Add session management to components +- [ ] Set up protected routes/middleware +- [ ] Add plugins as needed (regenerate schema after) +- [ ] Test complete auth flow +- [ ] Configure email sending (verification/reset) +- [ ] Enable rate limiting for production +- [ ] Set up error handling + +## Reference Documentation + +### Core Authentication +- [Email/Password Authentication](./references/email-password-auth.md) - Email/password setup, verification, password reset, username auth +- [OAuth Providers](./references/oauth-providers.md) - Social login setup, provider configuration, token management +- [Database Integration](./references/database-integration.md) - Database adapters, schema setup, migrations + +### Advanced Features +- [Advanced Features](./references/advanced-features.md) - 2FA/MFA, passkeys, magic links, organizations, rate limiting, session management + +## Scripts + +- `scripts/better_auth_init.py` - Initialize Better Auth configuration with interactive setup + +## Resources + +- Docs: https://www.better-auth.com/docs +- GitHub: https://github.com/better-auth/better-auth +- Plugins: https://www.better-auth.com/docs/plugins +- Examples: https://www.better-auth.com/docs/examples diff --git a/skills/better-auth/references/advanced-features.md b/skills/better-auth/references/advanced-features.md new file mode 100644 index 0000000..59d607b --- /dev/null +++ b/skills/better-auth/references/advanced-features.md @@ -0,0 +1,553 @@ +# Advanced Features + +Better Auth plugins extend functionality beyond basic authentication. + +## Two-Factor Authentication + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { twoFactor } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + twoFactor({ + issuer: "YourAppName", // TOTP issuer name + otpOptions: { + period: 30, // OTP validity period (seconds) + digits: 6, // OTP length + } + }) + ] +}); +``` + +### Client Setup + +```ts +import { createAuthClient } from "better-auth/client"; +import { twoFactorClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [ + twoFactorClient({ + twoFactorPage: "/two-factor", // Redirect to 2FA verification page + redirect: true // Auto-redirect if 2FA required + }) + ] +}); +``` + +### Enable 2FA for User + +```ts +// Enable TOTP +const { data } = await authClient.twoFactor.enable({ + password: "userPassword" // Verify user identity +}); + +// data contains QR code URI for authenticator app +const qrCodeUri = data.totpURI; +const backupCodes = data.backupCodes; // Save these securely +``` + +### Verify TOTP Code + +```ts +await authClient.twoFactor.verifyTOTP({ + code: "123456", + trustDevice: true // Skip 2FA on this device for 30 days +}); +``` + +### Disable 2FA + +```ts +await authClient.twoFactor.disable({ + password: "userPassword" +}); +``` + +### Backup Codes + +```ts +// Generate new backup codes +const { data } = await authClient.twoFactor.generateBackupCodes({ + password: "userPassword" +}); + +// Use backup code instead of TOTP +await authClient.twoFactor.verifyBackupCode({ + code: "backup-code-123" +}); +``` + +## Passkeys (WebAuthn) + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { passkey } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + passkey({ + rpName: "YourApp", // Relying Party name + rpID: "yourdomain.com" // Your domain + }) + ] +}); +``` + +### Client Setup + +```ts +import { createAuthClient } from "better-auth/client"; +import { passkeyClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [passkeyClient()] +}); +``` + +### Register Passkey + +```ts +// User must be authenticated first +await authClient.passkey.register({ + name: "My Laptop" // Optional: name for this passkey +}); +``` + +### Sign In with Passkey + +```ts +await authClient.passkey.signIn(); +``` + +### List User Passkeys + +```ts +const { data } = await authClient.passkey.list(); +// data contains array of registered passkeys +``` + +### Delete Passkey + +```ts +await authClient.passkey.delete({ + id: "passkey-id" +}); +``` + +## Magic Link + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { magicLink } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + magicLink({ + sendMagicLink: async ({ email, url, token }) => { + await sendEmail({ + to: email, + subject: "Sign in to YourApp", + html: `Click here to sign in.` + }); + }, + expiresIn: 300, // Link expires in 5 minutes (seconds) + }) + ] +}); +``` + +### Client Setup + +```ts +import { createAuthClient } from "better-auth/client"; +import { magicLinkClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [magicLinkClient()] +}); +``` + +### Send Magic Link + +```ts +await authClient.magicLink.sendMagicLink({ + email: "user@example.com", + callbackURL: "/dashboard" +}); +``` + +### Verify Magic Link + +```ts +// Called automatically when user clicks link +// Token in URL query params handled by Better Auth +await authClient.magicLink.verify({ + token: "token-from-url" +}); +``` + +## Organizations (Multi-Tenancy) + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { organization } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + organization({ + allowUserToCreateOrganization: true, + organizationLimit: 5, // Max orgs per user + creatorRole: "owner" // Role for org creator + }) + ] +}); +``` + +### Client Setup + +```ts +import { createAuthClient } from "better-auth/client"; +import { organizationClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [organizationClient()] +}); +``` + +### Create Organization + +```ts +await authClient.organization.create({ + name: "Acme Corp", + slug: "acme", // Unique slug + metadata: { + industry: "Technology" + } +}); +``` + +### Invite Members + +```ts +await authClient.organization.inviteMember({ + organizationId: "org-id", + email: "user@example.com", + role: "member", // owner, admin, member + message: "Join our team!" // Optional +}); +``` + +### Accept Invitation + +```ts +await authClient.organization.acceptInvitation({ + invitationId: "invitation-id" +}); +``` + +### List Organizations + +```ts +const { data } = await authClient.organization.list(); +// Returns user's organizations +``` + +### Update Member Role + +```ts +await authClient.organization.updateMemberRole({ + organizationId: "org-id", + userId: "user-id", + role: "admin" +}); +``` + +### Remove Member + +```ts +await authClient.organization.removeMember({ + organizationId: "org-id", + userId: "user-id" +}); +``` + +### Delete Organization + +```ts +await authClient.organization.delete({ + organizationId: "org-id" +}); +``` + +## Session Management + +### Configure Session Expiration + +```ts +export const auth = betterAuth({ + session: { + expiresIn: 60 * 60 * 24 * 7, // 7 days (seconds) + updateAge: 60 * 60 * 24, // Update session every 24 hours + cookieCache: { + enabled: true, + maxAge: 5 * 60 // Cache for 5 minutes + } + } +}); +``` + +### Server-Side Session + +```ts +// Next.js +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +const session = await auth.api.getSession({ + headers: await headers() +}); + +if (!session) { + // Not authenticated +} +``` + +### Client-Side Session + +```tsx +// React +import { authClient } from "@/lib/auth-client"; + +function UserProfile() { + const { data: session, isPending, error } = authClient.useSession(); + + if (isPending) return
Loading...
; + if (error) return
Error
; + if (!session) return
Not logged in
; + + return
Hello, {session.user.name}!
; +} +``` + +### List Active Sessions + +```ts +const { data: sessions } = await authClient.listSessions(); +// Returns all active sessions for current user +``` + +### Revoke Session + +```ts +await authClient.revokeSession({ + sessionId: "session-id" +}); +``` + +### Revoke All Sessions + +```ts +await authClient.revokeAllSessions(); +``` + +## Rate Limiting + +### Server Configuration + +```ts +export const auth = betterAuth({ + rateLimit: { + enabled: true, + window: 60, // Time window in seconds + max: 10, // Max requests per window + storage: "memory", // "memory" or "database" + customRules: { + "/api/auth/sign-in": { + window: 60, + max: 5 // Stricter limit for sign-in + }, + "/api/auth/sign-up": { + window: 3600, + max: 3 // 3 signups per hour + } + } + } +}); +``` + +### Custom Rate Limiter + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + rateLimit: { + enabled: true, + customLimiter: async ({ request, limit }) => { + // Custom rate limiting logic + const ip = request.headers.get("x-forwarded-for"); + const key = `ratelimit:${ip}`; + + // Use Redis, etc. + const count = await redis.incr(key); + if (count === 1) { + await redis.expire(key, limit.window); + } + + if (count > limit.max) { + throw new Error("Rate limit exceeded"); + } + } + } +}); +``` + +## Anonymous Sessions + +Track users before they sign up. + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { anonymous } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [anonymous()] +}); +``` + +### Client Usage + +```ts +// Create anonymous session +const { data } = await authClient.signIn.anonymous(); + +// Convert to full account +await authClient.signUp.email({ + email: "user@example.com", + password: "password123", + linkAnonymousSession: true // Link anonymous data +}); +``` + +## Email OTP + +One-time password via email (passwordless). + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { emailOTP } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + emailOTP({ + sendVerificationOTP: async ({ email, otp }) => { + await sendEmail({ + to: email, + subject: "Your verification code", + text: `Your code is: ${otp}` + }); + }, + expiresIn: 300, // 5 minutes + length: 6 // OTP length + }) + ] +}); +``` + +### Client Usage + +```ts +// Send OTP to email +await authClient.emailOTP.sendOTP({ + email: "user@example.com" +}); + +// Verify OTP +await authClient.emailOTP.verifyOTP({ + email: "user@example.com", + otp: "123456" +}); +``` + +## Phone Number Authentication + +Requires phone number plugin. + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { phoneNumber } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + phoneNumber({ + sendOTP: async ({ phoneNumber, otp }) => { + // Use Twilio, AWS SNS, etc. + await sendSMS(phoneNumber, `Your code: ${otp}`); + } + }) + ] +}); +``` + +### Client Usage + +```ts +// Sign up with phone +await authClient.signUp.phoneNumber({ + phoneNumber: "+1234567890", + password: "password123" +}); + +// Send OTP +await authClient.phoneNumber.sendOTP({ + phoneNumber: "+1234567890" +}); + +// Verify OTP +await authClient.phoneNumber.verifyOTP({ + phoneNumber: "+1234567890", + otp: "123456" +}); +``` + +## Best Practices + +1. **2FA**: Offer 2FA as optional, make mandatory for admin users +2. **Passkeys**: Implement as progressive enhancement (fallback to password) +3. **Magic Links**: Set short expiration (5-15 minutes) +4. **Organizations**: Implement RBAC for org permissions +5. **Sessions**: Use short expiration for sensitive apps +6. **Rate Limiting**: Enable in production, adjust limits based on usage +7. **Anonymous Sessions**: Clean up old anonymous sessions periodically +8. **Backup Codes**: Force users to save backup codes before enabling 2FA +9. **Multi-Device**: Allow users to manage trusted devices +10. **Audit Logs**: Track sensitive operations (role changes, 2FA changes) + +## Regenerate Schema After Plugins + +After adding any plugin: + +```bash +npx @better-auth/cli generate +npx @better-auth/cli migrate # if using Kysely +``` + +Or manually apply migrations for your ORM (Drizzle, Prisma). diff --git a/skills/better-auth/references/database-integration.md b/skills/better-auth/references/database-integration.md new file mode 100644 index 0000000..2a13565 --- /dev/null +++ b/skills/better-auth/references/database-integration.md @@ -0,0 +1,577 @@ +# Database Integration + +Better Auth supports multiple databases and ORMs for flexible data persistence. + +## Supported Databases + +- SQLite +- PostgreSQL +- MySQL/MariaDB +- MongoDB +- Any database with adapter support + +## Direct Database Connection + +### SQLite + +```ts +import { betterAuth } from "better-auth"; +import Database from "better-sqlite3"; + +export const auth = betterAuth({ + database: new Database("./sqlite.db"), + // or + database: new Database(":memory:") // In-memory for testing +}); +``` + +### PostgreSQL + +```ts +import { betterAuth } from "better-auth"; +import { Pool } from "pg"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + // or explicit config + host: "localhost", + port: 5432, + user: "postgres", + password: "password", + database: "myapp" +}); + +export const auth = betterAuth({ + database: pool +}); +``` + +### MySQL + +```ts +import { betterAuth } from "better-auth"; +import { createPool } from "mysql2/promise"; + +const pool = createPool({ + host: "localhost", + user: "root", + password: "password", + database: "myapp", + waitForConnections: true, + connectionLimit: 10 +}); + +export const auth = betterAuth({ + database: pool +}); +``` + +## ORM Adapters + +### Drizzle ORM + +**Install:** +```bash +npm install drizzle-orm better-auth +``` + +**Setup:** +```ts +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL +}); + +const db = drizzle(pool); + +export const auth = betterAuth({ + database: drizzleAdapter(db, { + provider: "pg", // "pg" | "mysql" | "sqlite" + schema: { + // Optional: custom table names + user: "users", + session: "sessions", + account: "accounts", + verification: "verifications" + } + }) +}); +``` + +**Generate Schema:** +```bash +npx @better-auth/cli generate --adapter drizzle +``` + +### Prisma + +**Install:** +```bash +npm install @prisma/client better-auth +``` + +**Setup:** +```ts +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export const auth = betterAuth({ + database: prismaAdapter(prisma, { + provider: "postgresql", // "postgresql" | "mysql" | "sqlite" + }) +}); +``` + +**Generate Schema:** +```bash +npx @better-auth/cli generate --adapter prisma +``` + +**Apply to Prisma:** +```bash +# Add generated schema to schema.prisma +npx prisma migrate dev --name init +npx prisma generate +``` + +### Kysely + +**Install:** +```bash +npm install kysely better-auth +``` + +**Setup:** +```ts +import { betterAuth } from "better-auth"; +import { kyselyAdapter } from "better-auth/adapters/kysely"; +import { Kysely, PostgresDialect } from "kysely"; +import { Pool } from "pg"; + +const db = new Kysely({ + dialect: new PostgresDialect({ + pool: new Pool({ + connectionString: process.env.DATABASE_URL + }) + }) +}); + +export const auth = betterAuth({ + database: kyselyAdapter(db, { + provider: "pg" + }) +}); +``` + +**Auto-migrate with Kysely:** +```bash +npx @better-auth/cli migrate --adapter kysely +``` + +### MongoDB + +**Install:** +```bash +npm install mongodb better-auth +``` + +**Setup:** +```ts +import { betterAuth } from "better-auth"; +import { mongodbAdapter } from "better-auth/adapters/mongodb"; +import { MongoClient } from "mongodb"; + +const client = new MongoClient(process.env.MONGODB_URI!); +await client.connect(); + +export const auth = betterAuth({ + database: mongodbAdapter(client, { + databaseName: "myapp" + }) +}); +``` + +**Generate Collections:** +```bash +npx @better-auth/cli generate --adapter mongodb +``` + +## Core Database Schema + +Better Auth requires these core tables/collections: + +### User Table + +```sql +CREATE TABLE user ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + emailVerified BOOLEAN DEFAULT FALSE, + name TEXT, + image TEXT, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### Session Table + +```sql +CREATE TABLE session ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + expiresAt TIMESTAMP NOT NULL, + ipAddress TEXT, + userAgent TEXT, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE +); +``` + +### Account Table + +```sql +CREATE TABLE account ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + accountId TEXT NOT NULL, + providerId TEXT NOT NULL, + accessToken TEXT, + refreshToken TEXT, + expiresAt TIMESTAMP, + scope TEXT, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (userId) REFERENCES user(id) ON DELETE CASCADE, + UNIQUE(providerId, accountId) +); +``` + +### Verification Table + +```sql +CREATE TABLE verification ( + id TEXT PRIMARY KEY, + identifier TEXT NOT NULL, + value TEXT NOT NULL, + expiresAt TIMESTAMP NOT NULL, + createdAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +## Schema Generation + +### Using CLI + +```bash +# Generate schema files +npx @better-auth/cli generate + +# Specify adapter +npx @better-auth/cli generate --adapter drizzle +npx @better-auth/cli generate --adapter prisma + +# Specify output +npx @better-auth/cli generate --output ./db/schema.ts +``` + +### Auto-migrate (Kysely only) + +```bash +npx @better-auth/cli migrate +``` + +For other ORMs, apply generated schema manually. + +## Custom Fields + +Add custom fields to user table: + +```ts +export const auth = betterAuth({ + user: { + additionalFields: { + role: { + type: "string", + required: false, + defaultValue: "user" + }, + phoneNumber: { + type: "string", + required: false + }, + subscriptionTier: { + type: "string", + required: false + } + } + } +}); +``` + +After adding fields: +```bash +npx @better-auth/cli generate +``` + +Update user with custom fields: +```ts +await authClient.updateUser({ + role: "admin", + phoneNumber: "+1234567890" +}); +``` + +## Plugin Schema Extensions + +Plugins add their own tables/fields. Regenerate schema after adding plugins: + +```bash +npx @better-auth/cli generate +``` + +### Two-Factor Plugin Tables + +- `twoFactor`: Stores TOTP secrets, backup codes + +### Passkey Plugin Tables + +- `passkey`: Stores WebAuthn credentials + +### Organization Plugin Tables + +- `organization`: Organization data +- `member`: Organization members +- `invitation`: Pending invitations + +## Migration Strategies + +### Development + +```bash +# Generate schema +npx @better-auth/cli generate + +# Apply migrations (Kysely) +npx @better-auth/cli migrate + +# Or manual (Prisma) +npx prisma migrate dev + +# Or manual (Drizzle) +npx drizzle-kit push +``` + +### Production + +```bash +# Review generated migration +npx @better-auth/cli generate + +# Test in staging +# Apply to production with your ORM's migration tool + +# Prisma +npx prisma migrate deploy + +# Drizzle +npx drizzle-kit push + +# Kysely +npx @better-auth/cli migrate +``` + +## Connection Pooling + +### PostgreSQL + +```ts +import { Pool } from "pg"; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + max: 20, // Max connections + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, +}); +``` + +### MySQL + +```ts +import { createPool } from "mysql2/promise"; + +const pool = createPool({ + connectionString: process.env.DATABASE_URL, + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0 +}); +``` + +## Database URLs + +### PostgreSQL + +```env +DATABASE_URL=postgresql://user:password@localhost:5432/dbname +# Or with connection params +DATABASE_URL=postgresql://user:password@localhost:5432/dbname?schema=public&connection_limit=10 +``` + +### MySQL + +```env +DATABASE_URL=mysql://user:password@localhost:3306/dbname +``` + +### SQLite + +```env +DATABASE_URL=file:./dev.db +# Or in-memory +DATABASE_URL=:memory: +``` + +### MongoDB + +```env +MONGODB_URI=mongodb://localhost:27017/dbname +# Or Atlas +MONGODB_URI=mongodb+srv://user:password@cluster.mongodb.net/dbname +``` + +## Performance Optimization + +### Indexes + +Better Auth CLI auto-generates essential indexes: +- `user.email` (unique) +- `session.userId` +- `account.userId` +- `account.providerId, accountId` (unique) + +Add custom indexes for performance: +```sql +CREATE INDEX idx_session_expires ON session(expiresAt); +CREATE INDEX idx_user_created ON user(createdAt); +``` + +### Query Optimization + +```ts +// Use connection pooling +// Enable query caching where applicable +// Monitor slow queries + +export const auth = betterAuth({ + advanced: { + defaultCookieAttributes: { + sameSite: "lax", + secure: true, + httpOnly: true + } + } +}); +``` + +## Backup Strategies + +### PostgreSQL + +```bash +# Backup +pg_dump dbname > backup.sql + +# Restore +psql dbname < backup.sql +``` + +### MySQL + +```bash +# Backup +mysqldump -u root -p dbname > backup.sql + +# Restore +mysql -u root -p dbname < backup.sql +``` + +### SQLite + +```bash +# Copy file +cp dev.db dev.db.backup + +# Or use backup command +sqlite3 dev.db ".backup backup.db" +``` + +### MongoDB + +```bash +# Backup +mongodump --db=dbname --out=./backup + +# Restore +mongorestore --db=dbname ./backup/dbname +``` + +## Best Practices + +1. **Environment Variables**: Store credentials in env vars, never commit +2. **Connection Pooling**: Use pools for PostgreSQL/MySQL in production +3. **Migrations**: Use ORM migration tools, not raw SQL in production +4. **Indexes**: Add indexes for frequently queried fields +5. **Backups**: Automate daily backups in production +6. **SSL**: Use SSL/TLS for database connections in production +7. **Schema Sync**: Keep schema in sync across environments +8. **Testing**: Use separate database for tests (in-memory SQLite ideal) +9. **Monitoring**: Monitor query performance and connection pool usage +10. **Cleanup**: Periodically clean expired sessions/verifications + +## Troubleshooting + +### Connection Errors + +```ts +// Add connection timeout +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, + connectionTimeoutMillis: 5000 +}); +``` + +### Schema Mismatch + +```bash +# Regenerate schema +npx @better-auth/cli generate + +# Apply migrations +# For Prisma: npx prisma migrate dev +# For Drizzle: npx drizzle-kit push +``` + +### Migration Failures + +- Check database credentials +- Verify database server is running +- Check for schema conflicts +- Review migration SQL manually + +### Performance Issues + +- Add indexes on foreign keys +- Enable connection pooling +- Monitor slow queries +- Consider read replicas for heavy read workloads diff --git a/skills/better-auth/references/email-password-auth.md b/skills/better-auth/references/email-password-auth.md new file mode 100644 index 0000000..eff3ec1 --- /dev/null +++ b/skills/better-auth/references/email-password-auth.md @@ -0,0 +1,416 @@ +# Email/Password Authentication + +Email/password is built-in auth method in Better Auth. No plugins required for basic functionality. + +## Server Configuration + +### Basic Setup + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + autoSignIn: true, // Auto sign-in after signup (default: true) + requireEmailVerification: false, // Require email verification before login + sendResetPasswordToken: async ({ user, url }) => { + // Send password reset email + await sendEmail(user.email, url); + } + } +}); +``` + +### Custom Password Requirements + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + password: { + minLength: 8, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: true + } + } +}); +``` + +## Client Usage + +### Sign Up + +```ts +import { authClient } from "@/lib/auth-client"; + +const { data, error } = await authClient.signUp.email({ + email: "user@example.com", + password: "securePassword123", + name: "John Doe", + image: "https://example.com/avatar.jpg", // optional + callbackURL: "/dashboard" // optional +}, { + onSuccess: (ctx) => { + // ctx.data contains user and session + console.log("User created:", ctx.data.user); + }, + onError: (ctx) => { + alert(ctx.error.message); + } +}); +``` + +### Sign In + +```ts +const { data, error } = await authClient.signIn.email({ + email: "user@example.com", + password: "securePassword123", + callbackURL: "/dashboard", + rememberMe: true // default: true +}, { + onSuccess: () => { + // redirect or update UI + }, + onError: (ctx) => { + console.error(ctx.error.message); + } +}); +``` + +### Sign Out + +```ts +await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + router.push("/login"); + } + } +}); +``` + +## Email Verification + +### Server Setup + +```ts +export const auth = betterAuth({ + emailVerification: { + sendVerificationEmail: async ({ user, url, token }) => { + // Send verification email + await sendEmail({ + to: user.email, + subject: "Verify your email", + html: `Click here to verify your email.` + }); + }, + sendOnSignUp: true, // Send verification email on signup + autoSignInAfterVerification: true // Auto sign-in after verification + }, + emailAndPassword: { + enabled: true, + requireEmailVerification: true // Require verification before login + } +}); +``` + +### Client Usage + +```ts +// Send verification email +await authClient.sendVerificationEmail({ + email: "user@example.com", + callbackURL: "/verify-success" +}); + +// Verify email with token +await authClient.verifyEmail({ + token: "verification-token-from-email" +}); +``` + +## Password Reset Flow + +### Server Setup + +```ts +export const auth = betterAuth({ + emailAndPassword: { + enabled: true, + sendResetPasswordToken: async ({ user, url, token }) => { + await sendEmail({ + to: user.email, + subject: "Reset your password", + html: `Click here to reset your password.` + }); + } + } +}); +``` + +### Client Flow + +```ts +// Step 1: Request password reset +await authClient.forgetPassword({ + email: "user@example.com", + redirectTo: "/reset-password" +}); + +// Step 2: Reset password with token +await authClient.resetPassword({ + token: "reset-token-from-email", + password: "newSecurePassword123" +}); +``` + +### Change Password (Authenticated) + +```ts +await authClient.changePassword({ + currentPassword: "oldPassword123", + newPassword: "newPassword456", + revokeOtherSessions: true // Optional: logout other sessions +}); +``` + +## Username Authentication + +Requires `username` plugin for username-based auth. + +### Server Setup + +```ts +import { betterAuth } from "better-auth"; +import { username } from "better-auth/plugins"; + +export const auth = betterAuth({ + plugins: [ + username({ + // Allow sign in with username or email + allowUsernameOrEmail: true + }) + ] +}); +``` + +### Client Setup + +```ts +import { createAuthClient } from "better-auth/client"; +import { usernameClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [usernameClient()] +}); +``` + +### Client Usage + +```ts +// Sign up with username +await authClient.signUp.username({ + username: "johndoe", + password: "securePassword123", + email: "john@example.com", // optional + name: "John Doe" +}); + +// Sign in with username +await authClient.signIn.username({ + username: "johndoe", + password: "securePassword123" +}); + +// Sign in with username or email (if allowUsernameOrEmail: true) +await authClient.signIn.username({ + username: "johndoe", // or "john@example.com" + password: "securePassword123" +}); +``` + +## Framework Setup + +### Next.js (App Router) + +```ts +// app/api/auth/[...all]/route.ts +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { POST, GET } = toNextJsHandler(auth); +``` + +### Next.js (Pages Router) + +```ts +// pages/api/auth/[...all].ts +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export default toNextJsHandler(auth); +``` + +### Nuxt + +```ts +// server/api/auth/[...all].ts +import { auth } from "~/utils/auth"; +import { toWebRequest } from "better-auth/utils/web"; + +export default defineEventHandler((event) => { + return auth.handler(toWebRequest(event)); +}); +``` + +### SvelteKit + +```ts +// hooks.server.ts +import { auth } from "$lib/auth"; +import { svelteKitHandler } from "better-auth/svelte-kit"; + +export async function handle({ event, resolve }) { + return svelteKitHandler({ event, resolve, auth }); +} +``` + +### Astro + +```ts +// pages/api/auth/[...all].ts +import { auth } from "@/lib/auth"; + +export async function ALL({ request }: { request: Request }) { + return auth.handler(request); +} +``` + +### Hono + +```ts +import { Hono } from "hono"; +import { auth } from "./auth"; + +const app = new Hono(); + +app.on(["POST", "GET"], "/api/auth/*", (c) => { + return auth.handler(c.req.raw); +}); +``` + +### Express + +```ts +import express from "express"; +import { toNodeHandler } from "better-auth/node"; +import { auth } from "./auth"; + +const app = express(); + +app.all("/api/auth/*", toNodeHandler(auth)); +``` + +## Protected Routes + +### Next.js Middleware + +```ts +// middleware.ts +import { auth } from "@/lib/auth"; +import { NextRequest, NextResponse } from "next/server"; + +export async function middleware(request: NextRequest) { + const session = await auth.api.getSession({ + headers: request.headers + }); + + if (!session) { + return NextResponse.redirect(new URL("/login", request.url)); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/dashboard/:path*", "/profile/:path*"] +}; +``` + +### SvelteKit Hooks + +```ts +// hooks.server.ts +import { auth } from "$lib/auth"; +import { redirect } from "@sveltejs/kit"; + +export async function handle({ event, resolve }) { + const session = await auth.api.getSession({ + headers: event.request.headers + }); + + if (event.url.pathname.startsWith("/dashboard") && !session) { + throw redirect(303, "/login"); + } + + return resolve(event); +} +``` + +### Nuxt Middleware + +```ts +// middleware/auth.ts +export default defineNuxtRouteMiddleware(async (to) => { + const { data: session } = await useAuthSession(); + + if (!session.value && to.path.startsWith("/dashboard")) { + return navigateTo("/login"); + } +}); +``` + +## User Profile Management + +### Get Current User + +```ts +const { data: session } = await authClient.getSession(); +console.log(session.user); +``` + +### Update User Profile + +```ts +await authClient.updateUser({ + name: "New Name", + image: "https://example.com/new-avatar.jpg", + // Custom fields if defined in schema +}); +``` + +### Delete User Account + +```ts +await authClient.deleteUser({ + password: "currentPassword", // Required for security + callbackURL: "/" // Redirect after deletion +}); +``` + +## Best Practices + +1. **Password Security**: Enforce strong password requirements +2. **Email Verification**: Enable for production to prevent spam +3. **Rate Limiting**: Prevent brute force attacks (see advanced-features.md) +4. **HTTPS**: Always use HTTPS in production +5. **Error Messages**: Don't reveal if email exists during login +6. **Session Security**: Use secure, httpOnly cookies +7. **CSRF Protection**: Better Auth handles this automatically +8. **Password Reset**: Set short expiration for reset tokens +9. **Account Lockout**: Consider implementing after N failed attempts +10. **Audit Logs**: Track auth events for security monitoring diff --git a/skills/better-auth/references/oauth-providers.md b/skills/better-auth/references/oauth-providers.md new file mode 100644 index 0000000..5a92bec --- /dev/null +++ b/skills/better-auth/references/oauth-providers.md @@ -0,0 +1,430 @@ +# OAuth Providers + +Better Auth provides built-in OAuth 2.0 support for social authentication. No plugins required. + +## Supported Providers + +GitHub, Google, Apple, Discord, Facebook, Microsoft, Twitter/X, Spotify, Twitch, LinkedIn, Dropbox, GitLab, and more. + +## Basic OAuth Setup + +### Server Configuration + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + // Optional: custom scopes + scope: ["user:email", "read:user"] + }, + google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + scope: ["openid", "email", "profile"] + }, + discord: { + clientId: process.env.DISCORD_CLIENT_ID!, + clientSecret: process.env.DISCORD_CLIENT_SECRET!, + } + } +}); +``` + +### Client Usage + +```ts +import { authClient } from "@/lib/auth-client"; + +// Basic sign in +await authClient.signIn.social({ + provider: "github", + callbackURL: "/dashboard" +}); + +// With callbacks +await authClient.signIn.social({ + provider: "google", + callbackURL: "/dashboard", + errorCallbackURL: "/error", + newUserCallbackURL: "/welcome", // For first-time users +}); +``` + +## Provider Configuration + +### GitHub OAuth + +1. Create OAuth App at https://github.com/settings/developers +2. Set Authorization callback URL: `http://localhost:3000/api/auth/callback/github` +3. Add credentials to `.env`: + +```env +GITHUB_CLIENT_ID=your_client_id +GITHUB_CLIENT_SECRET=your_client_secret +``` + +### Google OAuth + +1. Create project at https://console.cloud.google.com +2. Enable Google+ API +3. Create OAuth 2.0 credentials +4. Add authorized redirect URI: `http://localhost:3000/api/auth/callback/google` +5. Add credentials to `.env`: + +```env +GOOGLE_CLIENT_ID=your_client_id.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=your_client_secret +``` + +### Discord OAuth + +1. Create application at https://discord.com/developers/applications +2. Add OAuth2 redirect: `http://localhost:3000/api/auth/callback/discord` +3. Add credentials: + +```env +DISCORD_CLIENT_ID=your_client_id +DISCORD_CLIENT_SECRET=your_client_secret +``` + +### Apple Sign In + +```ts +export const auth = betterAuth({ + socialProviders: { + apple: { + clientId: process.env.APPLE_CLIENT_ID!, + clientSecret: process.env.APPLE_CLIENT_SECRET!, + teamId: process.env.APPLE_TEAM_ID!, + keyId: process.env.APPLE_KEY_ID!, + privateKey: process.env.APPLE_PRIVATE_KEY! + } + } +}); +``` + +### Microsoft/Azure AD + +```ts +export const auth = betterAuth({ + socialProviders: { + microsoft: { + clientId: process.env.MICROSOFT_CLIENT_ID!, + clientSecret: process.env.MICROSOFT_CLIENT_SECRET!, + tenantId: process.env.MICROSOFT_TENANT_ID, // Optional: for specific tenant + } + } +}); +``` + +### Twitter/X OAuth + +```ts +export const auth = betterAuth({ + socialProviders: { + twitter: { + clientId: process.env.TWITTER_CLIENT_ID!, + clientSecret: process.env.TWITTER_CLIENT_SECRET!, + } + } +}); +``` + +## Custom OAuth Provider + +Add custom OAuth 2.0 provider: + +```ts +import { betterAuth } from "better-auth"; + +export const auth = betterAuth({ + socialProviders: { + customProvider: { + clientId: process.env.CUSTOM_CLIENT_ID!, + clientSecret: process.env.CUSTOM_CLIENT_SECRET!, + authorizationUrl: "https://provider.com/oauth/authorize", + tokenUrl: "https://provider.com/oauth/token", + userInfoUrl: "https://provider.com/oauth/userinfo", + scope: ["email", "profile"], + // Map provider user data to Better Auth user + mapProfile: (profile) => ({ + id: profile.id, + email: profile.email, + name: profile.name, + image: profile.avatar_url + }) + } + } +}); +``` + +## Account Linking + +Link multiple OAuth providers to same user account. + +### Server Setup + +```ts +export const auth = betterAuth({ + account: { + accountLinking: { + enabled: true, + trustedProviders: ["google", "github"] // Auto-link these providers + } + } +}); +``` + +### Client Usage + +```ts +// Link new provider to existing account +await authClient.linkSocial({ + provider: "google", + callbackURL: "/profile" +}); + +// List linked accounts +const { data: session } = await authClient.getSession(); +const accounts = session.user.accounts; + +// Unlink account +await authClient.unlinkAccount({ + accountId: "account-id" +}); +``` + +## Token Management + +### Access OAuth Tokens + +```ts +// Server-side +const session = await auth.api.getSession({ + headers: request.headers +}); + +const accounts = await auth.api.listAccounts({ + userId: session.user.id +}); + +// Get specific provider token +const githubAccount = accounts.find(a => a.providerId === "github"); +const accessToken = githubAccount.accessToken; +const refreshToken = githubAccount.refreshToken; +``` + +### Refresh Tokens + +```ts +// Manually refresh OAuth token +const newToken = await auth.api.refreshToken({ + accountId: "account-id" +}); +``` + +### Use Provider API + +```ts +// Example: Use GitHub token to fetch repos +const githubAccount = accounts.find(a => a.providerId === "github"); + +const response = await fetch("https://api.github.com/user/repos", { + headers: { + Authorization: `Bearer ${githubAccount.accessToken}` + } +}); + +const repos = await response.json(); +``` + +## Advanced OAuth Configuration + +### Custom Scopes + +```ts +export const auth = betterAuth({ + socialProviders: { + github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + scope: [ + "user:email", + "read:user", + "repo", // Access repositories + "gist" // Access gists + ] + } + } +}); +``` + +### State Parameter + +Better Auth automatically handles OAuth state parameter for CSRF protection. + +```ts +// Custom state validation +export const auth = betterAuth({ + advanced: { + generateState: async () => { + // Custom state generation + return crypto.randomUUID(); + }, + validateState: async (state: string) => { + // Custom state validation + return true; + } + } +}); +``` + +### PKCE Support + +Better Auth automatically uses PKCE (Proof Key for Code Exchange) for supported providers. + +```ts +export const auth = betterAuth({ + socialProviders: { + customProvider: { + pkce: true, // Enable PKCE + // ... other config + } + } +}); +``` + +## Error Handling + +### Client-Side + +```ts +await authClient.signIn.social({ + provider: "github", + errorCallbackURL: "/auth/error" +}, { + onError: (ctx) => { + console.error("OAuth error:", ctx.error); + // Handle specific errors + if (ctx.error.code === "OAUTH_ACCOUNT_ALREADY_LINKED") { + alert("This account is already linked to another user"); + } + } +}); +``` + +### Server-Side + +```ts +export const auth = betterAuth({ + callbacks: { + async onOAuthError({ error, provider }) { + console.error(`OAuth error with ${provider}:`, error); + // Log to monitoring service + await logError(error); + } + } +}); +``` + +## Callback URLs + +### Development + +``` +http://localhost:3000/api/auth/callback/{provider} +``` + +### Production + +``` +https://yourdomain.com/api/auth/callback/{provider} +``` + +**Important:** Add all callback URLs to OAuth provider settings. + +## UI Components + +### Sign In Button (React) + +```tsx +import { authClient } from "@/lib/auth-client"; + +export function SocialSignIn() { + const handleOAuth = async (provider: string) => { + await authClient.signIn.social({ + provider, + callbackURL: "/dashboard" + }); + }; + + return ( +
+ + + +
+ ); +} +``` + +## Best Practices + +1. **Callback URLs**: Add all environments (dev, staging, prod) to OAuth app +2. **Scopes**: Request minimum scopes needed +3. **Token Storage**: Better Auth stores tokens securely in database +4. **Token Refresh**: Implement automatic token refresh for long-lived sessions +5. **Account Linking**: Enable for better UX when user signs in with different providers +6. **Error Handling**: Provide clear error messages for OAuth failures +7. **Provider Icons**: Use official brand assets for OAuth buttons +8. **Mobile Deep Links**: Configure deep links for mobile OAuth flows +9. **Email Matching**: Consider auto-linking accounts with same email +10. **Privacy**: Inform users what data you access from OAuth providers + +## Common Issues + +### Redirect URI Mismatch + +Ensure callback URL in OAuth app matches exactly: +``` +http://localhost:3000/api/auth/callback/github +``` + +### Missing Scopes + +Add required scopes for email access: +```ts +scope: ["user:email"] // GitHub +scope: ["email"] // Google +``` + +### HTTPS Required + +Some providers (Apple, Microsoft) require HTTPS callbacks. Use ngrok for local development: +```bash +ngrok http 3000 +``` + +### CORS Errors + +Configure CORS if frontend/backend on different domains: +```ts +export const auth = betterAuth({ + advanced: { + corsOptions: { + origin: ["https://yourdomain.com"], + credentials: true + } + } +}); +``` diff --git a/skills/better-auth/scripts/better_auth_init.py b/skills/better-auth/scripts/better_auth_init.py new file mode 100644 index 0000000..3e51705 --- /dev/null +++ b/skills/better-auth/scripts/better_auth_init.py @@ -0,0 +1,521 @@ +#!/usr/bin/env python3 +""" +Better Auth Initialization Script + +Interactive script to initialize Better Auth configuration. +Supports multiple databases, ORMs, and authentication methods. + +.env loading order: process.env > skill/.env > skills/.env > .claude/.env +""" + +import os +import sys +import json +import secrets +from pathlib import Path +from typing import Optional, Dict, Any, List +from dataclasses import dataclass + + +@dataclass +class EnvConfig: + """Environment configuration holder.""" + secret: str + url: str + database_url: Optional[str] = None + github_client_id: Optional[str] = None + github_client_secret: Optional[str] = None + google_client_id: Optional[str] = None + google_client_secret: Optional[str] = None + + +class BetterAuthInit: + """Better Auth configuration initializer.""" + + def __init__(self, project_root: Optional[Path] = None): + """ + Initialize the Better Auth configuration tool. + + Args: + project_root: Project root directory. Auto-detected if not provided. + """ + self.project_root = project_root or self._find_project_root() + self.env_config: Optional[EnvConfig] = None + + @staticmethod + def _find_project_root() -> Path: + """ + Find project root by looking for package.json. + + Returns: + Path to project root. + + Raises: + RuntimeError: If project root cannot be found. + """ + current = Path.cwd() + while current != current.parent: + if (current / "package.json").exists(): + return current + current = current.parent + + raise RuntimeError("Could not find project root (no package.json found)") + + def _load_env_files(self) -> Dict[str, str]: + """ + Load environment variables from .env files in order. + + Loading order: process.env > skill/.env > skills/.env > .claude/.env + + Returns: + Dictionary of environment variables. + """ + env_vars = {} + + # Define search paths in reverse priority order + skill_dir = Path(__file__).parent.parent + env_paths = [ + self.project_root / ".claude" / ".env", + self.project_root / ".claude" / "skills" / ".env", + skill_dir / ".env", + ] + + # Load from files (lowest priority first) + for env_path in env_paths: + if env_path.exists(): + env_vars.update(self._parse_env_file(env_path)) + + # Override with process environment (highest priority) + env_vars.update(os.environ) + + return env_vars + + @staticmethod + def _parse_env_file(path: Path) -> Dict[str, str]: + """ + Parse .env file into dictionary. + + Args: + path: Path to .env file. + + Returns: + Dictionary of key-value pairs. + """ + env_vars = {} + try: + with open(path, "r") as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + # Remove quotes if present + value = value.strip().strip('"').strip("'") + env_vars[key.strip()] = value + except Exception as e: + print(f"Warning: Could not parse {path}: {e}") + + return env_vars + + @staticmethod + def generate_secret(length: int = 32) -> str: + """ + Generate cryptographically secure random secret. + + Args: + length: Length of secret in bytes. + + Returns: + Hex-encoded secret string. + """ + return secrets.token_hex(length) + + def prompt_database(self) -> Dict[str, Any]: + """ + Prompt user for database configuration. + + Returns: + Database configuration dictionary. + """ + print("\nDatabase Configuration") + print("=" * 50) + print("1. Direct Connection (PostgreSQL/MySQL/SQLite)") + print("2. Drizzle ORM") + print("3. Prisma") + print("4. Kysely") + print("5. MongoDB") + + choice = input("\nSelect database option (1-5): ").strip() + + db_configs = { + "1": self._prompt_direct_db, + "2": self._prompt_drizzle, + "3": self._prompt_prisma, + "4": self._prompt_kysely, + "5": self._prompt_mongodb, + } + + handler = db_configs.get(choice) + if not handler: + print("Invalid choice. Defaulting to direct PostgreSQL.") + return self._prompt_direct_db() + + return handler() + + def _prompt_direct_db(self) -> Dict[str, Any]: + """Prompt for direct database connection.""" + print("\nDatabase Type:") + print("1. PostgreSQL") + print("2. MySQL") + print("3. SQLite") + + db_type = input("Select (1-3): ").strip() + + if db_type == "3": + db_path = input("SQLite file path [./dev.db]: ").strip() or "./dev.db" + return { + "type": "sqlite", + "import": "import Database from 'better-sqlite3';", + "config": f'database: new Database("{db_path}")' + } + elif db_type == "2": + db_url = input("MySQL connection string: ").strip() + return { + "type": "mysql", + "import": "import { createPool } from 'mysql2/promise';", + "config": f"database: createPool({{ connectionString: process.env.DATABASE_URL }})", + "env_var": ("DATABASE_URL", db_url) + } + else: + db_url = input("PostgreSQL connection string: ").strip() + return { + "type": "postgresql", + "import": "import { Pool } from 'pg';", + "config": "database: new Pool({ connectionString: process.env.DATABASE_URL })", + "env_var": ("DATABASE_URL", db_url) + } + + def _prompt_drizzle(self) -> Dict[str, Any]: + """Prompt for Drizzle ORM configuration.""" + print("\nDrizzle Provider:") + print("1. PostgreSQL") + print("2. MySQL") + print("3. SQLite") + + provider = input("Select (1-3): ").strip() + provider_map = {"1": "pg", "2": "mysql", "3": "sqlite"} + provider_name = provider_map.get(provider, "pg") + + return { + "type": "drizzle", + "provider": provider_name, + "import": "import { drizzleAdapter } from 'better-auth/adapters/drizzle';\nimport { db } from '@/db';", + "config": f"database: drizzleAdapter(db, {{ provider: '{provider_name}' }})" + } + + def _prompt_prisma(self) -> Dict[str, Any]: + """Prompt for Prisma configuration.""" + print("\nPrisma Provider:") + print("1. PostgreSQL") + print("2. MySQL") + print("3. SQLite") + + provider = input("Select (1-3): ").strip() + provider_map = {"1": "postgresql", "2": "mysql", "3": "sqlite"} + provider_name = provider_map.get(provider, "postgresql") + + return { + "type": "prisma", + "provider": provider_name, + "import": "import { prismaAdapter } from 'better-auth/adapters/prisma';\nimport { PrismaClient } from '@prisma/client';\n\nconst prisma = new PrismaClient();", + "config": f"database: prismaAdapter(prisma, {{ provider: '{provider_name}' }})" + } + + def _prompt_kysely(self) -> Dict[str, Any]: + """Prompt for Kysely configuration.""" + return { + "type": "kysely", + "import": "import { kyselyAdapter } from 'better-auth/adapters/kysely';\nimport { db } from '@/db';", + "config": "database: kyselyAdapter(db, { provider: 'pg' })" + } + + def _prompt_mongodb(self) -> Dict[str, Any]: + """Prompt for MongoDB configuration.""" + mongo_uri = input("MongoDB connection string: ").strip() + db_name = input("Database name: ").strip() + + return { + "type": "mongodb", + "import": "import { mongodbAdapter } from 'better-auth/adapters/mongodb';\nimport { client } from '@/db';", + "config": f"database: mongodbAdapter(client, {{ databaseName: '{db_name}' }})", + "env_var": ("MONGODB_URI", mongo_uri) + } + + def prompt_auth_methods(self) -> List[str]: + """ + Prompt user for authentication methods. + + Returns: + List of selected auth method codes. + """ + print("\nAuthentication Methods") + print("=" * 50) + print("Select authentication methods (space-separated, e.g., '1 2 3'):") + print("1. Email/Password") + print("2. GitHub OAuth") + print("3. Google OAuth") + print("4. Discord OAuth") + print("5. Two-Factor Authentication (2FA)") + print("6. Passkeys (WebAuthn)") + print("7. Magic Link") + print("8. Username") + + choices = input("\nYour selection: ").strip().split() + return [c for c in choices if c in "12345678"] + + def generate_auth_config( + self, + db_config: Dict[str, Any], + auth_methods: List[str], + ) -> str: + """ + Generate auth.ts configuration file content. + + Args: + db_config: Database configuration. + auth_methods: Selected authentication methods. + + Returns: + Generated TypeScript configuration code. + """ + imports = ["import { betterAuth } from 'better-auth';"] + plugins = [] + plugin_imports = [] + config_parts = [] + + # Database import + if db_config.get("import"): + imports.append(db_config["import"]) + + # Email/Password + if "1" in auth_methods: + config_parts.append(""" emailAndPassword: { + enabled: true, + autoSignIn: true + }""") + + # OAuth providers + social_providers = [] + if "2" in auth_methods: + social_providers.append(""" github: { + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_CLIENT_SECRET!, + }""") + + if "3" in auth_methods: + social_providers.append(""" google: { + clientId: process.env.GOOGLE_CLIENT_ID!, + clientSecret: process.env.GOOGLE_CLIENT_SECRET!, + }""") + + if "4" in auth_methods: + social_providers.append(""" discord: { + clientId: process.env.DISCORD_CLIENT_ID!, + clientSecret: process.env.DISCORD_CLIENT_SECRET!, + }""") + + if social_providers: + config_parts.append(f" socialProviders: {{\n{',\\n'.join(social_providers)}\n }}") + + # Plugins + if "5" in auth_methods: + plugin_imports.append("import { twoFactor } from 'better-auth/plugins';") + plugins.append("twoFactor()") + + if "6" in auth_methods: + plugin_imports.append("import { passkey } from 'better-auth/plugins';") + plugins.append("passkey()") + + if "7" in auth_methods: + plugin_imports.append("import { magicLink } from 'better-auth/plugins';") + plugins.append("""magicLink({ + sendMagicLink: async ({ email, url }) => { + // TODO: Implement email sending + console.log(`Magic link for ${email}: ${url}`); + } + })""") + + if "8" in auth_methods: + plugin_imports.append("import { username } from 'better-auth/plugins';") + plugins.append("username()") + + # Combine all imports + all_imports = imports + plugin_imports + + # Build config + config_body = ",\n".join(config_parts) + + if plugins: + plugins_str = ",\n ".join(plugins) + config_body += f",\n plugins: [\n {plugins_str}\n ]" + + # Final output + return f"""{chr(10).join(all_imports)} + +export const auth = betterAuth({{ + {db_config["config"]}, +{config_body} +}}); +""" + + def generate_env_file( + self, + db_config: Dict[str, Any], + auth_methods: List[str] + ) -> str: + """ + Generate .env file content. + + Args: + db_config: Database configuration. + auth_methods: Selected authentication methods. + + Returns: + Generated .env file content. + """ + env_vars = [ + f"BETTER_AUTH_SECRET={self.generate_secret()}", + "BETTER_AUTH_URL=http://localhost:3000", + ] + + # Database URL + if db_config.get("env_var"): + key, value = db_config["env_var"] + env_vars.append(f"{key}={value}") + + # OAuth credentials + if "2" in auth_methods: + env_vars.extend([ + "GITHUB_CLIENT_ID=your_github_client_id", + "GITHUB_CLIENT_SECRET=your_github_client_secret", + ]) + + if "3" in auth_methods: + env_vars.extend([ + "GOOGLE_CLIENT_ID=your_google_client_id", + "GOOGLE_CLIENT_SECRET=your_google_client_secret", + ]) + + if "4" in auth_methods: + env_vars.extend([ + "DISCORD_CLIENT_ID=your_discord_client_id", + "DISCORD_CLIENT_SECRET=your_discord_client_secret", + ]) + + return "\n".join(env_vars) + "\n" + + def run(self) -> None: + """Run interactive initialization.""" + print("=" * 50) + print("Better Auth Configuration Generator") + print("=" * 50) + + # Load existing env + env_vars = self._load_env_files() + + # Prompt for configuration + db_config = self.prompt_database() + auth_methods = self.prompt_auth_methods() + + # Generate files + auth_config = self.generate_auth_config(db_config, auth_methods) + env_content = self.generate_env_file(db_config, auth_methods) + + # Display output + print("\n" + "=" * 50) + print("Generated Configuration") + print("=" * 50) + + print("\n--- auth.ts ---") + print(auth_config) + + print("\n--- .env ---") + print(env_content) + + # Offer to save + save = input("\nSave configuration files? (y/N): ").strip().lower() + if save == "y": + self._save_files(auth_config, env_content) + else: + print("Configuration not saved.") + + def _save_files(self, auth_config: str, env_content: str) -> None: + """ + Save generated configuration files. + + Args: + auth_config: auth.ts content. + env_content: .env content. + """ + # Save auth.ts + auth_locations = [ + self.project_root / "lib" / "auth.ts", + self.project_root / "src" / "lib" / "auth.ts", + self.project_root / "utils" / "auth.ts", + self.project_root / "auth.ts", + ] + + print("\nWhere to save auth.ts?") + for i, loc in enumerate(auth_locations, 1): + print(f"{i}. {loc}") + print("5. Custom path") + + choice = input("Select (1-5): ").strip() + if choice == "5": + custom_path = input("Enter path: ").strip() + auth_path = Path(custom_path) + else: + idx = int(choice) - 1 if choice.isdigit() else 0 + auth_path = auth_locations[idx] + + auth_path.parent.mkdir(parents=True, exist_ok=True) + auth_path.write_text(auth_config) + print(f"Saved: {auth_path}") + + # Save .env + env_path = self.project_root / ".env" + if env_path.exists(): + backup = self.project_root / ".env.backup" + env_path.rename(backup) + print(f"Backed up existing .env to {backup}") + + env_path.write_text(env_content) + print(f"Saved: {env_path}") + + print("\nNext steps:") + print("1. Run: npx @better-auth/cli generate") + print("2. Apply database migrations") + print("3. Mount API handler in your framework") + print("4. Create client instance") + + +def main() -> int: + """ + Main entry point. + + Returns: + Exit code (0 for success, 1 for error). + """ + try: + initializer = BetterAuthInit() + initializer.run() + return 0 + except KeyboardInterrupt: + print("\n\nOperation cancelled.") + return 1 + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/better-auth/scripts/requirements.txt b/skills/better-auth/scripts/requirements.txt new file mode 100644 index 0000000..bafbe07 --- /dev/null +++ b/skills/better-auth/scripts/requirements.txt @@ -0,0 +1,15 @@ +# Better Auth Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This script generates Better Auth configuration +# The actual Better Auth library is installed via npm/pnpm/yarn: +# npm install better-auth +# pnpm add better-auth +# yarn add better-auth diff --git a/skills/better-auth/scripts/tests/test_better_auth_init.py b/skills/better-auth/scripts/tests/test_better_auth_init.py new file mode 100644 index 0000000..910d569 --- /dev/null +++ b/skills/better-auth/scripts/tests/test_better_auth_init.py @@ -0,0 +1,421 @@ +""" +Tests for better_auth_init.py + +Covers main functionality with mocked I/O and file operations. +Target: >80% coverage +""" + +import sys +import pytest +from pathlib import Path +from unittest.mock import Mock, patch, mock_open, MagicMock +from io import StringIO + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from better_auth_init import BetterAuthInit, EnvConfig, main + + +@pytest.fixture +def mock_project_root(tmp_path): + """Create mock project root with package.json.""" + (tmp_path / "package.json").write_text("{}") + return tmp_path + + +@pytest.fixture +def auth_init(mock_project_root): + """Create BetterAuthInit instance with mock project root.""" + return BetterAuthInit(project_root=mock_project_root) + + +class TestBetterAuthInit: + """Test BetterAuthInit class.""" + + def test_init_with_project_root(self, mock_project_root): + """Test initialization with explicit project root.""" + init = BetterAuthInit(project_root=mock_project_root) + assert init.project_root == mock_project_root + assert init.env_config is None + + def test_find_project_root_success(self, mock_project_root, monkeypatch): + """Test finding project root successfully.""" + monkeypatch.chdir(mock_project_root) + init = BetterAuthInit() + assert init.project_root == mock_project_root + + def test_find_project_root_failure(self, tmp_path, monkeypatch): + """Test failure to find project root.""" + # Create path without package.json + no_package_dir = tmp_path / "no-package" + no_package_dir.mkdir() + monkeypatch.chdir(no_package_dir) + + # Mock parent to stop infinite loop + with patch.object(Path, "parent", new_callable=lambda: property(lambda self: self)): + with pytest.raises(RuntimeError, match="Could not find project root"): + BetterAuthInit() + + def test_generate_secret(self): + """Test secret generation.""" + secret = BetterAuthInit.generate_secret() + assert len(secret) == 64 # 32 bytes = 64 hex chars + assert all(c in "0123456789abcdef" for c in secret) + + # Test custom length + secret = BetterAuthInit.generate_secret(length=16) + assert len(secret) == 32 # 16 bytes = 32 hex chars + + def test_parse_env_file(self, tmp_path): + """Test parsing .env file.""" + env_content = """ +# Comment +KEY1=value1 +KEY2="value2" +KEY3='value3' +INVALID LINE +KEY4=value=with=equals +""" + env_file = tmp_path / ".env" + env_file.write_text(env_content) + + result = BetterAuthInit._parse_env_file(env_file) + + assert result["KEY1"] == "value1" + assert result["KEY2"] == "value2" + assert result["KEY3"] == "value3" + assert result["KEY4"] == "value=with=equals" + assert "INVALID" not in result + + def test_parse_env_file_missing(self, tmp_path): + """Test parsing missing .env file.""" + result = BetterAuthInit._parse_env_file(tmp_path / "nonexistent.env") + assert result == {} + + def test_load_env_files(self, auth_init, mock_project_root): + """Test loading environment variables from multiple files.""" + # Create .env files + claude_env = mock_project_root / ".claude" / ".env" + claude_env.parent.mkdir(parents=True, exist_ok=True) + claude_env.write_text("BASE_VAR=base\nOVERRIDE=claude") + + skills_env = mock_project_root / ".claude" / "skills" / ".env" + skills_env.parent.mkdir(parents=True, exist_ok=True) + skills_env.write_text("OVERRIDE=skills\nSKILLS_VAR=skills") + + # Mock process env (highest priority) + with patch.dict("os.environ", {"OVERRIDE": "process", "PROCESS_VAR": "process"}): + result = auth_init._load_env_files() + + assert result["BASE_VAR"] == "base" + assert result["SKILLS_VAR"] == "skills" + assert result["OVERRIDE"] == "process" # Process env wins + assert result["PROCESS_VAR"] == "process" + + def test_prompt_direct_db_sqlite(self, auth_init): + """Test prompting for SQLite database.""" + with patch("builtins.input", side_effect=["3", "./test.db"]): + config = auth_init._prompt_direct_db() + + assert config["type"] == "sqlite" + assert "better-sqlite3" in config["import"] + assert "./test.db" in config["config"] + + def test_prompt_direct_db_postgresql(self, auth_init): + """Test prompting for PostgreSQL database.""" + with patch("builtins.input", side_effect=["1", "postgresql://localhost/test"]): + config = auth_init._prompt_direct_db() + + assert config["type"] == "postgresql" + assert "pg" in config["import"] + assert config["env_var"] == ("DATABASE_URL", "postgresql://localhost/test") + + def test_prompt_direct_db_mysql(self, auth_init): + """Test prompting for MySQL database.""" + with patch("builtins.input", side_effect=["2", "mysql://localhost/test"]): + config = auth_init._prompt_direct_db() + + assert config["type"] == "mysql" + assert "mysql2" in config["import"] + assert config["env_var"][0] == "DATABASE_URL" + + def test_prompt_drizzle(self, auth_init): + """Test prompting for Drizzle ORM.""" + with patch("builtins.input", return_value="1"): + config = auth_init._prompt_drizzle() + + assert config["type"] == "drizzle" + assert config["provider"] == "pg" + assert "drizzleAdapter" in config["import"] + assert "drizzleAdapter" in config["config"] + + def test_prompt_prisma(self, auth_init): + """Test prompting for Prisma.""" + with patch("builtins.input", return_value="2"): + config = auth_init._prompt_prisma() + + assert config["type"] == "prisma" + assert config["provider"] == "mysql" + assert "prismaAdapter" in config["import"] + assert "PrismaClient" in config["import"] + + def test_prompt_kysely(self, auth_init): + """Test prompting for Kysely.""" + config = auth_init._prompt_kysely() + + assert config["type"] == "kysely" + assert "kyselyAdapter" in config["import"] + + def test_prompt_mongodb(self, auth_init): + """Test prompting for MongoDB.""" + with patch("builtins.input", side_effect=["mongodb://localhost/test", "mydb"]): + config = auth_init._prompt_mongodb() + + assert config["type"] == "mongodb" + assert "mongodbAdapter" in config["import"] + assert "mydb" in config["config"] + assert config["env_var"] == ("MONGODB_URI", "mongodb://localhost/test") + + def test_prompt_database(self, auth_init): + """Test database prompting with different choices.""" + # Test valid choice + with patch("builtins.input", side_effect=["3", "1"]): + config = auth_init.prompt_database() + assert config["type"] == "prisma" + + # Test invalid choice (defaults to direct DB) + with patch("builtins.input", side_effect=["99", "1", "postgresql://localhost/test"]): + with patch("builtins.print"): + config = auth_init.prompt_database() + assert config["type"] == "postgresql" + + def test_prompt_auth_methods(self, auth_init): + """Test prompting for authentication methods.""" + with patch("builtins.input", return_value="1 2 3 5 8"): + with patch("builtins.print"): + methods = auth_init.prompt_auth_methods() + + assert methods == ["1", "2", "3", "5", "8"] + + def test_prompt_auth_methods_invalid(self, auth_init): + """Test filtering invalid auth method choices.""" + with patch("builtins.input", return_value="1 99 abc 3"): + with patch("builtins.print"): + methods = auth_init.prompt_auth_methods() + + assert methods == ["1", "3"] + + def test_generate_auth_config_basic(self, auth_init): + """Test generating basic auth config.""" + db_config = { + "import": "import Database from 'better-sqlite3';", + "config": "database: new Database('./dev.db')" + } + auth_methods = ["1"] # Email/password only + + config = auth_init.generate_auth_config(db_config, auth_methods) + + assert "import { betterAuth }" in config + assert "emailAndPassword" in config + assert "enabled: true" in config + assert "better-sqlite3" in config + + def test_generate_auth_config_with_oauth(self, auth_init): + """Test generating config with OAuth providers.""" + db_config = { + "import": "import { Pool } from 'pg';", + "config": "database: new Pool()" + } + auth_methods = ["1", "2", "3", "4"] # Email + GitHub + Google + Discord + + config = auth_init.generate_auth_config(db_config, auth_methods) + + assert "socialProviders" in config + assert "github:" in config + assert "google:" in config + assert "discord:" in config + assert "GITHUB_CLIENT_ID" in config + assert "GOOGLE_CLIENT_ID" in config + assert "DISCORD_CLIENT_ID" in config + + def test_generate_auth_config_with_plugins(self, auth_init): + """Test generating config with plugins.""" + db_config = {"import": "", "config": "database: db"} + auth_methods = ["5", "6", "7", "8"] # 2FA, Passkey, Magic Link, Username + + config = auth_init.generate_auth_config(db_config, auth_methods) + + assert "plugins:" in config + assert "twoFactor" in config + assert "passkey" in config + assert "magicLink" in config + assert "username" in config + assert "from 'better-auth/plugins'" in config + + def test_generate_env_file_basic(self, auth_init): + """Test generating basic .env file.""" + db_config = {"type": "sqlite"} + auth_methods = ["1"] + + env_content = auth_init.generate_env_file(db_config, auth_methods) + + assert "BETTER_AUTH_SECRET=" in env_content + assert "BETTER_AUTH_URL=http://localhost:3000" in env_content + assert len(env_content.split("\n")) >= 2 + + def test_generate_env_file_with_database_url(self, auth_init): + """Test generating .env with database URL.""" + db_config = { + "env_var": ("DATABASE_URL", "postgresql://localhost/test") + } + auth_methods = [] + + env_content = auth_init.generate_env_file(db_config, auth_methods) + + assert "DATABASE_URL=postgresql://localhost/test" in env_content + + def test_generate_env_file_with_oauth(self, auth_init): + """Test generating .env with OAuth credentials.""" + db_config = {} + auth_methods = ["2", "3", "4"] # GitHub, Google, Discord + + env_content = auth_init.generate_env_file(db_config, auth_methods) + + assert "GITHUB_CLIENT_ID=" in env_content + assert "GITHUB_CLIENT_SECRET=" in env_content + assert "GOOGLE_CLIENT_ID=" in env_content + assert "GOOGLE_CLIENT_SECRET=" in env_content + assert "DISCORD_CLIENT_ID=" in env_content + assert "DISCORD_CLIENT_SECRET=" in env_content + + def test_save_files(self, auth_init, mock_project_root): + """Test saving configuration files.""" + auth_config = "// auth config" + env_content = "SECRET=test" + + with patch("builtins.input", side_effect=["1"]): + auth_init._save_files(auth_config, env_content) + + # Check auth.ts was saved + auth_path = mock_project_root / "lib" / "auth.ts" + assert auth_path.exists() + assert auth_path.read_text() == auth_config + + # Check .env was saved + env_path = mock_project_root / ".env" + assert env_path.exists() + assert env_path.read_text() == env_content + + def test_save_files_custom_path(self, auth_init, mock_project_root): + """Test saving with custom path.""" + auth_config = "// config" + env_content = "SECRET=test" + + custom_path = str(mock_project_root / "custom" / "auth.ts") + with patch("builtins.input", side_effect=["5", custom_path]): + auth_init._save_files(auth_config, env_content) + + assert Path(custom_path).exists() + + def test_save_files_backup_existing_env(self, auth_init, mock_project_root): + """Test backing up existing .env file.""" + # Create existing .env + env_path = mock_project_root / ".env" + env_path.write_text("OLD_SECRET=old") + + auth_config = "// config" + env_content = "NEW_SECRET=new" + + with patch("builtins.input", return_value="1"): + auth_init._save_files(auth_config, env_content) + + # Check backup was created + backup_path = mock_project_root / ".env.backup" + assert backup_path.exists() + assert backup_path.read_text() == "OLD_SECRET=old" + + # Check new .env + assert env_path.read_text() == "NEW_SECRET=new" + + def test_run_full_flow(self, auth_init, mock_project_root): + """Test complete run flow.""" + inputs = [ + "1", # Direct DB + "1", # PostgreSQL + "postgresql://localhost/test", + "1 2", # Email + GitHub + "n" # Don't save + ] + + with patch("builtins.input", side_effect=inputs): + with patch("builtins.print"): + auth_init.run() + + # Should complete without errors + # Files not saved because user chose 'n' + assert not (mock_project_root / "auth.ts").exists() + + def test_run_save_files(self, auth_init, mock_project_root): + """Test run flow with file saving.""" + inputs = [ + "1", # Direct DB + "3", # SQLite + "", # Default path + "1", # Email only + "y", # Save + "1" # Save location + ] + + with patch("builtins.input", side_effect=inputs): + with patch("builtins.print"): + auth_init.run() + + # Check files were created + assert (mock_project_root / "lib" / "auth.ts").exists() + assert (mock_project_root / ".env").exists() + + +class TestMainFunction: + """Test main entry point.""" + + def test_main_success(self, tmp_path, monkeypatch): + """Test successful main execution.""" + (tmp_path / "package.json").write_text("{}") + monkeypatch.chdir(tmp_path) + + inputs = ["1", "3", "", "1", "n"] + + with patch("builtins.input", side_effect=inputs): + with patch("builtins.print"): + exit_code = main() + + assert exit_code == 0 + + def test_main_keyboard_interrupt(self, tmp_path, monkeypatch): + """Test main with keyboard interrupt.""" + (tmp_path / "package.json").write_text("{}") + monkeypatch.chdir(tmp_path) + + with patch("builtins.input", side_effect=KeyboardInterrupt()): + with patch("builtins.print"): + exit_code = main() + + assert exit_code == 1 + + def test_main_error(self, tmp_path, monkeypatch): + """Test main with error.""" + # No package.json - should fail + no_package = tmp_path / "no-package" + no_package.mkdir() + monkeypatch.chdir(no_package) + + with patch.object(Path, "parent", new_callable=lambda: property(lambda self: self)): + with patch("sys.stderr", new_callable=StringIO): + exit_code = main() + + assert exit_code == 1 + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "--cov=better_auth_init", "--cov-report=term-missing"]) diff --git a/skills/chrome-devtools/SKILL.md b/skills/chrome-devtools/SKILL.md new file mode 100644 index 0000000..e6daa59 --- /dev/null +++ b/skills/chrome-devtools/SKILL.md @@ -0,0 +1,360 @@ +--- +name: chrome-devtools +description: Browser automation, debugging, and performance analysis using Puppeteer CLI scripts. Use for automating browsers, taking screenshots, analyzing performance, monitoring network traffic, web scraping, form automation, and JavaScript debugging. +license: Apache-2.0 +--- + +# Chrome DevTools Agent Skill + +Browser automation via executable Puppeteer scripts. All scripts output JSON for easy parsing. + +## Quick Start + +**CRITICAL**: Always check `pwd` before running scripts. + +### Installation + +#### Step 1: Install System Dependencies (Linux/WSL only) + +On Linux/WSL, Chrome requires system libraries. Install them first: + +```bash +pwd # Should show current working directory +cd .claude/skills/chrome-devtools/scripts +./install-deps.sh # Auto-detects OS and installs required libs +``` + +Supports: Ubuntu, Debian, Fedora, RHEL, CentOS, Arch, Manjaro + +**macOS/Windows**: Skip this step (dependencies bundled with Chrome) + +#### Step 2: Install Node Dependencies + +```bash +npm install # Installs puppeteer, debug, yargs +``` + +#### Step 3: Install ImageMagick (Optional, Recommended) + +ImageMagick enables automatic screenshot compression to keep files under 5MB: + +**macOS:** +```bash +brew install imagemagick +``` + +**Ubuntu/Debian/WSL:** +```bash +sudo apt-get install imagemagick +``` + +**Verify:** +```bash +magick -version # or: convert -version +``` + +Without ImageMagick, screenshots >5MB will not be compressed (may fail to load in Gemini/Claude). + +### Test +```bash +node navigate.js --url https://example.com +# Output: {"success": true, "url": "https://example.com", "title": "Example Domain"} +``` + +## Available Scripts + +All scripts are in `.claude/skills/chrome-devtools/scripts/` + +**CRITICAL**: Always check `pwd` before running scripts. + +### Script Usage +- `./scripts/README.md` + +### Core Automation +- `navigate.js` - Navigate to URLs +- `screenshot.js` - Capture screenshots (full page or element) +- `click.js` - Click elements +- `fill.js` - Fill form fields +- `evaluate.js` - Execute JavaScript in page context + +### Analysis & Monitoring +- `snapshot.js` - Extract interactive elements with metadata +- `console.js` - Monitor console messages/errors +- `network.js` - Track HTTP requests/responses +- `performance.js` - Measure Core Web Vitals + record traces + +## Usage Patterns + +### Single Command +```bash +pwd # Should show current working directory +cd .claude/skills/chrome-devtools/scripts +node screenshot.js --url https://example.com --output ./docs/screenshots/page.png +``` +**Important**: Always save screenshots to `./docs/screenshots` directory. + +### Automatic Image Compression +Screenshots are **automatically compressed** if they exceed 5MB to ensure compatibility with Gemini API and Claude Code (which have 5MB limits). This uses ImageMagick internally: + +```bash +# Default: auto-compress if >5MB +node screenshot.js --url https://example.com --output page.png + +# Custom size threshold (e.g., 3MB) +node screenshot.js --url https://example.com --output page.png --max-size 3 + +# Disable compression +node screenshot.js --url https://example.com --output page.png --no-compress +``` + +**Compression behavior:** +- PNG: Resizes to 90% + quality 85 (or 75% + quality 70 if still too large) +- JPEG: Quality 80 + progressive encoding (or quality 60 if still too large) +- Other formats: Converted to JPEG with compression +- Requires ImageMagick installed (see imagemagick skill) + +**Output includes compression info:** +```json +{ + "success": true, + "output": "/path/to/page.png", + "compressed": true, + "originalSize": 8388608, + "size": 3145728, + "compressionRatio": "62.50%", + "url": "https://example.com" +} +``` + +### Chain Commands (reuse browser) +```bash +# Keep browser open with --close false +node navigate.js --url https://example.com/login --close false +node fill.js --selector "#email" --value "user@example.com" --close false +node fill.js --selector "#password" --value "secret" --close false +node click.js --selector "button[type=submit]" +``` + +### Parse JSON Output +```bash +# Extract specific fields with jq +node performance.js --url https://example.com | jq '.vitals.LCP' + +# Save to file +node network.js --url https://example.com --output /tmp/requests.json +``` + +## Execution Protocol + +### Working Directory Verification + +BEFORE executing any script: +1. Check current working directory with `pwd` +2. Verify in `.claude/skills/chrome-devtools/scripts/` directory +3. If wrong directory, `cd` to correct location +4. Use absolute paths for all output files + +Example: +```bash +pwd # Should show: .../chrome-devtools/scripts +# If wrong: +cd .claude/skills/chrome-devtools/scripts +``` + +### Output Validation + +AFTER screenshot/capture operations: +1. Verify file created with `ls -lh ` +2. Read screenshot using Read tool to confirm content +3. Check JSON output for success:true +4. Report file size and compression status + +Example: +```bash +node screenshot.js --url https://example.com --output ./docs/screenshots/page.png +ls -lh ./docs/screenshots/page.png # Verify file exists +# Then use Read tool to visually inspect +``` + +5. Restart working directory to the project root. + +### Error Recovery + +If script fails: +1. Check error message for selector issues +2. Use snapshot.js to discover correct selectors +3. Try XPath selector if CSS selector fails +4. Verify element is visible and interactive + +Example: +```bash +# CSS selector fails +node click.js --url https://example.com --selector ".btn-submit" +# Error: waiting for selector ".btn-submit" failed + +# Discover correct selector +node snapshot.js --url https://example.com | jq '.elements[] | select(.tagName=="BUTTON")' + +# Try XPath +node click.js --url https://example.com --selector "//button[contains(text(),'Submit')]" +``` + +### Common Mistakes + +❌ Wrong working directory → output files go to wrong location +❌ Skipping output validation → silent failures +❌ Using complex CSS selectors without testing → selector errors +❌ Not checking element visibility → timeout errors + +✅ Always verify `pwd` before running scripts +✅ Always validate output after screenshots +✅ Use snapshot.js to discover selectors +✅ Test selectors with simple commands first + +## Common Workflows + +### Web Scraping +```bash +node evaluate.js --url https://example.com --script " + Array.from(document.querySelectorAll('.item')).map(el => ({ + title: el.querySelector('h2')?.textContent, + link: el.querySelector('a')?.href + })) +" | jq '.result' +``` + +### Performance Testing +```bash +PERF=$(node performance.js --url https://example.com) +LCP=$(echo $PERF | jq '.vitals.LCP') +if (( $(echo "$LCP < 2500" | bc -l) )); then + echo "✓ LCP passed: ${LCP}ms" +else + echo "✗ LCP failed: ${LCP}ms" +fi +``` + +### Form Automation +```bash +node fill.js --url https://example.com --selector "#search" --value "query" --close false +node click.js --selector "button[type=submit]" +``` + +### Error Monitoring +```bash +node console.js --url https://example.com --types error,warn --duration 5000 | jq '.messageCount' +``` + +## Script Options + +All scripts support: +- `--headless false` - Show browser window +- `--close false` - Keep browser open for chaining +- `--timeout 30000` - Set timeout (milliseconds) +- `--wait-until networkidle2` - Wait strategy + +See `./scripts/README.md` for complete options. + +## Output Format + +All scripts output JSON to stdout: +```json +{ + "success": true, + "url": "https://example.com", + ... // script-specific data +} +``` + +Errors go to stderr: +```json +{ + "success": false, + "error": "Error message" +} +``` + +## Finding Elements + +Use `snapshot.js` to discover selectors: +```bash +node snapshot.js --url https://example.com | jq '.elements[] | {tagName, text, selector}' +``` + +## Troubleshooting + +### Common Errors + +**"Cannot find package 'puppeteer'"** +- Run: `npm install` in the scripts directory + +**"error while loading shared libraries: libnss3.so"** (Linux/WSL) +- Missing system dependencies +- Fix: Run `./install-deps.sh` in scripts directory +- Manual install: `sudo apt-get install -y libnss3 libnspr4 libasound2t64 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1` + +**"Failed to launch the browser process"** +- Check system dependencies installed (Linux/WSL) +- Verify Chrome downloaded: `ls ~/.cache/puppeteer` +- Try: `npm rebuild` then `npm install` + +**Chrome not found** +- Puppeteer auto-downloads Chrome during `npm install` +- If failed, manually trigger: `npx puppeteer browsers install chrome` + +### Script Issues + +**Element not found** +- Get snapshot first to find correct selector: `node snapshot.js --url ` + +**Script hangs** +- Increase timeout: `--timeout 60000` +- Change wait strategy: `--wait-until load` or `--wait-until domcontentloaded` + +**Blank screenshot** +- Wait for page load: `--wait-until networkidle2` +- Increase timeout: `--timeout 30000` + +**Permission denied on scripts** +- Make executable: `chmod +x *.sh` + +**Screenshot too large (>5MB)** +- Install ImageMagick for automatic compression +- Manually set lower threshold: `--max-size 3` +- Use JPEG format instead of PNG: `--format jpeg --quality 80` +- Capture specific element instead of full page: `--selector .main-content` + +**Compression not working** +- Verify ImageMagick installed: `magick -version` or `convert -version` +- Check file was actually compressed in output JSON: `"compressed": true` +- For very large pages, use `--selector` to capture only needed area + +## Reference Documentation + +Detailed guides available in `./references/`: +- [CDP Domains Reference](./references/cdp-domains.md) - 47 Chrome DevTools Protocol domains +- [Puppeteer Quick Reference](./references/puppeteer-reference.md) - Complete Puppeteer API patterns +- [Performance Analysis Guide](./references/performance-guide.md) - Core Web Vitals optimization + +## Advanced Usage + +### Custom Scripts +Create custom scripts using shared library: +```javascript +import { getBrowser, getPage, closeBrowser, outputJSON } from './lib/browser.js'; +// Your automation logic +``` + +### Direct CDP Access +```javascript +const client = await page.createCDPSession(); +await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); +``` + +See reference documentation for advanced patterns and complete API coverage. + +## External Resources + +- [Puppeteer Documentation](https://pptr.dev/) +- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) +- [Scripts README](./scripts/README.md) diff --git a/skills/chrome-devtools/references/cdp-domains.md b/skills/chrome-devtools/references/cdp-domains.md new file mode 100644 index 0000000..b4cf0f6 --- /dev/null +++ b/skills/chrome-devtools/references/cdp-domains.md @@ -0,0 +1,694 @@ +# Chrome DevTools Protocol (CDP) Domains Reference + +Complete reference of CDP domains and their capabilities for browser automation and debugging. + +## Overview + +CDP is organized into **47 domains**, each providing specific browser capabilities. Domains are grouped by functionality: + +- **Core** - Fundamental browser control +- **DOM & Styling** - Page structure and styling +- **Network & Fetch** - HTTP traffic management +- **Page & Navigation** - Page lifecycle control +- **Storage & Data** - Browser storage APIs +- **Performance & Profiling** - Metrics and analysis +- **Emulation & Simulation** - Device and network emulation +- **Worker & Service** - Background tasks +- **Developer Tools** - Debugging support + +--- + +## Core Domains + +### Runtime +**Purpose:** Execute JavaScript, manage objects, handle promises + +**Key Commands:** +- `Runtime.evaluate(expression)` - Execute JavaScript +- `Runtime.callFunctionOn(functionDeclaration, objectId)` - Call function on object +- `Runtime.getProperties(objectId)` - Get object properties +- `Runtime.awaitPromise(promiseObjectId)` - Wait for promise resolution + +**Key Events:** +- `Runtime.consoleAPICalled` - Console message logged +- `Runtime.exceptionThrown` - Uncaught exception + +**Use Cases:** +- Execute custom JavaScript +- Access page data +- Monitor console output +- Handle exceptions + +--- + +### Debugger +**Purpose:** JavaScript debugging, breakpoints, stack traces + +**Key Commands:** +- `Debugger.enable()` - Enable debugger +- `Debugger.setBreakpoint(location)` - Set breakpoint +- `Debugger.pause()` - Pause execution +- `Debugger.resume()` - Resume execution +- `Debugger.stepOver/stepInto/stepOut()` - Step through code + +**Key Events:** +- `Debugger.paused` - Execution paused +- `Debugger.resumed` - Execution resumed +- `Debugger.scriptParsed` - Script loaded + +**Use Cases:** +- Debug JavaScript errors +- Inspect call stacks +- Set conditional breakpoints +- Source map support + +--- + +### Console (Deprecated - Use Runtime/Log) +**Purpose:** Legacy console message access + +**Note:** Use `Runtime.consoleAPICalled` event instead for new implementations. + +--- + +## DOM & Styling Domains + +### DOM +**Purpose:** Access and manipulate DOM tree + +**Key Commands:** +- `DOM.getDocument()` - Get root document node +- `DOM.querySelector(nodeId, selector)` - Query selector +- `DOM.querySelectorAll(nodeId, selector)` - Query all +- `DOM.getAttributes(nodeId)` - Get element attributes +- `DOM.setOuterHTML(nodeId, outerHTML)` - Replace element +- `DOM.getBoxModel(nodeId)` - Get element layout box +- `DOM.focus(nodeId)` - Focus element + +**Key Events:** +- `DOM.documentUpdated` - Document changed +- `DOM.setChildNodes` - Child nodes updated + +**Use Cases:** +- Navigate DOM tree +- Query elements +- Modify DOM structure +- Get element positions + +--- + +### CSS +**Purpose:** Inspect and modify CSS styles + +**Key Commands:** +- `CSS.enable()` - Enable CSS domain +- `CSS.getComputedStyleForNode(nodeId)` - Get computed styles +- `CSS.getInlineStylesForNode(nodeId)` - Get inline styles +- `CSS.getMatchedStylesForNode(nodeId)` - Get matched CSS rules +- `CSS.setStyleTexts(edits)` - Modify styles + +**Key Events:** +- `CSS.styleSheetAdded` - Stylesheet added +- `CSS.styleSheetChanged` - Stylesheet modified + +**Use Cases:** +- Inspect element styles +- Debug CSS issues +- Modify styles dynamically +- Extract stylesheet data + +--- + +### Accessibility +**Purpose:** Access accessibility tree + +**Key Commands:** +- `Accessibility.enable()` - Enable accessibility +- `Accessibility.getFullAXTree()` - Get complete AX tree +- `Accessibility.getPartialAXTree(nodeId)` - Get node subtree +- `Accessibility.queryAXTree(nodeId, role, name)` - Query AX tree + +**Use Cases:** +- Accessibility testing +- Screen reader simulation +- ARIA attribute inspection +- AX tree analysis + +--- + +## Network & Fetch Domains + +### Network +**Purpose:** Monitor and control HTTP traffic + +**Key Commands:** +- `Network.enable()` - Enable network tracking +- `Network.setCacheDisabled(cacheDisabled)` - Disable cache +- `Network.setExtraHTTPHeaders(headers)` - Add custom headers +- `Network.getCookies(urls)` - Get cookies +- `Network.setCookie(name, value, domain)` - Set cookie +- `Network.getResponseBody(requestId)` - Get response body +- `Network.emulateNetworkConditions(offline, latency, downloadThroughput, uploadThroughput)` - Throttle network + +**Key Events:** +- `Network.requestWillBeSent` - Request starting +- `Network.responseReceived` - Response received +- `Network.loadingFinished` - Request completed +- `Network.loadingFailed` - Request failed + +**Use Cases:** +- Monitor API calls +- Intercept requests +- Analyze response data +- Simulate slow networks +- Manage cookies + +--- + +### Fetch +**Purpose:** Intercept and modify network requests + +**Key Commands:** +- `Fetch.enable(patterns)` - Enable request interception +- `Fetch.continueRequest(requestId, url, method, headers)` - Continue/modify request +- `Fetch.fulfillRequest(requestId, responseCode, headers, body)` - Mock response +- `Fetch.failRequest(requestId, errorReason)` - Fail request + +**Key Events:** +- `Fetch.requestPaused` - Request intercepted + +**Use Cases:** +- Mock API responses +- Block requests +- Modify request/response +- Test error scenarios + +--- + +## Page & Navigation Domains + +### Page +**Purpose:** Control page lifecycle and navigation + +**Key Commands:** +- `Page.enable()` - Enable page domain +- `Page.navigate(url)` - Navigate to URL +- `Page.reload(ignoreCache)` - Reload page +- `Page.goBack()/goForward()` - Navigate history +- `Page.captureScreenshot(format, quality)` - Take screenshot +- `Page.printToPDF(landscape, displayHeaderFooter)` - Generate PDF +- `Page.getLayoutMetrics()` - Get page dimensions +- `Page.createIsolatedWorld(frameId)` - Create isolated context +- `Page.handleJavaScriptDialog(accept, promptText)` - Handle alerts/confirms + +**Key Events:** +- `Page.loadEventFired` - Page loaded +- `Page.domContentEventFired` - DOM ready +- `Page.frameNavigated` - Frame navigated +- `Page.javascriptDialogOpening` - Alert/confirm shown + +**Use Cases:** +- Navigate pages +- Capture screenshots +- Generate PDFs +- Handle popups +- Monitor page lifecycle + +--- + +### Target +**Purpose:** Manage browser targets (tabs, workers, frames) + +**Key Commands:** +- `Target.getTargets()` - List all targets +- `Target.createTarget(url)` - Open new tab +- `Target.closeTarget(targetId)` - Close tab +- `Target.attachToTarget(targetId)` - Attach debugger +- `Target.detachFromTarget(sessionId)` - Detach debugger +- `Target.setDiscoverTargets(discover)` - Auto-discover targets + +**Key Events:** +- `Target.targetCreated` - New target created +- `Target.targetDestroyed` - Target closed +- `Target.targetInfoChanged` - Target updated + +**Use Cases:** +- Multi-tab automation +- Service worker debugging +- Frame inspection +- Extension debugging + +--- + +### Input +**Purpose:** Simulate user input + +**Key Commands:** +- `Input.dispatchKeyEvent(type, key, code)` - Keyboard input +- `Input.dispatchMouseEvent(type, x, y, button)` - Mouse input +- `Input.dispatchTouchEvent(type, touchPoints)` - Touch input +- `Input.synthesizePinchGesture(x, y, scaleFactor)` - Pinch gesture +- `Input.synthesizeScrollGesture(x, y, xDistance, yDistance)` - Scroll + +**Use Cases:** +- Simulate clicks +- Type text +- Drag and drop +- Touch gestures +- Scroll pages + +--- + +## Storage & Data Domains + +### Storage +**Purpose:** Manage browser storage + +**Key Commands:** +- `Storage.getCookies(browserContextId)` - Get cookies +- `Storage.setCookies(cookies)` - Set cookies +- `Storage.clearCookies(browserContextId)` - Clear cookies +- `Storage.clearDataForOrigin(origin, storageTypes)` - Clear storage +- `Storage.getUsageAndQuota(origin)` - Get storage usage + +**Storage Types:** +- appcache, cookies, file_systems, indexeddb, local_storage, shader_cache, websql, service_workers, cache_storage + +**Use Cases:** +- Cookie management +- Clear browser data +- Inspect storage usage +- Test quota limits + +--- + +### DOMStorage +**Purpose:** Access localStorage/sessionStorage + +**Key Commands:** +- `DOMStorage.enable()` - Enable storage tracking +- `DOMStorage.getDOMStorageItems(storageId)` - Get items +- `DOMStorage.setDOMStorageItem(storageId, key, value)` - Set item +- `DOMStorage.removeDOMStorageItem(storageId, key)` - Remove item + +**Key Events:** +- `DOMStorage.domStorageItemsCleared` - Storage cleared +- `DOMStorage.domStorageItemAdded/Updated/Removed` - Item changed + +--- + +### IndexedDB +**Purpose:** Query IndexedDB databases + +**Key Commands:** +- `IndexedDB.requestDatabaseNames(securityOrigin)` - List databases +- `IndexedDB.requestDatabase(securityOrigin, databaseName)` - Get DB structure +- `IndexedDB.requestData(securityOrigin, databaseName, objectStoreName)` - Query data + +**Use Cases:** +- Inspect IndexedDB data +- Debug database issues +- Extract stored data + +--- + +### CacheStorage +**Purpose:** Manage Cache API + +**Key Commands:** +- `CacheStorage.requestCacheNames(securityOrigin)` - List caches +- `CacheStorage.requestCachedResponses(cacheId, securityOrigin)` - List cached responses +- `CacheStorage.deleteCache(cacheId)` - Delete cache + +**Use Cases:** +- Service worker cache inspection +- Offline functionality testing + +--- + +## Performance & Profiling Domains + +### Performance +**Purpose:** Collect performance metrics + +**Key Commands:** +- `Performance.enable()` - Enable performance tracking +- `Performance.disable()` - Disable tracking +- `Performance.getMetrics()` - Get current metrics + +**Metrics:** +- Timestamp, Documents, Frames, JSEventListeners, Nodes, LayoutCount, RecalcStyleCount, LayoutDuration, RecalcStyleDuration, ScriptDuration, TaskDuration, JSHeapUsedSize, JSHeapTotalSize + +**Use Cases:** +- Monitor page metrics +- Track memory usage +- Measure render times + +--- + +### PerformanceTimeline +**Purpose:** Access Performance Timeline API + +**Key Commands:** +- `PerformanceTimeline.enable(eventTypes)` - Subscribe to events + +**Event Types:** +- mark, measure, navigation, resource, longtask, paint, layout-shift + +**Key Events:** +- `PerformanceTimeline.timelineEventAdded` - New performance entry + +--- + +### Tracing +**Purpose:** Record Chrome trace + +**Key Commands:** +- `Tracing.start(categories, options)` - Start recording +- `Tracing.end()` - Stop recording +- `Tracing.requestMemoryDump()` - Capture memory snapshot + +**Trace Categories:** +- blink, cc, devtools, gpu, loading, navigation, rendering, v8, disabled-by-default-* + +**Key Events:** +- `Tracing.dataCollected` - Trace chunk received +- `Tracing.tracingComplete` - Recording finished + +**Use Cases:** +- Deep performance analysis +- Frame rendering profiling +- CPU flame graphs +- Memory profiling + +--- + +### Profiler +**Purpose:** CPU profiling + +**Key Commands:** +- `Profiler.enable()` - Enable profiler +- `Profiler.start()` - Start CPU profiling +- `Profiler.stop()` - Stop and get profile + +**Use Cases:** +- Find CPU bottlenecks +- Optimize JavaScript +- Generate flame graphs + +--- + +### HeapProfiler (via Memory domain) +**Purpose:** Memory profiling + +**Key Commands:** +- `Memory.getDOMCounters()` - Get DOM object counts +- `Memory.prepareForLeakDetection()` - Prepare leak detection +- `Memory.forciblyPurgeJavaScriptMemory()` - Force GC +- `Memory.setPressureNotificationsSuppressed(suppressed)` - Control memory warnings +- `Memory.simulatePressureNotification(level)` - Simulate memory pressure + +**Use Cases:** +- Detect memory leaks +- Analyze heap snapshots +- Monitor object counts + +--- + +## Emulation & Simulation Domains + +### Emulation +**Purpose:** Emulate device conditions + +**Key Commands:** +- `Emulation.setDeviceMetricsOverride(width, height, deviceScaleFactor, mobile)` - Emulate device +- `Emulation.setGeolocationOverride(latitude, longitude, accuracy)` - Fake location +- `Emulation.setEmulatedMedia(media, features)` - Emulate media type +- `Emulation.setTimezoneOverride(timezoneId)` - Override timezone +- `Emulation.setLocaleOverride(locale)` - Override language +- `Emulation.setUserAgentOverride(userAgent)` - Change user agent + +**Use Cases:** +- Mobile device testing +- Geolocation testing +- Print media emulation +- Timezone/locale testing + +--- + +### DeviceOrientation +**Purpose:** Simulate device orientation + +**Key Commands:** +- `DeviceOrientation.setDeviceOrientationOverride(alpha, beta, gamma)` - Set orientation + +**Use Cases:** +- Test accelerometer features +- Orientation-dependent layouts + +--- + +## Worker & Service Domains + +### ServiceWorker +**Purpose:** Manage service workers + +**Key Commands:** +- `ServiceWorker.enable()` - Enable tracking +- `ServiceWorker.unregister(scopeURL)` - Unregister worker +- `ServiceWorker.startWorker(scopeURL)` - Start worker +- `ServiceWorker.stopWorker(versionId)` - Stop worker +- `ServiceWorker.inspectWorker(versionId)` - Debug worker + +**Key Events:** +- `ServiceWorker.workerRegistrationUpdated` - Registration changed +- `ServiceWorker.workerVersionUpdated` - Version updated + +--- + +### WebAuthn +**Purpose:** Simulate WebAuthn/FIDO2 + +**Key Commands:** +- `WebAuthn.enable()` - Enable virtual authenticators +- `WebAuthn.addVirtualAuthenticator(options)` - Add virtual device +- `WebAuthn.removeVirtualAuthenticator(authenticatorId)` - Remove device +- `WebAuthn.addCredential(authenticatorId, credential)` - Add credential + +**Use Cases:** +- Test WebAuthn flows +- Simulate biometric auth +- Test security keys + +--- + +## Developer Tools Support + +### Inspector +**Purpose:** Protocol-level debugging + +**Key Events:** +- `Inspector.detached` - Debugger disconnected +- `Inspector.targetCrashed` - Target crashed + +--- + +### Log +**Purpose:** Collect browser logs + +**Key Commands:** +- `Log.enable()` - Enable log collection +- `Log.clear()` - Clear logs + +**Key Events:** +- `Log.entryAdded` - New log entry + +**Use Cases:** +- Collect console logs +- Monitor violations +- Track deprecations + +--- + +### DOMDebugger +**Purpose:** DOM-level debugging + +**Key Commands:** +- `DOMDebugger.setDOMBreakpoint(nodeId, type)` - Break on DOM changes +- `DOMDebugger.setEventListenerBreakpoint(eventName)` - Break on event +- `DOMDebugger.setXHRBreakpoint(url)` - Break on XHR + +**Breakpoint Types:** +- subtree-modified, attribute-modified, node-removed + +--- + +### DOMSnapshot +**Purpose:** Capture complete DOM snapshot + +**Key Commands:** +- `DOMSnapshot.captureSnapshot(computedStyles)` - Capture full DOM + +**Use Cases:** +- Export page structure +- Offline analysis +- DOM diffing + +--- + +### Audits (Lighthouse Integration) +**Purpose:** Run automated audits + +**Key Commands:** +- `Audits.enable()` - Enable audits +- `Audits.getEncodingIssues()` - Check encoding issues + +--- + +### LayerTree +**Purpose:** Inspect rendering layers + +**Key Commands:** +- `LayerTree.enable()` - Enable layer tracking +- `LayerTree.compositingReasons(layerId)` - Get why layer created + +**Key Events:** +- `LayerTree.layerTreeDidChange` - Layers changed + +**Use Cases:** +- Debug rendering performance +- Identify layer creation +- Optimize compositing + +--- + +## Other Domains + +### Browser +**Purpose:** Browser-level control + +**Key Commands:** +- `Browser.getVersion()` - Get browser info +- `Browser.getBrowserCommandLine()` - Get launch args +- `Browser.setPermission(permission, setting, origin)` - Set permissions +- `Browser.grantPermissions(permissions, origin)` - Grant permissions + +**Permissions:** +- geolocation, midi, notifications, push, camera, microphone, background-sync, sensors, accessibility-events, clipboard-read, clipboard-write, payment-handler + +--- + +### IO +**Purpose:** File I/O operations + +**Key Commands:** +- `IO.read(handle, offset, size)` - Read stream +- `IO.close(handle)` - Close stream + +**Use Cases:** +- Read large response bodies +- Process binary data + +--- + +### Media +**Purpose:** Inspect media players + +**Key Commands:** +- `Media.enable()` - Track media players + +**Key Events:** +- `Media.playerPropertiesChanged` - Player state changed +- `Media.playerEventsAdded` - Player events + +--- + +### BackgroundService +**Purpose:** Track background services + +**Key Commands:** +- `BackgroundService.startObserving(service)` - Track service + +**Services:** +- backgroundFetch, backgroundSync, pushMessaging, notifications, paymentHandler, periodicBackgroundSync + +--- + +## Domain Dependencies + +Some domains depend on others and must be enabled in order: + +``` +Runtime (no dependencies) + ↓ +DOM (depends on Runtime) + ↓ +CSS (depends on DOM) + +Network (no dependencies) + +Page (depends on Runtime) + ↓ +Target (depends on Page) + +Debugger (depends on Runtime) +``` + +## Quick Command Reference + +### Most Common Commands + +```javascript +// Navigation +Page.navigate(url) +Page.reload() + +// JavaScript Execution +Runtime.evaluate(expression) + +// DOM Access +DOM.getDocument() +DOM.querySelector(nodeId, selector) + +// Screenshots +Page.captureScreenshot(format, quality) + +// Network Monitoring +Network.enable() +// Listen for Network.requestWillBeSent events + +// Console Messages +// Listen for Runtime.consoleAPICalled events + +// Cookies +Network.getCookies(urls) +Network.setCookie(...) + +// Device Emulation +Emulation.setDeviceMetricsOverride(width, height, ...) + +// Performance +Performance.getMetrics() +Tracing.start(categories) +Tracing.end() +``` + +--- + +## Best Practices + +1. **Enable domains before use:** Always call `.enable()` for stateful domains +2. **Handle events:** Subscribe to events for real-time updates +3. **Clean up:** Disable domains when done to reduce overhead +4. **Use sessions:** Attach to specific targets for isolated debugging +5. **Handle errors:** Implement proper error handling for command failures +6. **Version awareness:** Check browser version for experimental API support + +--- + +## Additional Resources + +- [Protocol Viewer](https://chromedevtools.github.io/devtools-protocol/) - Interactive domain browser +- [Protocol JSON](https://chromedevtools.github.io/devtools-protocol/tot/json) - Machine-readable specification +- [Getting Started with CDP](https://github.com/aslushnikov/getting-started-with-cdp) +- [devtools-protocol NPM](https://www.npmjs.com/package/devtools-protocol) - TypeScript definitions diff --git a/skills/chrome-devtools/references/performance-guide.md b/skills/chrome-devtools/references/performance-guide.md new file mode 100644 index 0000000..206bb42 --- /dev/null +++ b/skills/chrome-devtools/references/performance-guide.md @@ -0,0 +1,940 @@ +# Performance Analysis Guide + +Comprehensive guide to analyzing web performance using Chrome DevTools Protocol, Puppeteer, and chrome-devtools skill. + +## Table of Contents + +- [Core Web Vitals](#core-web-vitals) +- [Performance Tracing](#performance-tracing) +- [Network Analysis](#network-analysis) +- [JavaScript Performance](#javascript-performance) +- [Rendering Performance](#rendering-performance) +- [Memory Analysis](#memory-analysis) +- [Optimization Strategies](#optimization-strategies) + +--- + +## Core Web Vitals + +### Overview + +Core Web Vitals are Google's standardized metrics for measuring user experience: + +- **LCP (Largest Contentful Paint)** - Loading performance (< 2.5s good) +- **FID (First Input Delay)** - Interactivity (< 100ms good) +- **CLS (Cumulative Layout Shift)** - Visual stability (< 0.1 good) + +### Measuring with chrome-devtools-mcp + +```javascript +// Start performance trace +await useTool('performance_start_trace', { + categories: ['loading', 'rendering', 'scripting'] +}); + +// Navigate to page +await useTool('navigate_page', { + url: 'https://example.com' +}); + +// Wait for complete load +await useTool('wait_for', { + waitUntil: 'networkidle' +}); + +// Stop trace and get data +await useTool('performance_stop_trace'); + +// Get AI-powered insights +const insights = await useTool('performance_analyze_insight'); + +// insights will include: +// - LCP timing +// - FID analysis +// - CLS score +// - Performance recommendations +``` + +### Measuring with Puppeteer + +```javascript +import puppeteer from 'puppeteer'; + +const browser = await puppeteer.launch(); +const page = await browser.newPage(); + +// Measure Core Web Vitals +await page.goto('https://example.com', { + waitUntil: 'networkidle2' +}); + +const vitals = await page.evaluate(() => { + return new Promise((resolve) => { + const vitals = { + LCP: null, + FID: null, + CLS: 0 + }; + + // LCP + new PerformanceObserver((list) => { + const entries = list.getEntries(); + vitals.LCP = entries[entries.length - 1].renderTime || + entries[entries.length - 1].loadTime; + }).observe({ entryTypes: ['largest-contentful-paint'] }); + + // FID + new PerformanceObserver((list) => { + vitals.FID = list.getEntries()[0].processingStart - + list.getEntries()[0].startTime; + }).observe({ entryTypes: ['first-input'] }); + + // CLS + new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (!entry.hadRecentInput) { + vitals.CLS += entry.value; + } + }); + }).observe({ entryTypes: ['layout-shift'] }); + + // Wait 5 seconds for metrics + setTimeout(() => resolve(vitals), 5000); + }); +}); + +console.log('Core Web Vitals:', vitals); +``` + +### Other Important Metrics + +**TTFB (Time to First Byte)** +```javascript +const ttfb = await page.evaluate(() => { + const [navigationEntry] = performance.getEntriesByType('navigation'); + return navigationEntry.responseStart - navigationEntry.requestStart; +}); +``` + +**FCP (First Contentful Paint)** +```javascript +const fcp = await page.evaluate(() => { + const paintEntries = performance.getEntriesByType('paint'); + const fcpEntry = paintEntries.find(e => e.name === 'first-contentful-paint'); + return fcpEntry ? fcpEntry.startTime : null; +}); +``` + +**TTI (Time to Interactive)** +```javascript +// Requires lighthouse or manual calculation +const tti = await page.evaluate(() => { + // Complex calculation based on network idle and long tasks + // Best to use Lighthouse for accurate TTI +}); +``` + +--- + +## Performance Tracing + +### Chrome Trace Categories + +**Loading:** +- Page load events +- Resource loading +- Parser activity + +**Rendering:** +- Layout calculations +- Paint operations +- Compositing + +**Scripting:** +- JavaScript execution +- V8 compilation +- Garbage collection + +**Network:** +- HTTP requests +- WebSocket traffic +- Resource fetching + +**Input:** +- User input processing +- Touch/scroll events + +**GPU:** +- GPU operations +- Compositing work + +### Record Performance Trace + +**Using chrome-devtools-mcp:** +```javascript +// Start trace with specific categories +await useTool('performance_start_trace', { + categories: ['loading', 'rendering', 'scripting', 'network'] +}); + +// Perform actions +await useTool('navigate_page', { url: 'https://example.com' }); +await useTool('wait_for', { waitUntil: 'networkidle' }); + +// Optional: Interact with page +await useTool('click', { uid: 'button-uid' }); + +// Stop trace +const traceData = await useTool('performance_stop_trace'); + +// Analyze trace +const insights = await useTool('performance_analyze_insight'); +``` + +**Using Puppeteer:** +```javascript +// Start tracing +await page.tracing.start({ + path: 'trace.json', + categories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline', + 'disabled-by-default-v8.cpu_profiler' + ] +}); + +// Navigate +await page.goto('https://example.com', { + waitUntil: 'networkidle2' +}); + +// Stop tracing +await page.tracing.stop(); + +// Analyze in Chrome DevTools (chrome://tracing) +``` + +### Analyze Trace Data + +**Key Metrics from Trace:** + +1. **Main Thread Activity** + - JavaScript execution time + - Layout/reflow time + - Paint time + - Long tasks (> 50ms) + +2. **Network Waterfall** + - Request start times + - DNS lookup + - Connection time + - Download time + +3. **Rendering Pipeline** + - DOM construction + - Style calculation + - Layout + - Paint + - Composite + +**Common Issues to Look For:** +- Long tasks blocking main thread +- Excessive JavaScript execution +- Layout thrashing +- Unnecessary repaints +- Slow network requests +- Large bundle sizes + +--- + +## Network Analysis + +### Monitor Network Requests + +**Using chrome-devtools-mcp:** +```javascript +// Navigate to page +await useTool('navigate_page', { url: 'https://example.com' }); + +// Wait for all requests +await useTool('wait_for', { waitUntil: 'networkidle' }); + +// List all requests +const requests = await useTool('list_network_requests', { + resourceTypes: ['Document', 'Script', 'Stylesheet', 'Image', 'XHR', 'Fetch'], + pageSize: 100 +}); + +// Analyze specific request +for (const req of requests.requests) { + const details = await useTool('get_network_request', { + requestId: req.id + }); + + console.log({ + url: details.url, + method: details.method, + status: details.status, + size: details.encodedDataLength, + time: details.timing.receiveHeadersEnd - details.timing.requestTime, + cached: details.fromCache + }); +} +``` + +**Using Puppeteer:** +```javascript +const requests = []; + +// Capture all requests +page.on('request', (request) => { + requests.push({ + url: request.url(), + method: request.method(), + resourceType: request.resourceType(), + headers: request.headers() + }); +}); + +// Capture responses +page.on('response', (response) => { + const request = response.request(); + console.log({ + url: response.url(), + status: response.status(), + size: response.headers()['content-length'], + cached: response.fromCache(), + timing: response.timing() + }); +}); + +await page.goto('https://example.com'); +``` + +### Network Performance Metrics + +**Calculate Total Page Weight:** +```javascript +let totalBytes = 0; +let resourceCounts = {}; + +page.on('response', async (response) => { + const type = response.request().resourceType(); + const buffer = await response.buffer(); + + totalBytes += buffer.length; + resourceCounts[type] = (resourceCounts[type] || 0) + 1; +}); + +await page.goto('https://example.com'); + +console.log('Total size:', (totalBytes / 1024 / 1024).toFixed(2), 'MB'); +console.log('Resources:', resourceCounts); +``` + +**Identify Slow Requests:** +```javascript +page.on('response', (response) => { + const timing = response.timing(); + const totalTime = timing.receiveHeadersEnd - timing.requestTime; + + if (totalTime > 1000) { // Slower than 1 second + console.log('Slow request:', { + url: response.url(), + time: totalTime.toFixed(2) + 'ms', + size: response.headers()['content-length'] + }); + } +}); +``` + +### Network Throttling + +**Simulate Slow Connection:** +```javascript +// Using chrome-devtools-mcp +await useTool('emulate_network', { + throttlingOption: 'Slow 3G' // or 'Fast 3G', 'Slow 4G' +}); + +// Using Puppeteer +const client = await page.createCDPSession(); +await client.send('Network.emulateNetworkConditions', { + offline: false, + downloadThroughput: 400 * 1024 / 8, // 400 Kbps + uploadThroughput: 400 * 1024 / 8, + latency: 2000 // 2000ms RTT +}); +``` + +--- + +## JavaScript Performance + +### Identify Long Tasks + +**Using Performance Observer:** +```javascript +await page.evaluate(() => { + return new Promise((resolve) => { + const longTasks = []; + + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + longTasks.push({ + name: entry.name, + duration: entry.duration, + startTime: entry.startTime + }); + }); + }); + + observer.observe({ entryTypes: ['longtask'] }); + + // Collect for 10 seconds + setTimeout(() => { + observer.disconnect(); + resolve(longTasks); + }, 10000); + }); +}); +``` + +### CPU Profiling + +**Using Puppeteer:** +```javascript +// Start CPU profiling +const client = await page.createCDPSession(); +await client.send('Profiler.enable'); +await client.send('Profiler.start'); + +// Navigate and interact +await page.goto('https://example.com'); +await page.click('.button'); + +// Stop profiling +const { profile } = await client.send('Profiler.stop'); + +// Analyze profile (flame graph data) +// Import into Chrome DevTools for visualization +``` + +### JavaScript Coverage + +**Identify Unused Code:** +```javascript +// Start coverage +await Promise.all([ + page.coverage.startJSCoverage(), + page.coverage.startCSSCoverage() +]); + +// Navigate +await page.goto('https://example.com'); + +// Stop coverage +const [jsCoverage, cssCoverage] = await Promise.all([ + page.coverage.stopJSCoverage(), + page.coverage.stopCSSCoverage() +]); + +// Calculate unused bytes +function calculateUnusedBytes(coverage) { + let usedBytes = 0; + let totalBytes = 0; + + for (const entry of coverage) { + totalBytes += entry.text.length; + for (const range of entry.ranges) { + usedBytes += range.end - range.start - 1; + } + } + + return { + usedBytes, + totalBytes, + unusedBytes: totalBytes - usedBytes, + unusedPercentage: ((totalBytes - usedBytes) / totalBytes * 100).toFixed(2) + }; +} + +console.log('JS Coverage:', calculateUnusedBytes(jsCoverage)); +console.log('CSS Coverage:', calculateUnusedBytes(cssCoverage)); +``` + +### Bundle Size Analysis + +**Analyze JavaScript Bundles:** +```javascript +page.on('response', async (response) => { + const url = response.url(); + const type = response.request().resourceType(); + + if (type === 'script') { + const buffer = await response.buffer(); + const size = buffer.length; + + console.log({ + url: url.split('/').pop(), + size: (size / 1024).toFixed(2) + ' KB', + gzipped: response.headers()['content-encoding'] === 'gzip' + }); + } +}); +``` + +--- + +## Rendering Performance + +### Layout Thrashing Detection + +**Monitor Layout Recalculations:** +```javascript +// Using Performance Observer +await page.evaluate(() => { + return new Promise((resolve) => { + const measurements = []; + + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.entryType === 'measure' && + entry.name.includes('layout')) { + measurements.push({ + name: entry.name, + duration: entry.duration, + startTime: entry.startTime + }); + } + }); + }); + + observer.observe({ entryTypes: ['measure'] }); + + setTimeout(() => { + observer.disconnect(); + resolve(measurements); + }, 5000); + }); +}); +``` + +### Paint and Composite Metrics + +**Get Paint Metrics:** +```javascript +const paintMetrics = await page.evaluate(() => { + const paints = performance.getEntriesByType('paint'); + return { + firstPaint: paints.find(p => p.name === 'first-paint')?.startTime, + firstContentfulPaint: paints.find(p => p.name === 'first-contentful-paint')?.startTime + }; +}); +``` + +### Frame Rate Analysis + +**Monitor FPS:** +```javascript +await page.evaluate(() => { + return new Promise((resolve) => { + let frames = 0; + let lastTime = performance.now(); + + function countFrames() { + frames++; + requestAnimationFrame(countFrames); + } + + countFrames(); + + setTimeout(() => { + const now = performance.now(); + const elapsed = (now - lastTime) / 1000; + const fps = frames / elapsed; + resolve(fps); + }, 5000); + }); +}); +``` + +### Layout Shifts (CLS) + +**Track Individual Shifts:** +```javascript +await page.evaluate(() => { + return new Promise((resolve) => { + const shifts = []; + let totalCLS = 0; + + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (!entry.hadRecentInput) { + totalCLS += entry.value; + shifts.push({ + value: entry.value, + time: entry.startTime, + elements: entry.sources?.map(s => s.node) + }); + } + }); + }); + + observer.observe({ entryTypes: ['layout-shift'] }); + + setTimeout(() => { + observer.disconnect(); + resolve({ totalCLS, shifts }); + }, 10000); + }); +}); +``` + +--- + +## Memory Analysis + +### Memory Metrics + +**Get Memory Usage:** +```javascript +// Using chrome-devtools-mcp +await useTool('evaluate_script', { + expression: ` + ({ + usedJSHeapSize: performance.memory?.usedJSHeapSize, + totalJSHeapSize: performance.memory?.totalJSHeapSize, + jsHeapSizeLimit: performance.memory?.jsHeapSizeLimit + }) + `, + returnByValue: true +}); + +// Using Puppeteer +const metrics = await page.metrics(); +console.log({ + jsHeapUsed: (metrics.JSHeapUsedSize / 1024 / 1024).toFixed(2) + ' MB', + jsHeapTotal: (metrics.JSHeapTotalSize / 1024 / 1024).toFixed(2) + ' MB', + domNodes: metrics.Nodes, + documents: metrics.Documents, + jsEventListeners: metrics.JSEventListeners +}); +``` + +### Memory Leak Detection + +**Monitor Memory Over Time:** +```javascript +async function detectMemoryLeak(page, duration = 30000) { + const samples = []; + const interval = 1000; // Sample every second + const samples_count = duration / interval; + + for (let i = 0; i < samples_count; i++) { + const metrics = await page.metrics(); + samples.push({ + time: i, + heapUsed: metrics.JSHeapUsedSize + }); + + await page.waitForTimeout(interval); + } + + // Analyze trend + const firstSample = samples[0].heapUsed; + const lastSample = samples[samples.length - 1].heapUsed; + const increase = ((lastSample - firstSample) / firstSample * 100).toFixed(2); + + return { + samples, + memoryIncrease: increase + '%', + possibleLeak: increase > 50 // > 50% increase indicates possible leak + }; +} + +const leakAnalysis = await detectMemoryLeak(page, 30000); +console.log('Memory Analysis:', leakAnalysis); +``` + +### Heap Snapshot + +**Capture Heap Snapshot:** +```javascript +const client = await page.createCDPSession(); + +// Take snapshot +await client.send('HeapProfiler.enable'); +const { result } = await client.send('HeapProfiler.takeHeapSnapshot'); + +// Snapshot is streamed in chunks +// Save to file or analyze programmatically +``` + +--- + +## Optimization Strategies + +### Image Optimization + +**Detect Unoptimized Images:** +```javascript +const images = await page.evaluate(() => { + const images = Array.from(document.querySelectorAll('img')); + return images.map(img => ({ + src: img.src, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + displayWidth: img.width, + displayHeight: img.height, + oversized: img.naturalWidth > img.width * 1.5 || + img.naturalHeight > img.height * 1.5 + })); +}); + +const oversizedImages = images.filter(img => img.oversized); +console.log('Oversized images:', oversizedImages); +``` + +### Font Loading + +**Detect Render-Blocking Fonts:** +```javascript +const fonts = await page.evaluate(() => { + return Array.from(document.fonts).map(font => ({ + family: font.family, + weight: font.weight, + style: font.style, + status: font.status, + loaded: font.status === 'loaded' + })); +}); + +console.log('Fonts:', fonts); +``` + +### Third-Party Scripts + +**Measure Third-Party Impact:** +```javascript +const thirdPartyDomains = ['googletagmanager.com', 'facebook.net', 'doubleclick.net']; + +page.on('response', async (response) => { + const url = response.url(); + const isThirdParty = thirdPartyDomains.some(domain => url.includes(domain)); + + if (isThirdParty) { + const buffer = await response.buffer(); + console.log({ + url: url, + size: (buffer.length / 1024).toFixed(2) + ' KB', + type: response.request().resourceType() + }); + } +}); +``` + +### Critical Rendering Path + +**Identify Render-Blocking Resources:** +```javascript +await page.goto('https://example.com'); + +const renderBlockingResources = await page.evaluate(() => { + const resources = performance.getEntriesByType('resource'); + return resources.filter(resource => { + return (resource.initiatorType === 'link' && + resource.name.includes('.css')) || + (resource.initiatorType === 'script' && + !resource.name.includes('async')); + }).map(r => ({ + url: r.name, + duration: r.duration, + startTime: r.startTime + })); +}); + +console.log('Render-blocking resources:', renderBlockingResources); +``` + +### Lighthouse Integration + +**Run Lighthouse Audit:** +```javascript +import lighthouse from 'lighthouse'; +import { launch } from 'chrome-launcher'; + +// Launch Chrome +const chrome = await launch({ chromeFlags: ['--headless'] }); + +// Run Lighthouse +const { lhr } = await lighthouse('https://example.com', { + port: chrome.port, + onlyCategories: ['performance'] +}); + +// Get scores +console.log({ + performanceScore: lhr.categories.performance.score * 100, + metrics: { + FCP: lhr.audits['first-contentful-paint'].displayValue, + LCP: lhr.audits['largest-contentful-paint'].displayValue, + TBT: lhr.audits['total-blocking-time'].displayValue, + CLS: lhr.audits['cumulative-layout-shift'].displayValue, + SI: lhr.audits['speed-index'].displayValue + }, + opportunities: lhr.audits['opportunities'] +}); + +await chrome.kill(); +``` + +--- + +## Performance Budgets + +### Set Performance Budgets + +```javascript +const budgets = { + // Core Web Vitals + LCP: 2500, // ms + FID: 100, // ms + CLS: 0.1, // score + + // Other metrics + FCP: 1800, // ms + TTI: 3800, // ms + TBT: 300, // ms + + // Resource budgets + totalPageSize: 2 * 1024 * 1024, // 2 MB + jsSize: 500 * 1024, // 500 KB + cssSize: 100 * 1024, // 100 KB + imageSize: 1 * 1024 * 1024, // 1 MB + + // Request counts + totalRequests: 50, + jsRequests: 10, + cssRequests: 5 +}; + +async function checkBudgets(page, budgets) { + // Measure actual values + const vitals = await measureCoreWebVitals(page); + const resources = await analyzeResources(page); + + // Compare against budgets + const violations = []; + + if (vitals.LCP > budgets.LCP) { + violations.push(`LCP: ${vitals.LCP}ms exceeds budget of ${budgets.LCP}ms`); + } + + if (resources.totalSize > budgets.totalPageSize) { + violations.push(`Page size: ${resources.totalSize} exceeds budget of ${budgets.totalPageSize}`); + } + + // ... check other budgets + + return { + passed: violations.length === 0, + violations + }; +} +``` + +--- + +## Automated Performance Testing + +### CI/CD Integration + +```javascript +// performance-test.js +import puppeteer from 'puppeteer'; + +async function performanceTest(url) { + const browser = await puppeteer.launch(); + const page = await browser.newPage(); + + // Measure metrics + await page.goto(url, { waitUntil: 'networkidle2' }); + const metrics = await page.metrics(); + const vitals = await measureCoreWebVitals(page); + + await browser.close(); + + // Check against thresholds + const thresholds = { + LCP: 2500, + FID: 100, + CLS: 0.1, + jsHeapSize: 50 * 1024 * 1024 // 50 MB + }; + + const failed = []; + if (vitals.LCP > thresholds.LCP) failed.push('LCP'); + if (vitals.FID > thresholds.FID) failed.push('FID'); + if (vitals.CLS > thresholds.CLS) failed.push('CLS'); + if (metrics.JSHeapUsedSize > thresholds.jsHeapSize) failed.push('Memory'); + + if (failed.length > 0) { + console.error('Performance test failed:', failed); + process.exit(1); + } + + console.log('Performance test passed'); +} + +performanceTest(process.env.TEST_URL); +``` + +--- + +## Best Practices + +### Performance Testing Checklist + +1. **Measure Multiple Times** + - Run tests 3-5 times + - Use median values + - Account for variance + +2. **Test Different Conditions** + - Fast 3G + - Slow 3G + - Offline + - CPU throttling + +3. **Test Different Devices** + - Mobile (low-end) + - Mobile (high-end) + - Desktop + - Tablet + +4. **Monitor Over Time** + - Track metrics in CI/CD + - Set up alerts for regressions + - Create performance dashboards + +5. **Focus on User Experience** + - Prioritize Core Web Vitals + - Test real user journeys + - Consider perceived performance + +6. **Optimize Critical Path** + - Minimize render-blocking resources + - Defer non-critical JavaScript + - Optimize font loading + - Lazy load images + +--- + +## Resources + +- [Web.dev Performance](https://web.dev/performance/) +- [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) +- [Core Web Vitals](https://web.dev/vitals/) +- [Lighthouse](https://developer.chrome.com/docs/lighthouse/) +- [WebPageTest](https://www.webpagetest.org/) diff --git a/skills/chrome-devtools/references/puppeteer-reference.md b/skills/chrome-devtools/references/puppeteer-reference.md new file mode 100644 index 0000000..cbf7f6e --- /dev/null +++ b/skills/chrome-devtools/references/puppeteer-reference.md @@ -0,0 +1,953 @@ +# Puppeteer Quick Reference + +Complete guide to browser automation with Puppeteer - a high-level API over Chrome DevTools Protocol. + +## Table of Contents + +- [Setup](#setup) +- [Browser & Page Management](#browser--page-management) +- [Navigation](#navigation) +- [Element Interaction](#element-interaction) +- [JavaScript Execution](#javascript-execution) +- [Screenshots & PDFs](#screenshots--pdfs) +- [Network Interception](#network-interception) +- [Device Emulation](#device-emulation) +- [Performance](#performance) +- [Common Patterns](#common-patterns) + +--- + +## Setup + +### Installation + +```bash +# Install Puppeteer +npm install puppeteer + +# Install core only (bring your own Chrome) +npm install puppeteer-core +``` + +### Basic Usage + +```javascript +import puppeteer from 'puppeteer'; + +// Launch browser +const browser = await puppeteer.launch({ + headless: true, + args: ['--no-sandbox'] +}); + +// Open page +const page = await browser.newPage(); + +// Navigate +await page.goto('https://example.com'); + +// Do work... + +// Cleanup +await browser.close(); +``` + +--- + +## Browser & Page Management + +### Launch Browser + +```javascript +const browser = await puppeteer.launch({ + // Visibility + headless: false, // Show browser UI + headless: 'new', // New headless mode (Chrome 112+) + + // Chrome location + executablePath: '/path/to/chrome', + channel: 'chrome', // or 'chrome-canary', 'chrome-beta' + + // Browser context + userDataDir: './user-data', // Persistent profile + + // Window size + defaultViewport: { + width: 1920, + height: 1080, + deviceScaleFactor: 1, + isMobile: false + }, + + // Advanced options + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-web-security', + '--disable-features=IsolateOrigins', + '--disable-site-isolation-trials', + '--start-maximized' + ], + + // Debugging + devtools: true, // Open DevTools automatically + slowMo: 250, // Slow down by 250ms per action + + // Network + proxy: { + server: 'http://proxy.com:8080' + } +}); +``` + +### Connect to Running Browser + +```javascript +// Launch Chrome with debugging +// google-chrome --remote-debugging-port=9222 + +const browser = await puppeteer.connect({ + browserURL: 'http://localhost:9222', + // or browserWSEndpoint: 'ws://localhost:9222/devtools/browser/...' +}); +``` + +### Page Management + +```javascript +// Create new page +const page = await browser.newPage(); + +// Get all pages +const pages = await browser.pages(); + +// Close page +await page.close(); + +// Multiple pages +const page1 = await browser.newPage(); +const page2 = await browser.newPage(); + +// Switch between pages +await page1.bringToFront(); +``` + +### Browser Context (Incognito) + +```javascript +// Create isolated context +const context = await browser.createBrowserContext(); +const page = await context.newPage(); + +// Cleanup context +await context.close(); +``` + +--- + +## Navigation + +### Basic Navigation + +```javascript +// Navigate to URL +await page.goto('https://example.com'); + +// Navigate with options +await page.goto('https://example.com', { + waitUntil: 'networkidle2', // or 'load', 'domcontentloaded', 'networkidle0' + timeout: 30000 // Max wait time (ms) +}); + +// Reload page +await page.reload({ waitUntil: 'networkidle2' }); + +// Navigation history +await page.goBack(); +await page.goForward(); + +// Wait for navigation +await page.waitForNavigation({ + waitUntil: 'networkidle2' +}); +``` + +### Wait Until Options + +- `load` - Wait for load event +- `domcontentloaded` - Wait for DOMContentLoaded event +- `networkidle0` - Wait until no network connections for 500ms +- `networkidle2` - Wait until max 2 network connections for 500ms + +--- + +## Element Interaction + +### Selectors + +```javascript +// CSS selectors +await page.$('#id'); +await page.$('.class'); +await page.$('div > p'); + +// XPath +await page.$x('//button[text()="Submit"]'); + +// Get all matching elements +await page.$$('.item'); +await page.$$x('//div[@class="item"]'); +``` + +### Click Elements + +```javascript +// Click by selector +await page.click('.button'); + +// Click with options +await page.click('.button', { + button: 'left', // or 'right', 'middle' + clickCount: 1, // 2 for double-click + delay: 100 // Delay between mousedown and mouseup +}); + +// ElementHandle click +const button = await page.$('.button'); +await button.click(); +``` + +### Type Text + +```javascript +// Type into input +await page.type('#search', 'query text'); + +// Type with delay +await page.type('#search', 'slow typing', { delay: 100 }); + +// Clear and type +await page.$eval('#search', el => el.value = ''); +await page.type('#search', 'new text'); +``` + +### Form Interaction + +```javascript +// Fill input +await page.type('#username', 'john@example.com'); +await page.type('#password', 'secret123'); + +// Select dropdown option +await page.select('#country', 'US'); // By value +await page.select('#country', 'USA', 'UK'); // Multiple + +// Check/uncheck checkbox +await page.click('input[type="checkbox"]'); + +// Choose radio button +await page.click('input[value="option2"]'); + +// Upload file +const input = await page.$('input[type="file"]'); +await input.uploadFile('/path/to/file.pdf'); + +// Submit form +await page.click('button[type="submit"]'); +await page.waitForNavigation(); +``` + +### Hover & Focus + +```javascript +// Hover over element +await page.hover('.menu-item'); + +// Focus element +await page.focus('#input'); + +// Blur +await page.$eval('#input', el => el.blur()); +``` + +### Drag & Drop + +```javascript +const source = await page.$('.draggable'); +const target = await page.$('.drop-zone'); + +await source.drag(target); +await source.drop(target); +``` + +--- + +## JavaScript Execution + +### Evaluate in Page Context + +```javascript +// Execute JavaScript +const title = await page.evaluate(() => document.title); + +// With arguments +const text = await page.evaluate( + (selector) => document.querySelector(selector).textContent, + '.heading' +); + +// Return complex data +const data = await page.evaluate(() => ({ + title: document.title, + url: location.href, + cookies: document.cookie +})); + +// With ElementHandle +const element = await page.$('.button'); +const text = await page.evaluate(el => el.textContent, element); +``` + +### Query & Modify DOM + +```javascript +// Get element property +const value = await page.$eval('#input', el => el.value); + +// Get multiple elements +const items = await page.$$eval('.item', elements => + elements.map(el => el.textContent) +); + +// Modify element +await page.$eval('#input', (el, value) => { + el.value = value; +}, 'new value'); + +// Add class +await page.$eval('.element', el => el.classList.add('active')); +``` + +### Expose Functions + +```javascript +// Expose Node.js function to page +await page.exposeFunction('md5', (text) => + crypto.createHash('md5').update(text).digest('hex') +); + +// Call from page context +const hash = await page.evaluate(async () => { + return await window.md5('hello world'); +}); +``` + +--- + +## Screenshots & PDFs + +### Screenshots + +```javascript +// Full page screenshot +await page.screenshot({ + path: 'screenshot.png', + fullPage: true +}); + +// Viewport screenshot +await page.screenshot({ + path: 'viewport.png', + fullPage: false +}); + +// Element screenshot +const element = await page.$('.chart'); +await element.screenshot({ + path: 'chart.png' +}); + +// Screenshot options +await page.screenshot({ + path: 'page.png', + type: 'png', // or 'jpeg', 'webp' + quality: 80, // JPEG quality (0-100) + clip: { // Crop region + x: 0, + y: 0, + width: 500, + height: 500 + }, + omitBackground: true // Transparent background +}); + +// Screenshot to buffer +const buffer = await page.screenshot(); +``` + +### PDF Generation + +```javascript +// Generate PDF +await page.pdf({ + path: 'page.pdf', + format: 'A4', // or 'Letter', 'Legal', etc. + printBackground: true, + margin: { + top: '1cm', + right: '1cm', + bottom: '1cm', + left: '1cm' + } +}); + +// Custom page size +await page.pdf({ + path: 'custom.pdf', + width: '8.5in', + height: '11in', + landscape: true +}); + +// Header and footer +await page.pdf({ + path: 'report.pdf', + displayHeaderFooter: true, + headerTemplate: '
Header
', + footerTemplate: '
Page
' +}); +``` + +--- + +## Network Interception + +### Request Interception + +```javascript +// Enable request interception +await page.setRequestInterception(true); + +// Intercept requests +page.on('request', (request) => { + // Block specific resource types + if (request.resourceType() === 'image') { + request.abort(); + } + // Block URLs + else if (request.url().includes('ads')) { + request.abort(); + } + // Modify request + else if (request.url().includes('api')) { + request.continue({ + headers: { + ...request.headers(), + 'Authorization': 'Bearer token' + } + }); + } + // Continue normally + else { + request.continue(); + } +}); +``` + +### Mock Responses + +```javascript +await page.setRequestInterception(true); + +page.on('request', (request) => { + if (request.url().includes('/api/user')) { + request.respond({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + id: 1, + name: 'Mock User' + }) + }); + } else { + request.continue(); + } +}); +``` + +### Monitor Network + +```javascript +// Track requests +page.on('request', (request) => { + console.log('Request:', request.method(), request.url()); +}); + +// Track responses +page.on('response', (response) => { + console.log('Response:', response.status(), response.url()); +}); + +// Track failed requests +page.on('requestfailed', (request) => { + console.log('Failed:', request.failure().errorText, request.url()); +}); + +// Get response body +page.on('response', async (response) => { + if (response.url().includes('/api/data')) { + const json = await response.json(); + console.log('API Data:', json); + } +}); +``` + +--- + +## Device Emulation + +### Predefined Devices + +```javascript +import { devices } from 'puppeteer'; + +// Emulate iPhone +const iPhone = devices['iPhone 13 Pro']; +await page.emulate(iPhone); + +// Common devices +const iPad = devices['iPad Pro']; +const pixel = devices['Pixel 5']; +const galaxy = devices['Galaxy S9+']; + +// Navigate after emulation +await page.goto('https://example.com'); +``` + +### Custom Device + +```javascript +await page.emulate({ + viewport: { + width: 375, + height: 812, + deviceScaleFactor: 3, + isMobile: true, + hasTouch: true, + isLandscape: false + }, + userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)...' +}); +``` + +### Viewport Only + +```javascript +await page.setViewport({ + width: 1920, + height: 1080, + deviceScaleFactor: 1 +}); +``` + +### Geolocation + +```javascript +// Set geolocation +await page.setGeolocation({ + latitude: 37.7749, + longitude: -122.4194, + accuracy: 100 +}); + +// Grant permissions +const context = browser.defaultBrowserContext(); +await context.overridePermissions('https://example.com', ['geolocation']); +``` + +### Timezone & Locale + +```javascript +// Set timezone +await page.emulateTimezone('America/New_York'); + +// Set locale +await page.emulateMediaType('screen'); +await page.evaluateOnNewDocument(() => { + Object.defineProperty(navigator, 'language', { + get: () => 'en-US' + }); +}); +``` + +--- + +## Performance + +### CPU & Network Throttling + +```javascript +// CPU throttling +const client = await page.createCDPSession(); +await client.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + +// Network throttling +await page.emulateNetworkConditions({ + offline: false, + downloadThroughput: 1.5 * 1024 * 1024 / 8, // 1.5 Mbps + uploadThroughput: 750 * 1024 / 8, // 750 Kbps + latency: 40 // 40ms RTT +}); + +// Predefined profiles +await page.emulateNetworkConditions( + puppeteer.networkConditions['Fast 3G'] +); + +// Disable throttling +await page.emulateNetworkConditions({ + offline: false, + downloadThroughput: -1, + uploadThroughput: -1, + latency: 0 +}); +``` + +### Performance Metrics + +```javascript +// Get metrics +const metrics = await page.metrics(); +console.log(metrics); +// { +// Timestamp, Documents, Frames, JSEventListeners, +// Nodes, LayoutCount, RecalcStyleCount, +// LayoutDuration, RecalcStyleDuration, +// ScriptDuration, TaskDuration, +// JSHeapUsedSize, JSHeapTotalSize +// } +``` + +### Performance Tracing + +```javascript +// Start tracing +await page.tracing.start({ + path: 'trace.json', + categories: [ + 'devtools.timeline', + 'disabled-by-default-devtools.timeline' + ] +}); + +// Navigate +await page.goto('https://example.com'); + +// Stop tracing +await page.tracing.stop(); + +// Analyze trace in chrome://tracing +``` + +### Coverage (Code Usage) + +```javascript +// Start JS coverage +await page.coverage.startJSCoverage(); + +// Start CSS coverage +await page.coverage.startCSSCoverage(); + +// Navigate +await page.goto('https://example.com'); + +// Stop and get coverage +const jsCoverage = await page.coverage.stopJSCoverage(); +const cssCoverage = await page.coverage.stopCSSCoverage(); + +// Calculate unused bytes +let totalBytes = 0; +let usedBytes = 0; +for (const entry of [...jsCoverage, ...cssCoverage]) { + totalBytes += entry.text.length; + for (const range of entry.ranges) { + usedBytes += range.end - range.start - 1; + } +} + +console.log(`Used: ${usedBytes / totalBytes * 100}%`); +``` + +--- + +## Common Patterns + +### Wait for Elements + +```javascript +// Wait for selector +await page.waitForSelector('.element', { + visible: true, + timeout: 5000 +}); + +// Wait for XPath +await page.waitForXPath('//button[text()="Submit"]'); + +// Wait for function +await page.waitForFunction( + () => document.querySelector('.loading') === null, + { timeout: 10000 } +); + +// Wait for timeout +await page.waitForTimeout(2000); +``` + +### Handle Dialogs + +```javascript +// Alert, confirm, prompt +page.on('dialog', async (dialog) => { + console.log(dialog.type(), dialog.message()); + + // Accept + await dialog.accept(); + // or reject + // await dialog.dismiss(); + // or provide input for prompt + // await dialog.accept('input text'); +}); +``` + +### Handle Downloads + +```javascript +// Set download path +const client = await page.createCDPSession(); +await client.send('Page.setDownloadBehavior', { + behavior: 'allow', + downloadPath: '/path/to/downloads' +}); + +// Trigger download +await page.click('a[download]'); +``` + +### Multiple Pages (Tabs) + +```javascript +// Listen for new pages +browser.on('targetcreated', async (target) => { + if (target.type() === 'page') { + const newPage = await target.page(); + console.log('New page opened:', newPage.url()); + } +}); + +// Click link that opens new tab +const [newPage] = await Promise.all([ + new Promise(resolve => browser.once('targetcreated', target => resolve(target.page()))), + page.click('a[target="_blank"]') +]); + +console.log('New page URL:', newPage.url()); +``` + +### Frames (iframes) + +```javascript +// Get all frames +const frames = page.frames(); + +// Find frame by name +const frame = page.frames().find(f => f.name() === 'myframe'); + +// Find frame by URL +const frame = page.frames().find(f => f.url().includes('example.com')); + +// Main frame +const mainFrame = page.mainFrame(); + +// Interact with frame +await frame.click('.button'); +await frame.type('#input', 'text'); +``` + +### Infinite Scroll + +```javascript +async function autoScroll(page) { + await page.evaluate(async () => { + await new Promise((resolve) => { + let totalHeight = 0; + const distance = 100; + const timer = setInterval(() => { + const scrollHeight = document.body.scrollHeight; + window.scrollBy(0, distance); + totalHeight += distance; + + if (totalHeight >= scrollHeight) { + clearInterval(timer); + resolve(); + } + }, 100); + }); + }); +} + +await autoScroll(page); +``` + +### Cookies + +```javascript +// Get cookies +const cookies = await page.cookies(); + +// Set cookies +await page.setCookie({ + name: 'session', + value: 'abc123', + domain: 'example.com', + path: '/', + httpOnly: true, + secure: true, + sameSite: 'Strict' +}); + +// Delete cookies +await page.deleteCookie({ name: 'session' }); +``` + +### Local Storage + +```javascript +// Set localStorage +await page.evaluate(() => { + localStorage.setItem('key', 'value'); +}); + +// Get localStorage +const value = await page.evaluate(() => { + return localStorage.getItem('key'); +}); + +// Clear localStorage +await page.evaluate(() => localStorage.clear()); +``` + +### Error Handling + +```javascript +try { + await page.goto('https://example.com', { + waitUntil: 'networkidle2', + timeout: 30000 + }); +} catch (error) { + if (error.name === 'TimeoutError') { + console.error('Page load timeout'); + } else { + console.error('Navigation failed:', error); + } + + // Take screenshot on error + await page.screenshot({ path: 'error.png' }); +} +``` + +### Stealth Mode (Avoid Detection) + +```javascript +// Hide automation indicators +await page.evaluateOnNewDocument(() => { + // Override navigator.webdriver + Object.defineProperty(navigator, 'webdriver', { + get: () => false + }); + + // Mock chrome object + window.chrome = { + runtime: {} + }; + + // Mock permissions + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' ? + Promise.resolve({ state: 'granted' }) : + originalQuery(parameters) + ); +}); + +// Set realistic user agent +await page.setUserAgent( + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' +); +``` + +--- + +## Debugging Tips + +### Take Screenshots on Error + +```javascript +page.on('pageerror', async (error) => { + console.error('Page error:', error); + await page.screenshot({ path: `error-${Date.now()}.png` }); +}); +``` + +### Console Logging + +```javascript +// Forward console to Node +page.on('console', (msg) => { + console.log('PAGE LOG:', msg.text()); +}); +``` + +### Slow Down Execution + +```javascript +const browser = await puppeteer.launch({ + slowMo: 250 // 250ms delay between actions +}); +``` + +### Keep Browser Open + +```javascript +const browser = await puppeteer.launch({ + headless: false, + devtools: true +}); + +// Prevent auto-close +await page.evaluate(() => debugger); +``` + +--- + +## Best Practices + +1. **Always close browser:** Use try/finally or process cleanup +2. **Wait appropriately:** Use waitForSelector, not setTimeout +3. **Handle errors:** Wrap navigation in try/catch +4. **Optimize selectors:** Use specific selectors for reliability +5. **Avoid race conditions:** Wait for navigation after clicks +6. **Reuse pages:** Don't create new pages unnecessarily +7. **Set timeouts:** Always specify reasonable timeouts +8. **Clean up:** Close unused pages and contexts + +--- + +## Resources + +- [Puppeteer Documentation](https://pptr.dev/) +- [Puppeteer API](https://pptr.dev/api) +- [Puppeteer Examples](https://github.com/puppeteer/puppeteer/tree/main/examples) +- [Awesome Puppeteer](https://github.com/transitive-bullshit/awesome-puppeteer) diff --git a/skills/chrome-devtools/scripts/README.md b/skills/chrome-devtools/scripts/README.md new file mode 100644 index 0000000..f44fa6e --- /dev/null +++ b/skills/chrome-devtools/scripts/README.md @@ -0,0 +1,213 @@ +# Chrome DevTools Scripts + +CLI scripts for browser automation using Puppeteer. + +**CRITICAL**: Always check `pwd` before running scripts. + +## Installation + +### Quick Install + +```bash +pwd # Should show current working directory +cd .claude/skills/chrome-devtools/scripts +./install.sh # Auto-checks dependencies and installs +``` + +### Manual Installation + +**Linux/WSL** - Install system dependencies first: +```bash +./install-deps.sh # Auto-detects OS (Ubuntu, Debian, Fedora, etc.) +``` + +Or manually: +```bash +sudo apt-get install -y libnss3 libnspr4 libasound2t64 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 +``` + +**All platforms** - Install Node dependencies: +```bash +npm install +``` + +## Scripts + +**CRITICAL**: Always check `pwd` before running scripts. + +### navigate.js +Navigate to a URL. + +```bash +node navigate.js --url https://example.com [--wait-until networkidle2] [--timeout 30000] +``` + +### screenshot.js +Take a screenshot with automatic compression. + +**Important**: Always save screenshots to `./docs/screenshots` directory. + +```bash +node screenshot.js --output screenshot.png [--url https://example.com] [--full-page true] [--selector .element] [--max-size 5] [--no-compress] +``` + +**Automatic Compression**: Screenshots >5MB are automatically compressed using ImageMagick to ensure compatibility with Gemini API and Claude Code. Install ImageMagick for this feature: +- macOS: `brew install imagemagick` +- Linux: `sudo apt-get install imagemagick` + +Options: +- `--max-size N` - Custom size threshold in MB (default: 5) +- `--no-compress` - Disable automatic compression +- `--format png|jpeg` - Output format (default: png) +- `--quality N` - JPEG quality 0-100 (default: auto) + +### click.js +Click an element. + +```bash +node click.js --selector ".button" [--url https://example.com] [--wait-for ".result"] +``` + +### fill.js +Fill form fields. + +```bash +node fill.js --selector "#input" --value "text" [--url https://example.com] [--clear true] +``` + +### evaluate.js +Execute JavaScript in page context. + +```bash +node evaluate.js --script "document.title" [--url https://example.com] +``` + +### snapshot.js +Get DOM snapshot with interactive elements. + +```bash +node snapshot.js [--url https://example.com] [--output snapshot.json] +``` + +### console.js +Monitor console messages. + +```bash +node console.js --url https://example.com [--types error,warn] [--duration 5000] +``` + +### network.js +Monitor network requests. + +```bash +node network.js --url https://example.com [--types xhr,fetch] [--output requests.json] +``` + +### performance.js +Measure performance metrics and record trace. + +```bash +node performance.js --url https://example.com [--trace trace.json] [--metrics] [--resources true] +``` + +## Common Options + +- `--headless false` - Show browser window +- `--close false` - Keep browser open +- `--timeout 30000` - Set timeout in milliseconds +- `--wait-until networkidle2` - Wait strategy (load, domcontentloaded, networkidle0, networkidle2) + +## Selector Support + +Scripts that accept `--selector` (click.js, fill.js, screenshot.js) support both **CSS** and **XPath** selectors. + +### CSS Selectors (Default) + +```bash +# Element tag +node click.js --selector "button" --url https://example.com + +# Class selector +node click.js --selector ".btn-submit" --url https://example.com + +# ID selector +node fill.js --selector "#email" --value "user@example.com" --url https://example.com + +# Attribute selector +node click.js --selector 'button[type="submit"]' --url https://example.com + +# Complex selector +node screenshot.js --selector "div.container > button.btn-primary" --output btn.png +``` + +### XPath Selectors + +XPath selectors start with `/` or `(//` and are automatically detected: + +```bash +# Text matching - exact +node click.js --selector '//button[text()="Submit"]' --url https://example.com + +# Text matching - contains +node click.js --selector '//button[contains(text(),"Submit")]' --url https://example.com + +# Attribute matching +node fill.js --selector '//input[@type="email"]' --value "user@example.com" + +# Multiple conditions +node click.js --selector '//button[@type="submit" and contains(text(),"Save")]' + +# Descendant selection +node screenshot.js --selector '//div[@class="modal"]//button[@class="close"]' --output modal.png + +# Nth element +node click.js --selector '(//button)[2]' # Second button on page +``` + +### Discovering Selectors + +Use `snapshot.js` to discover correct selectors: + +```bash +# Get all interactive elements +node snapshot.js --url https://example.com | jq '.elements[]' + +# Find buttons +node snapshot.js --url https://example.com | jq '.elements[] | select(.tagName=="BUTTON")' + +# Find inputs +node snapshot.js --url https://example.com | jq '.elements[] | select(.tagName=="INPUT")' +``` + +### Security + +XPath selectors are validated to prevent injection attacks. The following patterns are blocked: +- `javascript:` +- ` { + describe('CSS Selectors', () => { + it('should detect simple CSS selectors', () => { + const result = parseSelector('button'); + assert.strictEqual(result.type, 'css'); + assert.strictEqual(result.selector, 'button'); + }); + + it('should detect class selectors', () => { + const result = parseSelector('.btn-submit'); + assert.strictEqual(result.type, 'css'); + assert.strictEqual(result.selector, '.btn-submit'); + }); + + it('should detect ID selectors', () => { + const result = parseSelector('#email-input'); + assert.strictEqual(result.type, 'css'); + assert.strictEqual(result.selector, '#email-input'); + }); + + it('should detect attribute selectors', () => { + const result = parseSelector('button[type="submit"]'); + assert.strictEqual(result.type, 'css'); + assert.strictEqual(result.selector, 'button[type="submit"]'); + }); + + it('should detect complex CSS selectors', () => { + const result = parseSelector('div.container > button.btn-primary:hover'); + assert.strictEqual(result.type, 'css'); + }); + }); + + describe('XPath Selectors', () => { + it('should detect absolute XPath', () => { + const result = parseSelector('/html/body/button'); + assert.strictEqual(result.type, 'xpath'); + assert.strictEqual(result.selector, '/html/body/button'); + }); + + it('should detect relative XPath', () => { + const result = parseSelector('//button'); + assert.strictEqual(result.type, 'xpath'); + assert.strictEqual(result.selector, '//button'); + }); + + it('should detect XPath with text matching', () => { + const result = parseSelector('//button[text()="Click Me"]'); + assert.strictEqual(result.type, 'xpath'); + }); + + it('should detect XPath with contains', () => { + const result = parseSelector('//button[contains(text(),"Submit")]'); + assert.strictEqual(result.type, 'xpath'); + }); + + it('should detect XPath with attributes', () => { + const result = parseSelector('//input[@type="email"]'); + assert.strictEqual(result.type, 'xpath'); + }); + + it('should detect grouped XPath', () => { + const result = parseSelector('(//button)[1]'); + assert.strictEqual(result.type, 'xpath'); + }); + }); + + describe('Security Validation', () => { + it('should block javascript: injection', () => { + assert.throws( + () => parseSelector('//button[@onclick="javascript:alert(1)"]'), + /XPath injection detected.*javascript:/i + ); + }); + + it('should block ")]'), + /XPath injection detected.* + ``` + +3. **Manual**: [from agent 3] + Download and include in project + +## Core Concepts + +[Synthesized from agents 2 & 4] +The library is built around three main concepts: + +1. **Components**: [definition from agent 2] +2. **State**: [definition from agent 4] +3. **Effects**: [definition from agent 2] + +## Examples + +[From agents 3 & 5, deduplicated] +... +``` + +Benefits: +- Organized by topic +- Deduplicated +- Clear narrative +- Easy to scan + +### Synthesis Techniques + +**Deduplication:** +``` +Agent 1: "Install with npm install foo" +Agent 2: "You can install using npm: npm install foo" +→ Synthesized: "Install: `npm install foo`" +``` + +**Prioritization:** +``` +Agent 1: Basic usage example +Agent 2: Basic usage example (same) +Agent 3: Advanced usage example +→ Keep: Basic (from agent 1) + Advanced (from agent 3) +``` + +**Organization:** +``` +Agents returned mixed information: +- Installation steps +- Configuration +- Usage example +- Installation requirements +- More usage examples + +→ Reorganize: +1. Installation (requirements + steps) +2. Configuration +3. Usage (all examples together) +``` + +## 7. Time Management + +### Why + +- **User experience**: Fast results +- **Resource efficiency**: Don't waste compute +- **Fail fast**: Quickly try alternatives +- **Practical limits**: Avoid hanging + +### Timeouts + +**Set explicit timeouts:** +``` +WebSearch: 30 seconds +WebFetch: 60 seconds +Repository clone: 5 minutes +Repomix processing: 10 minutes +Explorer agent: 5 minutes per URL +Researcher agent: 10 minutes +``` + +### Time Budgets + +**Simple query (single library, latest version):** +``` +Target: <2 minutes total + +Phase 1 (Discovery): 30 seconds +- llms.txt search: 15 seconds +- Fetch llms.txt: 15 seconds + +Phase 2 (Exploration): 60 seconds +- Launch agents: 5 seconds +- Agents fetch URLs: 60 seconds (parallel) + +Phase 3 (Aggregation): 30 seconds +- Synthesize results +- Format output + +Total: ~2 minutes +``` + +**Complex query (multiple versions, comparison):** +``` +Target: <5 minutes total + +Phase 1 (Discovery): 60 seconds +- Search both versions +- Fetch both llms.txt files + +Phase 2 (Exploration): 180 seconds +- Launch 6 agents (2 sets of 3) +- Parallel exploration + +Phase 3 (Comparison): 60 seconds +- Analyze differences +- Format side-by-side + +Total: ~5 minutes +``` + +### When to Extend Timeouts + +Acceptable to go longer when: +- User explicitly requests comprehensive analysis +- Repository is large but necessary +- Multiple fallbacks attempted +- User is informed of delay + +### When to Give Up + +Move to next method after: +- 3 failed attempts on same approach +- Timeout exceeded by 2x +- No progress for 30 seconds +- Error indicates permanent failure (404, auth required) + +## 8. Cache Findings + +### Why + +- **Speed**: Instant results for repeated requests +- **Efficiency**: Reduce network requests +- **Consistency**: Same results within session +- **Reliability**: Less dependent on network + +### What to Cache + +**High value (always cache):** +``` +- Repomix output (large, expensive to generate) +- llms.txt content (static, frequently referenced) +- Repository README (relatively static) +- Package registry metadata (changes rarely) +``` + +**Medium value (cache within session):** +``` +- Documentation page content +- Search results +- Repository structure +- Version lists +``` + +**Low value (don't cache):** +``` +- Real-time data (latest releases) +- User-specific content +- Time-sensitive information +``` + +### Cache Duration + +``` +Within conversation: +- All fetched content (reuse freely) + +Within session: +- Repomix output (until conversation ends) +- llms.txt content (until new version requested) + +Across sessions: +- Don't cache (start fresh each time) +``` + +### Cache Invalidation + +Refresh cache when: +``` +- User requests specific different version +- User says "get latest" or "refresh" +- Explicit time reference ("docs from today") +- Previous cache is from different library +``` + +### Implementation + +``` +# First request for library X +1. Fetch llms.txt +2. Store content in session variable +3. Use for processing + +# Second request for library X (same session) +1. Check if llms.txt cached +2. Reuse cached content +3. Skip redundant fetch + +# Request for library Y +1. Don't reuse library X cache +2. Fetch fresh for library Y +``` + +### Cache Hit Messages + +```markdown +ℹ️ Using cached llms.txt from 5 minutes ago. +To fetch fresh, say "refresh" or "get latest". +``` + +## Quick Reference Checklist + +### Before Starting + +- [ ] Identify library name clearly +- [ ] Confirm version (default: latest) +- [ ] Check if cached data available +- [ ] Plan method (llms.txt → repo → research) + +### During Discovery + +- [ ] Start with llms.txt search +- [ ] Verify source is official +- [ ] Check version matches requirement +- [ ] Set timeout for each operation +- [ ] Fall back quickly if method fails + +### During Exploration + +- [ ] Use parallel agents for 3+ URLs +- [ ] Launch all agents in single message +- [ ] Distribute workload evenly +- [ ] Monitor for errors/timeouts +- [ ] Be ready to retry or fallback + +### Before Presenting + +- [ ] Synthesize by topic (not by agent) +- [ ] Deduplicate repeated information +- [ ] Verify version is correct +- [ ] Include source attribution +- [ ] Note any limitations +- [ ] Format clearly +- [ ] Check completeness + +### Quality Gates + +Ask before presenting: +- [ ] Is information accurate? +- [ ] Are sources official? +- [ ] Does version match request? +- [ ] Are all key topics covered? +- [ ] Are limitations noted? +- [ ] Is methodology documented? +- [ ] Is output well-organized? diff --git a/skills/docs-seeker/references/documentation-sources.md b/skills/docs-seeker/references/documentation-sources.md new file mode 100644 index 0000000..81e4a1e --- /dev/null +++ b/skills/docs-seeker/references/documentation-sources.md @@ -0,0 +1,461 @@ +# Common Documentation Sources + +Reference guide for locating documentation across popular platforms and ecosystems. + +## context7.com Locations (PRIORITY) + +**ALWAYS try context7.com first for all libraries** + +### JavaScript/TypeScript Frameworks + +- **Astro**: https://context7.com/withastro/astro/llms.txt +- **Next.js**: https://context7.com/vercel/next.js/llms.txt +- **Remix**: https://context7.com/remix-run/remix/llms.txt +- **SvelteKit**: https://context7.com/sveltejs/kit/llms.txt +- **Nuxt**: https://context7.com/nuxt/nuxt/llms.txt + +### Frontend Libraries & UI + +- **React**: https://context7.com/facebook/react/llms.txt +- **Vue**: https://context7.com/vuejs/core/llms.txt +- **Svelte**: https://context7.com/sveltejs/svelte/llms.txt +- **shadcn/ui**: https://context7.com/shadcn-ui/ui/llms.txt +- **Radix UI**: https://context7.com/radix-ui/primitives/llms.txt + +### Backend/Full-stack + +- **Hono**: https://context7.com/honojs/hono/llms.txt +- **Fastify**: https://context7.com/fastify/fastify/llms.txt +- **tRPC**: https://context7.com/trpc/trpc/llms.txt + +### Build Tools + +- **Vite**: https://context7.com/vitejs/vite/llms.txt +- **Turbo**: https://context7.com/vercel/turbo/llms.txt + +### Databases/ORMs + +- **Prisma**: https://context7.com/prisma/prisma/llms.txt +- **Drizzle**: https://context7.com/drizzle-team/drizzle-orm/llms.txt + +### Authentication + +- **Better Auth**: https://context7.com/better-auth/better-auth/llms.txt +- **Auth.js**: https://context7.com/nextauthjs/next-auth/llms.txt + +### Image Processing + +- **ImageMagick**: https://context7.com/imagick/imagick/llms.txt + +### Topic-Specific Examples + +- **shadcn/ui date components**: https://context7.com/shadcn-ui/ui/llms.txt?topic=date +- **shadcn/ui buttons**: https://context7.com/shadcn-ui/ui/llms.txt?topic=button +- **Next.js caching**: https://context7.com/vercel/next.js/llms.txt?topic=cache +- **FFmpeg compression**: https://context7.com/websites/ffmpeg_doxygen_8_0/llms.txt?topic=compress + +## Official llms.txt Locations (FALLBACK) + +Use these only if context7.com returns 404: + +### JavaScript/TypeScript Frameworks + +- **Astro**: https://docs.astro.build/llms.txt +- **Next.js**: https://nextjs.org/llms.txt +- **Remix**: https://remix.run/llms.txt +- **SvelteKit**: https://kit.svelte.dev/llms.txt +- **Nuxt**: https://nuxt.com/llms.txt + +### Frontend Libraries + +- **React**: https://react.dev/llms.txt +- **Vue**: https://vuejs.org/llms.txt +- **Svelte**: https://svelte.dev/llms.txt + +### Backend/Full-stack + +- **Hono**: https://hono.dev/llms.txt +- **Fastify**: https://fastify.dev/llms.txt +- **tRPC**: https://trpc.io/llms.txt + +### Build Tools + +- **Vite**: https://vitejs.dev/llms.txt +- **Turbopack**: https://turbo.build/llms.txt + +### Databases/ORMs + +- **Prisma**: https://prisma.io/llms.txt +- **Drizzle**: https://orm.drizzle.team/llms.txt + +## Repository Patterns + +### GitHub (Most Common) + +**URL patterns:** +``` +https://github.com/[org]/[repo] +https://github.com/[user]/[repo] +``` + +**Common organization names:** +- Company name: `github.com/vercel/next.js` +- Project name: `github.com/remix-run/remix` +- Community: `github.com/facebook/react` + +**Documentation locations in repositories:** +``` +/docs/ +/documentation/ +/website/docs/ +/packages/docs/ +README.md +CONTRIBUTING.md +/examples/ +``` + +### GitLab + +**URL pattern:** +``` +https://gitlab.com/[org]/[repo] +``` + +### Bitbucket (Less Common) + +**URL pattern:** +``` +https://bitbucket.org/[org]/[repo] +``` + +## Package Registries + +### npm (JavaScript/TypeScript) + +**URL**: `https://npmjs.com/package/[name]` + +**Available info:** +- Description +- Homepage link +- Repository link +- Version history +- Dependencies + +**Useful for:** +- Finding official links +- Version information +- Package metadata + +### PyPI (Python) + +**URL**: `https://pypi.org/project/[name]` + +**Available info:** +- Description +- Documentation link +- Homepage +- Repository link +- Release history + +**Useful for:** +- Python package documentation +- Official links +- Version compatibility + +### RubyGems (Ruby) + +**URL**: `https://rubygems.org/gems/[name]` + +**Available info:** +- Description +- Homepage +- Documentation +- Source code link +- Dependencies + +**Useful for:** +- Ruby gem documentation +- Version information + +### Cargo (Rust) + +**URL**: `https://crates.io/crates/[name]` + +**Available info:** +- Description +- docs.rs link (auto-generated docs) +- Repository +- Version history + +**Useful for:** +- Rust crate documentation +- Auto-generated API docs +- Repository link + +### Maven Central (Java) + +**URL**: `https://search.maven.org/artifact/[group]/[artifact]` + +**Available info:** +- Versions +- Dependencies +- Repository link +- License + +**Useful for:** +- Java library information +- Dependency management + +## Documentation Hosting Platforms + +### Read the Docs + +**URL patterns:** +``` +https://[project].readthedocs.io +https://readthedocs.org/projects/[project] +``` + +**Features:** +- Version switching +- Multiple formats (HTML, PDF, ePub) +- Search functionality +- Often auto-generated from reStructuredText/Markdown + +### GitBook + +**URL patterns:** +``` +https://[org].gitbook.io/[project] +https://docs.[domain].com (often GitBook-powered) +``` + +**Features:** +- Clean, modern interface +- Good navigation +- Often manually curated +- May require API key for programmatic access + +### Docusaurus + +**URL patterns:** +``` +https://[project].io +https://docs.[project].com +``` + +**Common in:** +- React ecosystem +- Meta/Facebook projects +- Modern open-source projects + +**Features:** +- React-based +- Fast, static site +- Version management +- Good search + +### MkDocs + +**URL patterns:** +``` +https://[user].github.io/[project] +https://[custom-domain].com +``` + +**Features:** +- Python ecosystem +- Static site from Markdown +- Often on GitHub Pages +- Material theme popular + +### VitePress + +**URL patterns:** +``` +https://[project].dev +https://docs.[project].com +``` + +**Common in:** +- Vue ecosystem +- Modern projects +- Vite-based projects + +**Features:** +- Vue-powered +- Very fast +- Clean design +- Good DX + +## Documentation Search Patterns + +### Finding llms.txt + +**ALWAYS try context7.com first:** + +For GitHub repositories: +``` +https://context7.com/{org}/{repo}/llms.txt +``` + +For websites: +``` +https://context7.com/websites/{normalized-path}/llms.txt +``` + +With topic filter: +``` +https://context7.com/{path}/llms.txt?topic={query} +``` + +**Fallback: Traditional search if context7.com returns 404:** +``` +"[library] llms.txt site:[known-domain]" +``` + +**Alternative domains to try:** +``` +site:docs.[library].com +site:[library].dev +site:[library].io +site:[library].org +``` + +### Finding Official Repository + +**Search pattern:** +``` +"[library] official github repository" +"[library] source code github" +``` + +**Verification checklist:** +- Check organization/user is official +- Verify star count (popular libraries have many) +- Check last commit date (active maintenance) +- Look for official links in README + +### Finding Official Documentation + +**Search patterns:** +``` +"[library] official documentation" +"[library] docs site:official-domain" +"[library] API reference" +``` + +**Domain patterns:** +``` +docs.[library].com +[library].dev/docs +docs.[library].io +[library].readthedocs.io +``` + +## Common Documentation Structures + +### Typical Section Names + +**Getting started:** +- Getting Started +- Quick Start +- Introduction +- Installation +- Setup + +**Core concepts:** +- Core Concepts +- Fundamentals +- Basics +- Key Concepts +- Architecture + +**Guides:** +- Guides +- How-To Guides +- Tutorials +- Examples +- Recipes + +**Reference:** +- API Reference +- API Documentation +- Reference +- API +- CLI Reference + +**Advanced:** +- Advanced +- Advanced Topics +- Deep Dives +- Internals +- Performance + +### Common File Names + +``` +README.md +GETTING_STARTED.md +INSTALLATION.md +CONTRIBUTING.md +CHANGELOG.md +API.md +TUTORIAL.md +EXAMPLES.md +FAQ.md +``` + +## Framework-Specific Patterns + +### React Ecosystem + +**Common patterns:** +``` +- Uses Docusaurus +- Documentation at [project].dev or docs.[project].com +- Often has interactive examples +- CodeSandbox/StackBlitz embeds +``` + +### Vue Ecosystem + +**Common patterns:** +``` +- Uses VitePress +- Documentation at [project].vuejs.org +- Bilingual (English/Chinese) +- API reference auto-generated +``` + +### Python Ecosystem + +**Common patterns:** +``` +- Read the Docs hosting +- Sphinx-generated +- reStructuredText format +- [project].readthedocs.io +``` + +### Rust Ecosystem + +**Common patterns:** +``` +- docs.rs for API docs +- Book format for guides ([project].rs/book) +- Markdown in repository +- Well-structured examples/ +``` + +## Quick Lookup Table + +| Ecosystem | Registry | Docs Pattern | Common Host | +|-----------|----------|--------------|-------------| +| JavaScript/TS | npmjs.com | [name].dev | Docusaurus, VitePress | +| Python | pypi.org | readthedocs.io | Read the Docs | +| Rust | crates.io | docs.rs | docs.rs | +| Ruby | rubygems.org | rubydoc.info | RDoc | +| Go | pkg.go.dev | pkg.go.dev | pkg.go.dev | +| PHP | packagist.org | [name].org | Various | +| Java | maven.org | javadoc | Maven Central | diff --git a/skills/docs-seeker/references/error-handling.md b/skills/docs-seeker/references/error-handling.md new file mode 100644 index 0000000..9037b0d --- /dev/null +++ b/skills/docs-seeker/references/error-handling.md @@ -0,0 +1,621 @@ +# Error Handling Guide + +Comprehensive troubleshooting and error resolution strategies for documentation discovery. + +## context7.com Not Accessible + +### Symptoms + +- 404 error (library not indexed) +- Connection timeout +- Server error (500) +- Empty response + +### Troubleshooting Steps + +**1. Verify URL pattern:** + +For GitHub repos: +``` +✓ Correct: https://context7.com/vercel/next.js/llms.txt +✗ Wrong: https://context7.com/nextjs/llms.txt +``` + +For websites: +``` +✓ Correct: https://context7.com/websites/imgix/llms.txt +✗ Wrong: https://context7.com/docs.imgix.com/llms.txt +``` + +**2. Try official llms.txt as fallback:** +``` +https://docs.[library].com/llms.txt +https://[library].dev/llms.txt +https://[library].io/llms.txt +``` + +**3. Search for llms.txt if still not found:** +``` +WebSearch: "[library] llms.txt" +WebSearch: "[library] documentation AI format" +``` + +**4. Fall back to repository analysis:** +- If no llms.txt available anywhere +- Note in report: "llms.txt not available, used repository analysis" + +### Common Causes + +- Library not yet indexed by context7.com +- Very new or obscure library +- Private repository +- context7.com temporary outage + +### Example Resolution + +``` +Problem: https://context7.com/org/new-lib/llms.txt returns 404 + +Steps: +1. Check official site: https://new-lib.dev/llms.txt ✗ Not found +2. WebSearch for llms.txt ✗ Not found +3. Fall back to repository: https://github.com/org/new-lib ✓ Found +4. Use Repomix for documentation extraction +5. Note in report: "No llms.txt available, analyzed repository directly" +``` + +## llms.txt Not Accessible (Official Sites) + +### Symptoms + +- 404 error +- Connection timeout +- Access denied (403) +- Empty response + +### Troubleshooting Steps + +**1. ALWAYS try context7.com first:** +``` +https://context7.com/{org}/{repo}/llms.txt +``` + +**2. Try alternative official domains:** +``` +https://[name].dev/llms.txt +https://[name].io/llms.txt +https://[name].com/llms.txt +https://docs.[name].com/llms.txt +https://www.[name].com/llms.txt +``` + +**3. Check for redirects:** +- Old domain → new domain +- Non-HTTPS → HTTPS +- www → non-www or vice versa +- Root → /docs subdirectory + +**4. Search for llms.txt mention:** +``` +WebSearch: "[library] llms.txt" +WebSearch: "[library] documentation AI format" +``` + +**5. Check documentation announcements:** +- Blog posts about llms.txt +- GitHub discussions +- Recent release notes + +**6. If all fail:** +- Fall back to repository analysis (Phase 3) +- Note in report: "llms.txt not available" + +### Common Causes + +- Documentation recently moved/redesigned +- llms.txt not yet implemented +- Domain configuration issues +- Rate limiting or IP blocking +- Firewall/security restrictions + +### Example Resolution + +``` +Problem: https://example.dev/llms.txt returns 404 + +Steps: +1. Try: https://docs.example.dev/llms.txt ✓ Works! +2. Note: Documentation moved to docs subdomain +3. Proceed with Phase 2 using correct URL +``` + +## Repository Not Found + +### Symptoms + +- GitHub 404 error +- No official repository found +- Repository is private/requires auth +- Multiple competing repositories + +### Troubleshooting Steps + +**1. Search official website:** +``` +WebSearch: "[library] official website" +``` + +**2. Check package registries:** +``` +WebSearch: "[library] npm" +WebSearch: "[library] pypi" +WebSearch: "[library] crates.io" +``` + +**3. Look for organization GitHub:** +``` +WebSearch: "[company] github organization" +WebSearch: "[library] github org:[known-org]" +``` + +**4. Check for mirrors or forks:** +``` +WebSearch: "[library] github mirror" +WebSearch: "[library] source code" +``` + +**5. Verify through package manager:** +```bash +# npm example +npm info [package-name] repository + +# pip example +pip show [package-name] +``` + +**6. If all fail:** +- Use Researcher agents (Phase 4) +- Note: "No public repository available" + +### Common Causes + +- Proprietary/closed-source software +- Documentation separate from code repository +- Company uses internal hosting (GitLab, Bitbucket, self-hosted) +- Project discontinued or archived +- Repository renamed/moved + +### Verification Checklist + +When you find a repository, verify: +- [ ] Organization/user matches official entity +- [ ] Star count appropriate for library popularity +- [ ] Recent commits (active maintenance) +- [ ] README mentions official status +- [ ] Links back to official website +- [ ] License matches expectations + +## Repomix Failures + +### Symptoms + +- Out of memory error +- Command hangs indefinitely +- Output file empty or corrupted +- Permission errors +- Network timeout during clone + +### Troubleshooting Steps + +**1. Check repository size:** +```bash +# Clone and check size +git clone [url] /tmp/test-repo +du -sh /tmp/test-repo + +# If >500MB, use focused approach +``` + +**2. Focus on documentation only:** +```bash +repomix --include "docs/**,README.md,*.md" --output docs.xml +``` + +**3. Exclude large files:** +```bash +repomix --exclude "*.png,*.jpg,*.pdf,*.zip,dist/**,build/**,node_modules/**" --output repomix-output.xml +``` + +**4. Use shallow clone:** +```bash +git clone --depth 1 [url] /tmp/docs-analysis +cd /tmp/docs-analysis +repomix --output repomix-output.xml +``` + +**5. Alternative: Explorer agents** +``` +If Repomix fails completely: +1. Read README.md directly +2. List /docs directory structure +3. Launch Explorer agents for key files +4. Read specific documentation files +``` + +**6. Check system resources:** +```bash +# Check disk space +df -h /tmp + +# Check available memory +free -h + +# Kill if hung +pkill -9 repomix +``` + +### Common Causes + +- Repository too large (>1GB) +- Many binary files (images, videos) +- Large commit history +- Insufficient disk space +- Memory constraints +- Slow network connection +- Repository has submodules + +### Size Guidelines + +| Repo Size | Strategy | +|-----------|----------| +| <50MB | Full Repomix | +| 50-200MB | Exclude binaries | +| 200-500MB | Focus on /docs | +| 500MB-1GB | Shallow clone + focus | +| >1GB | Explorer agents only | + +## Multiple Conflicting Sources + +### Symptoms + +- Different installation instructions +- Conflicting API signatures +- Contradictory recommendations +- Version mismatches +- Breaking changes not documented + +### Resolution Steps + +**1. Check version of each source:** +``` +- Note documentation version number +- Check last-updated date +- Check URL for version indicator (v1/, v2/) +- Look for version selector on page +``` + +**2. Prioritize sources:** +``` +Priority order: +1. Official docs (latest version) +2. Official docs (specified version) +3. Package registry (verified) +4. Official repository README +5. Community tutorials (recent) +6. Stack Overflow (recent, high votes) +7. Blog posts (date-verified) +``` + +**3. Present both with context:** +```markdown +## Installation (v1.x - Legacy) +[old method] +Source: [link] (Last updated: [date]) + +## Installation (v2.x - Current) +[new method] +Source: [link] (Last updated: [date]) + +⚠️ Note: v2.x is recommended for new projects. +Migration guide: [link] +``` + +**4. Cross-reference:** +- Check if conflict is intentional (breaking change) +- Look for migration guides +- Check changelog/release notes +- Verify in GitHub issues/discussions + +**5. Document discrepancy:** +```markdown +## ⚠️ Conflicting Information Found + +**Source 1** (official docs): Method A +**Source 2** (repository): Method B + +**Analysis**: Source 1 reflects v2.x API. Source 2 README +not yet updated. Confirmed via changelog [link]. + +**Recommendation**: Use Method A (official docs). +``` + +### Version Identification + +**Check these locations:** +``` +- URL path: /docs/v2/... +- Page header/footer +- Version selector dropdown +- Git branch/tag +- Package.json or equivalent +- CHANGELOG.md date correlation +``` + +## Rate Limiting + +### Symptoms + +- 429 Too Many Requests +- 403 Forbidden (temporary) +- Slow responses +- Connection refused +- "Rate limit exceeded" message + +### Solutions + +**1. Add delays between requests:** +```bash +# Add 2-second delay +sleep 2 +``` + +**2. Use alternative sources:** +``` +Priority fallback chain: +GitHub → Official docs → Package registry → Repository → Archive +``` + +**3. Batch operations:** +``` +Instead of: +- WebFetch URL 1 +- WebFetch URL 2 +- WebFetch URL 3 + +Use: +- Launch 3 Explorer agents (single batch) +``` + +**4. Cache aggressively:** +``` +- Reuse fetched content within session +- Don't re-fetch same URLs +- Store repomix output for reuse +- Note fetch time, reuse if <1 hour old +``` + +**5. Check rate limit headers:** +``` +If available: +- X-RateLimit-Remaining +- X-RateLimit-Reset +- Retry-After +``` + +**6. Respect robots.txt:** +```bash +# Check before aggressive crawling +curl https://example.com/robots.txt +``` + +### Rate Limit Recovery + +**GitHub API (if applicable):** +``` +- Anonymous: 60 requests/hour +- Authenticated: 5000 requests/hour +- Wait period: 1 hour from first request +``` + +**General approach:** +``` +1. Detect rate limit (429 or slow responses) +2. Switch to alternative source immediately +3. Don't retry same endpoint repeatedly +4. Note in report: "Rate limit encountered, used [alternative]" +``` + +## Network Timeouts + +### Symptoms + +- Request hangs indefinitely +- Connection timeout error +- No response received +- Partial content received + +### Solutions + +**1. Set explicit timeouts:** +``` +WebSearch: 30 seconds max +WebFetch: 60 seconds max +Repository clone: 5 minutes max +Repomix processing: 10 minutes max +``` + +**2. Retry with timeout:** +``` +1st attempt: 60 seconds +2nd attempt: 90 seconds (if needed) +3rd attempt: Switch to alternative method +``` + +**3. Check network connectivity:** +```bash +# Test basic connectivity +ping -c 3 8.8.8.8 + +# Test DNS resolution +nslookup docs.example.com + +# Test specific host +curl -I https://docs.example.com +``` + +**4. Use alternative endpoints:** +``` +If main site times out: +- Try CDN version +- Try regional mirror +- Try cached version (Google Cache, Archive.org) +``` + +**5. Fall back gracefully:** +``` +Main docs timeout → Repository → Package registry → Research +``` + +## Incomplete Documentation + +### Symptoms + +- Documentation stub pages +- "Coming soon" sections +- Broken links (404) +- Missing API reference +- Outdated examples + +### Handling Strategy + +**1. Identify gaps:** +```markdown +## Documentation Status + +✅ Available: +- Installation guide +- Basic usage examples + +⚠️ Incomplete: +- Advanced features (stub page) +- API reference (404 links) + +❌ Missing: +- Migration guide +- Performance optimization +``` + +**2. Supplement from repository:** +``` +- Check /examples directory +- Read test files for usage +- Analyze TypeScript definitions +- Check CHANGELOG for features +``` + +**3. Use community sources:** +``` +- Recent Stack Overflow answers +- GitHub discussions +- Blog posts from maintainers +- Video tutorials +``` + +**4. Note limitations clearly:** +```markdown +⚠️ **Documentation Limitations** + +Official docs incomplete (as of [date]). +The following information inferred from: +- Repository examples +- TypeScript definitions +- Community discussions + +May not reflect official recommendations. +``` + +## Authentication/Access Issues + +### Symptoms + +- Private repository +- Login required +- Organization-only access +- Documentation behind paywall + +### Solutions + +**1. For private repositories:** +``` +- Note: "Repository is private" +- Check for public mirror +- Look for public documentation site +- Search package registry for info +``` + +**2. For paywalled docs:** +``` +- Check for free tier/trial +- Look for open-source alternative +- Search for community mirrors +- Use package registry info instead +``` + +**3. Document access limitation:** +```markdown +## ⚠️ Access Limitation + +Official repository is private. This report based on: +- Public documentation site: [url] +- Package registry info: [url] +- Community resources: [urls] + +May not include internal implementation details. +``` + +## Error Handling Best Practices + +### General Principles + +1. **Fail fast**: Don't retry same method repeatedly +2. **Fall back**: Have alternative strategies ready +3. **Document**: Note what failed and why +4. **Inform user**: Clear about limitations +5. **Partial success**: Deliver what you can find + +### Error Reporting Template + +```markdown +## ⚠️ Discovery Issues Encountered + +**Primary method**: [method] - [reason for failure] +**Fallback used**: [alternative method] +**Information completeness**: [percentage or description] + +**What was found**: +- [list available information] + +**What is missing**: +- [list gaps] + +**Recommended action**: +- [how user can get missing info] +``` + +### Recovery Decision Tree + +``` +Error encountered + ↓ +Is there an obvious alternative? + YES → Try alternative immediately + NO → Continue below + ↓ +Have we tried all primary methods? + NO → Try next method in sequence + YES → Continue below + ↓ +Is partial information useful? + YES → Deliver partial results with notes + NO → Inform user, request guidance +``` diff --git a/skills/docs-seeker/references/limitations.md b/skills/docs-seeker/references/limitations.md new file mode 100644 index 0000000..b17b73d --- /dev/null +++ b/skills/docs-seeker/references/limitations.md @@ -0,0 +1,821 @@ +# Limitations & Success Criteria + +Understanding boundaries and measuring effectiveness of documentation discovery. + +## context7.com Limitations + +### Not All Libraries Indexed + +**Limitation:** +- context7.com doesn't index every repository/website +- Very new libraries may not be available yet +- Private repositories not accessible +- Some niche libraries missing + +**Impact:** +- Need fallback to official llms.txt or repository analysis +- May add 10-20 seconds to discovery time +- Requires manual search for obscure libraries + +**Workarounds:** +``` +1. Try context7.com first (always) +2. If 404, fall back to official llms.txt search +3. If still not found, use repository analysis +4. Note in report which method was used +``` + +**Example:** +``` +Tried: https://context7.com/org/new-lib/llms.txt → 404 +Fallback: WebSearch for "new-lib llms.txt" → Found +Used: Official llms.txt from website +``` + +### Topic Filtering Accuracy + +**Limitation:** +- ?topic= parameter relies on keyword matching +- May miss relevant content with different terminology +- May include tangentially related content +- Quality depends on context7 indexing + +**Impact:** +- Occasionally need to review base llms.txt without topic filter +- May miss some relevant documentation + +**Workarounds:** +``` +- Try multiple topic keywords +- Fall back to full llms.txt if topic search insufficient +- Use broader terms for better coverage +``` + +## Cannot Handle + +### Password-Protected Documentation + +**Limitation:** +- No access to authentication-required content +- Cannot log in to platforms +- No credential management +- Cannot access organization-internal docs + +**Impact:** +- Enterprise documentation inaccessible +- Premium content unavailable +- Private beta docs unreachable +- Internal wikis not readable + +**Workarounds:** +``` +- Ask user for public alternatives +- Search for public subset of docs +- Use publicly available README/marketing +- Check if trial/demo access available +- Note limitation in report +``` + +**Report template:** +```markdown +⚠️ **Access Limitation** + +Documentation requires authentication. + +**What we can access**: +- Public README: [url] +- Package registry info: [url] +- Marketing site: [url] + +**Cannot access**: +- Full documentation (requires login) +- Internal guides +- Premium content + +**Recommendation**: Contact vendor for access or check if public docs available. +``` + +### Rate-Limited APIs + +**Limitation:** +- No API credentials for authenticated access +- Subject to anonymous rate limits +- Cannot request increased limits +- No retry with authentication + +**Impact:** +- Limited requests per hour (e.g., GitHub: 60/hour anonymous) +- May hit limits during comprehensive search +- Slower fallback required +- Incomplete coverage possible + +**Workarounds:** +``` +- Add delays between requests +- Use alternative sources (cached, mirrors) +- Prioritize critical pages +- Use Researcher agents instead of API +- Switch to repository analysis +``` + +**Detection:** +``` +Symptoms: +- 429 Too Many Requests +- X-RateLimit-Remaining: 0 +- Slow or refused connections + +Response: +- Immediately switch to alternative method +- Don't retry same endpoint +- Note in report which method used +``` + +### Real-Time Documentation + +**Limitation:** +- Uses snapshot at time of access +- Cannot monitor for updates +- No real-time synchronization +- May miss very recent changes + +**Impact:** +- Documentation updated minutes ago may not be reflected +- Breaking changes announced today might be missed +- Latest release notes may not be current +- Version just released may not be documented + +**Workarounds:** +``` +- Note access date in report +- Recommend user verify if critical +- Check last-modified headers +- Compare with release dates +- Suggest official site for latest +``` + +**Report template:** +```markdown +ℹ️ **Snapshot Information** + +Documentation retrieved: 2025-10-26 14:30 UTC + +**Last-Modified** (if available): +- Main docs: 2025-10-24 +- API reference: 2025-10-22 + +**Note**: For real-time updates, check official site: [url] +``` + +### Interactive Documentation + +**Limitation:** +- Cannot run interactive examples +- Cannot execute code playgrounds +- No ability to test API calls +- Cannot verify functionality + +**Impact:** +- Cannot confirm examples work +- Cannot test edge cases +- Cannot validate API responses +- Cannot verify performance claims + +**Workarounds:** +``` +- Provide code examples as-is +- Note: "Example provided, not tested" +- Recommend user run examples +- Link to interactive playground if available +- Include caveats about untested code +``` + +**Report template:** +```markdown +## Example Usage + +```python +# Example from official docs (not tested) +import library +result = library.do_thing() +``` + +⚠️ **Note**: Example provided from documentation but not executed. +Please test in your environment. + +**Interactive playground**: [url if available] +``` + +### Video-Only Documentation + +**Limitation:** +- Cannot process video content directly +- Limited transcript access +- Cannot extract code from video +- Cannot parse visual diagrams + +**Impact:** +- Video tutorials not usable +- YouTube courses inaccessible +- Screencasts not processable +- Visual walkthroughs unavailable + +**Workarounds:** +``` +- Search for transcript if available +- Look for accompanying blog post +- Find text-based alternative +- Check for community notes +- Use automated captions if available (low quality) +``` + +**Report template:** +```markdown +ℹ️ **Video Content Detected** + +Primary documentation is video-based: [url] + +**Alternatives found**: +- Blog post summary: [url] +- Community notes: [url] + +**Cannot extract**: +- Detailed walkthrough from video +- Visual examples +- Demonstration steps + +**Recommendation**: Watch video directly for visual content. +``` + +## May Struggle With + +### Very Large Repositories (>1GB) + +**Challenge:** +- Repomix may fail or hang +- Clone takes very long time +- Processing exceeds memory limits +- Output file too large to read + +**Success rate:** ~30% for >1GB repos + +**Mitigation:** +``` +1. Try shallow clone: git clone --depth 1 +2. Focus on docs only: repomix --include "docs/**" +3. Exclude binaries: --exclude "*.png,*.jpg,dist/**" +4. If fails: Use Explorer agents on specific files +5. Note limitation in report +``` + +**When to skip:** +``` +Repository size indicators: +- Git clone shows >1GB download +- Contains large binaries (ml models, datasets) +- Has extensive history (>10k commits) +- Many multimedia files + +→ Skip Repomix, use targeted exploration +``` + +### Documentation in Images/PDFs + +**Challenge:** +- Cannot reliably extract text from images +- PDF parsing limited +- Formatting often lost +- Code snippets may be corrupted + +**Success rate:** ~50% quality for PDFs, ~10% for images + +**Mitigation:** +``` +1. Search for text alternative +2. Try OCR if critical (low quality) +3. Provide image URL instead +4. Note content not extractable +5. Recommend manual review +``` + +**Report template:** +```markdown +⚠️ **Image-Based Documentation** + +Primary documentation in PDF/images: [url] + +**Extraction quality**: Limited +**Recommendation**: Download and review manually + +**Text alternatives found**: +- [any alternatives] +``` + +### Non-English Documentation + +**Challenge:** +- No automatic translation +- May miss context/nuance +- Technical terms may not translate well +- Examples may be language-specific + +**Success rate:** Variable (depends on user needs) + +**Mitigation:** +``` +1. Note language in report +2. Offer key section translation if user requests +3. Search for English version +4. Check if bilingual docs exist +5. Provide original with language note +``` + +**Report template:** +```markdown +ℹ️ **Language Notice** + +Primary documentation in: Japanese + +**English availability**: +- Partial translation: [url] +- Community translation: [url] +- No official English version found + +**Recommendation**: Use translation tool or request community help. +``` + +### Scattered Documentation + +**Challenge:** +- Multiple sites/repositories +- Inconsistent structure +- Conflicting information +- No central source + +**Success rate:** ~60% coverage + +**Mitigation:** +``` +1. Use Researcher agents +2. Prioritize official sources +3. Cross-reference findings +4. Note conflicts clearly +5. Take longer but be thorough +``` + +**Report template:** +```markdown +ℹ️ **Fragmented Documentation** + +Information found across multiple sources: + +**Official** (incomplete): +- Website: [url] +- Package registry: [url] + +**Community** (supplementary): +- Stack Overflow: [url] +- Tutorial: [url] + +**Note**: No centralized documentation. Information aggregated from +multiple sources. Conflicts resolved by prioritizing official sources. +``` + +### Deprecated/Legacy Libraries + +**Challenge:** +- Documentation removed or archived +- Only old versions available +- Outdated information +- No current maintenance + +**Success rate:** ~40% for fully deprecated libraries + +**Mitigation:** +``` +1. Use Internet Archive (Wayback Machine) +2. Search GitHub repository history +3. Check package registry for old README +4. Look for fork with docs +5. Note legacy status clearly +``` + +**Report template:** +```markdown +⚠️ **Legacy Library** + +**Status**: Deprecated as of [date] +**Last update**: [date] + +**Documentation sources**: +- Archived docs (via Wayback): [url] +- Repository (last commit [date]): [url] + +**Recommendation**: Consider modern alternative: [suggestion] + +**Migration path**: [if available] +``` + +## Success Criteria + +### 1. Finds Relevant Information + +**Measured by:** +- [ ] Answers user's specific question +- [ ] Covers requested topics +- [ ] Appropriate depth/detail +- [ ] Includes practical examples +- [ ] Links to additional resources + +**Quality levels:** + +**Excellent (100%):** +``` +- All requested topics covered +- Examples for each major concept +- Clear, comprehensive information +- Official source, current version +- No gaps or limitations +``` + +**Good (80-99%):** +``` +- Most requested topics covered +- Examples for core concepts +- Information mostly complete +- Official source, some gaps noted +- Minor limitations +``` + +**Acceptable (60-79%):** +``` +- Core topics covered +- Some examples present +- Information somewhat complete +- Mix of official/community sources +- Some gaps noted +``` + +**Poor (<60%):** +``` +- Only partial coverage +- Few or no examples +- Significant gaps +- Mostly unofficial sources +- Many limitations +``` + +### 2. Uses Most Efficient Method + +**Measured by:** +- [ ] Started with llms.txt +- [ ] Used parallel agents appropriately +- [ ] Avoided unnecessary operations +- [ ] Completed in reasonable time +- [ ] Fell back efficiently when needed + +**Efficiency score:** + +**Optimal:** +``` +- Found llms.txt immediately +- Parallel agents for all URLs +- Single batch processing +- Completed in <2 minutes +- No wasted operations +``` + +**Good:** +``` +- Found llms.txt after 1-2 tries +- Mostly parallel processing +- Minimal sequential operations +- Completed in <5 minutes +- One minor inefficiency +``` + +**Acceptable:** +``` +- Fell back to repository after llms.txt search +- Mix of parallel and sequential +- Some redundant operations +- Completed in <10 minutes +- A few inefficiencies +``` + +**Poor:** +``` +- Didn't try llms.txt first +- Mostly sequential processing +- Many redundant operations +- Took >10 minutes +- Multiple inefficiencies +``` + +### 3. Completes in Reasonable Time + +**Target times:** + +| Scenario | Excellent | Good | Acceptable | Poor | +|----------|-----------|------|------------|------| +| Simple (1-5 URLs) | <1 min | 1-2 min | 2-5 min | >5 min | +| Medium (6-15 URLs) | <2 min | 2-4 min | 4-7 min | >7 min | +| Complex (16+ URLs) | <3 min | 3-6 min | 6-10 min | >10 min | +| Repository | <3 min | 3-6 min | 6-10 min | >10 min | +| Research | <5 min | 5-8 min | 8-12 min | >12 min | + +**Factors affecting time:** +- Documentation structure (well-organized vs scattered) +- Source availability (llms.txt vs research) +- Content volume (few pages vs many) +- Network conditions (fast vs slow) +- Complexity (simple vs comprehensive) + +### 4. Provides Clear Source Attribution + +**Measured by:** +- [ ] Lists all sources used +- [ ] Notes method employed +- [ ] Includes URLs/references +- [ ] Identifies official vs community +- [ ] Credits authors when relevant + +**Quality template:** + +**Excellent:** +```markdown +## Sources + +**Primary method**: llms.txt +**URL**: https://docs.library.com/llms.txt + +**Documentation retrieved**: +1. Getting Started (official): [url] +2. API Reference (official): [url] +3. Examples (official): [url] + +**Additional sources**: +- Repository: https://github.com/org/library +- Package registry: https://npmjs.com/package/library + +**Method**: Parallel exploration with 3 agents +**Date**: 2025-10-26 14:30 UTC +``` + +### 5. Identifies Version/Date + +**Measured by:** +- [ ] Documentation version noted +- [ ] Last-updated date included +- [ ] Matches user's version requirement +- [ ] Flags if version mismatch +- [ ] Notes if version unclear + +**Best practice:** +```markdown +## Version Information + +**Documentation version**: v3.2.1 +**Last updated**: 2025-10-20 +**Retrieved**: 2025-10-26 + +**User requested**: v3.x ✓ Match + +**Note**: This is the latest stable version as of retrieval date. +``` + +### 6. Notes Limitations/Gaps + +**Measured by:** +- [ ] Missing information identified +- [ ] Incomplete sections noted +- [ ] Known issues mentioned +- [ ] Alternatives suggested +- [ ] Workarounds provided + +**Good practice:** +```markdown +## ⚠️ Limitations + +**Incomplete documentation**: +- Advanced features section (stub page) +- Migration guide (404 error) + +**Not available**: +- Video tutorials mentioned but not accessible +- Interactive examples require login + +**Workarounds**: +- Advanced features: See examples in repository +- Migration: Check CHANGELOG.md for breaking changes + +**Alternatives**: +- Community tutorial: [url] +- Stack Overflow: [url] +``` + +### 7. Well-Organized Output + +**Measured by:** +- [ ] Clear structure +- [ ] Logical flow +- [ ] Easy to scan +- [ ] Actionable information +- [ ] Proper formatting + +**Structure template:** +```markdown +# Documentation for [Library] [Version] + +## Overview +[Brief description] + +## Source +[Attribution] + +## Installation +[Step-by-step] + +## Quick Start +[Minimal example] + +## Core Concepts +[Main ideas] + +## API Reference +[Key methods] + +## Examples +[Practical usage] + +## Additional Resources +[Links] + +## Limitations +[Any gaps] +``` + +## Quality Checklist + +### Before Presenting Report + +**Content quality:** +- [ ] Information is accurate +- [ ] Sources are official (or noted as unofficial) +- [ ] Version matches request +- [ ] Examples are clear +- [ ] No obvious errors + +**Completeness:** +- [ ] All key topics covered +- [ ] Installation instructions present +- [ ] Usage examples included +- [ ] Configuration documented +- [ ] Troubleshooting information available + +**Organization:** +- [ ] Logical flow +- [ ] Clear headings +- [ ] Proper code formatting +- [ ] Links working (spot check) +- [ ] Easy to scan + +**Attribution:** +- [ ] Sources listed +- [ ] Method documented +- [ ] Version identified +- [ ] Date noted +- [ ] Limitations disclosed + +**Usability:** +- [ ] Actionable information +- [ ] Copy-paste ready examples +- [ ] Next steps clear +- [ ] Resources for deep dive +- [ ] Known issues noted + +## Performance Metrics + +### Time-to-Value + +**Measures:** How quickly user gets useful information + +**Targets:** +``` +First useful info: <30 seconds +Core coverage: <2 minutes +Complete report: <5 minutes +``` + +**Tracking:** +``` +Start → llms.txt found → First URL fetched → Critical info extracted + 15s 30s 45s 60s + +User can act on info at 60s mark +(even if full report takes 5 minutes) +``` + +### Coverage Completeness + +**Measures:** Percentage of user needs met + +**Calculation:** +``` +User needs 5 topics: +- Installation ✓ +- Basic usage ✓ +- API reference ✓ +- Configuration ✓ +- Examples ✗ (not found) + +Coverage: 4/5 = 80% +``` + +**Targets:** +``` +Excellent: 90-100% +Good: 75-89% +Acceptable: 60-74% +Poor: <60% +``` + +### Source Quality + +**Measures:** Reliability of sources used + +**Scoring:** +``` +Official docs: 100 points +Official repository: 80 points +Package registry: 60 points +Recent community (verified): 40 points +Old community (unverified): 20 points +``` + +**Target:** Average >70 points + +### User Satisfaction Indicators + +**Positive signals:** +``` +- User proceeds immediately with info +- No follow-up questions needed +- User says "perfect" or "thanks" +- User marks conversation complete +``` + +**Negative signals:** +``` +- User asks same question differently +- User requests more details +- User says "incomplete" or "not what I needed" +- User abandons task +``` + +## Continuous Improvement + +### Learn from Failures + +**When documentation discovery struggles:** + +1. **Document the issue** + ``` + - What was attempted + - What failed + - Why it failed + - How it was resolved (if at all) + ``` + +2. **Identify patterns** + ``` + - Is this library-specific? + - Is this ecosystem-specific? + - Is this a general limitation? + ``` + +3. **Update strategies** + ``` + - Add workaround to playbook + - Update fallback sequence + - Note limitation in documentation + ``` + +### Measure and Optimize + +**Track these metrics:** +``` +- Average time to complete +- Coverage percentage +- Source quality score +- User satisfaction +- Failure rate by method +``` + +**Optimize based on data:** +``` +- Which method succeeds most often? +- Which ecosystems need special handling? +- Where are time bottlenecks? +- What causes most failures? +``` diff --git a/skills/docs-seeker/references/performance.md b/skills/docs-seeker/references/performance.md new file mode 100644 index 0000000..e632aaf --- /dev/null +++ b/skills/docs-seeker/references/performance.md @@ -0,0 +1,574 @@ +# Performance Optimization + +Strategies and techniques for maximizing speed and efficiency in documentation discovery. + +## Core Principles + +### 0. Use context7.com for Instant llms.txt Access + +**Fastest Approach:** + +Direct URL construction instead of searching: +``` +Traditional: WebSearch (15-30s) → WebFetch (5-10s) = 20-40s +context7.com: Direct WebFetch (5-10s) = 5-10s + +Speed improvement: 2-4x faster +``` + +**Benefits:** +- No search required (instant URL construction) +- Consistent URL patterns +- Reliable availability +- Topic filtering for targeted results + +**Examples:** +``` +GitHub repo: +https://context7.com/vercel/next.js/llms.txt +→ Instant, no search needed + +Website: +https://context7.com/websites/imgix/llms.txt +→ Instant, no search needed + +Topic-specific: +https://context7.com/shadcn-ui/ui/llms.txt?topic=date +→ Filtered results, even faster +``` + +**Performance Impact:** +``` +Without context7.com: +1. WebSearch for llms.txt: 15s +2. WebFetch llms.txt: 5s +3. Launch agents: 5s +Total: 25s + +With context7.com: +1. Direct WebFetch: 5s +2. Launch agents: 5s +Total: 10s (2.5x faster!) + +With context7.com + topic: +1. Direct WebFetch (filtered): 3s +2. Process focused results: 2s +Total: 5s (5x faster!) +``` + +### 1. Minimize Sequential Operations + +**The Problem:** + +Sequential operations add up linearly: +``` +Total Time = Op1 + Op2 + Op3 + ... + OpN +``` + +Example: +``` +Fetch URL 1: 5 seconds +Fetch URL 2: 5 seconds +Fetch URL 3: 5 seconds +Total: 15 seconds +``` + +**The Solution:** + +Parallel operations complete in max time of slowest: +``` +Total Time = max(Op1, Op2, Op3, ..., OpN) +``` + +Example: +``` +Launch 3 agents simultaneously +All complete in: ~5 seconds +Total: 5 seconds (3x faster!) +``` + +### 2. Batch Related Operations + +**Benefits:** +- Fewer context switches +- Better resource utilization +- Easier to track +- More efficient aggregation + +**Grouping Strategies:** + +**By topic:** +``` +Agent 1: All authentication-related docs +Agent 2: All database-related docs +Agent 3: All API-related docs +``` + +**By content type:** +``` +Agent 1: All tutorials +Agent 2: All reference docs +Agent 3: All examples +``` + +**By priority:** +``` +Phase 1 (critical): Getting started, installation, core concepts +Phase 2 (important): Guides, API reference, configuration +Phase 3 (optional): Advanced topics, internals, optimization +``` + +### 3. Smart Caching + +**What to cache:** +- Repomix output (expensive to generate) +- llms.txt content (static) +- Repository structure (rarely changes) +- Documentation URLs (reference list) + +**When to refresh:** +- User requests specific version +- Documentation updated (check last-modified) +- Cache older than session +- User explicitly requests fresh data + +### 4. Early Termination + +**When to stop:** +``` +✓ User's core needs met +✓ Critical information found +✓ Time limit approaching +✓ Diminishing returns (90% coverage achieved) +``` + +**How to decide:** +``` +After Phase 1 (critical docs): +- Review what was found +- Check against user request +- If 80%+ covered → deliver now +- Offer to fetch more if needed +``` + +## Performance Patterns + +### Pattern 1: Parallel Exploration + +**Scenario:** llms.txt contains 10 URLs + +**Slow approach (sequential):** +``` +Time: 10 URLs × 5 seconds = 50 seconds + +Step 1: Fetch URL 1 (5s) +Step 2: Fetch URL 2 (5s) +Step 3: Fetch URL 3 (5s) +... +Step 10: Fetch URL 10 (5s) +``` + +**Fast approach (parallel):** +``` +Time: ~5-10 seconds total + +Step 1: Launch 5 Explorer agents (simultaneous) + Agent 1: URLs 1-2 + Agent 2: URLs 3-4 + Agent 3: URLs 5-6 + Agent 4: URLs 7-8 + Agent 5: URLs 9-10 + +Step 2: Wait for all (max time: ~5-10s) +Step 3: Aggregate results +``` + +**Speedup:** 5-10x faster + +### Pattern 2: Lazy Loading + +**Scenario:** Documentation has 30+ pages + +**Slow approach (fetch everything):** +``` +Time: 30 URLs × 5 seconds ÷ 5 agents = 30 seconds + +Fetch all 30 pages upfront +User only needs 5 of them +Wasted: 25 pages × 5 seconds ÷ 5 = 25 seconds +``` + +**Fast approach (priority loading):** +``` +Time: 10 URLs × 5 seconds ÷ 5 agents = 10 seconds + +Phase 1: Fetch critical 10 pages +Review: Does this cover user's needs? +If yes: Stop here (saved 20 seconds) +If no: Fetch additional as needed +``` + +**Speedup:** Up to 3x faster for typical use cases + +### Pattern 3: Smart Fallbacks + +**Scenario:** llms.txt not found + +**Slow approach (exhaustive search):** +``` +Time: ~5 minutes + +Try: docs.library.com/llms.txt (30s timeout) +Try: library.dev/llms.txt (30s timeout) +Try: library.io/llms.txt (30s timeout) +Try: library.org/llms.txt (30s timeout) +Try: www.library.com/llms.txt (30s timeout) +Then: Fall back to repository +``` + +**Fast approach (quick fallback):** +``` +Time: ~1 minute + +Try: docs.library.com/llms.txt (15s) +Try: library.dev/llms.txt (15s) +Not found → Immediately try repository (30s) +``` + +**Speedup:** 5x faster + +### Pattern 4: Incremental Results + +**Scenario:** Large documentation set + +**Slow approach (all-or-nothing):** +``` +Time: 5 minutes until first result + +Fetch all documentation +Aggregate everything +Present complete report +User waits 5 minutes +``` + +**Fast approach (streaming):** +``` +Time: 30 seconds to first result + +Phase 1: Fetch critical docs (30s) +Present: Initial findings +Phase 2: Fetch important docs (60s) +Update: Additional findings +Phase 3: Fetch supplementary (90s) +Final: Complete report +``` + +**Benefit:** User gets value immediately, can stop early if satisfied + +## Optimization Techniques + +### Technique 1: Workload Balancing + +**Problem:** Uneven distribution causes bottlenecks + +``` +Bad distribution: +Agent 1: 1 URL (small) → finishes in 5s +Agent 2: 10 URLs (large) → finishes in 50s +Total: 50s (bottlenecked by Agent 2) +``` + +**Solution:** Balance by estimated size + +``` +Good distribution: +Agent 1: 3 URLs (medium pages) → ~15s +Agent 2: 3 URLs (medium pages) → ~15s +Agent 3: 3 URLs (medium pages) → ~15s +Agent 4: 1 URL (large page) → ~15s +Total: ~15s (balanced) +``` + +### Technique 2: Request Coalescing + +**Problem:** Redundant requests slow things down + +``` +Bad: +Agent 1: Fetch README.md +Agent 2: Fetch README.md (duplicate!) +Agent 3: Fetch README.md (duplicate!) +Wasted: 2 redundant fetches +``` + +**Solution:** Deduplicate before fetching + +``` +Good: +Pre-processing: Identify unique URLs +Agent 1: Fetch README.md (once) +Agent 2: Fetch INSTALL.md +Agent 3: Fetch API.md +Share: README.md content across agents if needed +``` + +### Technique 3: Timeout Tuning + +**Problem:** Default timeouts too conservative + +``` +Slow: +WebFetch timeout: 120s (too long for fast sites) +If site is down: Wait 120s before failing +``` + +**Solution:** Adaptive timeouts + +``` +Fast: +Known fast sites (official docs): 30s timeout +Unknown sites: 60s timeout +Large repos: 120s timeout +If timeout hit: Immediately try alternative +``` + +### Technique 4: Selective Fetching + +**Problem:** Fetching irrelevant content + +``` +Wasteful: +Fetch: Installation guide ✓ (needed) +Fetch: API reference ✓ (needed) +Fetch: Internal architecture ✗ (not needed for basic usage) +Fetch: Contributing guide ✗ (not needed) +Fetch: Changelog ✗ (not needed) +``` + +**Solution:** Filter by user needs + +``` +Efficient: +User need: "How to get started" +Fetch only: Installation, basic usage, examples +Skip: Advanced topics, internals, contribution +Speedup: 50% less fetching +``` + +## Performance Benchmarks + +### Target Times + +| Scenario | Target Time | Acceptable | Too Slow | +|----------|-------------|------------|----------| +| Single URL | <10s | 10-20s | >20s | +| llms.txt (5 URLs) | <30s | 30-60s | >60s | +| llms.txt (15 URLs) | <60s | 60-120s | >120s | +| Repository analysis | <2min | 2-5min | >5min | +| Research fallback | <3min | 3-7min | >7min | + +### Real-World Examples + +**Fast case (Next.js with llms.txt):** +``` +00:00 - Start +00:05 - Found llms.txt +00:10 - Fetched content (12 URLs) +00:15 - Launched 4 agents +00:45 - All agents complete +00:55 - Report ready +Total: 55 seconds ✓ +``` + +**Medium case (Repository without llms.txt):** +``` +00:00 - Start +00:15 - llms.txt not found +00:20 - Found repository +00:30 - Cloned repository +02:00 - Repomix complete +02:30 - Analyzed output +02:45 - Report ready +Total: 2m 45s ✓ +``` + +**Slow case (Scattered documentation):** +``` +00:00 - Start +00:30 - llms.txt not found +00:45 - Repository not found +01:00 - Launched 4 Researcher agents +05:00 - All research complete +06:00 - Aggregated findings +06:30 - Report ready +Total: 6m 30s (acceptable for research) +``` + +## Common Performance Issues + +### Issue 1: Too Many Agents + +**Symptom:** Slower than sequential + +``` +Problem: +Launched 15 agents for 15 URLs +Overhead: Agent initialization, coordination +Result: Slower than 5 agents with 3 URLs each +``` + +**Solution:** +``` +Max 7 agents per batch +Group URLs sensibly +Use phases for large sets +``` + +### Issue 2: Blocking Operations + +**Symptom:** Agents waiting unnecessarily + +``` +Problem: +Agent 1: Fetch URL, wait for Agent 2 +Agent 2: Fetch URL, wait for Agent 3 +Agent 3: Fetch URL +Result: Sequential instead of parallel +``` + +**Solution:** +``` +Launch all agents independently +No dependencies between agents +Aggregate after all complete +``` + +### Issue 3: Redundant Fetching + +**Symptom:** Same content fetched multiple times + +``` +Problem: +Phase 1: Fetch installation guide +Phase 2: Fetch installation guide again +Result: Wasted time +``` + +**Solution:** +``` +Cache fetched content +Check cache before fetching +Reuse within session +``` + +### Issue 4: Late Bailout + +**Symptom:** Continuing when should stop + +``` +Problem: +Found 90% of needed info after 1 minute +Spent 4 more minutes on remaining 10% +Result: 5x time for marginal gain +``` + +**Solution:** +``` +Check progress after critical phase +If 80%+ covered → offer to stop +Only continue if user wants comprehensive +``` + +## Performance Monitoring + +### Key Metrics + +**Track these times:** +``` +- llms.txt discovery: Target <30s +- Repository clone: Target <60s +- Repomix processing: Target <2min +- Agent exploration: Target <60s +- Total time: Target <3min for typical case +``` + +### Performance Report Template + +```markdown +## Performance Summary + +**Total time**: 1m 25s +**Method**: llms.txt + parallel exploration + +**Breakdown**: +- Discovery: 15s (llms.txt search & fetch) +- Exploration: 50s (4 agents, 12 URLs) +- Aggregation: 20s (synthesis & formatting) + +**Efficiency**: 8.5x faster than sequential +(12 URLs × 5s = 60s sequential, actual: 50s parallel) +``` + +### When to Optimize Further + +Optimize if: +- [ ] Total time >2x target +- [ ] User explicitly requests "fast" +- [ ] Repeated similar queries (cache benefit) +- [ ] Large documentation set (>20 URLs) + +Don't over-optimize if: +- [ ] Already meeting targets +- [ ] One-time query +- [ ] User values completeness over speed +- [ ] Research requires thoroughness + +## Quick Optimization Checklist + +### Before Starting + +- [ ] Check if content already cached +- [ ] Identify fastest method for this case +- [ ] Plan for parallel execution +- [ ] Set appropriate timeouts + +### During Execution + +- [ ] Launch agents in parallel (not sequential) +- [ ] Use single message for multiple agents +- [ ] Monitor for bottlenecks +- [ ] Be ready to terminate early + +### After First Phase + +- [ ] Assess coverage achieved +- [ ] Determine if user needs met +- [ ] Decide: continue or deliver now +- [ ] Cache results for potential reuse + +### Optimization Decision Tree + +``` +Need documentation? + ↓ +Check cache + ↓ +HIT → Use cached (0s) ✓ +MISS → Continue + ↓ +llms.txt available? + ↓ +YES → Parallel agents (30-60s) ✓ +NO → Continue + ↓ +Repository available? + ↓ +YES → Repomix (2-5min) +NO → Research (3-7min) + ↓ +After Phase 1: +80%+ coverage? + ↓ +YES → Deliver now (save time) ✓ +NO → Continue to Phase 2 +``` diff --git a/skills/docs-seeker/references/tool-selection.md b/skills/docs-seeker/references/tool-selection.md new file mode 100644 index 0000000..a76a5f9 --- /dev/null +++ b/skills/docs-seeker/references/tool-selection.md @@ -0,0 +1,262 @@ +# Tool Selection Guide + +Complete reference for choosing and using the right tools for documentation discovery. + +## context7.com (PRIORITY) + +**Use FIRST for all llms.txt lookups** + +**Patterns:** + +GitHub repositories: +``` +Pattern: https://context7.com/{org}/{repo}/llms.txt +Examples: +- https://github.com/vercel/next.js → https://context7.com/vercel/next.js/llms.txt +- https://github.com/shadcn-ui/ui → https://context7.com/shadcn-ui/ui/llms.txt +``` + +Websites: +``` +Pattern: https://context7.com/websites/{normalized-path}/llms.txt +Examples: +- https://docs.imgix.com/ → https://context7.com/websites/imgix/llms.txt +- https://ffmpeg.org/doxygen/8.0/ → https://context7.com/websites/ffmpeg_doxygen_8_0/llms.txt +``` + +Topic-specific searches: +``` +Pattern: https://context7.com/{path}/llms.txt?topic={query} +Examples: +- https://context7.com/shadcn-ui/ui/llms.txt?topic=date +- https://context7.com/vercel/next.js/llms.txt?topic=cache +- https://context7.com/websites/ffmpeg_doxygen_8_0/llms.txt?topic=compress +``` + +**Benefits:** +- Comprehensive aggregator of documentation +- Up-to-date content +- Topic filtering for targeted results +- Consistent format across libraries +- Reduces search time + +**When to use:** +- ALWAYS try context7.com first before WebSearch +- Use topic parameter when user asks about specific feature +- Fall back to WebSearch only if context7.com returns 404 + +## WebSearch + +**Use when:** +- context7.com unavailable or returns 404 +- Finding GitHub repository URLs +- Locating official documentation sites +- Identifying package registries +- Searching for specific versions + +**Best practices:** +- Try context7.com FIRST +- Include domain in query: `site:docs.example.com` +- Specify version when needed: `v2.0 llms.txt` +- Use official terms: "official repository" "documentation" +- Check multiple domains if first fails + +**Example queries:** +``` +Good: "Next.js llms.txt site:nextjs.org" +Good: "React v18 documentation site:react.dev" +Good: "Vue 3 official github repository" + +Avoid: "how to use react" (too vague) +Avoid: "best react tutorial" (not official) +``` + +## WebFetch + +**Use when:** +- Reading llms.txt content +- Accessing single documentation pages +- Retrieving specific URLs +- Checking documentation structure +- Verifying content availability + +**Best practices:** +- Use specific prompt: "Extract all documentation URLs" +- Handle redirects properly +- Check for rate limiting +- Verify content is complete +- Note last-modified dates when available + +**Limitations:** +- Single URL at a time (use Explorer for multiple) +- May timeout on very large pages +- Cannot handle dynamic content +- No JavaScript execution + +## Task Tool with Explore Subagent + +**Use when:** +- Multiple URLs to read (3+) +- Need parallel exploration +- Comprehensive documentation coverage +- Time-sensitive requests +- Large documentation sets + +**Best practices:** +- Launch all agents in single message +- Distribute workload evenly +- Group related URLs per agent +- Maximum 7 agents per batch +- Provide clear extraction goals + +**Example prompt:** +``` +"Read the following URLs and extract: +1. Installation instructions +2. Core API methods +3. Configuration options +4. Common usage examples + +URLs: +- [url1] +- [url2] +- [url3]" +``` + +## Task Tool with Researcher Subagent + +**Use when:** +- No structured documentation found +- Need diverse information sources +- Community knowledge required +- Scattered documentation +- Comparative analysis needed + +**Best practices:** +- Assign specific research areas per agent +- Request source verification +- Ask for date/version information +- Prioritize official sources +- Cross-reference findings + +**Example prompt:** +``` +"Research [library] focusing on: +1. Official installation methods +2. Common usage patterns +3. Known limitations or issues +4. Community best practices + +Prioritize official sources and note version/date for all findings." +``` + +## Repomix + +**Use when:** +- GitHub repository available +- Need complete codebase analysis +- Documentation scattered in repository +- Want to analyze code structure +- API documentation in code comments + +**Installation:** +```bash +# Check if installed +which repomix + +# Install globally if needed +npm install -g repomix + +# Verify installation +repomix --version +``` + +**Usage:** +```bash +# Basic usage +git clone [repo-url] /tmp/docs-analysis +cd /tmp/docs-analysis +repomix --output repomix-output.xml + +# Focus on specific directory +repomix --include "docs/**" --output docs-only.xml + +# Exclude large files +repomix --exclude "*.png,*.jpg,*.pdf" --output repomix-output.xml +``` + +**When Repomix may fail:** +- Repository > 1GB (too large) +- Requires authentication (private repo) +- Slow network connection +- Limited disk space +- Binary-heavy repository + +**Alternatives if Repomix fails:** +```bash +# Option 1: Focus on docs directory only +repomix --include "docs/**,README.md" --output docs.xml + +# Option 2: Use Explorer agents to read specific files +# Launch agents to read key documentation files directly + +# Option 3: Manual repository exploration +# Read README, then explore /docs directory structure +``` + +## Tool Selection Decision Tree + +``` +Need documentation? + ↓ +Try context7.com first + ↓ +Know GitHub org/repo? + YES → https://context7.com/{org}/{repo}/llms.txt + NO → Continue + ↓ +Know website URL? + YES → https://context7.com/websites/{normalized-path}/llms.txt + NO → Continue + ↓ +Specific topic/feature? + YES → Add ?topic={query} parameter + NO → Use base llms.txt URL + ↓ +context7.com found? + YES → Process llms.txt URLs (go to URL count check) + NO → Continue + ↓ +Fallback: WebSearch for llms.txt + ↓ +Single URL? + YES → WebFetch + NO → Continue + ↓ +1-3 URLs? + YES → Single Explorer agent + NO → Continue + ↓ +4+ URLs? + YES → Multiple Explorer agents (3-7) + NO → Continue + ↓ +Need repository analysis? + YES → Repomix (if available) + NO → Continue + ↓ +No structured docs? + YES → Researcher agents +``` + +## Quick Reference + +| Tool | Best For | Speed | Coverage | Complexity | +|------|----------|-------|----------|------------| +| context7.com | llms.txt lookup | Instant | High | Low | +| context7.com?topic= | Targeted search | Instant | Focused | Low | +| WebSearch | Finding URLs | Fast | Narrow | Low | +| WebFetch | Single page | Fast | Single | Low | +| Explorer | Multiple URLs | Fast | Medium | Medium | +| Researcher | Scattered info | Slow | Wide | High | +| Repomix | Repository | Medium | Complete | Medium | diff --git a/skills/document-skills/docx/LICENSE.txt b/skills/document-skills/docx/LICENSE.txt new file mode 100644 index 0000000..c55ab42 --- /dev/null +++ b/skills/document-skills/docx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/skills/document-skills/docx/SKILL.md b/skills/document-skills/docx/SKILL.md new file mode 100644 index 0000000..6646638 --- /dev/null +++ b/skills/document-skills/docx/SKILL.md @@ -0,0 +1,197 @@ +--- +name: docx +description: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" +license: Proprietary. LICENSE.txt has complete terms +--- + +# DOCX creation, editing, and analysis + +## Overview + +A user may ask you to create, edit, or analyze the contents of a .docx file. A .docx file is essentially a ZIP archive containing XML files and other resources that you can read or edit. You have different tools and workflows available for different tasks. + +## Workflow Decision Tree + +### Reading/Analyzing Content +Use "Text extraction" or "Raw XML access" sections below + +### Creating New Document +Use "Creating a new Word document" workflow + +### Editing Existing Document +- **Your own document + simple changes** + Use "Basic OOXML editing" workflow + +- **Someone else's document** + Use **"Redlining workflow"** (recommended default) + +- **Legal, academic, business, or government docs** + Use **"Redlining workflow"** (required) + +## Reading and analyzing content + +### Text extraction +If you just need to read the text contents of a document, you should convert the document to markdown using pandoc. Pandoc provides excellent support for preserving document structure and can show tracked changes: + +```bash +# Convert document to markdown with tracked changes +pandoc --track-changes=all path-to-file.docx -o output.md +# Options: --track-changes=accept/reject/all +``` + +### Raw XML access +You need raw XML access for: comments, complex formatting, document structure, embedded media, and metadata. For any of these features, you'll need to unpack a document and read its raw XML contents. + +#### Unpacking a file +`python ooxml/scripts/unpack.py ` + +#### Key file structures +* `word/document.xml` - Main document contents +* `word/comments.xml` - Comments referenced in document.xml +* `word/media/` - Embedded images and media files +* Tracked changes use `` (insertions) and `` (deletions) tags + +## Creating a new Word document + +When creating a new Word document from scratch, use **docx-js**, which allows you to create Word documents using JavaScript/TypeScript. + +### Workflow +1. **MANDATORY - READ ENTIRE FILE**: Read [`docx-js.md`](docx-js.md) (~500 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Read the full file content for detailed syntax, critical formatting rules, and best practices before proceeding with document creation. +2. Create a JavaScript/TypeScript file using Document, Paragraph, TextRun components (You can assume all dependencies are installed, but if not, refer to the dependencies section below) +3. Export as .docx using Packer.toBuffer() + +## Editing an existing Word document + +When editing an existing Word document, use the **Document library** (a Python library for OOXML manipulation). The library automatically handles infrastructure setup and provides methods for document manipulation. For complex scenarios, you can access the underlying DOM directly through the library. + +### Workflow +1. **MANDATORY - READ ENTIRE FILE**: Read [`ooxml.md`](ooxml.md) (~600 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Read the full file content for the Document library API and XML patterns for directly editing document files. +2. Unpack the document: `python ooxml/scripts/unpack.py ` +3. Create and run a Python script using the Document library (see "Document Library" section in ooxml.md) +4. Pack the final document: `python ooxml/scripts/pack.py ` + +The Document library provides both high-level methods for common operations and direct DOM access for complex scenarios. + +## Redlining workflow for document review + +This workflow allows you to plan comprehensive tracked changes using markdown before implementing them in OOXML. **CRITICAL**: For complete tracked changes, you must implement ALL changes systematically. + +**Batching Strategy**: Group related changes into batches of 3-10 changes. This makes debugging manageable while maintaining efficiency. Test each batch before moving to the next. + +**Principle: Minimal, Precise Edits** +When implementing tracked changes, only mark text that actually changes. Repeating unchanged text makes edits harder to review and appears unprofessional. Break replacements into: [unchanged text] + [deletion] + [insertion] + [unchanged text]. Preserve the original run's RSID for unchanged text by extracting the `` element from the original and reusing it. + +Example - Changing "30 days" to "60 days" in a sentence: +```python +# BAD - Replaces entire sentence +'The term is 30 days.The term is 60 days.' + +# GOOD - Only marks what changed, preserves original for unchanged text +'The term is 3060 days.' +``` + +### Tracked changes workflow + +1. **Get markdown representation**: Convert document to markdown with tracked changes preserved: + ```bash + pandoc --track-changes=all path-to-file.docx -o current.md + ``` + +2. **Identify and group changes**: Review the document and identify ALL changes needed, organizing them into logical batches: + + **Location methods** (for finding changes in XML): + - Section/heading numbers (e.g., "Section 3.2", "Article IV") + - Paragraph identifiers if numbered + - Grep patterns with unique surrounding text + - Document structure (e.g., "first paragraph", "signature block") + - **DO NOT use markdown line numbers** - they don't map to XML structure + + **Batch organization** (group 3-10 related changes per batch): + - By section: "Batch 1: Section 2 amendments", "Batch 2: Section 5 updates" + - By type: "Batch 1: Date corrections", "Batch 2: Party name changes" + - By complexity: Start with simple text replacements, then tackle complex structural changes + - Sequential: "Batch 1: Pages 1-3", "Batch 2: Pages 4-6" + +3. **Read documentation and unpack**: + - **MANDATORY - READ ENTIRE FILE**: Read [`ooxml.md`](ooxml.md) (~600 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Pay special attention to the "Document Library" and "Tracked Change Patterns" sections. + - **Unpack the document**: `python ooxml/scripts/unpack.py ` + - **Note the suggested RSID**: The unpack script will suggest an RSID to use for your tracked changes. Copy this RSID for use in step 4b. + +4. **Implement changes in batches**: Group changes logically (by section, by type, or by proximity) and implement them together in a single script. This approach: + - Makes debugging easier (smaller batch = easier to isolate errors) + - Allows incremental progress + - Maintains efficiency (batch size of 3-10 changes works well) + + **Suggested batch groupings:** + - By document section (e.g., "Section 3 changes", "Definitions", "Termination clause") + - By change type (e.g., "Date changes", "Party name updates", "Legal term replacements") + - By proximity (e.g., "Changes on pages 1-3", "Changes in first half of document") + + For each batch of related changes: + + **a. Map text to XML**: Grep for text in `word/document.xml` to verify how text is split across `` elements. + + **b. Create and run script**: Use `get_node` to find nodes, implement changes, then `doc.save()`. See **"Document Library"** section in ooxml.md for patterns. + + **Note**: Always grep `word/document.xml` immediately before writing a script to get current line numbers and verify text content. Line numbers change after each script run. + +5. **Pack the document**: After all batches are complete, convert the unpacked directory back to .docx: + ```bash + python ooxml/scripts/pack.py unpacked reviewed-document.docx + ``` + +6. **Final verification**: Do a comprehensive check of the complete document: + - Convert final document to markdown: + ```bash + pandoc --track-changes=all reviewed-document.docx -o verification.md + ``` + - Verify ALL changes were applied correctly: + ```bash + grep "original phrase" verification.md # Should NOT find it + grep "replacement phrase" verification.md # Should find it + ``` + - Check that no unintended changes were introduced + + +## Converting Documents to Images + +To visually analyze Word documents, convert them to images using a two-step process: + +1. **Convert DOCX to PDF**: + ```bash + soffice --headless --convert-to pdf document.docx + ``` + +2. **Convert PDF pages to JPEG images**: + ```bash + pdftoppm -jpeg -r 150 document.pdf page + ``` + This creates files like `page-1.jpg`, `page-2.jpg`, etc. + +Options: +- `-r 150`: Sets resolution to 150 DPI (adjust for quality/size balance) +- `-jpeg`: Output JPEG format (use `-png` for PNG if preferred) +- `-f N`: First page to convert (e.g., `-f 2` starts from page 2) +- `-l N`: Last page to convert (e.g., `-l 5` stops at page 5) +- `page`: Prefix for output files + +Example for specific range: +```bash +pdftoppm -jpeg -r 150 -f 2 -l 5 document.pdf page # Converts only pages 2-5 +``` + +## Code Style Guidelines +**IMPORTANT**: When generating code for DOCX operations: +- Write concise code +- Avoid verbose variable names and redundant operations +- Avoid unnecessary print statements + +## Dependencies + +Required dependencies (install if not available): + +- **pandoc**: `sudo apt-get install pandoc` (for text extraction) +- **docx**: `npm install -g docx` (for creating new documents) +- **LibreOffice**: `sudo apt-get install libreoffice` (for PDF conversion) +- **Poppler**: `sudo apt-get install poppler-utils` (for pdftoppm to convert PDF to images) +- **defusedxml**: `pip install defusedxml` (for secure XML parsing) \ No newline at end of file diff --git a/skills/document-skills/docx/docx-js.md b/skills/document-skills/docx/docx-js.md new file mode 100644 index 0000000..c6d7b2d --- /dev/null +++ b/skills/document-skills/docx/docx-js.md @@ -0,0 +1,350 @@ +# DOCX Library Tutorial + +Generate .docx files with JavaScript/TypeScript. + +**Important: Read this entire document before starting.** Critical formatting rules and common pitfalls are covered throughout - skipping sections may result in corrupted files or rendering issues. + +## Setup +Assumes docx is already installed globally +If not installed: `npm install -g docx` + +```javascript +const { Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell, ImageRun, Media, + Header, Footer, AlignmentType, PageOrientation, LevelFormat, ExternalHyperlink, + InternalHyperlink, TableOfContents, HeadingLevel, BorderStyle, WidthType, TabStopType, + TabStopPosition, UnderlineType, ShadingType, VerticalAlign, SymbolRun, PageNumber, + FootnoteReferenceRun, Footnote, PageBreak } = require('docx'); + +// Create & Save +const doc = new Document({ sections: [{ children: [/* content */] }] }); +Packer.toBuffer(doc).then(buffer => fs.writeFileSync("doc.docx", buffer)); // Node.js +Packer.toBlob(doc).then(blob => { /* download logic */ }); // Browser +``` + +## Text & Formatting +```javascript +// IMPORTANT: Never use \n for line breaks - always use separate Paragraph elements +// ❌ WRONG: new TextRun("Line 1\nLine 2") +// ✅ CORRECT: new Paragraph({ children: [new TextRun("Line 1")] }), new Paragraph({ children: [new TextRun("Line 2")] }) + +// Basic text with all formatting options +new Paragraph({ + alignment: AlignmentType.CENTER, + spacing: { before: 200, after: 200 }, + indent: { left: 720, right: 720 }, + children: [ + new TextRun({ text: "Bold", bold: true }), + new TextRun({ text: "Italic", italics: true }), + new TextRun({ text: "Underlined", underline: { type: UnderlineType.DOUBLE, color: "FF0000" } }), + new TextRun({ text: "Colored", color: "FF0000", size: 28, font: "Arial" }), // Arial default + new TextRun({ text: "Highlighted", highlight: "yellow" }), + new TextRun({ text: "Strikethrough", strike: true }), + new TextRun({ text: "x2", superScript: true }), + new TextRun({ text: "H2O", subScript: true }), + new TextRun({ text: "SMALL CAPS", smallCaps: true }), + new SymbolRun({ char: "2022", font: "Symbol" }), // Bullet • + new SymbolRun({ char: "00A9", font: "Arial" }) // Copyright © - Arial for symbols + ] +}) +``` + +## Styles & Professional Formatting + +```javascript +const doc = new Document({ + styles: { + default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default + paragraphStyles: [ + // Document title style - override built-in Title style + { id: "Title", name: "Title", basedOn: "Normal", + run: { size: 56, bold: true, color: "000000", font: "Arial" }, + paragraph: { spacing: { before: 240, after: 120 }, alignment: AlignmentType.CENTER } }, + // IMPORTANT: Override built-in heading styles by using their exact IDs + { id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 32, bold: true, color: "000000", font: "Arial" }, // 16pt + paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 } }, // Required for TOC + { id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true, + run: { size: 28, bold: true, color: "000000", font: "Arial" }, // 14pt + paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 } }, + // Custom styles use your own IDs + { id: "myStyle", name: "My Style", basedOn: "Normal", + run: { size: 28, bold: true, color: "000000" }, + paragraph: { spacing: { after: 120 }, alignment: AlignmentType.CENTER } } + ], + characterStyles: [{ id: "myCharStyle", name: "My Char Style", + run: { color: "FF0000", bold: true, underline: { type: UnderlineType.SINGLE } } }] + }, + sections: [{ + properties: { page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } } }, + children: [ + new Paragraph({ heading: HeadingLevel.TITLE, children: [new TextRun("Document Title")] }), // Uses overridden Title style + new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Heading 1")] }), // Uses overridden Heading1 style + new Paragraph({ style: "myStyle", children: [new TextRun("Custom paragraph style")] }), + new Paragraph({ children: [ + new TextRun("Normal with "), + new TextRun({ text: "custom char style", style: "myCharStyle" }) + ]}) + ] + }] +}); +``` + +**Professional Font Combinations:** +- **Arial (Headers) + Arial (Body)** - Most universally supported, clean and professional +- **Times New Roman (Headers) + Arial (Body)** - Classic serif headers with modern sans-serif body +- **Georgia (Headers) + Verdana (Body)** - Optimized for screen reading, elegant contrast + +**Key Styling Principles:** +- **Override built-in styles**: Use exact IDs like "Heading1", "Heading2", "Heading3" to override Word's built-in heading styles +- **HeadingLevel constants**: `HeadingLevel.HEADING_1` uses "Heading1" style, `HeadingLevel.HEADING_2` uses "Heading2" style, etc. +- **Include outlineLevel**: Set `outlineLevel: 0` for H1, `outlineLevel: 1` for H2, etc. to ensure TOC works correctly +- **Use custom styles** instead of inline formatting for consistency +- **Set a default font** using `styles.default.document.run.font` - Arial is universally supported +- **Establish visual hierarchy** with different font sizes (titles > headers > body) +- **Add proper spacing** with `before` and `after` paragraph spacing +- **Use colors sparingly**: Default to black (000000) and shades of gray for titles and headings (heading 1, heading 2, etc.) +- **Set consistent margins** (1440 = 1 inch is standard) + + +## Lists (ALWAYS USE PROPER LISTS - NEVER USE UNICODE BULLETS) +```javascript +// Bullets - ALWAYS use the numbering config, NOT unicode symbols +// CRITICAL: Use LevelFormat.BULLET constant, NOT the string "bullet" +const doc = new Document({ + numbering: { + config: [ + { reference: "bullet-list", + levels: [{ level: 0, format: LevelFormat.BULLET, text: "•", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + { reference: "first-numbered-list", + levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] }, + { reference: "second-numbered-list", // Different reference = restarts at 1 + levels: [{ level: 0, format: LevelFormat.DECIMAL, text: "%1.", alignment: AlignmentType.LEFT, + style: { paragraph: { indent: { left: 720, hanging: 360 } } } }] } + ] + }, + sections: [{ + children: [ + // Bullet list items + new Paragraph({ numbering: { reference: "bullet-list", level: 0 }, + children: [new TextRun("First bullet point")] }), + new Paragraph({ numbering: { reference: "bullet-list", level: 0 }, + children: [new TextRun("Second bullet point")] }), + // Numbered list items + new Paragraph({ numbering: { reference: "first-numbered-list", level: 0 }, + children: [new TextRun("First numbered item")] }), + new Paragraph({ numbering: { reference: "first-numbered-list", level: 0 }, + children: [new TextRun("Second numbered item")] }), + // ⚠️ CRITICAL: Different reference = INDEPENDENT list that restarts at 1 + // Same reference = CONTINUES previous numbering + new Paragraph({ numbering: { reference: "second-numbered-list", level: 0 }, + children: [new TextRun("Starts at 1 again (because different reference)")] }) + ] + }] +}); + +// ⚠️ CRITICAL NUMBERING RULE: Each reference creates an INDEPENDENT numbered list +// - Same reference = continues numbering (1, 2, 3... then 4, 5, 6...) +// - Different reference = restarts at 1 (1, 2, 3... then 1, 2, 3...) +// Use unique reference names for each separate numbered section! + +// ⚠️ CRITICAL: NEVER use unicode bullets - they create fake lists that don't work properly +// new TextRun("• Item") // WRONG +// new SymbolRun({ char: "2022" }) // WRONG +// ✅ ALWAYS use numbering config with LevelFormat.BULLET for real Word lists +``` + +## Tables +```javascript +// Complete table with margins, borders, headers, and bullet points +const tableBorder = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" }; +const cellBorders = { top: tableBorder, bottom: tableBorder, left: tableBorder, right: tableBorder }; + +new Table({ + columnWidths: [4680, 4680], // ⚠️ CRITICAL: Set column widths at table level - values in DXA (twentieths of a point) + margins: { top: 100, bottom: 100, left: 180, right: 180 }, // Set once for all cells + rows: [ + new TableRow({ + tableHeader: true, + children: [ + new TableCell({ + borders: cellBorders, + width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell + // ⚠️ CRITICAL: Always use ShadingType.CLEAR to prevent black backgrounds in Word. + shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, + verticalAlign: VerticalAlign.CENTER, + children: [new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "Header", bold: true, size: 22 })] + })] + }), + new TableCell({ + borders: cellBorders, + width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell + shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, + children: [new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: "Bullet Points", bold: true, size: 22 })] + })] + }) + ] + }), + new TableRow({ + children: [ + new TableCell({ + borders: cellBorders, + width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell + children: [new Paragraph({ children: [new TextRun("Regular data")] })] + }), + new TableCell({ + borders: cellBorders, + width: { size: 4680, type: WidthType.DXA }, // ALSO set width on each cell + children: [ + new Paragraph({ + numbering: { reference: "bullet-list", level: 0 }, + children: [new TextRun("First bullet point")] + }), + new Paragraph({ + numbering: { reference: "bullet-list", level: 0 }, + children: [new TextRun("Second bullet point")] + }) + ] + }) + ] + }) + ] +}) +``` + +**IMPORTANT: Table Width & Borders** +- Use BOTH `columnWidths: [width1, width2, ...]` array AND `width: { size: X, type: WidthType.DXA }` on each cell +- Values in DXA (twentieths of a point): 1440 = 1 inch, Letter usable width = 9360 DXA (with 1" margins) +- Apply borders to individual `TableCell` elements, NOT the `Table` itself + +**Precomputed Column Widths (Letter size with 1" margins = 9360 DXA total):** +- **2 columns:** `columnWidths: [4680, 4680]` (equal width) +- **3 columns:** `columnWidths: [3120, 3120, 3120]` (equal width) + +## Links & Navigation +```javascript +// TOC (requires headings) - CRITICAL: Use HeadingLevel only, NOT custom styles +// ❌ WRONG: new Paragraph({ heading: HeadingLevel.HEADING_1, style: "customHeader", children: [new TextRun("Title")] }) +// ✅ CORRECT: new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun("Title")] }) +new TableOfContents("Table of Contents", { hyperlink: true, headingStyleRange: "1-3" }), + +// External link +new Paragraph({ + children: [new ExternalHyperlink({ + children: [new TextRun({ text: "Google", style: "Hyperlink" })], + link: "https://www.google.com" + })] +}), + +// Internal link & bookmark +new Paragraph({ + children: [new InternalHyperlink({ + children: [new TextRun({ text: "Go to Section", style: "Hyperlink" })], + anchor: "section1" + })] +}), +new Paragraph({ + children: [new TextRun("Section Content")], + bookmark: { id: "section1", name: "section1" } +}), +``` + +## Images & Media +```javascript +// Basic image with sizing & positioning +// CRITICAL: Always specify 'type' parameter - it's REQUIRED for ImageRun +new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new ImageRun({ + type: "png", // NEW REQUIREMENT: Must specify image type (png, jpg, jpeg, gif, bmp, svg) + data: fs.readFileSync("image.png"), + transformation: { width: 200, height: 150, rotation: 0 }, // rotation in degrees + altText: { title: "Logo", description: "Company logo", name: "Name" } // IMPORTANT: All three fields are required + })] +}) +``` + +## Page Breaks +```javascript +// Manual page break +new Paragraph({ children: [new PageBreak()] }), + +// Page break before paragraph +new Paragraph({ + pageBreakBefore: true, + children: [new TextRun("This starts on a new page")] +}) + +// ⚠️ CRITICAL: NEVER use PageBreak standalone - it will create invalid XML that Word cannot open +// ❌ WRONG: new PageBreak() +// ✅ CORRECT: new Paragraph({ children: [new PageBreak()] }) +``` + +## Headers/Footers & Page Setup +```javascript +const doc = new Document({ + sections: [{ + properties: { + page: { + margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, // 1440 = 1 inch + size: { orientation: PageOrientation.LANDSCAPE }, + pageNumbers: { start: 1, formatType: "decimal" } // "upperRoman", "lowerRoman", "upperLetter", "lowerLetter" + } + }, + headers: { + default: new Header({ children: [new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [new TextRun("Header Text")] + })] }) + }, + footers: { + default: new Footer({ children: [new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun("Page "), new TextRun({ children: [PageNumber.CURRENT] }), new TextRun(" of "), new TextRun({ children: [PageNumber.TOTAL_PAGES] })] + })] }) + }, + children: [/* content */] + }] +}); +``` + +## Tabs +```javascript +new Paragraph({ + tabStops: [ + { type: TabStopType.LEFT, position: TabStopPosition.MAX / 4 }, + { type: TabStopType.CENTER, position: TabStopPosition.MAX / 2 }, + { type: TabStopType.RIGHT, position: TabStopPosition.MAX * 3 / 4 } + ], + children: [new TextRun("Left\tCenter\tRight")] +}) +``` + +## Constants & Quick Reference +- **Underlines:** `SINGLE`, `DOUBLE`, `WAVY`, `DASH` +- **Borders:** `SINGLE`, `DOUBLE`, `DASHED`, `DOTTED` +- **Numbering:** `DECIMAL` (1,2,3), `UPPER_ROMAN` (I,II,III), `LOWER_LETTER` (a,b,c) +- **Tabs:** `LEFT`, `CENTER`, `RIGHT`, `DECIMAL` +- **Symbols:** `"2022"` (•), `"00A9"` (©), `"00AE"` (®), `"2122"` (™), `"00B0"` (°), `"F070"` (✓), `"F0FC"` (✗) + +## Critical Issues & Common Mistakes +- **CRITICAL: PageBreak must ALWAYS be inside a Paragraph** - standalone PageBreak creates invalid XML that Word cannot open +- **ALWAYS use ShadingType.CLEAR for table cell shading** - Never use ShadingType.SOLID (causes black background). +- Measurements in DXA (1440 = 1 inch) | Each table cell needs ≥1 Paragraph | TOC requires HeadingLevel styles only +- **ALWAYS use custom styles** with Arial font for professional appearance and proper visual hierarchy +- **ALWAYS set a default font** using `styles.default.document.run.font` - Arial recommended +- **ALWAYS use columnWidths array for tables** + individual cell widths for compatibility +- **NEVER use unicode symbols for bullets** - always use proper numbering configuration with `LevelFormat.BULLET` constant (NOT the string "bullet") +- **NEVER use \n for line breaks anywhere** - always use separate Paragraph elements for each line +- **ALWAYS use TextRun objects within Paragraph children** - never use text property directly on Paragraph +- **CRITICAL for images**: ImageRun REQUIRES `type` parameter - always specify "png", "jpg", "jpeg", "gif", "bmp", or "svg" +- **CRITICAL for bullets**: Must use `LevelFormat.BULLET` constant, not string "bullet", and include `text: "•"` for the bullet character +- **CRITICAL for numbering**: Each numbering reference creates an INDEPENDENT list. Same reference = continues numbering (1,2,3 then 4,5,6). Different reference = restarts at 1 (1,2,3 then 1,2,3). Use unique reference names for each separate numbered section! +- **CRITICAL for TOC**: When using TableOfContents, headings must use HeadingLevel ONLY - do NOT add custom styles to heading paragraphs or TOC will break +- **Tables**: Set `columnWidths` array + individual cell widths, apply borders to cells not table +- **Set table margins at TABLE level** for consistent cell padding (avoids repetition per cell) \ No newline at end of file diff --git a/skills/document-skills/docx/ooxml.md b/skills/document-skills/docx/ooxml.md new file mode 100644 index 0000000..7677e7b --- /dev/null +++ b/skills/document-skills/docx/ooxml.md @@ -0,0 +1,610 @@ +# Office Open XML Technical Reference + +**Important: Read this entire document before starting.** This document covers: +- [Technical Guidelines](#technical-guidelines) - Schema compliance rules and validation requirements +- [Document Content Patterns](#document-content-patterns) - XML patterns for headings, lists, tables, formatting, etc. +- [Document Library (Python)](#document-library-python) - Recommended approach for OOXML manipulation with automatic infrastructure setup +- [Tracked Changes (Redlining)](#tracked-changes-redlining) - XML patterns for implementing tracked changes + +## Technical Guidelines + +### Schema Compliance +- **Element ordering in ``**: ``, ``, ``, ``, `` +- **Whitespace**: Add `xml:space='preserve'` to `` elements with leading/trailing spaces +- **Unicode**: Escape characters in ASCII content: `"` becomes `“` + - **Character encoding reference**: Curly quotes `""` become `“”`, apostrophe `'` becomes `’`, em-dash `—` becomes `—` +- **Tracked changes**: Use `` and `` tags with `w:author="Claude"` outside `` elements + - **Critical**: `` closes with ``, `` closes with `` - never mix + - **RSIDs must be 8-digit hex**: Use values like `00AB1234` (only 0-9, A-F characters) + - **trackRevisions placement**: Add `` after `` in settings.xml +- **Images**: Add to `word/media/`, reference in `document.xml`, set dimensions to prevent overflow + +## Document Content Patterns + +### Basic Structure +```xml + + Text content + +``` + +### Headings and Styles +```xml + + + + + + Document Title + + + + + Section Heading + +``` + +### Text Formatting +```xml + +Bold + +Italic + +Underlined + +Highlighted +``` + +### Lists +```xml + + + + + + + + First item + + + + + + + + + + New list item 1 + + + + + + + + + + + Bullet item + +``` + +### Tables +```xml + + + + + + + + + + + + Cell 1 + + + + Cell 2 + + + +``` + +### Layout +```xml + + + + + + + + + + + + New Section Title + + + + + + + + + + Centered text + + + + + + + + Monospace text + + + + + + + This text is Courier New + + and this text uses default font + +``` + +## File Updates + +When adding content, update these files: + +**`word/_rels/document.xml.rels`:** +```xml + + +``` + +**`[Content_Types].xml`:** +```xml + + +``` + +### Images +**CRITICAL**: Calculate dimensions to prevent page overflow and maintain aspect ratio. + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Links (Hyperlinks) + +**IMPORTANT**: All hyperlinks (both internal and external) require the Hyperlink style to be defined in styles.xml. Without this style, links will look like regular text instead of blue underlined clickable links. + +**External Links:** +```xml + + + + + Link Text + + + + + +``` + +**Internal Links:** + +```xml + + + + + Link Text + + + + + +Target content + +``` + +**Hyperlink Style (required in styles.xml):** +```xml + + + + + + + + + + +``` + +## Document Library (Python) + +Use the Document class from `scripts/document.py` for all tracked changes and comments. It automatically handles infrastructure setup (people.xml, RSIDs, settings.xml, comment files, relationships, content types). Only use direct XML manipulation for complex scenarios not supported by the library. + +**Working with Unicode and Entities:** +- **Searching**: Both entity notation and Unicode characters work - `contains="“Company"` and `contains="\u201cCompany"` find the same text +- **Replacing**: Use either entities (`“`) or Unicode (`\u201c`) - both work and will be converted appropriately based on the file's encoding (ascii → entities, utf-8 → Unicode) + +### Initialization + +**Find the docx skill root** (directory containing `scripts/` and `ooxml/`): +```bash +# Search for document.py to locate the skill root +# Note: /mnt/skills is used here as an example; check your context for the actual location +find /mnt/skills -name "document.py" -path "*/docx/scripts/*" 2>/dev/null | head -1 +# Example output: /mnt/skills/docx/scripts/document.py +# Skill root is: /mnt/skills/docx +``` + +**Run your script with PYTHONPATH** set to the docx skill root: +```bash +PYTHONPATH=/mnt/skills/docx python your_script.py +``` + +**In your script**, import from the skill root: +```python +from scripts.document import Document, DocxXMLEditor + +# Basic initialization (automatically creates temp copy and sets up infrastructure) +doc = Document('unpacked') + +# Customize author and initials +doc = Document('unpacked', author="John Doe", initials="JD") + +# Enable track revisions mode +doc = Document('unpacked', track_revisions=True) + +# Specify custom RSID (auto-generated if not provided) +doc = Document('unpacked', rsid="07DC5ECB") +``` + +### Creating Tracked Changes + +**CRITICAL**: Only mark text that actually changes. Keep ALL unchanged text outside ``/`` tags. Marking unchanged text makes edits unprofessional and harder to review. + +**Attribute Handling**: The Document class auto-injects attributes (w:id, w:date, w:rsidR, w:rsidDel, w16du:dateUtc, xml:space) into new elements. When preserving unchanged text from the original document, copy the original `` element with its existing attributes to maintain document integrity. + +**Method Selection Guide**: +- **Adding your own changes to regular text**: Use `replace_node()` with ``/`` tags, or `suggest_deletion()` for removing entire `` or `` elements +- **Partially modifying another author's tracked change**: Use `replace_node()` to nest your changes inside their ``/`` +- **Completely rejecting another author's insertion**: Use `revert_insertion()` on the `` element (NOT `suggest_deletion()`) +- **Completely rejecting another author's deletion**: Use `revert_deletion()` on the `` element to restore deleted content using tracked changes + +```python +# Minimal edit - change one word: "The report is monthly" → "The report is quarterly" +# Original: The report is monthly +node = doc["word/document.xml"].get_node(tag="w:r", contains="The report is monthly") +rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else "" +replacement = f'{rpr}The report is {rpr}monthly{rpr}quarterly' +doc["word/document.xml"].replace_node(node, replacement) + +# Minimal edit - change number: "within 30 days" → "within 45 days" +# Original: within 30 days +node = doc["word/document.xml"].get_node(tag="w:r", contains="within 30 days") +rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else "" +replacement = f'{rpr}within {rpr}30{rpr}45{rpr} days' +doc["word/document.xml"].replace_node(node, replacement) + +# Complete replacement - preserve formatting even when replacing all text +node = doc["word/document.xml"].get_node(tag="w:r", contains="apple") +rpr = tags[0].toxml() if (tags := node.getElementsByTagName("w:rPr")) else "" +replacement = f'{rpr}apple{rpr}banana orange' +doc["word/document.xml"].replace_node(node, replacement) + +# Insert new content (no attributes needed - auto-injected) +node = doc["word/document.xml"].get_node(tag="w:r", contains="existing text") +doc["word/document.xml"].insert_after(node, 'new text') + +# Partially delete another author's insertion +# Original: quarterly financial report +# Goal: Delete only "financial" to make it "quarterly report" +node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"}) +# IMPORTANT: Preserve w:author="Jane Smith" on the outer to maintain authorship +replacement = ''' + quarterly + financial + report +''' +doc["word/document.xml"].replace_node(node, replacement) + +# Change part of another author's insertion +# Original: in silence, safe and sound +# Goal: Change "safe and sound" to "soft and unbound" +node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "8"}) +replacement = f''' + in silence, + + + soft and unbound + + + safe and sound +''' +doc["word/document.xml"].replace_node(node, replacement) + +# Delete entire run (use only when deleting all content; use replace_node for partial deletions) +node = doc["word/document.xml"].get_node(tag="w:r", contains="text to delete") +doc["word/document.xml"].suggest_deletion(node) + +# Delete entire paragraph (in-place, handles both regular and numbered list paragraphs) +para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph to delete") +doc["word/document.xml"].suggest_deletion(para) + +# Add new numbered list item +target_para = doc["word/document.xml"].get_node(tag="w:p", contains="existing list item") +pPr = tags[0].toxml() if (tags := target_para.getElementsByTagName("w:pPr")) else "" +new_item = f'{pPr}New item' +tracked_para = DocxXMLEditor.suggest_paragraph(new_item) +doc["word/document.xml"].insert_after(target_para, tracked_para) +# Optional: add spacing paragraph before content for better visual separation +# spacing = DocxXMLEditor.suggest_paragraph('') +# doc["word/document.xml"].insert_after(target_para, spacing + tracked_para) +``` + +### Adding Comments + +```python +# Add comment spanning two existing tracked changes +# Note: w:id is auto-generated. Only search by w:id if you know it from XML inspection +start_node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) +end_node = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "2"}) +doc.add_comment(start=start_node, end=end_node, text="Explanation of this change") + +# Add comment on a paragraph +para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text") +doc.add_comment(start=para, end=para, text="Comment on this paragraph") + +# Add comment on newly created tracked change +# First create the tracked change +node = doc["word/document.xml"].get_node(tag="w:r", contains="old") +new_nodes = doc["word/document.xml"].replace_node( + node, + 'oldnew' +) +# Then add comment on the newly created elements +# new_nodes[0] is the , new_nodes[1] is the +doc.add_comment(start=new_nodes[0], end=new_nodes[1], text="Changed old to new per requirements") + +# Reply to existing comment +doc.reply_to_comment(parent_comment_id=0, text="I agree with this change") +``` + +### Rejecting Tracked Changes + +**IMPORTANT**: Use `revert_insertion()` to reject insertions and `revert_deletion()` to restore deletions using tracked changes. Use `suggest_deletion()` only for regular unmarked content. + +```python +# Reject insertion (wraps it in deletion) +# Use this when another author inserted text that you want to delete +ins = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"}) +nodes = doc["word/document.xml"].revert_insertion(ins) # Returns [ins] + +# Reject deletion (creates insertion to restore deleted content) +# Use this when another author deleted text that you want to restore +del_elem = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "3"}) +nodes = doc["word/document.xml"].revert_deletion(del_elem) # Returns [del_elem, new_ins] + +# Reject all insertions in a paragraph +para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text") +nodes = doc["word/document.xml"].revert_insertion(para) # Returns [para] + +# Reject all deletions in a paragraph +para = doc["word/document.xml"].get_node(tag="w:p", contains="paragraph text") +nodes = doc["word/document.xml"].revert_deletion(para) # Returns [para] +``` + +### Inserting Images + +**CRITICAL**: The Document class works with a temporary copy at `doc.unpacked_path`. Always copy images to this temp directory, not the original unpacked folder. + +```python +from PIL import Image +import shutil, os + +# Initialize document first +doc = Document('unpacked') + +# Copy image and calculate full-width dimensions with aspect ratio +media_dir = os.path.join(doc.unpacked_path, 'word/media') +os.makedirs(media_dir, exist_ok=True) +shutil.copy('image.png', os.path.join(media_dir, 'image1.png')) +img = Image.open(os.path.join(media_dir, 'image1.png')) +width_emus = int(6.5 * 914400) # 6.5" usable width, 914400 EMUs/inch +height_emus = int(width_emus * img.size[1] / img.size[0]) + +# Add relationship and content type +rels_editor = doc['word/_rels/document.xml.rels'] +next_rid = rels_editor.get_next_rid() +rels_editor.append_to(rels_editor.dom.documentElement, + f'') +doc['[Content_Types].xml'].append_to(doc['[Content_Types].xml'].dom.documentElement, + '') + +# Insert image +node = doc["word/document.xml"].get_node(tag="w:p", line_number=100) +doc["word/document.xml"].insert_after(node, f''' + + + + + + + + + + + + + + + + + +''') +``` + +### Getting Nodes + +```python +# By text content +node = doc["word/document.xml"].get_node(tag="w:p", contains="specific text") + +# By line range +para = doc["word/document.xml"].get_node(tag="w:p", line_number=range(100, 150)) + +# By attributes +node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) + +# By exact line number (must be line number where tag opens) +para = doc["word/document.xml"].get_node(tag="w:p", line_number=42) + +# Combine filters +node = doc["word/document.xml"].get_node(tag="w:r", line_number=range(40, 60), contains="text") + +# Disambiguate when text appears multiple times - add line_number range +node = doc["word/document.xml"].get_node(tag="w:r", contains="Section", line_number=range(2400, 2500)) +``` + +### Saving + +```python +# Save with automatic validation (copies back to original directory) +doc.save() # Validates by default, raises error if validation fails + +# Save to different location +doc.save('modified-unpacked') + +# Skip validation (debugging only - needing this in production indicates XML issues) +doc.save(validate=False) +``` + +### Direct DOM Manipulation + +For complex scenarios not covered by the library: + +```python +# Access any XML file +editor = doc["word/document.xml"] +editor = doc["word/comments.xml"] + +# Direct DOM access (defusedxml.minidom.Document) +node = doc["word/document.xml"].get_node(tag="w:p", line_number=5) +parent = node.parentNode +parent.removeChild(node) +parent.appendChild(node) # Move to end + +# General document manipulation (without tracked changes) +old_node = doc["word/document.xml"].get_node(tag="w:p", contains="original text") +doc["word/document.xml"].replace_node(old_node, "replacement text") + +# Multiple insertions - use return value to maintain order +node = doc["word/document.xml"].get_node(tag="w:r", line_number=100) +nodes = doc["word/document.xml"].insert_after(node, "A") +nodes = doc["word/document.xml"].insert_after(nodes[-1], "B") +nodes = doc["word/document.xml"].insert_after(nodes[-1], "C") +# Results in: original_node, A, B, C +``` + +## Tracked Changes (Redlining) + +**Use the Document class above for all tracked changes.** The patterns below are for reference when constructing replacement XML strings. + +### Validation Rules +The validator checks that the document text matches the original after reverting Claude's changes. This means: +- **NEVER modify text inside another author's `` or `` tags** +- **ALWAYS use nested deletions** to remove another author's insertions +- **Every edit must be properly tracked** with `` or `` tags + +### Tracked Change Patterns + +**CRITICAL RULES**: +1. Never modify the content inside another author's tracked changes. Always use nested deletions. +2. **XML Structure**: Always place `` and `` at paragraph level containing complete `` elements. Never nest inside `` elements - this creates invalid XML that breaks document processing. + +**Text Insertion:** +```xml + + + inserted text + + +``` + +**Text Deletion:** +```xml + + + deleted text + + +``` + +**Deleting Another Author's Insertion (MUST use nested structure):** +```xml + + + + monthly + + + + weekly + +``` + +**Restoring Another Author's Deletion:** +```xml + + + within 30 days + + + within 30 days + +``` \ No newline at end of file diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 0000000..6454ef9 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 0000000..afa4f46 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 0000000..64e66b8 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 0000000..687eea8 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 0000000..6ac81b0 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 0000000..1dbf051 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 0000000..f1af17d --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 0000000..0a185ab --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 0000000..14ef488 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 0000000..c20f3bf --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 0000000..ac60252 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 0000000..424b8ba --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 0000000..2bddce2 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 0000000..8a8c18b --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 0000000..5c42706 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 0000000..853c341 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 0000000..da835ee --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 0000000..87ad265 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 0000000..9e86f1b --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 0000000..d0be42e --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 0000000..8821dd1 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 0000000..ca2575c --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 0000000..dd079e6 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 0000000..3dd6cf6 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 0000000..f1041e3 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 0000000..9c5b7a6 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 0000000..0f13678 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 0000000..a6de9d2 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 0000000..10e978b --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 0000000..4248bf7 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 0000000..5649746 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/mce/mc.xsd b/skills/document-skills/docx/ooxml/schemas/mce/mc.xsd new file mode 100644 index 0000000..ef72545 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2010.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2010.xsd new file mode 100644 index 0000000..f65f777 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2012.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2012.xsd new file mode 100644 index 0000000..6b00755 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2018.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2018.xsd new file mode 100644 index 0000000..f321d33 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-cex-2018.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 0000000..364c6a9 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-cid-2016.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 0000000..fed9d15 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 0000000..680cf15 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/skills/document-skills/docx/ooxml/schemas/microsoft/wml-symex-2015.xsd b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 0000000..89ada90 --- /dev/null +++ b/skills/document-skills/docx/ooxml/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/skills/document-skills/docx/ooxml/scripts/pack.py b/skills/document-skills/docx/ooxml/scripts/pack.py new file mode 100755 index 0000000..68bc088 --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/pack.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Tool to pack a directory into a .docx, .pptx, or .xlsx file with XML formatting undone. + +Example usage: + python pack.py [--force] +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +import defusedxml.minidom +import zipfile +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description="Pack a directory into an Office file") + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument("--force", action="store_true", help="Skip validation") + args = parser.parse_args() + + try: + success = pack_document( + args.input_directory, args.output_file, validate=not args.force + ) + + # Show warning if validation was skipped + if args.force: + print("Warning: Skipped validation, file may be corrupt", file=sys.stderr) + # Exit with error if validation failed + elif not success: + print("Contents would produce a corrupt file.", file=sys.stderr) + print("Please validate XML before repacking.", file=sys.stderr) + print("Use --force to skip validation and pack anyway.", file=sys.stderr) + sys.exit(1) + + except ValueError as e: + sys.exit(f"Error: {e}") + + +def pack_document(input_dir, output_file, validate=False): + """Pack a directory into an Office file (.docx/.pptx/.xlsx). + + Args: + input_dir: Path to unpacked Office document directory + output_file: Path to output Office file + validate: If True, validates with soffice (default: False) + + Returns: + bool: True if successful, False if validation failed + """ + input_dir = Path(input_dir) + output_file = Path(output_file) + + if not input_dir.is_dir(): + raise ValueError(f"{input_dir} is not a directory") + if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}: + raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file") + + # Work in temporary directory to avoid modifying original + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + # Process XML files to remove pretty-printing whitespace + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + condense_xml(xml_file) + + # Create final Office file as zip archive + output_file.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + # Validate if requested + if validate: + if not validate_document(output_file): + output_file.unlink() # Delete the corrupt file + return False + + return True + + +def validate_document(doc_path): + """Validate document by converting to HTML with soffice.""" + # Determine the correct filter based on file extension + match doc_path.suffix.lower(): + case ".docx": + filter_name = "html:HTML" + case ".pptx": + filter_name = "html:impress_html_Export" + case ".xlsx": + filter_name = "html:HTML (StarCalc)" + + with tempfile.TemporaryDirectory() as temp_dir: + try: + result = subprocess.run( + [ + "soffice", + "--headless", + "--convert-to", + filter_name, + "--outdir", + temp_dir, + str(doc_path), + ], + capture_output=True, + timeout=10, + text=True, + ) + if not (Path(temp_dir) / f"{doc_path.stem}.html").exists(): + error_msg = result.stderr.strip() or "Document validation failed" + print(f"Validation error: {error_msg}", file=sys.stderr) + return False + return True + except FileNotFoundError: + print("Warning: soffice not found. Skipping validation.", file=sys.stderr) + return True + except subprocess.TimeoutExpired: + print("Validation error: Timeout during conversion", file=sys.stderr) + return False + except Exception as e: + print(f"Validation error: {e}", file=sys.stderr) + return False + + +def condense_xml(xml_file): + """Strip unnecessary whitespace and remove comments.""" + with open(xml_file, "r", encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + # Process each element to remove whitespace and comments + for element in dom.getElementsByTagName("*"): + # Skip w:t elements and their processing + if element.tagName.endswith(":t"): + continue + + # Remove whitespace-only text nodes and comment nodes + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + # Write back the condensed XML + with open(xml_file, "wb") as f: + f.write(dom.toxml(encoding="UTF-8")) + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/docx/ooxml/scripts/unpack.py b/skills/document-skills/docx/ooxml/scripts/unpack.py new file mode 100755 index 0000000..4938798 --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/unpack.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Unpack and format XML contents of Office files (.docx, .pptx, .xlsx)""" + +import random +import sys +import defusedxml.minidom +import zipfile +from pathlib import Path + +# Get command line arguments +assert len(sys.argv) == 3, "Usage: python unpack.py " +input_file, output_dir = sys.argv[1], sys.argv[2] + +# Extract and format +output_path = Path(output_dir) +output_path.mkdir(parents=True, exist_ok=True) +zipfile.ZipFile(input_file).extractall(output_path) + +# Pretty print all XML files +xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) +for xml_file in xml_files: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="ascii")) + +# For .docx files, suggest an RSID for tracked changes +if input_file.endswith(".docx"): + suggested_rsid = "".join(random.choices("0123456789ABCDEF", k=8)) + print(f"Suggested RSID for edit session: {suggested_rsid}") diff --git a/skills/document-skills/docx/ooxml/scripts/validate.py b/skills/document-skills/docx/ooxml/scripts/validate.py new file mode 100755 index 0000000..508c589 --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/validate.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py --original +""" + +import argparse +import sys +from pathlib import Path + +from validation import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "unpacked_dir", + help="Path to unpacked Office document directory", + ) + parser.add_argument( + "--original", + required=True, + help="Path to original file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + args = parser.parse_args() + + # Validate paths + unpacked_dir = Path(args.unpacked_dir) + original_file = Path(args.original) + file_extension = original_file.suffix.lower() + assert unpacked_dir.is_dir(), f"Error: {unpacked_dir} is not a directory" + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + # Run validations + match file_extension: + case ".docx": + validators = [DOCXSchemaValidator, RedliningValidator] + case ".pptx": + validators = [PPTXSchemaValidator] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + # Run validators + success = True + for V in validators: + validator = V(unpacked_dir, original_file, verbose=args.verbose) + if not validator.validate(): + success = False + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/docx/ooxml/scripts/validation/__init__.py b/skills/document-skills/docx/ooxml/scripts/validation/__init__.py new file mode 100644 index 0000000..db092ec --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/validation/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/skills/document-skills/docx/ooxml/scripts/validation/base.py b/skills/document-skills/docx/ooxml/scripts/validation/base.py new file mode 100644 index 0000000..0681b19 --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/validation/base.py @@ -0,0 +1,951 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import lxml.etree + + +class BaseSchemaValidator: + """Base validator with common validation logic for document files.""" + + # Elements whose 'id' attributes must be unique within their file + # Format: element_name -> (attribute_name, scope) + # scope can be 'file' (unique within file) or 'global' (unique across all files) + UNIQUE_ID_REQUIREMENTS = { + # Word elements + "comment": ("id", "file"), # Comment IDs in comments.xml + "commentrangestart": ("id", "file"), # Must match comment IDs + "commentrangeend": ("id", "file"), # Must match comment IDs + "bookmarkstart": ("id", "file"), # Bookmark start IDs + "bookmarkend": ("id", "file"), # Bookmark end IDs + # Note: ins and del (track changes) can share IDs when part of same revision + # PowerPoint elements + "sldid": ("id", "file"), # Slide IDs in presentation.xml + "sldmasterid": ("id", "global"), # Slide master IDs must be globally unique + "sldlayoutid": ("id", "global"), # Slide layout IDs must be globally unique + "cm": ("authorid", "file"), # Comment author IDs + # Excel elements + "sheet": ("sheetid", "file"), # Sheet IDs in workbook.xml + "definedname": ("id", "file"), # Named range IDs + # Drawing/Shape elements (all formats) + "cxnsp": ("id", "file"), # Connection shape IDs + "sp": ("id", "file"), # Shape IDs + "pic": ("id", "file"), # Picture IDs + "grpsp": ("id", "file"), # Group shape IDs + } + + # Mapping of element names to expected relationship types + # Subclasses should override this with format-specific mappings + ELEMENT_RELATIONSHIP_TYPES = {} + + # Unified schema mappings for all Office document types + SCHEMA_MAPPINGS = { + # Document type specific schemas + "word": "ISO-IEC29500-4_2016/wml.xsd", # Word documents + "ppt": "ISO-IEC29500-4_2016/pml.xsd", # PowerPoint presentations + "xl": "ISO-IEC29500-4_2016/sml.xsd", # Excel spreadsheets + # Common file types + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + # Word-specific files + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + # Chart files (common across document types) + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + # Theme files (common across document types) + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + # Drawing and media files + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + # Unified namespace constants + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + # Common OOXML namespaces used across validators + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + # Folders where we should clean ignorable namespaces + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + # All allowed OOXML namespaces (superset of all document types) + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) + self.verbose = verbose + + # Set schemas directory + self.schemas_dir = Path(__file__).parent.parent.parent / "schemas" + + # Get all XML and .rels files + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + """Run all validation checks and return True if all pass.""" + raise NotImplementedError("Subclasses must implement the validate method") + + def validate_xml(self): + """Validate that all XML files are well-formed.""" + errors = [] + + for xml_file in self.xml_files: + try: + # Try to parse the XML file + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + """Validate that namespace prefixes in Ignorable attributes are declared.""" + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} # Exclude default namespace + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + """Validate that specific IDs are unique according to OOXML requirements.""" + errors = [] + global_ids = {} # Track globally unique IDs across all files + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} # Track IDs that must be unique within this file + + # Remove all mc:AlternateContent elements from the tree + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + # Now check IDs in the cleaned tree + for elem in root.iter(): + # Get the element name without namespace + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + # Check if this element type has ID uniqueness requirements + if tag in self.UNIQUE_ID_REQUIREMENTS: + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + # Look for the specified attribute + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + # Check global uniqueness + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + # Check file-level uniqueness + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + """ + Validate that all .rels files properly reference files and that all files are referenced. + """ + errors = [] + + # Find all .rels files + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + # Get all files in the unpacked directory (excluding reference files) + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): # This file is not referenced by .rels + all_files.append(file_path.resolve()) + + # Track all files that are referenced by any .rels file + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + # Check each .rels file + for rels_file in rels_files: + try: + # Parse relationships file + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + # Get the directory where this .rels file is located + rels_dir = rels_file.parent + + # Find all relationships and their targets + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): # Skip external URLs + # Resolve the target path relative to the .rels file location + if rels_file.name == ".rels": + # Root .rels file - targets are relative to unpacked_dir + target_path = self.unpacked_dir / target + else: + # Other .rels files - targets are relative to their parent's parent + # e.g., word/_rels/document.xml.rels -> targets relative to word/ + base_dir = rels_dir.parent + target_path = base_dir / target + + # Normalize the path and check if it exists + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + # Report broken references + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + # Check for unreferenced files (files that exist but are not referenced anywhere) + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + """ + Validate that all r:id attributes in XML files reference existing IDs + in their corresponding .rels files, and optionally validate relationship types. + """ + import lxml.etree + + errors = [] + + # Process each XML file that might contain r:id references + for xml_file in self.xml_files: + # Skip .rels files themselves + if xml_file.suffix == ".rels": + continue + + # Determine the corresponding .rels file + # For dir/file.xml, it's dir/_rels/file.xml.rels + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + # Skip if there's no corresponding .rels file (that's okay) + if not rels_file.exists(): + continue + + try: + # Parse the .rels file to get valid relationship IDs and their types + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + # Check for duplicate rIds + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + # Extract just the type name from the full URL + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + # Parse the XML file to find all r:id references + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + # Find all elements with r:id attributes + for elem in xml_root.iter(): + # Check for r:id attribute (relationship ID) + rid_attr = elem.get(f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id") + if rid_attr: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + # Check if the ID exists + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + # Check if we have type expectations for this element + elif self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + # Check if the actual type matches or contains the expected type + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + """ + Get the expected relationship type for an element. + First checks the explicit mapping, then tries pattern detection. + """ + # Normalize element name to lowercase + elem_lower = element_name.lower() + + # Check explicit mapping first + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + # Try pattern detection for common patterns + # Pattern 1: Elements ending in "Id" often expect a relationship of the prefix type + if elem_lower.endswith("id") and len(elem_lower) > 2: + # e.g., "sldId" -> "sld", "sldMasterId" -> "sldMaster" + prefix = elem_lower[:-2] # Remove "id" + # Check if this might be a compound like "sldMasterId" + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + # Simple case like "sldId" -> "slide" + # Common transformations + if prefix == "sld": + return "slide" + return prefix.lower() + + # Pattern 2: Elements ending in "Reference" expect a relationship of the prefix type + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] # Remove "reference" + return prefix.lower() + + return None + + def validate_content_types(self): + """Validate that all content files are properly declared in [Content_Types].xml.""" + errors = [] + + # Find [Content_Types].xml file + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + # Parse and get all declared parts and extensions + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + # Get Override declarations (specific files) + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + # Get Default declarations (by extension) + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + # Root elements that require content type declaration + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", # PowerPoint + "document", # Word + "workbook", + "worksheet", # Excel + "theme", # Common + } + + # Common media file extensions that should be declared + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + # Get all files in the unpacked directory + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + # Check all XML files for Override declarations + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + # Skip non-content files + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue # Skip unparseable files + + # Check all non-XML files for Default extension declarations + for file_path in all_files: + # Skip XML files and metadata files (already checked above) + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + # Check if it's a known media extension that should be declared + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + """Validate a single XML file against XSD schema, comparing with original. + + Args: + xml_file: Path to XML file to validate + verbose: Enable verbose output + + Returns: + tuple: (is_valid, new_errors_set) where is_valid is True/False/None (skipped) + """ + # Resolve both paths to handle symlinks + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + # Validate current file + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() # Skipped + elif is_valid: + return True, set() # Valid, no errors + + # Get errors from original file for this specific file + original_errors = self._get_original_file_errors(xml_file) + + # Compare with original (both are guaranteed to be sets here) + assert current_errors is not None + new_errors = current_errors - original_errors + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + # All errors existed in original + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + """Validate XML files against XSD schemas, showing only new errors compared to original.""" + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + # Had errors but all existed in original + original_error_count += 1 + valid_count += 1 + continue + + # Has new errors + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: # Show first 3 errors + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + # Print summary + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + """Determine the appropriate schema path for an XML file.""" + # Check exact filename match + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + # Check .rels files + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + # Check chart files + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + # Check theme files + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + # Check if file is in a main content folder and use appropriate schema + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + """Remove attributes and elements not in allowed namespaces.""" + # Create a clean copy + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + # Remove attributes not in allowed namespaces + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + # Check if attribute is from a namespace other than allowed ones + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + # Remove collected attributes + for attr in attrs_to_remove: + del elem.attrib[attr] + + # Remove elements not in allowed namespaces + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + """Recursively remove all elements not in allowed namespaces.""" + elements_to_remove = [] + + # Find elements to remove + for elem in list(root): + # Skip non-element nodes (comments, processing instructions, etc.) + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + # Recursively clean child elements + self._remove_ignorable_elements(elem) + + # Remove collected elements + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + """Preprocess XML to handle mc:Ignorable attribute properly.""" + # Remove mc:Ignorable attributes before validation + root = xml_doc.getroot() + + # Remove mc:Ignorable attribute from root + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + """Validate a single XML file against XSD schema. Returns (is_valid, errors_set).""" + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None # Skip file + + try: + # Load schema + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + # Load and preprocess XML + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + # Clean ignorable namespaces if needed + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + # Validate + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + # Store normalized error message (without line numbers for comparison) + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + """Get XSD validation errors from a single file in the original document. + + Args: + xml_file: Path to the XML file in unpacked_dir to check + + Returns: + set: Set of error messages from the original file + """ + import tempfile + import zipfile + + # Resolve both paths to handle symlinks (e.g., /var vs /private/var on macOS) + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Extract original file + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + # Find corresponding file in original + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + # File didn't exist in original, so no original errors + return set() + + # Validate the specific file in original + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + """Remove template tags from XML text nodes and collect warnings. + + Template tags follow the pattern {{ ... }} and are used as placeholders + for content replacement. They should be removed from text content before + XSD validation while preserving XML structure. + + Returns: + tuple: (cleaned_xml_doc, warnings_list) + """ + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + # Create a copy of the document to avoid modifying the original + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + # Process all text nodes in the document + for elem in xml_copy.iter(): + # Skip processing if this is a w:t element + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/docx/ooxml/scripts/validation/docx.py b/skills/document-skills/docx/ooxml/scripts/validation/docx.py new file mode 100644 index 0000000..602c470 --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/validation/docx.py @@ -0,0 +1,274 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import re +import tempfile +import zipfile + +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + """Validator for Word document XML files against XSD schemas.""" + + # Word-specific namespace + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + # Word-specific element to relationship type mappings + # Start with empty mapping - add specific cases as we discover them + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + """Run all validation checks and return True if all pass.""" + # Test 0: XML well-formedness + if not self.validate_xml(): + return False + + # Test 1: Namespace declarations + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + # Test 2: Unique IDs + if not self.validate_unique_ids(): + all_valid = False + + # Test 3: Relationship and file reference validation + if not self.validate_file_references(): + all_valid = False + + # Test 4: Content type declarations + if not self.validate_content_types(): + all_valid = False + + # Test 5: XSD schema validation + if not self.validate_against_xsd(): + all_valid = False + + # Test 6: Whitespace preservation + if not self.validate_whitespace_preservation(): + all_valid = False + + # Test 7: Deletion validation + if not self.validate_deletions(): + all_valid = False + + # Test 8: Insertion validation + if not self.validate_insertions(): + all_valid = False + + # Test 9: Relationship ID reference validation + if not self.validate_all_relationship_ids(): + all_valid = False + + # Count and compare paragraphs + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + """ + Validate that w:t elements with whitespace have xml:space='preserve'. + """ + errors = [] + + for xml_file in self.xml_files: + # Only check document.xml files + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + # Find all w:t elements + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + # Check if text starts or ends with whitespace + if re.match(r"^\s.*", text) or re.match(r".*\s$", text): + # Check if xml:space="preserve" attribute exists + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + # Show a preview of the text + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + """ + Validate that w:t elements are not within w:del elements. + For some reason, XSD validation does not catch this, so we do it manually. + """ + errors = [] + + for xml_file in self.xml_files: + # Only check document.xml files + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + # Find all w:t elements that are descendants of w:del elements + namespaces = {"w": self.WORD_2006_NAMESPACE} + xpath_expression = ".//w:del//w:t" + problematic_t_elements = root.xpath( + xpath_expression, namespaces=namespaces + ) + for t_elem in problematic_t_elements: + if t_elem.text: + # Show a preview of the text + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + """Count the number of paragraphs in the unpacked document.""" + count = 0 + + for xml_file in self.xml_files: + # Only check document.xml files + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + # Count all w:p elements + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + """Count the number of paragraphs in the original docx file.""" + count = 0 + + try: + # Create temporary directory to unpack original + with tempfile.TemporaryDirectory() as temp_dir: + # Unpack original docx + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + # Parse document.xml + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + # Count all w:p elements + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + """ + Validate that w:delText elements are not within w:ins elements. + w:delText is only allowed in w:ins if nested within a w:del. + """ + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + # Find w:delText in w:ins that are NOT within w:del + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", + namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + """Compare paragraph counts between original and new document.""" + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/docx/ooxml/scripts/validation/pptx.py b/skills/document-skills/docx/ooxml/scripts/validation/pptx.py new file mode 100644 index 0000000..66d5b1e --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/validation/pptx.py @@ -0,0 +1,315 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + """Validator for PowerPoint presentation XML files against XSD schemas.""" + + # PowerPoint presentation namespace + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + # PowerPoint-specific element to relationship type mappings + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + """Run all validation checks and return True if all pass.""" + # Test 0: XML well-formedness + if not self.validate_xml(): + return False + + # Test 1: Namespace declarations + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + # Test 2: Unique IDs + if not self.validate_unique_ids(): + all_valid = False + + # Test 3: UUID ID validation + if not self.validate_uuid_ids(): + all_valid = False + + # Test 4: Relationship and file reference validation + if not self.validate_file_references(): + all_valid = False + + # Test 5: Slide layout ID validation + if not self.validate_slide_layout_ids(): + all_valid = False + + # Test 6: Content type declarations + if not self.validate_content_types(): + all_valid = False + + # Test 7: XSD schema validation + if not self.validate_against_xsd(): + all_valid = False + + # Test 8: Notes slide reference validation + if not self.validate_notes_slide_references(): + all_valid = False + + # Test 9: Relationship ID reference validation + if not self.validate_all_relationship_ids(): + all_valid = False + + # Test 10: Duplicate slide layout references validation + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + """Validate that ID attributes that look like UUIDs contain only hex values.""" + import lxml.etree + + errors = [] + # UUID pattern: 8-4-4-4-12 hex digits with optional braces/hyphens + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + # Check all elements for ID attributes + for elem in root.iter(): + for attr, value in elem.attrib.items(): + # Check if this is an ID attribute + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + # Check if value looks like a UUID (has the right length and pattern structure) + if self._looks_like_uuid(value): + # Validate that it contains only hex characters in the right positions + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + """Check if a value has the general structure of a UUID.""" + # Remove common UUID delimiters + clean_value = value.strip("{}()").replace("-", "") + # Check if it's 32 hex-like characters (could include invalid hex chars) + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + """Validate that sldLayoutId elements in slide masters reference valid slide layouts.""" + import lxml.etree + + errors = [] + + # Find all slide master files + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + # Parse the slide master file + root = lxml.etree.parse(str(slide_master)).getroot() + + # Find the corresponding _rels file for this slide master + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + # Parse the relationships file + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + # Build a set of valid relationship IDs that point to slide layouts + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + # Find all sldLayoutId elements in the slide master + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + """Validate that each slide has exactly one slideLayout reference.""" + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + # Find all slideLayout relationships + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + """Validate that each notesSlide file is referenced by only one slide.""" + import lxml.etree + + errors = [] + notes_slide_references = {} # Track which slides reference each notesSlide + + # Find all slide relationship files + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + # Parse the relationships file + root = lxml.etree.parse(str(rels_file)).getroot() + + # Find all notesSlide relationships + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + # Normalize the target path to handle relative paths + normalized_target = target.replace("../", "") + + # Track which slide references this notesSlide + slide_name = rels_file.stem.replace( + ".xml", "" + ) # e.g., "slide1" + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + # Check for duplicate references + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/docx/ooxml/scripts/validation/redlining.py b/skills/document-skills/docx/ooxml/scripts/validation/redlining.py new file mode 100644 index 0000000..7ed425e --- /dev/null +++ b/skills/document-skills/docx/ooxml/scripts/validation/redlining.py @@ -0,0 +1,279 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + """Validator for tracked changes in Word documents.""" + + def __init__(self, unpacked_dir, original_docx, verbose=False): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def validate(self): + """Main validation method that returns True if valid, False otherwise.""" + # Verify unpacked directory exists and has correct structure + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + # First, check if there are any tracked changes by Claude to validate + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + # Check for w:del or w:ins tags authored by Claude + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + # Filter to only include changes by Claude + claude_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude" + ] + claude_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude" + ] + + # Redlining validation is only needed if tracked changes by Claude have been used. + if not claude_del_elements and not claude_ins_elements: + if self.verbose: + print("PASSED - No tracked changes by Claude found.") + return True + + except Exception: + # If we can't parse the XML, continue with full validation + pass + + # Create temporary directory for unpacking original docx + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Unpack original docx + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + # Parse both XML files using xml.etree.ElementTree for redlining validation + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + # Remove Claude's tracked changes from both documents + self._remove_claude_tracked_changes(original_root) + self._remove_claude_tracked_changes(modified_root) + + # Extract and compare text content + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + # Show detailed character-level differences for each paragraph + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print("PASSED - All changes by Claude are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + """Generate detailed word-level differences using git word diff.""" + error_parts = [ + "FAILED - Document text doesn't match after removing Claude's tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + # Show git word diff + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + """Generate word diff using git with character-level precision.""" + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create two files + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + # Try character-level diff first for precise differences + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", # Character-by-character diff + "-U0", # Zero lines of context - show only changed lines + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + # Clean up the output - remove git diff header lines + lines = result.stdout.split("\n") + # Skip the header lines (diff --git, index, +++, ---, @@) + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + # Fallback to word-level diff if character-level is too verbose + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", # Zero lines of context + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + # Git not available or other error, return None to use fallback + pass + + return None + + def _remove_claude_tracked_changes(self, root): + """Remove tracked changes authored by Claude from the XML root.""" + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + # Remove w:ins elements + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == "Claude": + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + # Unwrap content in w:del elements where author is "Claude" + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == "Claude": + to_process.append((child, list(parent).index(child))) + + # Process in reverse order to maintain indices + for del_elem, del_index in reversed(to_process): + # Convert w:delText to w:t before moving + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + # Move all children of w:del to its parent before removing w:del + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + """Extract text content from Word XML, preserving paragraph structure. + + Empty paragraphs are skipped to avoid false positives when tracked + insertions add only structural elements without text content. + """ + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + # Get all text elements within this paragraph + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + # Skip empty paragraphs - they don't affect content validation + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/docx/scripts/__init__.py b/skills/document-skills/docx/scripts/__init__.py new file mode 100755 index 0000000..bf9c562 --- /dev/null +++ b/skills/document-skills/docx/scripts/__init__.py @@ -0,0 +1 @@ +# Make scripts directory a package for relative imports in tests diff --git a/skills/document-skills/docx/scripts/document.py b/skills/document-skills/docx/scripts/document.py new file mode 100755 index 0000000..ae9328d --- /dev/null +++ b/skills/document-skills/docx/scripts/document.py @@ -0,0 +1,1276 @@ +#!/usr/bin/env python3 +""" +Library for working with Word documents: comments, tracked changes, and editing. + +Usage: + from skills.docx.scripts.document import Document + + # Initialize + doc = Document('workspace/unpacked') + doc = Document('workspace/unpacked', author="John Doe", initials="JD") + + # Find nodes + node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) + node = doc["word/document.xml"].get_node(tag="w:p", line_number=10) + + # Add comments + doc.add_comment(start=node, end=node, text="Comment text") + doc.reply_to_comment(parent_comment_id=0, text="Reply text") + + # Suggest tracked changes + doc["word/document.xml"].suggest_deletion(node) # Delete content + doc["word/document.xml"].revert_insertion(ins_node) # Reject insertion + doc["word/document.xml"].revert_deletion(del_node) # Reject deletion + + # Save + doc.save() +""" + +import html +import random +import shutil +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +from defusedxml import minidom +from ooxml.scripts.pack import pack_document +from ooxml.scripts.validation.docx import DOCXSchemaValidator +from ooxml.scripts.validation.redlining import RedliningValidator + +from .utilities import XMLEditor + +# Path to template files +TEMPLATE_DIR = Path(__file__).parent / "templates" + + +class DocxXMLEditor(XMLEditor): + """XMLEditor that automatically applies RSID, author, and date to new elements. + + Automatically adds attributes to elements that support them when inserting new content: + - w:rsidR, w:rsidRDefault, w:rsidP (for w:p and w:r elements) + - w:author and w:date (for w:ins, w:del, w:comment elements) + - w:id (for w:ins and w:del elements) + + Attributes: + dom (defusedxml.minidom.Document): The DOM document for direct manipulation + """ + + def __init__( + self, xml_path, rsid: str, author: str = "Claude", initials: str = "C" + ): + """Initialize with required RSID and optional author. + + Args: + xml_path: Path to XML file to edit + rsid: RSID to automatically apply to new elements + author: Author name for tracked changes and comments (default: "Claude") + initials: Author initials (default: "C") + """ + super().__init__(xml_path) + self.rsid = rsid + self.author = author + self.initials = initials + + def _get_next_change_id(self): + """Get the next available change ID by checking all tracked change elements.""" + max_id = -1 + for tag in ("w:ins", "w:del"): + elements = self.dom.getElementsByTagName(tag) + for elem in elements: + change_id = elem.getAttribute("w:id") + if change_id: + try: + max_id = max(max_id, int(change_id)) + except ValueError: + pass + return max_id + 1 + + def _ensure_w16du_namespace(self): + """Ensure w16du namespace is declared on the root element.""" + root = self.dom.documentElement + if not root.hasAttribute("xmlns:w16du"): # type: ignore + root.setAttribute( # type: ignore + "xmlns:w16du", + "http://schemas.microsoft.com/office/word/2023/wordml/word16du", + ) + + def _ensure_w16cex_namespace(self): + """Ensure w16cex namespace is declared on the root element.""" + root = self.dom.documentElement + if not root.hasAttribute("xmlns:w16cex"): # type: ignore + root.setAttribute( # type: ignore + "xmlns:w16cex", + "http://schemas.microsoft.com/office/word/2018/wordml/cex", + ) + + def _ensure_w14_namespace(self): + """Ensure w14 namespace is declared on the root element.""" + root = self.dom.documentElement + if not root.hasAttribute("xmlns:w14"): # type: ignore + root.setAttribute( # type: ignore + "xmlns:w14", + "http://schemas.microsoft.com/office/word/2010/wordml", + ) + + def _inject_attributes_to_nodes(self, nodes): + """Inject RSID, author, and date attributes into DOM nodes where applicable. + + Adds attributes to elements that support them: + - w:r: gets w:rsidR (or w:rsidDel if inside w:del) + - w:p: gets w:rsidR, w:rsidRDefault, w:rsidP, w14:paraId, w14:textId + - w:t: gets xml:space="preserve" if text has leading/trailing whitespace + - w:ins, w:del: get w:id, w:author, w:date, w16du:dateUtc + - w:comment: gets w:author, w:date, w:initials + - w16cex:commentExtensible: gets w16cex:dateUtc + + Args: + nodes: List of DOM nodes to process + """ + from datetime import datetime, timezone + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + def is_inside_deletion(elem): + """Check if element is inside a w:del element.""" + parent = elem.parentNode + while parent: + if parent.nodeType == parent.ELEMENT_NODE and parent.tagName == "w:del": + return True + parent = parent.parentNode + return False + + def add_rsid_to_p(elem): + if not elem.hasAttribute("w:rsidR"): + elem.setAttribute("w:rsidR", self.rsid) + if not elem.hasAttribute("w:rsidRDefault"): + elem.setAttribute("w:rsidRDefault", self.rsid) + if not elem.hasAttribute("w:rsidP"): + elem.setAttribute("w:rsidP", self.rsid) + # Add w14:paraId and w14:textId if not present + if not elem.hasAttribute("w14:paraId"): + self._ensure_w14_namespace() + elem.setAttribute("w14:paraId", _generate_hex_id()) + if not elem.hasAttribute("w14:textId"): + self._ensure_w14_namespace() + elem.setAttribute("w14:textId", _generate_hex_id()) + + def add_rsid_to_r(elem): + # Use w:rsidDel for inside , otherwise w:rsidR + if is_inside_deletion(elem): + if not elem.hasAttribute("w:rsidDel"): + elem.setAttribute("w:rsidDel", self.rsid) + else: + if not elem.hasAttribute("w:rsidR"): + elem.setAttribute("w:rsidR", self.rsid) + + def add_tracked_change_attrs(elem): + # Auto-assign w:id if not present + if not elem.hasAttribute("w:id"): + elem.setAttribute("w:id", str(self._get_next_change_id())) + if not elem.hasAttribute("w:author"): + elem.setAttribute("w:author", self.author) + if not elem.hasAttribute("w:date"): + elem.setAttribute("w:date", timestamp) + # Add w16du:dateUtc for tracked changes (same as w:date since we generate UTC timestamps) + if elem.tagName in ("w:ins", "w:del") and not elem.hasAttribute( + "w16du:dateUtc" + ): + self._ensure_w16du_namespace() + elem.setAttribute("w16du:dateUtc", timestamp) + + def add_comment_attrs(elem): + if not elem.hasAttribute("w:author"): + elem.setAttribute("w:author", self.author) + if not elem.hasAttribute("w:date"): + elem.setAttribute("w:date", timestamp) + if not elem.hasAttribute("w:initials"): + elem.setAttribute("w:initials", self.initials) + + def add_comment_extensible_date(elem): + # Add w16cex:dateUtc for comment extensible elements + if not elem.hasAttribute("w16cex:dateUtc"): + self._ensure_w16cex_namespace() + elem.setAttribute("w16cex:dateUtc", timestamp) + + def add_xml_space_to_t(elem): + # Add xml:space="preserve" to w:t if text has leading/trailing whitespace + if ( + elem.firstChild + and elem.firstChild.nodeType == elem.firstChild.TEXT_NODE + ): + text = elem.firstChild.data + if text and (text[0].isspace() or text[-1].isspace()): + if not elem.hasAttribute("xml:space"): + elem.setAttribute("xml:space", "preserve") + + for node in nodes: + if node.nodeType != node.ELEMENT_NODE: + continue + + # Handle the node itself + if node.tagName == "w:p": + add_rsid_to_p(node) + elif node.tagName == "w:r": + add_rsid_to_r(node) + elif node.tagName == "w:t": + add_xml_space_to_t(node) + elif node.tagName in ("w:ins", "w:del"): + add_tracked_change_attrs(node) + elif node.tagName == "w:comment": + add_comment_attrs(node) + elif node.tagName == "w16cex:commentExtensible": + add_comment_extensible_date(node) + + # Process descendants (getElementsByTagName doesn't return the element itself) + for elem in node.getElementsByTagName("w:p"): + add_rsid_to_p(elem) + for elem in node.getElementsByTagName("w:r"): + add_rsid_to_r(elem) + for elem in node.getElementsByTagName("w:t"): + add_xml_space_to_t(elem) + for tag in ("w:ins", "w:del"): + for elem in node.getElementsByTagName(tag): + add_tracked_change_attrs(elem) + for elem in node.getElementsByTagName("w:comment"): + add_comment_attrs(elem) + for elem in node.getElementsByTagName("w16cex:commentExtensible"): + add_comment_extensible_date(elem) + + def replace_node(self, elem, new_content): + """Replace node with automatic attribute injection.""" + nodes = super().replace_node(elem, new_content) + self._inject_attributes_to_nodes(nodes) + return nodes + + def insert_after(self, elem, xml_content): + """Insert after with automatic attribute injection.""" + nodes = super().insert_after(elem, xml_content) + self._inject_attributes_to_nodes(nodes) + return nodes + + def insert_before(self, elem, xml_content): + """Insert before with automatic attribute injection.""" + nodes = super().insert_before(elem, xml_content) + self._inject_attributes_to_nodes(nodes) + return nodes + + def append_to(self, elem, xml_content): + """Append to with automatic attribute injection.""" + nodes = super().append_to(elem, xml_content) + self._inject_attributes_to_nodes(nodes) + return nodes + + def revert_insertion(self, elem): + """Reject an insertion by wrapping its content in a deletion. + + Wraps all runs inside w:ins in w:del, converting w:t to w:delText. + Can process a single w:ins element or a container element with multiple w:ins. + + Args: + elem: Element to process (w:ins, w:p, w:body, etc.) + + Returns: + list: List containing the processed element(s) + + Raises: + ValueError: If the element contains no w:ins elements + + Example: + # Reject a single insertion + ins = doc["word/document.xml"].get_node(tag="w:ins", attrs={"w:id": "5"}) + doc["word/document.xml"].revert_insertion(ins) + + # Reject all insertions in a paragraph + para = doc["word/document.xml"].get_node(tag="w:p", line_number=42) + doc["word/document.xml"].revert_insertion(para) + """ + # Collect insertions + ins_elements = [] + if elem.tagName == "w:ins": + ins_elements.append(elem) + else: + ins_elements.extend(elem.getElementsByTagName("w:ins")) + + # Validate that there are insertions to reject + if not ins_elements: + raise ValueError( + f"revert_insertion requires w:ins elements. " + f"The provided element <{elem.tagName}> contains no insertions. " + ) + + # Process all insertions - wrap all children in w:del + for ins_elem in ins_elements: + runs = list(ins_elem.getElementsByTagName("w:r")) + if not runs: + continue + + # Create deletion wrapper + del_wrapper = self.dom.createElement("w:del") + + # Process each run + for run in runs: + # Convert w:t → w:delText and w:rsidR → w:rsidDel + if run.hasAttribute("w:rsidR"): + run.setAttribute("w:rsidDel", run.getAttribute("w:rsidR")) + run.removeAttribute("w:rsidR") + elif not run.hasAttribute("w:rsidDel"): + run.setAttribute("w:rsidDel", self.rsid) + + for t_elem in list(run.getElementsByTagName("w:t")): + del_text = self.dom.createElement("w:delText") + # Copy ALL child nodes (not just firstChild) to handle entities + while t_elem.firstChild: + del_text.appendChild(t_elem.firstChild) + for i in range(t_elem.attributes.length): + attr = t_elem.attributes.item(i) + del_text.setAttribute(attr.name, attr.value) + t_elem.parentNode.replaceChild(del_text, t_elem) + + # Move all children from ins to del wrapper + while ins_elem.firstChild: + del_wrapper.appendChild(ins_elem.firstChild) + + # Add del wrapper back to ins + ins_elem.appendChild(del_wrapper) + + # Inject attributes to the deletion wrapper + self._inject_attributes_to_nodes([del_wrapper]) + + return [elem] + + def revert_deletion(self, elem): + """Reject a deletion by re-inserting the deleted content. + + Creates w:ins elements after each w:del, copying deleted content and + converting w:delText back to w:t. + Can process a single w:del element or a container element with multiple w:del. + + Args: + elem: Element to process (w:del, w:p, w:body, etc.) + + Returns: + list: If elem is w:del, returns [elem, new_ins]. Otherwise returns [elem]. + + Raises: + ValueError: If the element contains no w:del elements + + Example: + # Reject a single deletion - returns [w:del, w:ins] + del_elem = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "3"}) + nodes = doc["word/document.xml"].revert_deletion(del_elem) + + # Reject all deletions in a paragraph - returns [para] + para = doc["word/document.xml"].get_node(tag="w:p", line_number=42) + nodes = doc["word/document.xml"].revert_deletion(para) + """ + # Collect deletions FIRST - before we modify the DOM + del_elements = [] + is_single_del = elem.tagName == "w:del" + + if is_single_del: + del_elements.append(elem) + else: + del_elements.extend(elem.getElementsByTagName("w:del")) + + # Validate that there are deletions to reject + if not del_elements: + raise ValueError( + f"revert_deletion requires w:del elements. " + f"The provided element <{elem.tagName}> contains no deletions. " + ) + + # Track created insertion (only relevant if elem is a single w:del) + created_insertion = None + + # Process all deletions - create insertions that copy the deleted content + for del_elem in del_elements: + # Clone the deleted runs and convert them to insertions + runs = list(del_elem.getElementsByTagName("w:r")) + if not runs: + continue + + # Create insertion wrapper + ins_elem = self.dom.createElement("w:ins") + + for run in runs: + # Clone the run + new_run = run.cloneNode(True) + + # Convert w:delText → w:t + for del_text in list(new_run.getElementsByTagName("w:delText")): + t_elem = self.dom.createElement("w:t") + # Copy ALL child nodes (not just firstChild) to handle entities + while del_text.firstChild: + t_elem.appendChild(del_text.firstChild) + for i in range(del_text.attributes.length): + attr = del_text.attributes.item(i) + t_elem.setAttribute(attr.name, attr.value) + del_text.parentNode.replaceChild(t_elem, del_text) + + # Update run attributes: w:rsidDel → w:rsidR + if new_run.hasAttribute("w:rsidDel"): + new_run.setAttribute("w:rsidR", new_run.getAttribute("w:rsidDel")) + new_run.removeAttribute("w:rsidDel") + elif not new_run.hasAttribute("w:rsidR"): + new_run.setAttribute("w:rsidR", self.rsid) + + ins_elem.appendChild(new_run) + + # Insert the new insertion after the deletion + nodes = self.insert_after(del_elem, ins_elem.toxml()) + + # If processing a single w:del, track the created insertion + if is_single_del and nodes: + created_insertion = nodes[0] + + # Return based on input type + if is_single_del and created_insertion: + return [elem, created_insertion] + else: + return [elem] + + @staticmethod + def suggest_paragraph(xml_content: str) -> str: + """Transform paragraph XML to add tracked change wrapping for insertion. + + Wraps runs in and adds to w:rPr in w:pPr for numbered lists. + + Args: + xml_content: XML string containing a element + + Returns: + str: Transformed XML with tracked change wrapping + """ + wrapper = f'{xml_content}' + doc = minidom.parseString(wrapper) + para = doc.getElementsByTagName("w:p")[0] + + # Ensure w:pPr exists + pPr_list = para.getElementsByTagName("w:pPr") + if not pPr_list: + pPr = doc.createElement("w:pPr") + para.insertBefore( + pPr, para.firstChild + ) if para.firstChild else para.appendChild(pPr) + else: + pPr = pPr_list[0] + + # Ensure w:rPr exists in w:pPr + rPr_list = pPr.getElementsByTagName("w:rPr") + if not rPr_list: + rPr = doc.createElement("w:rPr") + pPr.appendChild(rPr) + else: + rPr = rPr_list[0] + + # Add to w:rPr + ins_marker = doc.createElement("w:ins") + rPr.insertBefore( + ins_marker, rPr.firstChild + ) if rPr.firstChild else rPr.appendChild(ins_marker) + + # Wrap all non-pPr children in + ins_wrapper = doc.createElement("w:ins") + for child in [c for c in para.childNodes if c.nodeName != "w:pPr"]: + para.removeChild(child) + ins_wrapper.appendChild(child) + para.appendChild(ins_wrapper) + + return para.toxml() + + def suggest_deletion(self, elem): + """Mark a w:r or w:p element as deleted with tracked changes (in-place DOM manipulation). + + For w:r: wraps in , converts to , preserves w:rPr + For w:p (regular): wraps content in , converts to + For w:p (numbered list): adds to w:rPr in w:pPr, wraps content in + + Args: + elem: A w:r or w:p DOM element without existing tracked changes + + Returns: + Element: The modified element + + Raises: + ValueError: If element has existing tracked changes or invalid structure + """ + if elem.nodeName == "w:r": + # Check for existing w:delText + if elem.getElementsByTagName("w:delText"): + raise ValueError("w:r element already contains w:delText") + + # Convert w:t → w:delText + for t_elem in list(elem.getElementsByTagName("w:t")): + del_text = self.dom.createElement("w:delText") + # Copy ALL child nodes (not just firstChild) to handle entities + while t_elem.firstChild: + del_text.appendChild(t_elem.firstChild) + # Preserve attributes like xml:space + for i in range(t_elem.attributes.length): + attr = t_elem.attributes.item(i) + del_text.setAttribute(attr.name, attr.value) + t_elem.parentNode.replaceChild(del_text, t_elem) + + # Update run attributes: w:rsidR → w:rsidDel + if elem.hasAttribute("w:rsidR"): + elem.setAttribute("w:rsidDel", elem.getAttribute("w:rsidR")) + elem.removeAttribute("w:rsidR") + elif not elem.hasAttribute("w:rsidDel"): + elem.setAttribute("w:rsidDel", self.rsid) + + # Wrap in w:del + del_wrapper = self.dom.createElement("w:del") + parent = elem.parentNode + parent.insertBefore(del_wrapper, elem) + parent.removeChild(elem) + del_wrapper.appendChild(elem) + + # Inject attributes to the deletion wrapper + self._inject_attributes_to_nodes([del_wrapper]) + + return del_wrapper + + elif elem.nodeName == "w:p": + # Check for existing tracked changes + if elem.getElementsByTagName("w:ins") or elem.getElementsByTagName("w:del"): + raise ValueError("w:p element already contains tracked changes") + + # Check if it's a numbered list item + pPr_list = elem.getElementsByTagName("w:pPr") + is_numbered = pPr_list and pPr_list[0].getElementsByTagName("w:numPr") + + if is_numbered: + # Add to w:rPr in w:pPr + pPr = pPr_list[0] + rPr_list = pPr.getElementsByTagName("w:rPr") + + if not rPr_list: + rPr = self.dom.createElement("w:rPr") + pPr.appendChild(rPr) + else: + rPr = rPr_list[0] + + # Add marker + del_marker = self.dom.createElement("w:del") + rPr.insertBefore( + del_marker, rPr.firstChild + ) if rPr.firstChild else rPr.appendChild(del_marker) + + # Convert w:t → w:delText in all runs + for t_elem in list(elem.getElementsByTagName("w:t")): + del_text = self.dom.createElement("w:delText") + # Copy ALL child nodes (not just firstChild) to handle entities + while t_elem.firstChild: + del_text.appendChild(t_elem.firstChild) + # Preserve attributes like xml:space + for i in range(t_elem.attributes.length): + attr = t_elem.attributes.item(i) + del_text.setAttribute(attr.name, attr.value) + t_elem.parentNode.replaceChild(del_text, t_elem) + + # Update run attributes: w:rsidR → w:rsidDel + for run in elem.getElementsByTagName("w:r"): + if run.hasAttribute("w:rsidR"): + run.setAttribute("w:rsidDel", run.getAttribute("w:rsidR")) + run.removeAttribute("w:rsidR") + elif not run.hasAttribute("w:rsidDel"): + run.setAttribute("w:rsidDel", self.rsid) + + # Wrap all non-pPr children in + del_wrapper = self.dom.createElement("w:del") + for child in [c for c in elem.childNodes if c.nodeName != "w:pPr"]: + elem.removeChild(child) + del_wrapper.appendChild(child) + elem.appendChild(del_wrapper) + + # Inject attributes to the deletion wrapper + self._inject_attributes_to_nodes([del_wrapper]) + + return elem + + else: + raise ValueError(f"Element must be w:r or w:p, got {elem.nodeName}") + + +def _generate_hex_id() -> str: + """Generate random 8-character hex ID for para/durable IDs. + + Values are constrained to be less than 0x7FFFFFFF per OOXML spec: + - paraId must be < 0x80000000 + - durableId must be < 0x7FFFFFFF + We use the stricter constraint (0x7FFFFFFF) for both. + """ + return f"{random.randint(1, 0x7FFFFFFE):08X}" + + +def _generate_rsid() -> str: + """Generate random 8-character hex RSID.""" + return "".join(random.choices("0123456789ABCDEF", k=8)) + + +class Document: + """Manages comments in unpacked Word documents.""" + + def __init__( + self, + unpacked_dir, + rsid=None, + track_revisions=False, + author="Claude", + initials="C", + ): + """ + Initialize with path to unpacked Word document directory. + Automatically sets up comment infrastructure (people.xml, RSIDs). + + Args: + unpacked_dir: Path to unpacked DOCX directory (must contain word/ subdirectory) + rsid: Optional RSID to use for all comment elements. If not provided, one will be generated. + track_revisions: If True, enables track revisions in settings.xml (default: False) + author: Default author name for comments (default: "Claude") + initials: Default author initials for comments (default: "C") + """ + self.original_path = Path(unpacked_dir) + + if not self.original_path.exists() or not self.original_path.is_dir(): + raise ValueError(f"Directory not found: {unpacked_dir}") + + # Create temporary directory with subdirectories for unpacked content and baseline + self.temp_dir = tempfile.mkdtemp(prefix="docx_") + self.unpacked_path = Path(self.temp_dir) / "unpacked" + shutil.copytree(self.original_path, self.unpacked_path) + + # Pack original directory into temporary .docx for validation baseline (outside unpacked dir) + self.original_docx = Path(self.temp_dir) / "original.docx" + pack_document(self.original_path, self.original_docx, validate=False) + + self.word_path = self.unpacked_path / "word" + + # Generate RSID if not provided + self.rsid = rsid if rsid else _generate_rsid() + print(f"Using RSID: {self.rsid}") + + # Set default author and initials + self.author = author + self.initials = initials + + # Cache for lazy-loaded editors + self._editors = {} + + # Comment file paths + self.comments_path = self.word_path / "comments.xml" + self.comments_extended_path = self.word_path / "commentsExtended.xml" + self.comments_ids_path = self.word_path / "commentsIds.xml" + self.comments_extensible_path = self.word_path / "commentsExtensible.xml" + + # Load existing comments and determine next ID (before setup modifies files) + self.existing_comments = self._load_existing_comments() + self.next_comment_id = self._get_next_comment_id() + + # Convenient access to document.xml editor (semi-private) + self._document = self["word/document.xml"] + + # Setup tracked changes infrastructure + self._setup_tracking(track_revisions=track_revisions) + + # Add author to people.xml + self._add_author_to_people(author) + + def __getitem__(self, xml_path: str) -> DocxXMLEditor: + """ + Get or create a DocxXMLEditor for the specified XML file. + + Enables lazy-loaded editors with bracket notation: + node = doc["word/document.xml"].get_node(tag="w:p", line_number=42) + + Args: + xml_path: Relative path to XML file (e.g., "word/document.xml", "word/comments.xml") + + Returns: + DocxXMLEditor instance for the specified file + + Raises: + ValueError: If the file does not exist + + Example: + # Get node from document.xml + node = doc["word/document.xml"].get_node(tag="w:del", attrs={"w:id": "1"}) + + # Get node from comments.xml + comment = doc["word/comments.xml"].get_node(tag="w:comment", attrs={"w:id": "0"}) + """ + if xml_path not in self._editors: + file_path = self.unpacked_path / xml_path + if not file_path.exists(): + raise ValueError(f"XML file not found: {xml_path}") + # Use DocxXMLEditor with RSID, author, and initials for all editors + self._editors[xml_path] = DocxXMLEditor( + file_path, rsid=self.rsid, author=self.author, initials=self.initials + ) + return self._editors[xml_path] + + def add_comment(self, start, end, text: str) -> int: + """ + Add a comment spanning from one element to another. + + Args: + start: DOM element for the starting point + end: DOM element for the ending point + text: Comment content + + Returns: + The comment ID that was created + + Example: + start_node = cm.get_document_node(tag="w:del", id="1") + end_node = cm.get_document_node(tag="w:ins", id="2") + cm.add_comment(start=start_node, end=end_node, text="Explanation") + """ + comment_id = self.next_comment_id + para_id = _generate_hex_id() + durable_id = _generate_hex_id() + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + # Add comment ranges to document.xml immediately + self._document.insert_before(start, self._comment_range_start_xml(comment_id)) + + # If end node is a paragraph, append comment markup inside it + # Otherwise insert after it (for run-level anchors) + if end.tagName == "w:p": + self._document.append_to(end, self._comment_range_end_xml(comment_id)) + else: + self._document.insert_after(end, self._comment_range_end_xml(comment_id)) + + # Add to comments.xml immediately + self._add_to_comments_xml( + comment_id, para_id, text, self.author, self.initials, timestamp + ) + + # Add to commentsExtended.xml immediately + self._add_to_comments_extended_xml(para_id, parent_para_id=None) + + # Add to commentsIds.xml immediately + self._add_to_comments_ids_xml(para_id, durable_id) + + # Add to commentsExtensible.xml immediately + self._add_to_comments_extensible_xml(durable_id) + + # Update existing_comments so replies work + self.existing_comments[comment_id] = {"para_id": para_id} + + self.next_comment_id += 1 + return comment_id + + def reply_to_comment( + self, + parent_comment_id: int, + text: str, + ) -> int: + """ + Add a reply to an existing comment. + + Args: + parent_comment_id: The w:id of the parent comment to reply to + text: Reply text + + Returns: + The comment ID that was created for the reply + + Example: + cm.reply_to_comment(parent_comment_id=0, text="I agree with this change") + """ + if parent_comment_id not in self.existing_comments: + raise ValueError(f"Parent comment with id={parent_comment_id} not found") + + parent_info = self.existing_comments[parent_comment_id] + comment_id = self.next_comment_id + para_id = _generate_hex_id() + durable_id = _generate_hex_id() + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + # Add comment ranges to document.xml immediately + parent_start_elem = self._document.get_node( + tag="w:commentRangeStart", attrs={"w:id": str(parent_comment_id)} + ) + parent_ref_elem = self._document.get_node( + tag="w:commentReference", attrs={"w:id": str(parent_comment_id)} + ) + + self._document.insert_after( + parent_start_elem, self._comment_range_start_xml(comment_id) + ) + parent_ref_run = parent_ref_elem.parentNode + self._document.insert_after( + parent_ref_run, f'' + ) + self._document.insert_after( + parent_ref_run, self._comment_ref_run_xml(comment_id) + ) + + # Add to comments.xml immediately + self._add_to_comments_xml( + comment_id, para_id, text, self.author, self.initials, timestamp + ) + + # Add to commentsExtended.xml immediately (with parent) + self._add_to_comments_extended_xml( + para_id, parent_para_id=parent_info["para_id"] + ) + + # Add to commentsIds.xml immediately + self._add_to_comments_ids_xml(para_id, durable_id) + + # Add to commentsExtensible.xml immediately + self._add_to_comments_extensible_xml(durable_id) + + # Update existing_comments so replies work + self.existing_comments[comment_id] = {"para_id": para_id} + + self.next_comment_id += 1 + return comment_id + + def __del__(self): + """Clean up temporary directory on deletion.""" + if hasattr(self, "temp_dir") and Path(self.temp_dir).exists(): + shutil.rmtree(self.temp_dir) + + def validate(self) -> None: + """ + Validate the document against XSD schema and redlining rules. + + Raises: + ValueError: If validation fails. + """ + # Create validators with current state + schema_validator = DOCXSchemaValidator( + self.unpacked_path, self.original_docx, verbose=False + ) + redlining_validator = RedliningValidator( + self.unpacked_path, self.original_docx, verbose=False + ) + + # Run validations + if not schema_validator.validate(): + raise ValueError("Schema validation failed") + if not redlining_validator.validate(): + raise ValueError("Redlining validation failed") + + def save(self, destination=None, validate=True) -> None: + """ + Save all modified XML files to disk and copy to destination directory. + + This persists all changes made via add_comment() and reply_to_comment(). + + Args: + destination: Optional path to save to. If None, saves back to original directory. + validate: If True, validates document before saving (default: True). + """ + # Only ensure comment relationships and content types if comment files exist + if self.comments_path.exists(): + self._ensure_comment_relationships() + self._ensure_comment_content_types() + + # Save all modified XML files in temp directory + for editor in self._editors.values(): + editor.save() + + # Validate by default + if validate: + self.validate() + + # Copy contents from temp directory to destination (or original directory) + target_path = Path(destination) if destination else self.original_path + shutil.copytree(self.unpacked_path, target_path, dirs_exist_ok=True) + + # ==================== Private: Initialization ==================== + + def _get_next_comment_id(self): + """Get the next available comment ID.""" + if not self.comments_path.exists(): + return 0 + + editor = self["word/comments.xml"] + max_id = -1 + for comment_elem in editor.dom.getElementsByTagName("w:comment"): + comment_id = comment_elem.getAttribute("w:id") + if comment_id: + try: + max_id = max(max_id, int(comment_id)) + except ValueError: + pass + return max_id + 1 + + def _load_existing_comments(self): + """Load existing comments from files to enable replies.""" + if not self.comments_path.exists(): + return {} + + editor = self["word/comments.xml"] + existing = {} + + for comment_elem in editor.dom.getElementsByTagName("w:comment"): + comment_id = comment_elem.getAttribute("w:id") + if not comment_id: + continue + + # Find para_id from the w:p element within the comment + para_id = None + for p_elem in comment_elem.getElementsByTagName("w:p"): + para_id = p_elem.getAttribute("w14:paraId") + if para_id: + break + + if not para_id: + continue + + existing[int(comment_id)] = {"para_id": para_id} + + return existing + + # ==================== Private: Setup Methods ==================== + + def _setup_tracking(self, track_revisions=False): + """Set up comment infrastructure in unpacked directory. + + Args: + track_revisions: If True, enables track revisions in settings.xml + """ + # Create or update word/people.xml + people_file = self.word_path / "people.xml" + self._update_people_xml(people_file) + + # Update XML files + self._add_content_type_for_people(self.unpacked_path / "[Content_Types].xml") + self._add_relationship_for_people( + self.word_path / "_rels" / "document.xml.rels" + ) + + # Always add RSID to settings.xml, optionally enable trackRevisions + self._update_settings( + self.word_path / "settings.xml", track_revisions=track_revisions + ) + + def _update_people_xml(self, path): + """Create people.xml if it doesn't exist.""" + if not path.exists(): + # Copy from template + shutil.copy(TEMPLATE_DIR / "people.xml", path) + + def _add_content_type_for_people(self, path): + """Add people.xml content type to [Content_Types].xml if not already present.""" + editor = self["[Content_Types].xml"] + + if self._has_override(editor, "/word/people.xml"): + return + + # Add Override element + root = editor.dom.documentElement + override_xml = '' + editor.append_to(root, override_xml) + + def _add_relationship_for_people(self, path): + """Add people.xml relationship to document.xml.rels if not already present.""" + editor = self["word/_rels/document.xml.rels"] + + if self._has_relationship(editor, "people.xml"): + return + + root = editor.dom.documentElement + root_tag = root.tagName # type: ignore + prefix = root_tag.split(":")[0] + ":" if ":" in root_tag else "" + next_rid = editor.get_next_rid() + + # Create the relationship entry + rel_xml = f'<{prefix}Relationship Id="{next_rid}" Type="http://schemas.microsoft.com/office/2011/relationships/people" Target="people.xml"/>' + editor.append_to(root, rel_xml) + + def _update_settings(self, path, track_revisions=False): + """Add RSID and optionally enable track revisions in settings.xml. + + Args: + path: Path to settings.xml + track_revisions: If True, adds trackRevisions element + + Places elements per OOXML schema order: + - trackRevisions: early (before defaultTabStop) + - rsids: late (after compat) + """ + editor = self["word/settings.xml"] + root = editor.get_node(tag="w:settings") + prefix = root.tagName.split(":")[0] if ":" in root.tagName else "w" + + # Conditionally add trackRevisions if requested + if track_revisions: + track_revisions_exists = any( + elem.tagName == f"{prefix}:trackRevisions" + for elem in editor.dom.getElementsByTagName(f"{prefix}:trackRevisions") + ) + + if not track_revisions_exists: + track_rev_xml = f"<{prefix}:trackRevisions/>" + # Try to insert before documentProtection, defaultTabStop, or at start + inserted = False + for tag in [f"{prefix}:documentProtection", f"{prefix}:defaultTabStop"]: + elements = editor.dom.getElementsByTagName(tag) + if elements: + editor.insert_before(elements[0], track_rev_xml) + inserted = True + break + if not inserted: + # Insert as first child of settings + if root.firstChild: + editor.insert_before(root.firstChild, track_rev_xml) + else: + editor.append_to(root, track_rev_xml) + + # Always check if rsids section exists + rsids_elements = editor.dom.getElementsByTagName(f"{prefix}:rsids") + + if not rsids_elements: + # Add new rsids section + rsids_xml = f'''<{prefix}:rsids> + <{prefix}:rsidRoot {prefix}:val="{self.rsid}"/> + <{prefix}:rsid {prefix}:val="{self.rsid}"/> +''' + + # Try to insert after compat, before clrSchemeMapping, or before closing tag + inserted = False + compat_elements = editor.dom.getElementsByTagName(f"{prefix}:compat") + if compat_elements: + editor.insert_after(compat_elements[0], rsids_xml) + inserted = True + + if not inserted: + clr_elements = editor.dom.getElementsByTagName( + f"{prefix}:clrSchemeMapping" + ) + if clr_elements: + editor.insert_before(clr_elements[0], rsids_xml) + inserted = True + + if not inserted: + editor.append_to(root, rsids_xml) + else: + # Check if this rsid already exists + rsids_elem = rsids_elements[0] + rsid_exists = any( + elem.getAttribute(f"{prefix}:val") == self.rsid + for elem in rsids_elem.getElementsByTagName(f"{prefix}:rsid") + ) + + if not rsid_exists: + rsid_xml = f'<{prefix}:rsid {prefix}:val="{self.rsid}"/>' + editor.append_to(rsids_elem, rsid_xml) + + # ==================== Private: XML File Creation ==================== + + def _add_to_comments_xml( + self, comment_id, para_id, text, author, initials, timestamp + ): + """Add a single comment to comments.xml.""" + if not self.comments_path.exists(): + shutil.copy(TEMPLATE_DIR / "comments.xml", self.comments_path) + + editor = self["word/comments.xml"] + root = editor.get_node(tag="w:comments") + + escaped_text = ( + text.replace("&", "&").replace("<", "<").replace(">", ">") + ) + # Note: w:rsidR, w:rsidRDefault, w:rsidP on w:p, w:rsidR on w:r, + # and w:author, w:date, w:initials on w:comment are automatically added by DocxXMLEditor + comment_xml = f''' + + + {escaped_text} + +''' + editor.append_to(root, comment_xml) + + def _add_to_comments_extended_xml(self, para_id, parent_para_id): + """Add a single comment to commentsExtended.xml.""" + if not self.comments_extended_path.exists(): + shutil.copy( + TEMPLATE_DIR / "commentsExtended.xml", self.comments_extended_path + ) + + editor = self["word/commentsExtended.xml"] + root = editor.get_node(tag="w15:commentsEx") + + if parent_para_id: + xml = f'' + else: + xml = f'' + editor.append_to(root, xml) + + def _add_to_comments_ids_xml(self, para_id, durable_id): + """Add a single comment to commentsIds.xml.""" + if not self.comments_ids_path.exists(): + shutil.copy(TEMPLATE_DIR / "commentsIds.xml", self.comments_ids_path) + + editor = self["word/commentsIds.xml"] + root = editor.get_node(tag="w16cid:commentsIds") + + xml = f'' + editor.append_to(root, xml) + + def _add_to_comments_extensible_xml(self, durable_id): + """Add a single comment to commentsExtensible.xml.""" + if not self.comments_extensible_path.exists(): + shutil.copy( + TEMPLATE_DIR / "commentsExtensible.xml", self.comments_extensible_path + ) + + editor = self["word/commentsExtensible.xml"] + root = editor.get_node(tag="w16cex:commentsExtensible") + + xml = f'' + editor.append_to(root, xml) + + # ==================== Private: XML Fragments ==================== + + def _comment_range_start_xml(self, comment_id): + """Generate XML for comment range start.""" + return f'' + + def _comment_range_end_xml(self, comment_id): + """Generate XML for comment range end with reference run. + + Note: w:rsidR is automatically added by DocxXMLEditor. + """ + return f''' + + + +''' + + def _comment_ref_run_xml(self, comment_id): + """Generate XML for comment reference run. + + Note: w:rsidR is automatically added by DocxXMLEditor. + """ + return f''' + + +''' + + # ==================== Private: Metadata Updates ==================== + + def _has_relationship(self, editor, target): + """Check if a relationship with given target exists.""" + for rel_elem in editor.dom.getElementsByTagName("Relationship"): + if rel_elem.getAttribute("Target") == target: + return True + return False + + def _has_override(self, editor, part_name): + """Check if an override with given part name exists.""" + for override_elem in editor.dom.getElementsByTagName("Override"): + if override_elem.getAttribute("PartName") == part_name: + return True + return False + + def _has_author(self, editor, author): + """Check if an author already exists in people.xml.""" + for person_elem in editor.dom.getElementsByTagName("w15:person"): + if person_elem.getAttribute("w15:author") == author: + return True + return False + + def _add_author_to_people(self, author): + """Add author to people.xml (called during initialization).""" + people_path = self.word_path / "people.xml" + + # people.xml should already exist from _setup_tracking + if not people_path.exists(): + raise ValueError("people.xml should exist after _setup_tracking") + + editor = self["word/people.xml"] + root = editor.get_node(tag="w15:people") + + # Check if author already exists + if self._has_author(editor, author): + return + + # Add author with proper XML escaping to prevent injection + escaped_author = html.escape(author, quote=True) + person_xml = f''' + +''' + editor.append_to(root, person_xml) + + def _ensure_comment_relationships(self): + """Ensure word/_rels/document.xml.rels has comment relationships.""" + editor = self["word/_rels/document.xml.rels"] + + if self._has_relationship(editor, "comments.xml"): + return + + root = editor.dom.documentElement + root_tag = root.tagName # type: ignore + prefix = root_tag.split(":")[0] + ":" if ":" in root_tag else "" + next_rid_num = int(editor.get_next_rid()[3:]) + + # Add relationship elements + rels = [ + ( + next_rid_num, + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments", + "comments.xml", + ), + ( + next_rid_num + 1, + "http://schemas.microsoft.com/office/2011/relationships/commentsExtended", + "commentsExtended.xml", + ), + ( + next_rid_num + 2, + "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds", + "commentsIds.xml", + ), + ( + next_rid_num + 3, + "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible", + "commentsExtensible.xml", + ), + ] + + for rel_id, rel_type, target in rels: + rel_xml = f'<{prefix}Relationship Id="rId{rel_id}" Type="{rel_type}" Target="{target}"/>' + editor.append_to(root, rel_xml) + + def _ensure_comment_content_types(self): + """Ensure [Content_Types].xml has comment content types.""" + editor = self["[Content_Types].xml"] + + if self._has_override(editor, "/word/comments.xml"): + return + + root = editor.dom.documentElement + + # Add Override elements + overrides = [ + ( + "/word/comments.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", + ), + ( + "/word/commentsExtended.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtended+xml", + ), + ( + "/word/commentsIds.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsIds+xml", + ), + ( + "/word/commentsExtensible.xml", + "application/vnd.openxmlformats-officedocument.wordprocessingml.commentsExtensible+xml", + ), + ] + + for part_name, content_type in overrides: + override_xml = ( + f'' + ) + editor.append_to(root, override_xml) diff --git a/skills/document-skills/docx/scripts/templates/comments.xml b/skills/document-skills/docx/scripts/templates/comments.xml new file mode 100644 index 0000000..b5dace0 --- /dev/null +++ b/skills/document-skills/docx/scripts/templates/comments.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/skills/document-skills/docx/scripts/templates/commentsExtended.xml b/skills/document-skills/docx/scripts/templates/commentsExtended.xml new file mode 100644 index 0000000..b4cf23e --- /dev/null +++ b/skills/document-skills/docx/scripts/templates/commentsExtended.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/skills/document-skills/docx/scripts/templates/commentsExtensible.xml b/skills/document-skills/docx/scripts/templates/commentsExtensible.xml new file mode 100644 index 0000000..e32a05e --- /dev/null +++ b/skills/document-skills/docx/scripts/templates/commentsExtensible.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/skills/document-skills/docx/scripts/templates/commentsIds.xml b/skills/document-skills/docx/scripts/templates/commentsIds.xml new file mode 100644 index 0000000..d04bc8e --- /dev/null +++ b/skills/document-skills/docx/scripts/templates/commentsIds.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/skills/document-skills/docx/scripts/templates/people.xml b/skills/document-skills/docx/scripts/templates/people.xml new file mode 100644 index 0000000..a839caf --- /dev/null +++ b/skills/document-skills/docx/scripts/templates/people.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/skills/document-skills/docx/scripts/utilities.py b/skills/document-skills/docx/scripts/utilities.py new file mode 100755 index 0000000..d92dae6 --- /dev/null +++ b/skills/document-skills/docx/scripts/utilities.py @@ -0,0 +1,374 @@ +#!/usr/bin/env python3 +""" +Utilities for editing OOXML documents. + +This module provides XMLEditor, a tool for manipulating XML files with support for +line-number-based node finding and DOM manipulation. Each element is automatically +annotated with its original line and column position during parsing. + +Example usage: + editor = XMLEditor("document.xml") + + # Find node by line number or range + elem = editor.get_node(tag="w:r", line_number=519) + elem = editor.get_node(tag="w:p", line_number=range(100, 200)) + + # Find node by text content + elem = editor.get_node(tag="w:p", contains="specific text") + + # Find node by attributes + elem = editor.get_node(tag="w:r", attrs={"w:id": "target"}) + + # Combine filters + elem = editor.get_node(tag="w:p", line_number=range(1, 50), contains="text") + + # Replace, insert, or manipulate + new_elem = editor.replace_node(elem, "new text") + editor.insert_after(new_elem, "more") + + # Save changes + editor.save() +""" + +import html +from pathlib import Path +from typing import Optional, Union + +import defusedxml.minidom +import defusedxml.sax + + +class XMLEditor: + """ + Editor for manipulating OOXML XML files with line-number-based node finding. + + This class parses XML files and tracks the original line and column position + of each element. This enables finding nodes by their line number in the original + file, which is useful when working with Read tool output. + + Attributes: + xml_path: Path to the XML file being edited + encoding: Detected encoding of the XML file ('ascii' or 'utf-8') + dom: Parsed DOM tree with parse_position attributes on elements + """ + + def __init__(self, xml_path): + """ + Initialize with path to XML file and parse with line number tracking. + + Args: + xml_path: Path to XML file to edit (str or Path) + + Raises: + ValueError: If the XML file does not exist + """ + self.xml_path = Path(xml_path) + if not self.xml_path.exists(): + raise ValueError(f"XML file not found: {xml_path}") + + with open(self.xml_path, "rb") as f: + header = f.read(200).decode("utf-8", errors="ignore") + self.encoding = "ascii" if 'encoding="ascii"' in header else "utf-8" + + parser = _create_line_tracking_parser() + self.dom = defusedxml.minidom.parse(str(self.xml_path), parser) + + def get_node( + self, + tag: str, + attrs: Optional[dict[str, str]] = None, + line_number: Optional[Union[int, range]] = None, + contains: Optional[str] = None, + ): + """ + Get a DOM element by tag and identifier. + + Finds an element by either its line number in the original file or by + matching attribute values. Exactly one match must be found. + + Args: + tag: The XML tag name (e.g., "w:del", "w:ins", "w:r") + attrs: Dictionary of attribute name-value pairs to match (e.g., {"w:id": "1"}) + line_number: Line number (int) or line range (range) in original XML file (1-indexed) + contains: Text string that must appear in any text node within the element. + Supports both entity notation (“) and Unicode characters (\u201c). + + Returns: + defusedxml.minidom.Element: The matching DOM element + + Raises: + ValueError: If node not found or multiple matches found + + Example: + elem = editor.get_node(tag="w:r", line_number=519) + elem = editor.get_node(tag="w:r", line_number=range(100, 200)) + elem = editor.get_node(tag="w:del", attrs={"w:id": "1"}) + elem = editor.get_node(tag="w:p", attrs={"w14:paraId": "12345678"}) + elem = editor.get_node(tag="w:commentRangeStart", attrs={"w:id": "0"}) + elem = editor.get_node(tag="w:p", contains="specific text") + elem = editor.get_node(tag="w:t", contains="“Agreement") # Entity notation + elem = editor.get_node(tag="w:t", contains="\u201cAgreement") # Unicode character + """ + matches = [] + for elem in self.dom.getElementsByTagName(tag): + # Check line_number filter + if line_number is not None: + parse_pos = getattr(elem, "parse_position", (None,)) + elem_line = parse_pos[0] + + # Handle both single line number and range + if isinstance(line_number, range): + if elem_line not in line_number: + continue + else: + if elem_line != line_number: + continue + + # Check attrs filter + if attrs is not None: + if not all( + elem.getAttribute(attr_name) == attr_value + for attr_name, attr_value in attrs.items() + ): + continue + + # Check contains filter + if contains is not None: + elem_text = self._get_element_text(elem) + # Normalize the search string: convert HTML entities to Unicode characters + # This allows searching for both "“Rowan" and ""Rowan" + normalized_contains = html.unescape(contains) + if normalized_contains not in elem_text: + continue + + # If all applicable filters passed, this is a match + matches.append(elem) + + if not matches: + # Build descriptive error message + filters = [] + if line_number is not None: + line_str = ( + f"lines {line_number.start}-{line_number.stop - 1}" + if isinstance(line_number, range) + else f"line {line_number}" + ) + filters.append(f"at {line_str}") + if attrs is not None: + filters.append(f"with attributes {attrs}") + if contains is not None: + filters.append(f"containing '{contains}'") + + filter_desc = " ".join(filters) if filters else "" + base_msg = f"Node not found: <{tag}> {filter_desc}".strip() + + # Add helpful hint based on filters used + if contains: + hint = "Text may be split across elements or use different wording." + elif line_number: + hint = "Line numbers may have changed if document was modified." + elif attrs: + hint = "Verify attribute values are correct." + else: + hint = "Try adding filters (attrs, line_number, or contains)." + + raise ValueError(f"{base_msg}. {hint}") + if len(matches) > 1: + raise ValueError( + f"Multiple nodes found: <{tag}>. " + f"Add more filters (attrs, line_number, or contains) to narrow the search." + ) + return matches[0] + + def _get_element_text(self, elem): + """ + Recursively extract all text content from an element. + + Skips text nodes that contain only whitespace (spaces, tabs, newlines), + which typically represent XML formatting rather than document content. + + Args: + elem: defusedxml.minidom.Element to extract text from + + Returns: + str: Concatenated text from all non-whitespace text nodes within the element + """ + text_parts = [] + for node in elem.childNodes: + if node.nodeType == node.TEXT_NODE: + # Skip whitespace-only text nodes (XML formatting) + if node.data.strip(): + text_parts.append(node.data) + elif node.nodeType == node.ELEMENT_NODE: + text_parts.append(self._get_element_text(node)) + return "".join(text_parts) + + def replace_node(self, elem, new_content): + """ + Replace a DOM element with new XML content. + + Args: + elem: defusedxml.minidom.Element to replace + new_content: String containing XML to replace the node with + + Returns: + List[defusedxml.minidom.Node]: All inserted nodes + + Example: + new_nodes = editor.replace_node(old_elem, "text") + """ + parent = elem.parentNode + nodes = self._parse_fragment(new_content) + for node in nodes: + parent.insertBefore(node, elem) + parent.removeChild(elem) + return nodes + + def insert_after(self, elem, xml_content): + """ + Insert XML content after a DOM element. + + Args: + elem: defusedxml.minidom.Element to insert after + xml_content: String containing XML to insert + + Returns: + List[defusedxml.minidom.Node]: All inserted nodes + + Example: + new_nodes = editor.insert_after(elem, "text") + """ + parent = elem.parentNode + next_sibling = elem.nextSibling + nodes = self._parse_fragment(xml_content) + for node in nodes: + if next_sibling: + parent.insertBefore(node, next_sibling) + else: + parent.appendChild(node) + return nodes + + def insert_before(self, elem, xml_content): + """ + Insert XML content before a DOM element. + + Args: + elem: defusedxml.minidom.Element to insert before + xml_content: String containing XML to insert + + Returns: + List[defusedxml.minidom.Node]: All inserted nodes + + Example: + new_nodes = editor.insert_before(elem, "text") + """ + parent = elem.parentNode + nodes = self._parse_fragment(xml_content) + for node in nodes: + parent.insertBefore(node, elem) + return nodes + + def append_to(self, elem, xml_content): + """ + Append XML content as a child of a DOM element. + + Args: + elem: defusedxml.minidom.Element to append to + xml_content: String containing XML to append + + Returns: + List[defusedxml.minidom.Node]: All inserted nodes + + Example: + new_nodes = editor.append_to(elem, "text") + """ + nodes = self._parse_fragment(xml_content) + for node in nodes: + elem.appendChild(node) + return nodes + + def get_next_rid(self): + """Get the next available rId for relationships files.""" + max_id = 0 + for rel_elem in self.dom.getElementsByTagName("Relationship"): + rel_id = rel_elem.getAttribute("Id") + if rel_id.startswith("rId"): + try: + max_id = max(max_id, int(rel_id[3:])) + except ValueError: + pass + return f"rId{max_id + 1}" + + def save(self): + """ + Save the edited XML back to the file. + + Serializes the DOM tree and writes it back to the original file path, + preserving the original encoding (ascii or utf-8). + """ + content = self.dom.toxml(encoding=self.encoding) + self.xml_path.write_bytes(content) + + def _parse_fragment(self, xml_content): + """ + Parse XML fragment and return list of imported nodes. + + Args: + xml_content: String containing XML fragment + + Returns: + List of defusedxml.minidom.Node objects imported into this document + + Raises: + AssertionError: If fragment contains no element nodes + """ + # Extract namespace declarations from the root document element + root_elem = self.dom.documentElement + namespaces = [] + if root_elem and root_elem.attributes: + for i in range(root_elem.attributes.length): + attr = root_elem.attributes.item(i) + if attr.name.startswith("xmlns"): # type: ignore + namespaces.append(f'{attr.name}="{attr.value}"') # type: ignore + + ns_decl = " ".join(namespaces) + wrapper = f"{xml_content}" + fragment_doc = defusedxml.minidom.parseString(wrapper) + nodes = [ + self.dom.importNode(child, deep=True) + for child in fragment_doc.documentElement.childNodes # type: ignore + ] + elements = [n for n in nodes if n.nodeType == n.ELEMENT_NODE] + assert elements, "Fragment must contain at least one element" + return nodes + + +def _create_line_tracking_parser(): + """ + Create a SAX parser that tracks line and column numbers for each element. + + Monkey patches the SAX content handler to store the current line and column + position from the underlying expat parser onto each element as a parse_position + attribute (line, column) tuple. + + Returns: + defusedxml.sax.xmlreader.XMLReader: Configured SAX parser + """ + + def set_content_handler(dom_handler): + def startElementNS(name, tagName, attrs): + orig_start_cb(name, tagName, attrs) + cur_elem = dom_handler.elementStack[-1] + cur_elem.parse_position = ( + parser._parser.CurrentLineNumber, # type: ignore + parser._parser.CurrentColumnNumber, # type: ignore + ) + + orig_start_cb = dom_handler.startElementNS + dom_handler.startElementNS = startElementNS + orig_set_content_handler(dom_handler) + + parser = defusedxml.sax.make_parser() + orig_set_content_handler = parser.setContentHandler + parser.setContentHandler = set_content_handler # type: ignore + return parser diff --git a/skills/document-skills/pdf/LICENSE.txt b/skills/document-skills/pdf/LICENSE.txt new file mode 100644 index 0000000..c55ab42 --- /dev/null +++ b/skills/document-skills/pdf/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/skills/document-skills/pdf/SKILL.md b/skills/document-skills/pdf/SKILL.md new file mode 100644 index 0000000..f6a22dd --- /dev/null +++ b/skills/document-skills/pdf/SKILL.md @@ -0,0 +1,294 @@ +--- +name: pdf +description: Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale. +license: Proprietary. LICENSE.txt has complete terms +--- + +# PDF Processing Guide + +## Overview + +This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions. + +## Quick Start + +```python +from pypdf import PdfReader, PdfWriter + +# Read a PDF +reader = PdfReader("document.pdf") +print(f"Pages: {len(reader.pages)}") + +# Extract text +text = "" +for page in reader.pages: + text += page.extract_text() +``` + +## Python Libraries + +### pypdf - Basic Operations + +#### Merge PDFs +```python +from pypdf import PdfWriter, PdfReader + +writer = PdfWriter() +for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]: + reader = PdfReader(pdf_file) + for page in reader.pages: + writer.add_page(page) + +with open("merged.pdf", "wb") as output: + writer.write(output) +``` + +#### Split PDF +```python +reader = PdfReader("input.pdf") +for i, page in enumerate(reader.pages): + writer = PdfWriter() + writer.add_page(page) + with open(f"page_{i+1}.pdf", "wb") as output: + writer.write(output) +``` + +#### Extract Metadata +```python +reader = PdfReader("document.pdf") +meta = reader.metadata +print(f"Title: {meta.title}") +print(f"Author: {meta.author}") +print(f"Subject: {meta.subject}") +print(f"Creator: {meta.creator}") +``` + +#### Rotate Pages +```python +reader = PdfReader("input.pdf") +writer = PdfWriter() + +page = reader.pages[0] +page.rotate(90) # Rotate 90 degrees clockwise +writer.add_page(page) + +with open("rotated.pdf", "wb") as output: + writer.write(output) +``` + +### pdfplumber - Text and Table Extraction + +#### Extract Text with Layout +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + for page in pdf.pages: + text = page.extract_text() + print(text) +``` + +#### Extract Tables +```python +with pdfplumber.open("document.pdf") as pdf: + for i, page in enumerate(pdf.pages): + tables = page.extract_tables() + for j, table in enumerate(tables): + print(f"Table {j+1} on page {i+1}:") + for row in table: + print(row) +``` + +#### Advanced Table Extraction +```python +import pandas as pd + +with pdfplumber.open("document.pdf") as pdf: + all_tables = [] + for page in pdf.pages: + tables = page.extract_tables() + for table in tables: + if table: # Check if table is not empty + df = pd.DataFrame(table[1:], columns=table[0]) + all_tables.append(df) + +# Combine all tables +if all_tables: + combined_df = pd.concat(all_tables, ignore_index=True) + combined_df.to_excel("extracted_tables.xlsx", index=False) +``` + +### reportlab - Create PDFs + +#### Basic PDF Creation +```python +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas + +c = canvas.Canvas("hello.pdf", pagesize=letter) +width, height = letter + +# Add text +c.drawString(100, height - 100, "Hello World!") +c.drawString(100, height - 120, "This is a PDF created with reportlab") + +# Add a line +c.line(100, height - 140, 400, height - 140) + +# Save +c.save() +``` + +#### Create PDF with Multiple Pages +```python +from reportlab.lib.pagesizes import letter +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak +from reportlab.lib.styles import getSampleStyleSheet + +doc = SimpleDocTemplate("report.pdf", pagesize=letter) +styles = getSampleStyleSheet() +story = [] + +# Add content +title = Paragraph("Report Title", styles['Title']) +story.append(title) +story.append(Spacer(1, 12)) + +body = Paragraph("This is the body of the report. " * 20, styles['Normal']) +story.append(body) +story.append(PageBreak()) + +# Page 2 +story.append(Paragraph("Page 2", styles['Heading1'])) +story.append(Paragraph("Content for page 2", styles['Normal'])) + +# Build PDF +doc.build(story) +``` + +## Command-Line Tools + +### pdftotext (poppler-utils) +```bash +# Extract text +pdftotext input.pdf output.txt + +# Extract text preserving layout +pdftotext -layout input.pdf output.txt + +# Extract specific pages +pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5 +``` + +### qpdf +```bash +# Merge PDFs +qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf + +# Split pages +qpdf input.pdf --pages . 1-5 -- pages1-5.pdf +qpdf input.pdf --pages . 6-10 -- pages6-10.pdf + +# Rotate pages +qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees + +# Remove password +qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf +``` + +### pdftk (if available) +```bash +# Merge +pdftk file1.pdf file2.pdf cat output merged.pdf + +# Split +pdftk input.pdf burst + +# Rotate +pdftk input.pdf rotate 1east output rotated.pdf +``` + +## Common Tasks + +### Extract Text from Scanned PDFs +```python +# Requires: pip install pytesseract pdf2image +import pytesseract +from pdf2image import convert_from_path + +# Convert PDF to images +images = convert_from_path('scanned.pdf') + +# OCR each page +text = "" +for i, image in enumerate(images): + text += f"Page {i+1}:\n" + text += pytesseract.image_to_string(image) + text += "\n\n" + +print(text) +``` + +### Add Watermark +```python +from pypdf import PdfReader, PdfWriter + +# Create watermark (or load existing) +watermark = PdfReader("watermark.pdf").pages[0] + +# Apply to all pages +reader = PdfReader("document.pdf") +writer = PdfWriter() + +for page in reader.pages: + page.merge_page(watermark) + writer.add_page(page) + +with open("watermarked.pdf", "wb") as output: + writer.write(output) +``` + +### Extract Images +```bash +# Using pdfimages (poppler-utils) +pdfimages -j input.pdf output_prefix + +# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc. +``` + +### Password Protection +```python +from pypdf import PdfReader, PdfWriter + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +for page in reader.pages: + writer.add_page(page) + +# Add password +writer.encrypt("userpassword", "ownerpassword") + +with open("encrypted.pdf", "wb") as output: + writer.write(output) +``` + +## Quick Reference + +| Task | Best Tool | Command/Code | +|------|-----------|--------------| +| Merge PDFs | pypdf | `writer.add_page(page)` | +| Split PDFs | pypdf | One page per file | +| Extract text | pdfplumber | `page.extract_text()` | +| Extract tables | pdfplumber | `page.extract_tables()` | +| Create PDFs | reportlab | Canvas or Platypus | +| Command line merge | qpdf | `qpdf --empty --pages ...` | +| OCR scanned PDFs | pytesseract | Convert to image first | +| Fill PDF forms | pdf-lib or pypdf (see forms.md) | See forms.md | + +## Next Steps + +- For advanced pypdfium2 usage, see reference.md +- For JavaScript libraries (pdf-lib), see reference.md +- If you need to fill out a PDF form, follow the instructions in forms.md +- For troubleshooting guides, see reference.md diff --git a/skills/document-skills/pdf/forms.md b/skills/document-skills/pdf/forms.md new file mode 100644 index 0000000..4e23450 --- /dev/null +++ b/skills/document-skills/pdf/forms.md @@ -0,0 +1,205 @@ +**CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.** + +If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: + `python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions. + +# Fillable fields +If the PDF has fillable form fields: +- Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format: +``` +[ + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page), + "type": ("text", "checkbox", "radio_group", or "choice"), + }, + // Checkboxes have "checked_value" and "unchecked_value" properties: + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "checkbox", + "checked_value": (Set the field to this value to check the checkbox), + "unchecked_value": (Set the field to this value to uncheck the checkbox), + }, + // Radio groups have a "radio_options" list with the possible choices. + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "radio_group", + "radio_options": [ + { + "value": (set the field to this value to select this radio option), + "rect": (bounding box for the radio button for this option) + }, + // Other radio options + ] + }, + // Multiple choice fields have a "choice_options" list with the possible choices: + { + "field_id": (unique ID for the field), + "page": (page number, 1-based), + "type": "choice", + "choice_options": [ + { + "value": (set the field to this value to select this option), + "text": (display text of the option) + }, + // Other choice options + ], + } +] +``` +- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory): +`python scripts/convert_pdf_to_images.py ` +Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates). +- Create a `field_values.json` file in this format with the values to be entered for each field: +``` +[ + { + "field_id": "last_name", // Must match the field_id from `extract_form_field_info.py` + "description": "The user's last name", + "page": 1, // Must match the "page" value in field_info.json + "value": "Simpson" + }, + { + "field_id": "Checkbox12", + "description": "Checkbox to be checked if the user is 18 or over", + "page": 1, + "value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options". + }, + // more fields +] +``` +- Run the `fill_fillable_fields.py` script from this file's directory to create a filled-in PDF: +`python scripts/fill_fillable_fields.py ` +This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again. + +# Non-fillable fields +If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create text annotations. Follow the below steps *exactly*. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below. +- Convert the PDF to PNG images and determine field bounding boxes. +- Create a JSON file with field information and validation images showing the bounding boxes. +- Validate the the bounding boxes. +- Use the bounding boxes to fill in the form. + +## Step 1: Visual Analysis (REQUIRED) +- Convert the PDF to PNG images. Run this script from this file's directory: +`python scripts/convert_pdf_to_images.py ` +The script will create a PNG image for each page in the PDF. +- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter text, determine bounding boxes for both the form field label, and the area where the user should enter text. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text. + +These are some examples of form structures that you might see: + +*Label inside box* +``` +┌────────────────────────┐ +│ Name: │ +└────────────────────────┘ +``` +The input area should be to the right of the "Name" label and extend to the edge of the box. + +*Label before line* +``` +Email: _______________________ +``` +The input area should be above the line and include its entire width. + +*Label under line* +``` +_________________________ +Name +``` +The input area should be above the line and include the entire width of the line. This is common for signature and date fields. + +*Label above line* +``` +Please enter any special requests: +________________________________________________ +``` +The input area should extend from the bottom of the label to the line, and should include the entire width of the line. + +*Checkboxes* +``` +Are you a US citizen? Yes □ No □ +``` +For checkboxes: +- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels. +- Distinguish between label text ("Yes", "No") and the clickable checkbox squares. +- The entry bounding box should cover ONLY the small square, not the text label. + +### Step 2: Create fields.json and validation images (REQUIRED) +- Create a file named `fields.json` with information for the form fields and bounding boxes in this format: +``` +{ + "pages": [ + { + "page_number": 1, + "image_width": (first page image width in pixels), + "image_height": (first page image height in pixels), + }, + { + "page_number": 2, + "image_width": (second page image width in pixels), + "image_height": (second page image height in pixels), + } + // additional pages + ], + "form_fields": [ + // Example for a text field. + { + "page_number": 1, + "description": "The user's last name should be entered here", + // Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap. + "field_label": "Last name", + "label_bounding_box": [30, 125, 95, 142], + "entry_bounding_box": [100, 125, 280, 142], + "entry_text": { + "text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location + "font_size": 14, // optional, defaults to 14 + "font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black) + } + }, + // Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT + { + "page_number": 2, + "description": "Checkbox that should be checked if the user is over 18", + "entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square + "field_label": "Yes", + "label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text + // Use "X" to check a checkbox. + "entry_text": { + "text": "X", + } + } + // additional form field entries + ] +} +``` + +Create validation images by running this script from this file's directory for each page: +`python scripts/create_validation_image.py + +The validation images will have red rectangles where text should be entered, and blue rectangles covering label text. + +### Step 3: Validate Bounding Boxes (REQUIRED) +#### Automated intersection check +- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the `check_bounding_boxes.py` script (run from this file's directory): +`python scripts/check_bounding_boxes.py ` + +If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not. + +#### Manual image inspection +**CRITICAL: Do not proceed without visually inspecting validation images** +- Red rectangles must ONLY cover input areas +- Red rectangles MUST NOT contain any text +- Blue rectangles should contain label text +- For checkboxes: + - Red rectangle MUST be centered on the checkbox square + - Blue rectangle should cover the text label for the checkbox + +- If any rectangles look wrong, fix fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate. + + +### Step 4: Add annotations to the PDF +Run this script from this file's directory to create a filled-out PDF using the information in fields.json: +`python scripts/fill_pdf_form_with_annotations.py diff --git a/skills/document-skills/pdf/reference.md b/skills/document-skills/pdf/reference.md new file mode 100644 index 0000000..41400bf --- /dev/null +++ b/skills/document-skills/pdf/reference.md @@ -0,0 +1,612 @@ +# PDF Processing Advanced Reference + +This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions. + +## pypdfium2 Library (Apache/BSD License) + +### Overview +pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement. + +### Render PDF to Images +```python +import pypdfium2 as pdfium +from PIL import Image + +# Load PDF +pdf = pdfium.PdfDocument("document.pdf") + +# Render page to image +page = pdf[0] # First page +bitmap = page.render( + scale=2.0, # Higher resolution + rotation=0 # No rotation +) + +# Convert to PIL Image +img = bitmap.to_pil() +img.save("page_1.png", "PNG") + +# Process multiple pages +for i, page in enumerate(pdf): + bitmap = page.render(scale=1.5) + img = bitmap.to_pil() + img.save(f"page_{i+1}.jpg", "JPEG", quality=90) +``` + +### Extract Text with pypdfium2 +```python +import pypdfium2 as pdfium + +pdf = pdfium.PdfDocument("document.pdf") +for i, page in enumerate(pdf): + text = page.get_text() + print(f"Page {i+1} text length: {len(text)} chars") +``` + +## JavaScript Libraries + +### pdf-lib (MIT License) + +pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment. + +#### Load and Manipulate Existing PDF +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function manipulatePDF() { + // Load existing PDF + const existingPdfBytes = fs.readFileSync('input.pdf'); + const pdfDoc = await PDFDocument.load(existingPdfBytes); + + // Get page count + const pageCount = pdfDoc.getPageCount(); + console.log(`Document has ${pageCount} pages`); + + // Add new page + const newPage = pdfDoc.addPage([600, 400]); + newPage.drawText('Added by pdf-lib', { + x: 100, + y: 300, + size: 16 + }); + + // Save modified PDF + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('modified.pdf', pdfBytes); +} +``` + +#### Create Complex PDFs from Scratch +```javascript +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import fs from 'fs'; + +async function createPDF() { + const pdfDoc = await PDFDocument.create(); + + // Add fonts + const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); + const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // Add page + const page = pdfDoc.addPage([595, 842]); // A4 size + const { width, height } = page.getSize(); + + // Add text with styling + page.drawText('Invoice #12345', { + x: 50, + y: height - 50, + size: 18, + font: helveticaBold, + color: rgb(0.2, 0.2, 0.8) + }); + + // Add rectangle (header background) + page.drawRectangle({ + x: 40, + y: height - 100, + width: width - 80, + height: 30, + color: rgb(0.9, 0.9, 0.9) + }); + + // Add table-like content + const items = [ + ['Item', 'Qty', 'Price', 'Total'], + ['Widget', '2', '$50', '$100'], + ['Gadget', '1', '$75', '$75'] + ]; + + let yPos = height - 150; + items.forEach(row => { + let xPos = 50; + row.forEach(cell => { + page.drawText(cell, { + x: xPos, + y: yPos, + size: 12, + font: helveticaFont + }); + xPos += 120; + }); + yPos -= 25; + }); + + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('created.pdf', pdfBytes); +} +``` + +#### Advanced Merge and Split Operations +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function mergePDFs() { + // Create new document + const mergedPdf = await PDFDocument.create(); + + // Load source PDFs + const pdf1Bytes = fs.readFileSync('doc1.pdf'); + const pdf2Bytes = fs.readFileSync('doc2.pdf'); + + const pdf1 = await PDFDocument.load(pdf1Bytes); + const pdf2 = await PDFDocument.load(pdf2Bytes); + + // Copy pages from first PDF + const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices()); + pdf1Pages.forEach(page => mergedPdf.addPage(page)); + + // Copy specific pages from second PDF (pages 0, 2, 4) + const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]); + pdf2Pages.forEach(page => mergedPdf.addPage(page)); + + const mergedPdfBytes = await mergedPdf.save(); + fs.writeFileSync('merged.pdf', mergedPdfBytes); +} +``` + +### pdfjs-dist (Apache License) + +PDF.js is Mozilla's JavaScript library for rendering PDFs in the browser. + +#### Basic PDF Loading and Rendering +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +// Configure worker (important for performance) +pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js'; + +async function renderPDF() { + // Load PDF + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + console.log(`Loaded PDF with ${pdf.numPages} pages`); + + // Get first page + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1.5 }); + + // Render to canvas + const canvas = document.createElement('canvas'); + const context = canvas.getContext('2d'); + canvas.height = viewport.height; + canvas.width = viewport.width; + + const renderContext = { + canvasContext: context, + viewport: viewport + }; + + await page.render(renderContext).promise; + document.body.appendChild(canvas); +} +``` + +#### Extract Text with Coordinates +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractText() { + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + let fullText = ''; + + // Extract text from all pages + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + + const pageText = textContent.items + .map(item => item.str) + .join(' '); + + fullText += `\n--- Page ${i} ---\n${pageText}`; + + // Get text with coordinates for advanced processing + const textWithCoords = textContent.items.map(item => ({ + text: item.str, + x: item.transform[4], + y: item.transform[5], + width: item.width, + height: item.height + })); + } + + console.log(fullText); + return fullText; +} +``` + +#### Extract Annotations and Forms +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractAnnotations() { + const loadingTask = pdfjsLib.getDocument('annotated.pdf'); + const pdf = await loadingTask.promise; + + for (let i = 1; i <= pdf.numPages; i++) { + const page = await pdf.getPage(i); + const annotations = await page.getAnnotations(); + + annotations.forEach(annotation => { + console.log(`Annotation type: ${annotation.subtype}`); + console.log(`Content: ${annotation.contents}`); + console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`); + }); + } +} +``` + +## Advanced Command-Line Operations + +### poppler-utils Advanced Features + +#### Extract Text with Bounding Box Coordinates +```bash +# Extract text with bounding box coordinates (essential for structured data) +pdftotext -bbox-layout document.pdf output.xml + +# The XML output contains precise coordinates for each text element +``` + +#### Advanced Image Conversion +```bash +# Convert to PNG images with specific resolution +pdftoppm -png -r 300 document.pdf output_prefix + +# Convert specific page range with high resolution +pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages + +# Convert to JPEG with quality setting +pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output +``` + +#### Extract Embedded Images +```bash +# Extract all embedded images with metadata +pdfimages -j -p document.pdf page_images + +# List image info without extracting +pdfimages -list document.pdf + +# Extract images in their original format +pdfimages -all document.pdf images/img +``` + +### qpdf Advanced Features + +#### Complex Page Manipulation +```bash +# Split PDF into groups of pages +qpdf --split-pages=3 input.pdf output_group_%02d.pdf + +# Extract specific pages with complex ranges +qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf + +# Merge specific pages from multiple PDFs +qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf +``` + +#### PDF Optimization and Repair +```bash +# Optimize PDF for web (linearize for streaming) +qpdf --linearize input.pdf optimized.pdf + +# Remove unused objects and compress +qpdf --optimize-level=all input.pdf compressed.pdf + +# Attempt to repair corrupted PDF structure +qpdf --check input.pdf +qpdf --fix-qdf damaged.pdf repaired.pdf + +# Show detailed PDF structure for debugging +qpdf --show-all-pages input.pdf > structure.txt +``` + +#### Advanced Encryption +```bash +# Add password protection with specific permissions +qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf + +# Check encryption status +qpdf --show-encryption encrypted.pdf + +# Remove password protection (requires password) +qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf +``` + +## Advanced Python Techniques + +### pdfplumber Advanced Features + +#### Extract Text with Precise Coordinates +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + page = pdf.pages[0] + + # Extract all text with coordinates + chars = page.chars + for char in chars[:10]: # First 10 characters + print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}") + + # Extract text by bounding box (left, top, right, bottom) + bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text() +``` + +#### Advanced Table Extraction with Custom Settings +```python +import pdfplumber +import pandas as pd + +with pdfplumber.open("complex_table.pdf") as pdf: + page = pdf.pages[0] + + # Extract tables with custom settings for complex layouts + table_settings = { + "vertical_strategy": "lines", + "horizontal_strategy": "lines", + "snap_tolerance": 3, + "intersection_tolerance": 15 + } + tables = page.extract_tables(table_settings) + + # Visual debugging for table extraction + img = page.to_image(resolution=150) + img.save("debug_layout.png") +``` + +### reportlab Advanced Features + +#### Create Professional Reports with Tables +```python +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib import colors + +# Sample data +data = [ + ['Product', 'Q1', 'Q2', 'Q3', 'Q4'], + ['Widgets', '120', '135', '142', '158'], + ['Gadgets', '85', '92', '98', '105'] +] + +# Create PDF with table +doc = SimpleDocTemplate("report.pdf") +elements = [] + +# Add title +styles = getSampleStyleSheet() +title = Paragraph("Quarterly Sales Report", styles['Title']) +elements.append(title) + +# Add table with advanced styling +table = Table(data) +table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.grey), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 14), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + ('BACKGROUND', (0, 1), (-1, -1), colors.beige), + ('GRID', (0, 0), (-1, -1), 1, colors.black) +])) +elements.append(table) + +doc.build(elements) +``` + +## Complex Workflows + +### Extract Figures/Images from PDF + +#### Method 1: Using pdfimages (fastest) +```bash +# Extract all images with original quality +pdfimages -all document.pdf images/img +``` + +#### Method 2: Using pypdfium2 + Image Processing +```python +import pypdfium2 as pdfium +from PIL import Image +import numpy as np + +def extract_figures(pdf_path, output_dir): + pdf = pdfium.PdfDocument(pdf_path) + + for page_num, page in enumerate(pdf): + # Render high-resolution page + bitmap = page.render(scale=3.0) + img = bitmap.to_pil() + + # Convert to numpy for processing + img_array = np.array(img) + + # Simple figure detection (non-white regions) + mask = np.any(img_array != [255, 255, 255], axis=2) + + # Find contours and extract bounding boxes + # (This is simplified - real implementation would need more sophisticated detection) + + # Save detected figures + # ... implementation depends on specific needs +``` + +### Batch PDF Processing with Error Handling +```python +import os +import glob +from pypdf import PdfReader, PdfWriter +import logging + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def batch_process_pdfs(input_dir, operation='merge'): + pdf_files = glob.glob(os.path.join(input_dir, "*.pdf")) + + if operation == 'merge': + writer = PdfWriter() + for pdf_file in pdf_files: + try: + reader = PdfReader(pdf_file) + for page in reader.pages: + writer.add_page(page) + logger.info(f"Processed: {pdf_file}") + except Exception as e: + logger.error(f"Failed to process {pdf_file}: {e}") + continue + + with open("batch_merged.pdf", "wb") as output: + writer.write(output) + + elif operation == 'extract_text': + for pdf_file in pdf_files: + try: + reader = PdfReader(pdf_file) + text = "" + for page in reader.pages: + text += page.extract_text() + + output_file = pdf_file.replace('.pdf', '.txt') + with open(output_file, 'w', encoding='utf-8') as f: + f.write(text) + logger.info(f"Extracted text from: {pdf_file}") + + except Exception as e: + logger.error(f"Failed to extract text from {pdf_file}: {e}") + continue +``` + +### Advanced PDF Cropping +```python +from pypdf import PdfWriter, PdfReader + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +# Crop page (left, bottom, right, top in points) +page = reader.pages[0] +page.mediabox.left = 50 +page.mediabox.bottom = 50 +page.mediabox.right = 550 +page.mediabox.top = 750 + +writer.add_page(page) +with open("cropped.pdf", "wb") as output: + writer.write(output) +``` + +## Performance Optimization Tips + +### 1. For Large PDFs +- Use streaming approaches instead of loading entire PDF in memory +- Use `qpdf --split-pages` for splitting large files +- Process pages individually with pypdfium2 + +### 2. For Text Extraction +- `pdftotext -bbox-layout` is fastest for plain text extraction +- Use pdfplumber for structured data and tables +- Avoid `pypdf.extract_text()` for very large documents + +### 3. For Image Extraction +- `pdfimages` is much faster than rendering pages +- Use low resolution for previews, high resolution for final output + +### 4. For Form Filling +- pdf-lib maintains form structure better than most alternatives +- Pre-validate form fields before processing + +### 5. Memory Management +```python +# Process PDFs in chunks +def process_large_pdf(pdf_path, chunk_size=10): + reader = PdfReader(pdf_path) + total_pages = len(reader.pages) + + for start_idx in range(0, total_pages, chunk_size): + end_idx = min(start_idx + chunk_size, total_pages) + writer = PdfWriter() + + for i in range(start_idx, end_idx): + writer.add_page(reader.pages[i]) + + # Process chunk + with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output: + writer.write(output) +``` + +## Troubleshooting Common Issues + +### Encrypted PDFs +```python +# Handle password-protected PDFs +from pypdf import PdfReader + +try: + reader = PdfReader("encrypted.pdf") + if reader.is_encrypted: + reader.decrypt("password") +except Exception as e: + print(f"Failed to decrypt: {e}") +``` + +### Corrupted PDFs +```bash +# Use qpdf to repair +qpdf --check corrupted.pdf +qpdf --replace-input corrupted.pdf +``` + +### Text Extraction Issues +```python +# Fallback to OCR for scanned PDFs +import pytesseract +from pdf2image import convert_from_path + +def extract_text_with_ocr(pdf_path): + images = convert_from_path(pdf_path) + text = "" + for i, image in enumerate(images): + text += pytesseract.image_to_string(image) + return text +``` + +## License Information + +- **pypdf**: BSD License +- **pdfplumber**: MIT License +- **pypdfium2**: Apache/BSD License +- **reportlab**: BSD License +- **poppler-utils**: GPL-2 License +- **qpdf**: Apache License +- **pdf-lib**: MIT License +- **pdfjs-dist**: Apache License \ No newline at end of file diff --git a/skills/document-skills/pdf/scripts/check_bounding_boxes.py b/skills/document-skills/pdf/scripts/check_bounding_boxes.py new file mode 100644 index 0000000..7443660 --- /dev/null +++ b/skills/document-skills/pdf/scripts/check_bounding_boxes.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass +import json +import sys + + +# Script to check that the `fields.json` file that Claude creates when analyzing PDFs +# does not have overlapping bounding boxes. See forms.md. + + +@dataclass +class RectAndField: + rect: list[float] + rect_type: str + field: dict + + +# Returns a list of messages that are printed to stdout for Claude to read. +def get_bounding_box_messages(fields_json_stream) -> list[str]: + messages = [] + fields = json.load(fields_json_stream) + messages.append(f"Read {len(fields['form_fields'])} fields") + + def rects_intersect(r1, r2): + disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0] + disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1] + return not (disjoint_horizontal or disjoint_vertical) + + rects_and_fields = [] + for f in fields["form_fields"]: + rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f)) + rects_and_fields.append(RectAndField(f["entry_bounding_box"], "entry", f)) + + has_error = False + for i, ri in enumerate(rects_and_fields): + # This is O(N^2); we can optimize if it becomes a problem. + for j in range(i + 1, len(rects_and_fields)): + rj = rects_and_fields[j] + if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect): + has_error = True + if ri.field is rj.field: + messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})") + else: + messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})") + if len(messages) >= 20: + messages.append("Aborting further checks; fix bounding boxes and try again") + return messages + if ri.rect_type == "entry": + if "entry_text" in ri.field: + font_size = ri.field["entry_text"].get("font_size", 14) + entry_height = ri.rect[3] - ri.rect[1] + if entry_height < font_size: + has_error = True + messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.") + if len(messages) >= 20: + messages.append("Aborting further checks; fix bounding boxes and try again") + return messages + + if not has_error: + messages.append("SUCCESS: All bounding boxes are valid") + return messages + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: check_bounding_boxes.py [fields.json]") + sys.exit(1) + # Input file should be in the `fields.json` format described in forms.md. + with open(sys.argv[1]) as f: + messages = get_bounding_box_messages(f) + for msg in messages: + print(msg) diff --git a/skills/document-skills/pdf/scripts/check_bounding_boxes_test.py b/skills/document-skills/pdf/scripts/check_bounding_boxes_test.py new file mode 100644 index 0000000..1dbb463 --- /dev/null +++ b/skills/document-skills/pdf/scripts/check_bounding_boxes_test.py @@ -0,0 +1,226 @@ +import unittest +import json +import io +from check_bounding_boxes import get_bounding_box_messages + + +# Currently this is not run automatically in CI; it's just for documentation and manual checking. +class TestGetBoundingBoxMessages(unittest.TestCase): + + def create_json_stream(self, data): + """Helper to create a JSON stream from data""" + return io.StringIO(json.dumps(data)) + + def test_no_intersections(self): + """Test case with no bounding box intersections""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 1, + "label_bounding_box": [10, 40, 50, 60], + "entry_bounding_box": [60, 40, 150, 60] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_label_entry_intersection_same_field(self): + """Test intersection between label and entry of the same field""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 60, 30], + "entry_bounding_box": [50, 10, 150, 30] # Overlaps with label + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_intersection_between_different_fields(self): + """Test intersection between bounding boxes of different fields""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 1, + "label_bounding_box": [40, 20, 80, 40], # Overlaps with Name's boxes + "entry_bounding_box": [160, 10, 250, 30] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_different_pages_no_intersection(self): + """Test that boxes on different pages don't count as intersecting""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 2, + "label_bounding_box": [10, 10, 50, 30], # Same coordinates but different page + "entry_bounding_box": [60, 10, 150, 30] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_entry_height_too_small(self): + """Test that entry box height is checked against font size""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20], # Height is 10 + "entry_text": { + "font_size": 14 # Font size larger than height + } + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_entry_height_adequate(self): + """Test that adequate entry box height passes""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30], # Height is 20 + "entry_text": { + "font_size": 14 # Font size smaller than height + } + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_default_font_size(self): + """Test that default font size is used when not specified""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20], # Height is 10 + "entry_text": {} # No font_size specified, should use default 14 + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_no_entry_text(self): + """Test that missing entry_text doesn't cause height check""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20] # Small height but no entry_text + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_multiple_errors_limit(self): + """Test that error messages are limited to prevent excessive output""" + fields = [] + # Create many overlapping fields + for i in range(25): + fields.append({ + "description": f"Field{i}", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], # All overlap + "entry_bounding_box": [20, 15, 60, 35] # All overlap + }) + + data = {"form_fields": fields} + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + # Should abort after ~20 messages + self.assertTrue(any("Aborting" in msg for msg in messages)) + # Should have some FAILURE messages but not hundreds + failure_count = sum(1 for msg in messages if "FAILURE" in msg) + self.assertGreater(failure_count, 0) + self.assertLess(len(messages), 30) # Should be limited + + def test_edge_touching_boxes(self): + """Test that boxes touching at edges don't count as intersecting""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [50, 10, 150, 30] # Touches at x=50 + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + +if __name__ == '__main__': + unittest.main() diff --git a/skills/document-skills/pdf/scripts/check_fillable_fields.py b/skills/document-skills/pdf/scripts/check_fillable_fields.py new file mode 100644 index 0000000..dc43d18 --- /dev/null +++ b/skills/document-skills/pdf/scripts/check_fillable_fields.py @@ -0,0 +1,12 @@ +import sys +from pypdf import PdfReader + + +# Script for Claude to run to determine whether a PDF has fillable form fields. See forms.md. + + +reader = PdfReader(sys.argv[1]) +if (reader.get_fields()): + print("This PDF has fillable form fields") +else: + print("This PDF does not have fillable form fields; you will need to visually determine where to enter data") diff --git a/skills/document-skills/pdf/scripts/convert_pdf_to_images.py b/skills/document-skills/pdf/scripts/convert_pdf_to_images.py new file mode 100644 index 0000000..f8a4ec5 --- /dev/null +++ b/skills/document-skills/pdf/scripts/convert_pdf_to_images.py @@ -0,0 +1,35 @@ +import os +import sys + +from pdf2image import convert_from_path + + +# Converts each page of a PDF to a PNG image. + + +def convert(pdf_path, output_dir, max_dim=1000): + images = convert_from_path(pdf_path, dpi=200) + + for i, image in enumerate(images): + # Scale image if needed to keep width/height under `max_dim` + width, height = image.size + if width > max_dim or height > max_dim: + scale_factor = min(max_dim / width, max_dim / height) + new_width = int(width * scale_factor) + new_height = int(height * scale_factor) + image = image.resize((new_width, new_height)) + + image_path = os.path.join(output_dir, f"page_{i+1}.png") + image.save(image_path) + print(f"Saved page {i+1} as {image_path} (size: {image.size})") + + print(f"Converted {len(images)} pages to PNG images") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: convert_pdf_to_images.py [input pdf] [output directory]") + sys.exit(1) + pdf_path = sys.argv[1] + output_directory = sys.argv[2] + convert(pdf_path, output_directory) diff --git a/skills/document-skills/pdf/scripts/create_validation_image.py b/skills/document-skills/pdf/scripts/create_validation_image.py new file mode 100644 index 0000000..4913f8f --- /dev/null +++ b/skills/document-skills/pdf/scripts/create_validation_image.py @@ -0,0 +1,41 @@ +import json +import sys + +from PIL import Image, ImageDraw + + +# Creates "validation" images with rectangles for the bounding box information that +# Claude creates when determining where to add text annotations in PDFs. See forms.md. + + +def create_validation_image(page_number, fields_json_path, input_path, output_path): + # Input file should be in the `fields.json` format described in forms.md. + with open(fields_json_path, 'r') as f: + data = json.load(f) + + img = Image.open(input_path) + draw = ImageDraw.Draw(img) + num_boxes = 0 + + for field in data["form_fields"]: + if field["page_number"] == page_number: + entry_box = field['entry_bounding_box'] + label_box = field['label_bounding_box'] + # Draw red rectangle over entry bounding box and blue rectangle over the label. + draw.rectangle(entry_box, outline='red', width=2) + draw.rectangle(label_box, outline='blue', width=2) + num_boxes += 2 + + img.save(output_path) + print(f"Created validation image at {output_path} with {num_boxes} bounding boxes") + + +if __name__ == "__main__": + if len(sys.argv) != 5: + print("Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]") + sys.exit(1) + page_number = int(sys.argv[1]) + fields_json_path = sys.argv[2] + input_image_path = sys.argv[3] + output_image_path = sys.argv[4] + create_validation_image(page_number, fields_json_path, input_image_path, output_image_path) diff --git a/skills/document-skills/pdf/scripts/extract_form_field_info.py b/skills/document-skills/pdf/scripts/extract_form_field_info.py new file mode 100644 index 0000000..f42a2df --- /dev/null +++ b/skills/document-skills/pdf/scripts/extract_form_field_info.py @@ -0,0 +1,152 @@ +import json +import sys + +from pypdf import PdfReader + + +# Extracts data for the fillable form fields in a PDF and outputs JSON that +# Claude uses to fill the fields. See forms.md. + + +# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods. +def get_full_annotation_field_id(annotation): + components = [] + while annotation: + field_name = annotation.get('/T') + if field_name: + components.append(field_name) + annotation = annotation.get('/Parent') + return ".".join(reversed(components)) if components else None + + +def make_field_dict(field, field_id): + field_dict = {"field_id": field_id} + ft = field.get('/FT') + if ft == "/Tx": + field_dict["type"] = "text" + elif ft == "/Btn": + field_dict["type"] = "checkbox" # radio groups handled separately + states = field.get("/_States_", []) + if len(states) == 2: + # "/Off" seems to always be the unchecked value, as suggested by + # https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448 + # It can be either first or second in the "/_States_" list. + if "/Off" in states: + field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1] + field_dict["unchecked_value"] = "/Off" + else: + print(f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results.") + field_dict["checked_value"] = states[0] + field_dict["unchecked_value"] = states[1] + elif ft == "/Ch": + field_dict["type"] = "choice" + states = field.get("/_States_", []) + field_dict["choice_options"] = [{ + "value": state[0], + "text": state[1], + } for state in states] + else: + field_dict["type"] = f"unknown ({ft})" + return field_dict + + +# Returns a list of fillable PDF fields: +# [ +# { +# "field_id": "name", +# "page": 1, +# "type": ("text", "checkbox", "radio_group", or "choice") +# // Per-type additional fields described in forms.md +# }, +# ] +def get_field_info(reader: PdfReader): + fields = reader.get_fields() + + field_info_by_id = {} + possible_radio_names = set() + + for field_id, field in fields.items(): + # Skip if this is a container field with children, except that it might be + # a parent group for radio button options. + if field.get("/Kids"): + if field.get("/FT") == "/Btn": + possible_radio_names.add(field_id) + continue + field_info_by_id[field_id] = make_field_dict(field, field_id) + + # Bounding rects are stored in annotations in page objects. + + # Radio button options have a separate annotation for each choice; + # all choices have the same field name. + # See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html + radio_fields_by_id = {} + + for page_index, page in enumerate(reader.pages): + annotations = page.get('/Annots', []) + for ann in annotations: + field_id = get_full_annotation_field_id(ann) + if field_id in field_info_by_id: + field_info_by_id[field_id]["page"] = page_index + 1 + field_info_by_id[field_id]["rect"] = ann.get('/Rect') + elif field_id in possible_radio_names: + try: + # ann['/AP']['/N'] should have two items. One of them is '/Off', + # the other is the active value. + on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"] + except KeyError: + continue + if len(on_values) == 1: + rect = ann.get("/Rect") + if field_id not in radio_fields_by_id: + radio_fields_by_id[field_id] = { + "field_id": field_id, + "type": "radio_group", + "page": page_index + 1, + "radio_options": [], + } + # Note: at least on macOS 15.7, Preview.app doesn't show selected + # radio buttons correctly. (It does if you remove the leading slash + # from the value, but that causes them not to appear correctly in + # Chrome/Firefox/Acrobat/etc). + radio_fields_by_id[field_id]["radio_options"].append({ + "value": on_values[0], + "rect": rect, + }) + + # Some PDFs have form field definitions without corresponding annotations, + # so we can't tell where they are. Ignore these fields for now. + fields_with_location = [] + for field_info in field_info_by_id.values(): + if "page" in field_info: + fields_with_location.append(field_info) + else: + print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring") + + # Sort by page number, then Y position (flipped in PDF coordinate system), then X. + def sort_key(f): + if "radio_options" in f: + rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] + else: + rect = f.get("rect") or [0, 0, 0, 0] + adjusted_position = [-rect[1], rect[0]] + return [f.get("page"), adjusted_position] + + sorted_fields = fields_with_location + list(radio_fields_by_id.values()) + sorted_fields.sort(key=sort_key) + + return sorted_fields + + +def write_field_info(pdf_path: str, json_output_path: str): + reader = PdfReader(pdf_path) + field_info = get_field_info(reader) + with open(json_output_path, "w") as f: + json.dump(field_info, f, indent=2) + print(f"Wrote {len(field_info)} fields to {json_output_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: extract_form_field_info.py [input pdf] [output json]") + sys.exit(1) + write_field_info(sys.argv[1], sys.argv[2]) diff --git a/skills/document-skills/pdf/scripts/fill_fillable_fields.py b/skills/document-skills/pdf/scripts/fill_fillable_fields.py new file mode 100644 index 0000000..ac35753 --- /dev/null +++ b/skills/document-skills/pdf/scripts/fill_fillable_fields.py @@ -0,0 +1,114 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter + +from extract_form_field_info import get_field_info + + +# Fills fillable form fields in a PDF. See forms.md. + + +def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str): + with open(fields_json_path) as f: + fields = json.load(f) + # Group by page number. + fields_by_page = {} + for field in fields: + if "value" in field: + field_id = field["field_id"] + page = field["page"] + if page not in fields_by_page: + fields_by_page[page] = {} + fields_by_page[page][field_id] = field["value"] + + reader = PdfReader(input_pdf_path) + + has_error = False + field_info = get_field_info(reader) + fields_by_ids = {f["field_id"]: f for f in field_info} + for field in fields: + existing_field = fields_by_ids.get(field["field_id"]) + if not existing_field: + has_error = True + print(f"ERROR: `{field['field_id']}` is not a valid field ID") + elif field["page"] != existing_field["page"]: + has_error = True + print(f"ERROR: Incorrect page number for `{field['field_id']}` (got {field['page']}, expected {existing_field['page']})") + else: + if "value" in field: + err = validation_error_for_field_value(existing_field, field["value"]) + if err: + print(err) + has_error = True + if has_error: + sys.exit(1) + + writer = PdfWriter(clone_from=reader) + for page, field_values in fields_by_page.items(): + writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False) + + # This seems to be necessary for many PDF viewers to format the form values correctly. + # It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes. + writer.set_need_appearances_writer(True) + + with open(output_pdf_path, "wb") as f: + writer.write(f) + + +def validation_error_for_field_value(field_info, field_value): + field_type = field_info["type"] + field_id = field_info["field_id"] + if field_type == "checkbox": + checked_val = field_info["checked_value"] + unchecked_val = field_info["unchecked_value"] + if field_value != checked_val and field_value != unchecked_val: + return f'ERROR: Invalid value "{field_value}" for checkbox field "{field_id}". The checked value is "{checked_val}" and the unchecked value is "{unchecked_val}"' + elif field_type == "radio_group": + option_values = [opt["value"] for opt in field_info["radio_options"]] + if field_value not in option_values: + return f'ERROR: Invalid value "{field_value}" for radio group field "{field_id}". Valid values are: {option_values}' + elif field_type == "choice": + choice_values = [opt["value"] for opt in field_info["choice_options"]] + if field_value not in choice_values: + return f'ERROR: Invalid value "{field_value}" for choice field "{field_id}". Valid values are: {choice_values}' + return None + + +# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field. +# In _writer.py around line 966: +# +# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0: +# txt = "\n".join(annotation.get_inherited(FA.Opt, [])) +# +# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like +# [["value1", "Text 1"], ["value2", "Text 2"], ...] +# This causes `join` to throw a TypeError because it expects an iterable of strings. +# The horrible workaround is to patch `get_inherited` to return a list of the value strings. +# We call the original method and adjust the return value only if the argument to `get_inherited` +# is `FA.Opt` and if the return value is a list of two-element lists. +def monkeypatch_pydpf_method(): + from pypdf.generic import DictionaryObject + from pypdf.constants import FieldDictionaryAttributes + + original_get_inherited = DictionaryObject.get_inherited + + def patched_get_inherited(self, key: str, default = None): + result = original_get_inherited(self, key, default) + if key == FieldDictionaryAttributes.Opt: + if isinstance(result, list) and all(isinstance(v, list) and len(v) == 2 for v in result): + result = [r[0] for r in result] + return result + + DictionaryObject.get_inherited = patched_get_inherited + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]") + sys.exit(1) + monkeypatch_pydpf_method() + input_pdf = sys.argv[1] + fields_json = sys.argv[2] + output_pdf = sys.argv[3] + fill_pdf_fields(input_pdf, fields_json, output_pdf) diff --git a/skills/document-skills/pdf/scripts/fill_pdf_form_with_annotations.py b/skills/document-skills/pdf/scripts/fill_pdf_form_with_annotations.py new file mode 100644 index 0000000..f980531 --- /dev/null +++ b/skills/document-skills/pdf/scripts/fill_pdf_form_with_annotations.py @@ -0,0 +1,108 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import FreeText + + +# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md. + + +def transform_coordinates(bbox, image_width, image_height, pdf_width, pdf_height): + """Transform bounding box from image coordinates to PDF coordinates""" + # Image coordinates: origin at top-left, y increases downward + # PDF coordinates: origin at bottom-left, y increases upward + x_scale = pdf_width / image_width + y_scale = pdf_height / image_height + + left = bbox[0] * x_scale + right = bbox[2] * x_scale + + # Flip Y coordinates for PDF + top = pdf_height - (bbox[1] * y_scale) + bottom = pdf_height - (bbox[3] * y_scale) + + return left, bottom, right, top + + +def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): + """Fill the PDF form with data from fields.json""" + + # `fields.json` format described in forms.md. + with open(fields_json_path, "r") as f: + fields_data = json.load(f) + + # Open the PDF + reader = PdfReader(input_pdf_path) + writer = PdfWriter() + + # Copy all pages to writer + writer.append(reader) + + # Get PDF dimensions for each page + pdf_dimensions = {} + for i, page in enumerate(reader.pages): + mediabox = page.mediabox + pdf_dimensions[i + 1] = [mediabox.width, mediabox.height] + + # Process each form field + annotations = [] + for field in fields_data["form_fields"]: + page_num = field["page_number"] + + # Get page dimensions and transform coordinates. + page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num) + image_width = page_info["image_width"] + image_height = page_info["image_height"] + pdf_width, pdf_height = pdf_dimensions[page_num] + + transformed_entry_box = transform_coordinates( + field["entry_bounding_box"], + image_width, image_height, + pdf_width, pdf_height + ) + + # Skip empty fields + if "entry_text" not in field or "text" not in field["entry_text"]: + continue + entry_text = field["entry_text"] + text = entry_text["text"] + if not text: + continue + + font_name = entry_text.get("font", "Arial") + font_size = str(entry_text.get("font_size", 14)) + "pt" + font_color = entry_text.get("font_color", "000000") + + # Font size/color seems to not work reliably across viewers: + # https://github.com/py-pdf/pypdf/issues/2084 + annotation = FreeText( + text=text, + rect=transformed_entry_box, + font=font_name, + font_size=font_size, + font_color=font_color, + border_color=None, + background_color=None, + ) + annotations.append(annotation) + # page_number is 0-based for pypdf + writer.add_annotation(page_number=page_num - 1, annotation=annotation) + + # Save the filled PDF + with open(output_pdf_path, "wb") as output: + writer.write(output) + + print(f"Successfully filled PDF form and saved to {output_pdf_path}") + print(f"Added {len(annotations)} text annotations") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_pdf_form_with_annotations.py [input pdf] [fields.json] [output pdf]") + sys.exit(1) + input_pdf = sys.argv[1] + fields_json = sys.argv[2] + output_pdf = sys.argv[3] + + fill_pdf_form(input_pdf, fields_json, output_pdf) \ No newline at end of file diff --git a/skills/document-skills/pptx/LICENSE.txt b/skills/document-skills/pptx/LICENSE.txt new file mode 100644 index 0000000..c55ab42 --- /dev/null +++ b/skills/document-skills/pptx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/skills/document-skills/pptx/SKILL.md b/skills/document-skills/pptx/SKILL.md new file mode 100644 index 0000000..b93b875 --- /dev/null +++ b/skills/document-skills/pptx/SKILL.md @@ -0,0 +1,484 @@ +--- +name: pptx +description: "Presentation creation, editing, and analysis. When Claude needs to work with presentations (.pptx files) for: (1) Creating new presentations, (2) Modifying or editing content, (3) Working with layouts, (4) Adding comments or speaker notes, or any other presentation tasks" +license: Proprietary. LICENSE.txt has complete terms +--- + +# PPTX creation, editing, and analysis + +## Overview + +A user may ask you to create, edit, or analyze the contents of a .pptx file. A .pptx file is essentially a ZIP archive containing XML files and other resources that you can read or edit. You have different tools and workflows available for different tasks. + +## Reading and analyzing content + +### Text extraction +If you just need to read the text contents of a presentation, you should convert the document to markdown: + +```bash +# Convert document to markdown +python -m markitdown path-to-file.pptx +``` + +### Raw XML access +You need raw XML access for: comments, speaker notes, slide layouts, animations, design elements, and complex formatting. For any of these features, you'll need to unpack a presentation and read its raw XML contents. + +#### Unpacking a file +`python ooxml/scripts/unpack.py ` + +**Note**: The unpack.py script is located at `skills/pptx/ooxml/scripts/unpack.py` relative to the project root. If the script doesn't exist at this path, use `find . -name "unpack.py"` to locate it. + +#### Key file structures +* `ppt/presentation.xml` - Main presentation metadata and slide references +* `ppt/slides/slide{N}.xml` - Individual slide contents (slide1.xml, slide2.xml, etc.) +* `ppt/notesSlides/notesSlide{N}.xml` - Speaker notes for each slide +* `ppt/comments/modernComment_*.xml` - Comments for specific slides +* `ppt/slideLayouts/` - Layout templates for slides +* `ppt/slideMasters/` - Master slide templates +* `ppt/theme/` - Theme and styling information +* `ppt/media/` - Images and other media files + +#### Typography and color extraction +**When given an example design to emulate**: Always analyze the presentation's typography and colors first using the methods below: +1. **Read theme file**: Check `ppt/theme/theme1.xml` for colors (``) and fonts (``) +2. **Sample slide content**: Examine `ppt/slides/slide1.xml` for actual font usage (``) and colors +3. **Search for patterns**: Use grep to find color (``, ``) and font references across all XML files + +## Creating a new PowerPoint presentation **without a template** + +When creating a new PowerPoint presentation from scratch, use the **html2pptx** workflow to convert HTML slides to PowerPoint with accurate positioning. + +### Design Principles + +**CRITICAL**: Before creating any presentation, analyze the content and choose appropriate design elements: +1. **Consider the subject matter**: What is this presentation about? What tone, industry, or mood does it suggest? +2. **Check for branding**: If the user mentions a company/organization, consider their brand colors and identity +3. **Match palette to content**: Select colors that reflect the subject +4. **State your approach**: Explain your design choices before writing code + +**Requirements**: +- ✅ State your content-informed design approach BEFORE writing code +- ✅ Use web-safe fonts only: Arial, Helvetica, Times New Roman, Georgia, Courier New, Verdana, Tahoma, Trebuchet MS, Impact +- ✅ Create clear visual hierarchy through size, weight, and color +- ✅ Ensure readability: strong contrast, appropriately sized text, clean alignment +- ✅ Be consistent: repeat patterns, spacing, and visual language across slides + +#### Color Palette Selection + +**Choosing colors creatively**: +- **Think beyond defaults**: What colors genuinely match this specific topic? Avoid autopilot choices. +- **Consider multiple angles**: Topic, industry, mood, energy level, target audience, brand identity (if mentioned) +- **Be adventurous**: Try unexpected combinations - a healthcare presentation doesn't have to be green, finance doesn't have to be navy +- **Build your palette**: Pick 3-5 colors that work together (dominant colors + supporting tones + accent) +- **Ensure contrast**: Text must be clearly readable on backgrounds + +**Example color palettes** (use these to spark creativity - choose one, adapt it, or create your own): + +1. **Classic Blue**: Deep navy (#1C2833), slate gray (#2E4053), silver (#AAB7B8), off-white (#F4F6F6) +2. **Teal & Coral**: Teal (#5EA8A7), deep teal (#277884), coral (#FE4447), white (#FFFFFF) +3. **Bold Red**: Red (#C0392B), bright red (#E74C3C), orange (#F39C12), yellow (#F1C40F), green (#2ECC71) +4. **Warm Blush**: Mauve (#A49393), blush (#EED6D3), rose (#E8B4B8), cream (#FAF7F2) +5. **Burgundy Luxury**: Burgundy (#5D1D2E), crimson (#951233), rust (#C15937), gold (#997929) +6. **Deep Purple & Emerald**: Purple (#B165FB), dark blue (#181B24), emerald (#40695B), white (#FFFFFF) +7. **Cream & Forest Green**: Cream (#FFE1C7), forest green (#40695B), white (#FCFCFC) +8. **Pink & Purple**: Pink (#F8275B), coral (#FF574A), rose (#FF737D), purple (#3D2F68) +9. **Lime & Plum**: Lime (#C5DE82), plum (#7C3A5F), coral (#FD8C6E), blue-gray (#98ACB5) +10. **Black & Gold**: Gold (#BF9A4A), black (#000000), cream (#F4F6F6) +11. **Sage & Terracotta**: Sage (#87A96B), terracotta (#E07A5F), cream (#F4F1DE), charcoal (#2C2C2C) +12. **Charcoal & Red**: Charcoal (#292929), red (#E33737), light gray (#CCCBCB) +13. **Vibrant Orange**: Orange (#F96D00), light gray (#F2F2F2), charcoal (#222831) +14. **Forest Green**: Black (#191A19), green (#4E9F3D), dark green (#1E5128), white (#FFFFFF) +15. **Retro Rainbow**: Purple (#722880), pink (#D72D51), orange (#EB5C18), amber (#F08800), gold (#DEB600) +16. **Vintage Earthy**: Mustard (#E3B448), sage (#CBD18F), forest green (#3A6B35), cream (#F4F1DE) +17. **Coastal Rose**: Old rose (#AD7670), beaver (#B49886), eggshell (#F3ECDC), ash gray (#BFD5BE) +18. **Orange & Turquoise**: Light orange (#FC993E), grayish turquoise (#667C6F), white (#FCFCFC) + +#### Visual Details Options + +**Geometric Patterns**: +- Diagonal section dividers instead of horizontal +- Asymmetric column widths (30/70, 40/60, 25/75) +- Rotated text headers at 90° or 270° +- Circular/hexagonal frames for images +- Triangular accent shapes in corners +- Overlapping shapes for depth + +**Border & Frame Treatments**: +- Thick single-color borders (10-20pt) on one side only +- Double-line borders with contrasting colors +- Corner brackets instead of full frames +- L-shaped borders (top+left or bottom+right) +- Underline accents beneath headers (3-5pt thick) + +**Typography Treatments**: +- Extreme size contrast (72pt headlines vs 11pt body) +- All-caps headers with wide letter spacing +- Numbered sections in oversized display type +- Monospace (Courier New) for data/stats/technical content +- Condensed fonts (Arial Narrow) for dense information +- Outlined text for emphasis + +**Chart & Data Styling**: +- Monochrome charts with single accent color for key data +- Horizontal bar charts instead of vertical +- Dot plots instead of bar charts +- Minimal gridlines or none at all +- Data labels directly on elements (no legends) +- Oversized numbers for key metrics + +**Layout Innovations**: +- Full-bleed images with text overlays +- Sidebar column (20-30% width) for navigation/context +- Modular grid systems (3×3, 4×4 blocks) +- Z-pattern or F-pattern content flow +- Floating text boxes over colored shapes +- Magazine-style multi-column layouts + +**Background Treatments**: +- Solid color blocks occupying 40-60% of slide +- Gradient fills (vertical or diagonal only) +- Split backgrounds (two colors, diagonal or vertical) +- Edge-to-edge color bands +- Negative space as a design element + +### Layout Tips +**When creating slides with charts or tables:** +- **Two-column layout (PREFERRED)**: Use a header spanning the full width, then two columns below - text/bullets in one column and the featured content in the other. This provides better balance and makes charts/tables more readable. Use flexbox with unequal column widths (e.g., 40%/60% split) to optimize space for each content type. +- **Full-slide layout**: Let the featured content (chart/table) take up the entire slide for maximum impact and readability +- **NEVER vertically stack**: Do not place charts/tables below text in a single column - this causes poor readability and layout issues + +### Workflow +1. **MANDATORY - READ ENTIRE FILE**: Read [`html2pptx.md`](html2pptx.md) completely from start to finish. **NEVER set any range limits when reading this file.** Read the full file content for detailed syntax, critical formatting rules, and best practices before proceeding with presentation creation. +2. Create an HTML file for each slide with proper dimensions (e.g., 720pt × 405pt for 16:9) + - Use `

`, `

`-`

`, `
    `, `
      ` for all text content + - Use `class="placeholder"` for areas where charts/tables will be added (render with gray background for visibility) + - **CRITICAL**: Rasterize gradients and icons as PNG images FIRST using Sharp, then reference in HTML + - **LAYOUT**: For slides with charts/tables/images, use either full-slide layout or two-column layout for better readability +3. Create and run a JavaScript file using the [`html2pptx.js`](scripts/html2pptx.js) library to convert HTML slides to PowerPoint and save the presentation + - Use the `html2pptx()` function to process each HTML file + - Add charts and tables to placeholder areas using PptxGenJS API + - Save the presentation using `pptx.writeFile()` +4. **Visual validation**: Generate thumbnails and inspect for layout issues + - Create thumbnail grid: `python scripts/thumbnail.py output.pptx workspace/thumbnails --cols 4` + - Read and carefully examine the thumbnail image for: + - **Text cutoff**: Text being cut off by header bars, shapes, or slide edges + - **Text overlap**: Text overlapping with other text or shapes + - **Positioning issues**: Content too close to slide boundaries or other elements + - **Contrast issues**: Insufficient contrast between text and backgrounds + - If issues found, adjust HTML margins/spacing/colors and regenerate the presentation + - Repeat until all slides are visually correct + +## Editing an existing PowerPoint presentation + +When edit slides in an existing PowerPoint presentation, you need to work with the raw Office Open XML (OOXML) format. This involves unpacking the .pptx file, editing the XML content, and repacking it. + +### Workflow +1. **MANDATORY - READ ENTIRE FILE**: Read [`ooxml.md`](ooxml.md) (~500 lines) completely from start to finish. **NEVER set any range limits when reading this file.** Read the full file content for detailed guidance on OOXML structure and editing workflows before any presentation editing. +2. Unpack the presentation: `python ooxml/scripts/unpack.py ` +3. Edit the XML files (primarily `ppt/slides/slide{N}.xml` and related files) +4. **CRITICAL**: Validate immediately after each edit and fix any validation errors before proceeding: `python ooxml/scripts/validate.py --original ` +5. Pack the final presentation: `python ooxml/scripts/pack.py ` + +## Creating a new PowerPoint presentation **using a template** + +When you need to create a presentation that follows an existing template's design, you'll need to duplicate and re-arrange template slides before then replacing placeholder context. + +### Workflow +1. **Extract template text AND create visual thumbnail grid**: + * Extract text: `python -m markitdown template.pptx > template-content.md` + * Read `template-content.md`: Read the entire file to understand the contents of the template presentation. **NEVER set any range limits when reading this file.** + * Create thumbnail grids: `python scripts/thumbnail.py template.pptx` + * See [Creating Thumbnail Grids](#creating-thumbnail-grids) section for more details + +2. **Analyze template and save inventory to a file**: + * **Visual Analysis**: Review thumbnail grid(s) to understand slide layouts, design patterns, and visual structure + * Create and save a template inventory file at `template-inventory.md` containing: + ```markdown + # Template Inventory Analysis + **Total Slides: [count]** + **IMPORTANT: Slides are 0-indexed (first slide = 0, last slide = count-1)** + + ## [Category Name] + - Slide 0: [Layout code if available] - Description/purpose + - Slide 1: [Layout code] - Description/purpose + - Slide 2: [Layout code] - Description/purpose + [... EVERY slide must be listed individually with its index ...] + ``` + * **Using the thumbnail grid**: Reference the visual thumbnails to identify: + - Layout patterns (title slides, content layouts, section dividers) + - Image placeholder locations and counts + - Design consistency across slide groups + - Visual hierarchy and structure + * This inventory file is REQUIRED for selecting appropriate templates in the next step + +3. **Create presentation outline based on template inventory**: + * Review available templates from step 2. + * Choose an intro or title template for the first slide. This should be one of the first templates. + * Choose safe, text-based layouts for the other slides. + * **CRITICAL: Match layout structure to actual content**: + - Single-column layouts: Use for unified narrative or single topic + - Two-column layouts: Use ONLY when you have exactly 2 distinct items/concepts + - Three-column layouts: Use ONLY when you have exactly 3 distinct items/concepts + - Image + text layouts: Use ONLY when you have actual images to insert + - Quote layouts: Use ONLY for actual quotes from people (with attribution), never for emphasis + - Never use layouts with more placeholders than you have content + - If you have 2 items, don't force them into a 3-column layout + - If you have 4+ items, consider breaking into multiple slides or using a list format + * Count your actual content pieces BEFORE selecting the layout + * Verify each placeholder in the chosen layout will be filled with meaningful content + * Select one option representing the **best** layout for each content section. + * Save `outline.md` with content AND template mapping that leverages available designs + * Example template mapping: + ``` + # Template slides to use (0-based indexing) + # WARNING: Verify indices are within range! Template with 73 slides has indices 0-72 + # Mapping: slide numbers from outline -> template slide indices + template_mapping = [ + 0, # Use slide 0 (Title/Cover) + 34, # Use slide 34 (B1: Title and body) + 34, # Use slide 34 again (duplicate for second B1) + 50, # Use slide 50 (E1: Quote) + 54, # Use slide 54 (F2: Closing + Text) + ] + ``` + +4. **Duplicate, reorder, and delete slides using `rearrange.py`**: + * Use the `scripts/rearrange.py` script to create a new presentation with slides in the desired order: + ```bash + python scripts/rearrange.py template.pptx working.pptx 0,34,34,50,52 + ``` + * The script handles duplicating repeated slides, deleting unused slides, and reordering automatically + * Slide indices are 0-based (first slide is 0, second is 1, etc.) + * The same slide index can appear multiple times to duplicate that slide + +5. **Extract ALL text using the `inventory.py` script**: + * **Run inventory extraction**: + ```bash + python scripts/inventory.py working.pptx text-inventory.json + ``` + * **Read text-inventory.json**: Read the entire text-inventory.json file to understand all shapes and their properties. **NEVER set any range limits when reading this file.** + + * The inventory JSON structure: + ```json + { + "slide-0": { + "shape-0": { + "placeholder_type": "TITLE", // or null for non-placeholders + "left": 1.5, // position in inches + "top": 2.0, + "width": 7.5, + "height": 1.2, + "paragraphs": [ + { + "text": "Paragraph text", + // Optional properties (only included when non-default): + "bullet": true, // explicit bullet detected + "level": 0, // only included when bullet is true + "alignment": "CENTER", // CENTER, RIGHT (not LEFT) + "space_before": 10.0, // space before paragraph in points + "space_after": 6.0, // space after paragraph in points + "line_spacing": 22.4, // line spacing in points + "font_name": "Arial", // from first run + "font_size": 14.0, // in points + "bold": true, + "italic": false, + "underline": false, + "color": "FF0000" // RGB color + } + ] + } + } + } + ``` + + * Key features: + - **Slides**: Named as "slide-0", "slide-1", etc. + - **Shapes**: Ordered by visual position (top-to-bottom, left-to-right) as "shape-0", "shape-1", etc. + - **Placeholder types**: TITLE, CENTER_TITLE, SUBTITLE, BODY, OBJECT, or null + - **Default font size**: `default_font_size` in points extracted from layout placeholders (when available) + - **Slide numbers are filtered**: Shapes with SLIDE_NUMBER placeholder type are automatically excluded from inventory + - **Bullets**: When `bullet: true`, `level` is always included (even if 0) + - **Spacing**: `space_before`, `space_after`, and `line_spacing` in points (only included when set) + - **Colors**: `color` for RGB (e.g., "FF0000"), `theme_color` for theme colors (e.g., "DARK_1") + - **Properties**: Only non-default values are included in the output + +6. **Generate replacement text and save the data to a JSON file** + Based on the text inventory from the previous step: + - **CRITICAL**: First verify which shapes exist in the inventory - only reference shapes that are actually present + - **VALIDATION**: The replace.py script will validate that all shapes in your replacement JSON exist in the inventory + - If you reference a non-existent shape, you'll get an error showing available shapes + - If you reference a non-existent slide, you'll get an error indicating the slide doesn't exist + - All validation errors are shown at once before the script exits + - **IMPORTANT**: The replace.py script uses inventory.py internally to identify ALL text shapes + - **AUTOMATIC CLEARING**: ALL text shapes from the inventory will be cleared unless you provide "paragraphs" for them + - Add a "paragraphs" field to shapes that need content (not "replacement_paragraphs") + - Shapes without "paragraphs" in the replacement JSON will have their text cleared automatically + - Paragraphs with bullets will be automatically left aligned. Don't set the `alignment` property on when `"bullet": true` + - Generate appropriate replacement content for placeholder text + - Use shape size to determine appropriate content length + - **CRITICAL**: Include paragraph properties from the original inventory - don't just provide text + - **IMPORTANT**: When bullet: true, do NOT include bullet symbols (•, -, *) in text - they're added automatically + - **ESSENTIAL FORMATTING RULES**: + - Headers/titles should typically have `"bold": true` + - List items should have `"bullet": true, "level": 0` (level is required when bullet is true) + - Preserve any alignment properties (e.g., `"alignment": "CENTER"` for centered text) + - Include font properties when different from default (e.g., `"font_size": 14.0`, `"font_name": "Lora"`) + - Colors: Use `"color": "FF0000"` for RGB or `"theme_color": "DARK_1"` for theme colors + - The replacement script expects **properly formatted paragraphs**, not just text strings + - **Overlapping shapes**: Prefer shapes with larger default_font_size or more appropriate placeholder_type + - Save the updated inventory with replacements to `replacement-text.json` + - **WARNING**: Different template layouts have different shape counts - always check the actual inventory before creating replacements + + Example paragraphs field showing proper formatting: + ```json + "paragraphs": [ + { + "text": "New presentation title text", + "alignment": "CENTER", + "bold": true + }, + { + "text": "Section Header", + "bold": true + }, + { + "text": "First bullet point without bullet symbol", + "bullet": true, + "level": 0 + }, + { + "text": "Red colored text", + "color": "FF0000" + }, + { + "text": "Theme colored text", + "theme_color": "DARK_1" + }, + { + "text": "Regular paragraph text without special formatting" + } + ] + ``` + + **Shapes not listed in the replacement JSON are automatically cleared**: + ```json + { + "slide-0": { + "shape-0": { + "paragraphs": [...] // This shape gets new text + } + // shape-1 and shape-2 from inventory will be cleared automatically + } + } + ``` + + **Common formatting patterns for presentations**: + - Title slides: Bold text, sometimes centered + - Section headers within slides: Bold text + - Bullet lists: Each item needs `"bullet": true, "level": 0` + - Body text: Usually no special properties needed + - Quotes: May have special alignment or font properties + +7. **Apply replacements using the `replace.py` script** + ```bash + python scripts/replace.py working.pptx replacement-text.json output.pptx + ``` + + The script will: + - First extract the inventory of ALL text shapes using functions from inventory.py + - Validate that all shapes in the replacement JSON exist in the inventory + - Clear text from ALL shapes identified in the inventory + - Apply new text only to shapes with "paragraphs" defined in the replacement JSON + - Preserve formatting by applying paragraph properties from the JSON + - Handle bullets, alignment, font properties, and colors automatically + - Save the updated presentation + + Example validation errors: + ``` + ERROR: Invalid shapes in replacement JSON: + - Shape 'shape-99' not found on 'slide-0'. Available shapes: shape-0, shape-1, shape-4 + - Slide 'slide-999' not found in inventory + ``` + + ``` + ERROR: Replacement text made overflow worse in these shapes: + - slide-0/shape-2: overflow worsened by 1.25" (was 0.00", now 1.25") + ``` + +## Creating Thumbnail Grids + +To create visual thumbnail grids of PowerPoint slides for quick analysis and reference: + +```bash +python scripts/thumbnail.py template.pptx [output_prefix] +``` + +**Features**: +- Creates: `thumbnails.jpg` (or `thumbnails-1.jpg`, `thumbnails-2.jpg`, etc. for large decks) +- Default: 5 columns, max 30 slides per grid (5×6) +- Custom prefix: `python scripts/thumbnail.py template.pptx my-grid` + - Note: The output prefix should include the path if you want output in a specific directory (e.g., `workspace/my-grid`) +- Adjust columns: `--cols 4` (range: 3-6, affects slides per grid) +- Grid limits: 3 cols = 12 slides/grid, 4 cols = 20, 5 cols = 30, 6 cols = 42 +- Slides are zero-indexed (Slide 0, Slide 1, etc.) + +**Use cases**: +- Template analysis: Quickly understand slide layouts and design patterns +- Content review: Visual overview of entire presentation +- Navigation reference: Find specific slides by their visual appearance +- Quality check: Verify all slides are properly formatted + +**Examples**: +```bash +# Basic usage +python scripts/thumbnail.py presentation.pptx + +# Combine options: custom name, columns +python scripts/thumbnail.py template.pptx analysis --cols 4 +``` + +## Converting Slides to Images + +To visually analyze PowerPoint slides, convert them to images using a two-step process: + +1. **Convert PPTX to PDF**: + ```bash + soffice --headless --convert-to pdf template.pptx + ``` + +2. **Convert PDF pages to JPEG images**: + ```bash + pdftoppm -jpeg -r 150 template.pdf slide + ``` + This creates files like `slide-1.jpg`, `slide-2.jpg`, etc. + +Options: +- `-r 150`: Sets resolution to 150 DPI (adjust for quality/size balance) +- `-jpeg`: Output JPEG format (use `-png` for PNG if preferred) +- `-f N`: First page to convert (e.g., `-f 2` starts from page 2) +- `-l N`: Last page to convert (e.g., `-l 5` stops at page 5) +- `slide`: Prefix for output files + +Example for specific range: +```bash +pdftoppm -jpeg -r 150 -f 2 -l 5 template.pdf slide # Converts only pages 2-5 +``` + +## Code Style Guidelines +**IMPORTANT**: When generating code for PPTX operations: +- Write concise code +- Avoid verbose variable names and redundant operations +- Avoid unnecessary print statements + +## Dependencies + +Required dependencies (should already be installed): + +- **markitdown**: `pip install "markitdown[pptx]"` (for text extraction from presentations) +- **pptxgenjs**: `npm install -g pptxgenjs` (for creating presentations via html2pptx) +- **playwright**: `npm install -g playwright` (for HTML rendering in html2pptx) +- **react-icons**: `npm install -g react-icons react react-dom` (for icons) +- **sharp**: `npm install -g sharp` (for SVG rasterization and image processing) +- **LibreOffice**: `sudo apt-get install libreoffice` (for PDF conversion) +- **Poppler**: `sudo apt-get install poppler-utils` (for pdftoppm to convert PDF to images) +- **defusedxml**: `pip install defusedxml` (for secure XML parsing) \ No newline at end of file diff --git a/skills/document-skills/pptx/html2pptx.md b/skills/document-skills/pptx/html2pptx.md new file mode 100644 index 0000000..106adf7 --- /dev/null +++ b/skills/document-skills/pptx/html2pptx.md @@ -0,0 +1,625 @@ +# HTML to PowerPoint Guide + +Convert HTML slides to PowerPoint presentations with accurate positioning using the `html2pptx.js` library. + +## Table of Contents + +1. [Creating HTML Slides](#creating-html-slides) +2. [Using the html2pptx Library](#using-the-html2pptx-library) +3. [Using PptxGenJS](#using-pptxgenjs) + +--- + +## Creating HTML Slides + +Every HTML slide must include proper body dimensions: + +### Layout Dimensions + +- **16:9** (default): `width: 720pt; height: 405pt` +- **4:3**: `width: 720pt; height: 540pt` +- **16:10**: `width: 720pt; height: 450pt` + +### Supported Elements + +- `

      `, `

      `-`

      ` - Text with styling +- `
        `, `
          ` - Lists (never use manual bullets •, -, *) +- ``, `` - Bold text (inline formatting) +- ``, `` - Italic text (inline formatting) +- `` - Underlined text (inline formatting) +- `` - Inline formatting with CSS styles (bold, italic, underline, color) +- `
          ` - Line breaks +- `
          ` with bg/border - Becomes shape +- `` - Images +- `class="placeholder"` - Reserved space for charts (returns `{ id, x, y, w, h }`) + +### Critical Text Rules + +**ALL text MUST be inside `

          `, `

          `-`

          `, `
            `, or `
              ` tags:** +- ✅ Correct: `

              Text here

              ` +- ❌ Wrong: `
              Text here
              ` - **Text will NOT appear in PowerPoint** +- ❌ Wrong: `Text` - **Text will NOT appear in PowerPoint** +- Text in `
              ` or `` without a text tag will be silently ignored + +**NEVER use manual bullet symbols (•, -, *, etc.)** - Use `
                ` or `
                  ` lists instead + +**ONLY use web-safe fonts that are universally available:** +- ✅ Web-safe fonts: `Arial`, `Helvetica`, `Times New Roman`, `Georgia`, `Courier New`, `Verdana`, `Tahoma`, `Trebuchet MS`, `Impact`, `Comic Sans MS` +- ❌ Wrong: `'Segoe UI'`, `'SF Pro'`, `'Roboto'`, custom fonts - **Might cause rendering issues** + +### Styling + +- Use `display: flex` on body to prevent margin collapse from breaking overflow validation +- Use `margin` for spacing (padding included in size) +- Inline formatting: Use ``, ``, `` tags OR `` with CSS styles + - `` supports: `font-weight: bold`, `font-style: italic`, `text-decoration: underline`, `color: #rrggbb` + - `` does NOT support: `margin`, `padding` (not supported in PowerPoint text runs) + - Example: `Bold blue text` +- Flexbox works - positions calculated from rendered layout +- Use hex colors with `#` prefix in CSS +- **Text alignment**: Use CSS `text-align` (`center`, `right`, etc.) when needed as a hint to PptxGenJS for text formatting if text lengths are slightly off + +### Shape Styling (DIV elements only) + +**IMPORTANT: Backgrounds, borders, and shadows only work on `
                  ` elements, NOT on text elements (`

                  `, `

                  `-`

                  `, `
                    `, `
                      `)** + +- **Backgrounds**: CSS `background` or `background-color` on `
                      ` elements only + - Example: `
                      ` - Creates a shape with background +- **Borders**: CSS `border` on `
                      ` elements converts to PowerPoint shape borders + - Supports uniform borders: `border: 2px solid #333333` + - Supports partial borders: `border-left`, `border-right`, `border-top`, `border-bottom` (rendered as line shapes) + - Example: `
                      ` +- **Border radius**: CSS `border-radius` on `
                      ` elements for rounded corners + - `border-radius: 50%` or higher creates circular shape + - Percentages <50% calculated relative to shape's smaller dimension + - Supports px and pt units (e.g., `border-radius: 8pt;`, `border-radius: 12px;`) + - Example: `
                      ` on 100x200px box = 25% of 100px = 25px radius +- **Box shadows**: CSS `box-shadow` on `
                      ` elements converts to PowerPoint shadows + - Supports outer shadows only (inset shadows are ignored to prevent corruption) + - Example: `
                      ` + - Note: Inset/inner shadows are not supported by PowerPoint and will be skipped + +### Icons & Gradients + +- **CRITICAL: Never use CSS gradients (`linear-gradient`, `radial-gradient`)** - They don't convert to PowerPoint +- **ALWAYS create gradient/icon PNGs FIRST using Sharp, then reference in HTML** +- For gradients: Rasterize SVG to PNG background images +- For icons: Rasterize react-icons SVG to PNG images +- All visual effects must be pre-rendered as raster images before HTML rendering + +**Rasterizing Icons with Sharp:** + +```javascript +const React = require('react'); +const ReactDOMServer = require('react-dom/server'); +const sharp = require('sharp'); +const { FaHome } = require('react-icons/fa'); + +async function rasterizeIconPng(IconComponent, color, size = "256", filename) { + const svgString = ReactDOMServer.renderToStaticMarkup( + React.createElement(IconComponent, { color: `#${color}`, size: size }) + ); + + // Convert SVG to PNG using Sharp + await sharp(Buffer.from(svgString)) + .png() + .toFile(filename); + + return filename; +} + +// Usage: Rasterize icon before using in HTML +const iconPath = await rasterizeIconPng(FaHome, "4472c4", "256", "home-icon.png"); +// Then reference in HTML: +``` + +**Rasterizing Gradients with Sharp:** + +```javascript +const sharp = require('sharp'); + +async function createGradientBackground(filename) { + const svg = ` + + + + + + + + `; + + await sharp(Buffer.from(svg)) + .png() + .toFile(filename); + + return filename; +} + +// Usage: Create gradient background before HTML +const bgPath = await createGradientBackground("gradient-bg.png"); +// Then in HTML: +``` + +### Example + +```html + + + + + + +
                      +

                      Recipe Title

                      +
                        +
                      • Item: Description
                      • +
                      +

                      Text with bold, italic, underline.

                      +
                      + + +
                      +

                      5

                      +
                      +
                      + + +``` + +## Using the html2pptx Library + +### Dependencies + +These libraries have been globally installed and are available to use: +- `pptxgenjs` +- `playwright` +- `sharp` + +### Basic Usage + +```javascript +const pptxgen = require('pptxgenjs'); +const html2pptx = require('./html2pptx'); + +const pptx = new pptxgen(); +pptx.layout = 'LAYOUT_16x9'; // Must match HTML body dimensions + +const { slide, placeholders } = await html2pptx('slide1.html', pptx); + +// Add chart to placeholder area +if (placeholders.length > 0) { + slide.addChart(pptx.charts.LINE, chartData, placeholders[0]); +} + +await pptx.writeFile('output.pptx'); +``` + +### API Reference + +#### Function Signature +```javascript +await html2pptx(htmlFile, pres, options) +``` + +#### Parameters +- `htmlFile` (string): Path to HTML file (absolute or relative) +- `pres` (pptxgen): PptxGenJS presentation instance with layout already set +- `options` (object, optional): + - `tmpDir` (string): Temporary directory for generated files (default: `process.env.TMPDIR || '/tmp'`) + - `slide` (object): Existing slide to reuse (default: creates new slide) + +#### Returns +```javascript +{ + slide: pptxgenSlide, // The created/updated slide + placeholders: [ // Array of placeholder positions + { id: string, x: number, y: number, w: number, h: number }, + ... + ] +} +``` + +### Validation + +The library automatically validates and collects all errors before throwing: + +1. **HTML dimensions must match presentation layout** - Reports dimension mismatches +2. **Content must not overflow body** - Reports overflow with exact measurements +3. **CSS gradients** - Reports unsupported gradient usage +4. **Text element styling** - Reports backgrounds/borders/shadows on text elements (only allowed on divs) + +**All validation errors are collected and reported together** in a single error message, allowing you to fix all issues at once instead of one at a time. + +### Working with Placeholders + +```javascript +const { slide, placeholders } = await html2pptx('slide.html', pptx); + +// Use first placeholder +slide.addChart(pptx.charts.BAR, data, placeholders[0]); + +// Find by ID +const chartArea = placeholders.find(p => p.id === 'chart-area'); +slide.addChart(pptx.charts.LINE, data, chartArea); +``` + +### Complete Example + +```javascript +const pptxgen = require('pptxgenjs'); +const html2pptx = require('./html2pptx'); + +async function createPresentation() { + const pptx = new pptxgen(); + pptx.layout = 'LAYOUT_16x9'; + pptx.author = 'Your Name'; + pptx.title = 'My Presentation'; + + // Slide 1: Title + const { slide: slide1 } = await html2pptx('slides/title.html', pptx); + + // Slide 2: Content with chart + const { slide: slide2, placeholders } = await html2pptx('slides/data.html', pptx); + + const chartData = [{ + name: 'Sales', + labels: ['Q1', 'Q2', 'Q3', 'Q4'], + values: [4500, 5500, 6200, 7100] + }]; + + slide2.addChart(pptx.charts.BAR, chartData, { + ...placeholders[0], + showTitle: true, + title: 'Quarterly Sales', + showCatAxisTitle: true, + catAxisTitle: 'Quarter', + showValAxisTitle: true, + valAxisTitle: 'Sales ($000s)' + }); + + // Save + await pptx.writeFile({ fileName: 'presentation.pptx' }); + console.log('Presentation created successfully!'); +} + +createPresentation().catch(console.error); +``` + +## Using PptxGenJS + +After converting HTML to slides with `html2pptx`, you'll use PptxGenJS to add dynamic content like charts, images, and additional elements. + +### ⚠️ Critical Rules + +#### Colors +- **NEVER use `#` prefix** with hex colors in PptxGenJS - causes file corruption +- ✅ Correct: `color: "FF0000"`, `fill: { color: "0066CC" }` +- ❌ Wrong: `color: "#FF0000"` (breaks document) + +### Adding Images + +Always calculate aspect ratios from actual image dimensions: + +```javascript +// Get image dimensions: identify image.png | grep -o '[0-9]* x [0-9]*' +const imgWidth = 1860, imgHeight = 1519; // From actual file +const aspectRatio = imgWidth / imgHeight; + +const h = 3; // Max height +const w = h * aspectRatio; +const x = (10 - w) / 2; // Center on 16:9 slide + +slide.addImage({ path: "chart.png", x, y: 1.5, w, h }); +``` + +### Adding Text + +```javascript +// Rich text with formatting +slide.addText([ + { text: "Bold ", options: { bold: true } }, + { text: "Italic ", options: { italic: true } }, + { text: "Normal" } +], { + x: 1, y: 2, w: 8, h: 1 +}); +``` + +### Adding Shapes + +```javascript +// Rectangle +slide.addShape(pptx.shapes.RECTANGLE, { + x: 1, y: 1, w: 3, h: 2, + fill: { color: "4472C4" }, + line: { color: "000000", width: 2 } +}); + +// Circle +slide.addShape(pptx.shapes.OVAL, { + x: 5, y: 1, w: 2, h: 2, + fill: { color: "ED7D31" } +}); + +// Rounded rectangle +slide.addShape(pptx.shapes.ROUNDED_RECTANGLE, { + x: 1, y: 4, w: 3, h: 1.5, + fill: { color: "70AD47" }, + rectRadius: 0.2 +}); +``` + +### Adding Charts + +**Required for most charts:** Axis labels using `catAxisTitle` (category) and `valAxisTitle` (value). + +**Chart Data Format:** +- Use **single series with all labels** for simple bar/line charts +- Each series creates a separate legend entry +- Labels array defines X-axis values + +**Time Series Data - Choose Correct Granularity:** +- **< 30 days**: Use daily grouping (e.g., "10-01", "10-02") - avoid monthly aggregation that creates single-point charts +- **30-365 days**: Use monthly grouping (e.g., "2024-01", "2024-02") +- **> 365 days**: Use yearly grouping (e.g., "2023", "2024") +- **Validate**: Charts with only 1 data point likely indicate incorrect aggregation for the time period + +```javascript +const { slide, placeholders } = await html2pptx('slide.html', pptx); + +// CORRECT: Single series with all labels +slide.addChart(pptx.charts.BAR, [{ + name: "Sales 2024", + labels: ["Q1", "Q2", "Q3", "Q4"], + values: [4500, 5500, 6200, 7100] +}], { + ...placeholders[0], // Use placeholder position + barDir: 'col', // 'col' = vertical bars, 'bar' = horizontal + showTitle: true, + title: 'Quarterly Sales', + showLegend: false, // No legend needed for single series + // Required axis labels + showCatAxisTitle: true, + catAxisTitle: 'Quarter', + showValAxisTitle: true, + valAxisTitle: 'Sales ($000s)', + // Optional: Control scaling (adjust min based on data range for better visualization) + valAxisMaxVal: 8000, + valAxisMinVal: 0, // Use 0 for counts/amounts; for clustered data (e.g., 4500-7100), consider starting closer to min value + valAxisMajorUnit: 2000, // Control y-axis label spacing to prevent crowding + catAxisLabelRotate: 45, // Rotate labels if crowded + dataLabelPosition: 'outEnd', + dataLabelColor: '000000', + // Use single color for single-series charts + chartColors: ["4472C4"] // All bars same color +}); +``` + +#### Scatter Chart + +**IMPORTANT**: Scatter chart data format is unusual - first series contains X-axis values, subsequent series contain Y-values: + +```javascript +// Prepare data +const data1 = [{ x: 10, y: 20 }, { x: 15, y: 25 }, { x: 20, y: 30 }]; +const data2 = [{ x: 12, y: 18 }, { x: 18, y: 22 }]; + +const allXValues = [...data1.map(d => d.x), ...data2.map(d => d.x)]; + +slide.addChart(pptx.charts.SCATTER, [ + { name: 'X-Axis', values: allXValues }, // First series = X values + { name: 'Series 1', values: data1.map(d => d.y) }, // Y values only + { name: 'Series 2', values: data2.map(d => d.y) } // Y values only +], { + x: 1, y: 1, w: 8, h: 4, + lineSize: 0, // 0 = no connecting lines + lineDataSymbol: 'circle', + lineDataSymbolSize: 6, + showCatAxisTitle: true, + catAxisTitle: 'X Axis', + showValAxisTitle: true, + valAxisTitle: 'Y Axis', + chartColors: ["4472C4", "ED7D31"] +}); +``` + +#### Line Chart + +```javascript +slide.addChart(pptx.charts.LINE, [{ + name: "Temperature", + labels: ["Jan", "Feb", "Mar", "Apr"], + values: [32, 35, 42, 55] +}], { + x: 1, y: 1, w: 8, h: 4, + lineSize: 4, + lineSmooth: true, + // Required axis labels + showCatAxisTitle: true, + catAxisTitle: 'Month', + showValAxisTitle: true, + valAxisTitle: 'Temperature (°F)', + // Optional: Y-axis range (set min based on data range for better visualization) + valAxisMinVal: 0, // For ranges starting at 0 (counts, percentages, etc.) + valAxisMaxVal: 60, + valAxisMajorUnit: 20, // Control y-axis label spacing to prevent crowding (e.g., 10, 20, 25) + // valAxisMinVal: 30, // PREFERRED: For data clustered in a range (e.g., 32-55 or ratings 3-5), start axis closer to min value to show variation + // Optional: Chart colors + chartColors: ["4472C4", "ED7D31", "A5A5A5"] +}); +``` + +#### Pie Chart (No Axis Labels Required) + +**CRITICAL**: Pie charts require a **single data series** with all categories in the `labels` array and corresponding values in the `values` array. + +```javascript +slide.addChart(pptx.charts.PIE, [{ + name: "Market Share", + labels: ["Product A", "Product B", "Other"], // All categories in one array + values: [35, 45, 20] // All values in one array +}], { + x: 2, y: 1, w: 6, h: 4, + showPercent: true, + showLegend: true, + legendPos: 'r', // right + chartColors: ["4472C4", "ED7D31", "A5A5A5"] +}); +``` + +#### Multiple Data Series + +```javascript +slide.addChart(pptx.charts.LINE, [ + { + name: "Product A", + labels: ["Q1", "Q2", "Q3", "Q4"], + values: [10, 20, 30, 40] + }, + { + name: "Product B", + labels: ["Q1", "Q2", "Q3", "Q4"], + values: [15, 25, 20, 35] + } +], { + x: 1, y: 1, w: 8, h: 4, + showCatAxisTitle: true, + catAxisTitle: 'Quarter', + showValAxisTitle: true, + valAxisTitle: 'Revenue ($M)' +}); +``` + +### Chart Colors + +**CRITICAL**: Use hex colors **without** the `#` prefix - including `#` causes file corruption. + +**Align chart colors with your chosen design palette**, ensuring sufficient contrast and distinctiveness for data visualization. Adjust colors for: +- Strong contrast between adjacent series +- Readability against slide backgrounds +- Accessibility (avoid red-green only combinations) + +```javascript +// Example: Ocean palette-inspired chart colors (adjusted for contrast) +const chartColors = ["16A085", "FF6B9D", "2C3E50", "F39C12", "9B59B6"]; + +// Single-series chart: Use one color for all bars/points +slide.addChart(pptx.charts.BAR, [{ + name: "Sales", + labels: ["Q1", "Q2", "Q3", "Q4"], + values: [4500, 5500, 6200, 7100] +}], { + ...placeholders[0], + chartColors: ["16A085"], // All bars same color + showLegend: false +}); + +// Multi-series chart: Each series gets a different color +slide.addChart(pptx.charts.LINE, [ + { name: "Product A", labels: ["Q1", "Q2", "Q3"], values: [10, 20, 30] }, + { name: "Product B", labels: ["Q1", "Q2", "Q3"], values: [15, 25, 20] } +], { + ...placeholders[0], + chartColors: ["16A085", "FF6B9D"] // One color per series +}); +``` + +### Adding Tables + +Tables can be added with basic or advanced formatting: + +#### Basic Table + +```javascript +slide.addTable([ + ["Header 1", "Header 2", "Header 3"], + ["Row 1, Col 1", "Row 1, Col 2", "Row 1, Col 3"], + ["Row 2, Col 1", "Row 2, Col 2", "Row 2, Col 3"] +], { + x: 0.5, + y: 1, + w: 9, + h: 3, + border: { pt: 1, color: "999999" }, + fill: { color: "F1F1F1" } +}); +``` + +#### Table with Custom Formatting + +```javascript +const tableData = [ + // Header row with custom styling + [ + { text: "Product", options: { fill: { color: "4472C4" }, color: "FFFFFF", bold: true } }, + { text: "Revenue", options: { fill: { color: "4472C4" }, color: "FFFFFF", bold: true } }, + { text: "Growth", options: { fill: { color: "4472C4" }, color: "FFFFFF", bold: true } } + ], + // Data rows + ["Product A", "$50M", "+15%"], + ["Product B", "$35M", "+22%"], + ["Product C", "$28M", "+8%"] +]; + +slide.addTable(tableData, { + x: 1, + y: 1.5, + w: 8, + h: 3, + colW: [3, 2.5, 2.5], // Column widths + rowH: [0.5, 0.6, 0.6, 0.6], // Row heights + border: { pt: 1, color: "CCCCCC" }, + align: "center", + valign: "middle", + fontSize: 14 +}); +``` + +#### Table with Merged Cells + +```javascript +const mergedTableData = [ + [ + { text: "Q1 Results", options: { colspan: 3, fill: { color: "4472C4" }, color: "FFFFFF", bold: true } } + ], + ["Product", "Sales", "Market Share"], + ["Product A", "$25M", "35%"], + ["Product B", "$18M", "25%"] +]; + +slide.addTable(mergedTableData, { + x: 1, + y: 1, + w: 8, + h: 2.5, + colW: [3, 2.5, 2.5], + border: { pt: 1, color: "DDDDDD" } +}); +``` + +### Table Options + +Common table options: +- `x, y, w, h` - Position and size +- `colW` - Array of column widths (in inches) +- `rowH` - Array of row heights (in inches) +- `border` - Border style: `{ pt: 1, color: "999999" }` +- `fill` - Background color (no # prefix) +- `align` - Text alignment: "left", "center", "right" +- `valign` - Vertical alignment: "top", "middle", "bottom" +- `fontSize` - Text size +- `autoPage` - Auto-create new slides if content overflows \ No newline at end of file diff --git a/skills/document-skills/pptx/ooxml.md b/skills/document-skills/pptx/ooxml.md new file mode 100644 index 0000000..951b3cf --- /dev/null +++ b/skills/document-skills/pptx/ooxml.md @@ -0,0 +1,427 @@ +# Office Open XML Technical Reference for PowerPoint + +**Important: Read this entire document before starting.** Critical XML schema rules and formatting requirements are covered throughout. Incorrect implementation can create invalid PPTX files that PowerPoint cannot open. + +## Technical Guidelines + +### Schema Compliance +- **Element ordering in ``**: ``, ``, `` +- **Whitespace**: Add `xml:space='preserve'` to `` elements with leading/trailing spaces +- **Unicode**: Escape characters in ASCII content: `"` becomes `“` +- **Images**: Add to `ppt/media/`, reference in slide XML, set dimensions to fit slide bounds +- **Relationships**: Update `ppt/slides/_rels/slideN.xml.rels` for each slide's resources +- **Dirty attribute**: Add `dirty="0"` to `` and `` elements to indicate clean state + +## Presentation Structure + +### Basic Slide Structure +```xml + + + + + ... + ... + + + + +``` + +### Text Box / Shape with Text +```xml + + + + + + + + + + + + + + + + + + + + + + Slide Title + + + + +``` + +### Text Formatting +```xml + + + + Bold Text + + + + + + Italic Text + + + + + + Underlined + + + + + + + + + + Highlighted Text + + + + + + + + + + Colored Arial 24pt + + + + + + + + + + Formatted text + +``` + +### Lists +```xml + + + + + + + First bullet point + + + + + + + + + + First numbered item + + + + + + + + + + Indented bullet + + +``` + +### Shapes +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Images +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +### Tables +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + Cell 1 + + + + + + + + + + + Cell 2 + + + + + + + + + +``` + +### Slide Layouts + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +## File Updates + +When adding content, update these files: + +**`ppt/_rels/presentation.xml.rels`:** +```xml + + +``` + +**`ppt/slides/_rels/slide1.xml.rels`:** +```xml + + +``` + +**`[Content_Types].xml`:** +```xml + + + +``` + +**`ppt/presentation.xml`:** +```xml + + + + +``` + +**`docProps/app.xml`:** Update slide count and statistics +```xml +2 +10 +50 +``` + +## Slide Operations + +### Adding a New Slide +When adding a slide to the end of the presentation: + +1. **Create the slide file** (`ppt/slides/slideN.xml`) +2. **Update `[Content_Types].xml`**: Add Override for the new slide +3. **Update `ppt/_rels/presentation.xml.rels`**: Add relationship for the new slide +4. **Update `ppt/presentation.xml`**: Add slide ID to `` +5. **Create slide relationships** (`ppt/slides/_rels/slideN.xml.rels`) if needed +6. **Update `docProps/app.xml`**: Increment slide count and update statistics (if present) + +### Duplicating a Slide +1. Copy the source slide XML file with a new name +2. Update all IDs in the new slide to be unique +3. Follow the "Adding a New Slide" steps above +4. **CRITICAL**: Remove or update any notes slide references in `_rels` files +5. Remove references to unused media files + +### Reordering Slides +1. **Update `ppt/presentation.xml`**: Reorder `` elements in `` +2. The order of `` elements determines slide order +3. Keep slide IDs and relationship IDs unchanged + +Example: +```xml + + + + + + + + + + + + + +``` + +### Deleting a Slide +1. **Remove from `ppt/presentation.xml`**: Delete the `` entry +2. **Remove from `ppt/_rels/presentation.xml.rels`**: Delete the relationship +3. **Remove from `[Content_Types].xml`**: Delete the Override entry +4. **Delete files**: Remove `ppt/slides/slideN.xml` and `ppt/slides/_rels/slideN.xml.rels` +5. **Update `docProps/app.xml`**: Decrement slide count and update statistics +6. **Clean up unused media**: Remove orphaned images from `ppt/media/` + +Note: Don't renumber remaining slides - keep their original IDs and filenames. + + +## Common Errors to Avoid + +- **Encodings**: Escape unicode characters in ASCII content: `"` becomes `“` +- **Images**: Add to `ppt/media/` and update relationship files +- **Lists**: Omit bullets from list headers +- **IDs**: Use valid hexadecimal values for UUIDs +- **Themes**: Check all themes in `theme` directory for colors + +## Validation Checklist for Template-Based Presentations + +### Before Packing, Always: +- **Clean unused resources**: Remove unreferenced media, fonts, and notes directories +- **Fix Content_Types.xml**: Declare ALL slides, layouts, and themes present in the package +- **Fix relationship IDs**: + - Remove font embed references if not using embedded fonts +- **Remove broken references**: Check all `_rels` files for references to deleted resources + +### Common Template Duplication Pitfalls: +- Multiple slides referencing the same notes slide after duplication +- Image/media references from template slides that no longer exist +- Font embedding references when fonts aren't included +- Missing slideLayout declarations for layouts 12-25 +- docProps directory may not unpack - this is optional \ No newline at end of file diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd new file mode 100644 index 0000000..6454ef9 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chart.xsd @@ -0,0 +1,1499 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd new file mode 100644 index 0000000..afa4f46 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd new file mode 100644 index 0000000..64e66b8 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd @@ -0,0 +1,1085 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd new file mode 100644 index 0000000..687eea8 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd @@ -0,0 +1,11 @@ + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd new file mode 100644 index 0000000..6ac81b0 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-main.xsd @@ -0,0 +1,3081 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd new file mode 100644 index 0000000..1dbf051 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-picture.xsd @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd new file mode 100644 index 0000000..f1af17d --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd new file mode 100644 index 0000000..0a185ab --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd @@ -0,0 +1,287 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd new file mode 100644 index 0000000..14ef488 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/pml.xsd @@ -0,0 +1,1676 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd new file mode 100644 index 0000000..c20f3bf --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd new file mode 100644 index 0000000..ac60252 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd new file mode 100644 index 0000000..424b8ba --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd new file mode 100644 index 0000000..2bddce2 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd new file mode 100644 index 0000000..8a8c18b --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd new file mode 100644 index 0000000..5c42706 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd new file mode 100644 index 0000000..853c341 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd new file mode 100644 index 0000000..da835ee --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd @@ -0,0 +1,195 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd new file mode 100644 index 0000000..87ad265 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-math.xsd @@ -0,0 +1,582 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd new file mode 100644 index 0000000..9e86f1b --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd new file mode 100644 index 0000000..d0be42e --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/sml.xsd @@ -0,0 +1,4439 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd new file mode 100644 index 0000000..8821dd1 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-main.xsd @@ -0,0 +1,570 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd new file mode 100644 index 0000000..ca2575c --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd @@ -0,0 +1,509 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd new file mode 100644 index 0000000..dd079e6 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd @@ -0,0 +1,12 @@ + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd new file mode 100644 index 0000000..3dd6cf6 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd new file mode 100644 index 0000000..f1041e3 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd new file mode 100644 index 0000000..9c5b7a6 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/wml.xsd @@ -0,0 +1,3646 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd new file mode 100644 index 0000000..0f13678 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ISO-IEC29500-4_2016/xml.xsd @@ -0,0 +1,116 @@ + + + + + + See http://www.w3.org/XML/1998/namespace.html and + http://www.w3.org/TR/REC-xml for information about this namespace. + + This schema document describes the XML namespace, in a form + suitable for import by other schema documents. + + Note that local names in this namespace are intended to be defined + only by the World Wide Web Consortium or its subgroups. The + following names are currently defined in this namespace and should + not be used with conflicting semantics by any Working Group, + specification, or document instance: + + base (as an attribute name): denotes an attribute whose value + provides a URI to be used as the base for interpreting any + relative URIs in the scope of the element on which it + appears; its value is inherited. This name is reserved + by virtue of its definition in the XML Base specification. + + lang (as an attribute name): denotes an attribute whose value + is a language code for the natural language of the content of + any element; its value is inherited. This name is reserved + by virtue of its definition in the XML specification. + + space (as an attribute name): denotes an attribute whose + value is a keyword indicating what whitespace processing + discipline is intended for the content of the element; its + value is inherited. This name is reserved by virtue of its + definition in the XML specification. + + Father (in any context at all): denotes Jon Bosak, the chair of + the original XML Working Group. This name is reserved by + the following decision of the W3C XML Plenary and + XML Coordination groups: + + In appreciation for his vision, leadership and dedication + the W3C XML Plenary on this 10th day of February, 2000 + reserves for Jon Bosak in perpetuity the XML name + xml:Father + + + + + This schema defines attributes and an attribute group + suitable for use by + schemas wishing to allow xml:base, xml:lang or xml:space attributes + on elements they define. + + To enable this, such a schema must import this schema + for the XML namespace, e.g. as follows: + <schema . . .> + . . . + <import namespace="http://www.w3.org/XML/1998/namespace" + schemaLocation="http://www.w3.org/2001/03/xml.xsd"/> + + Subsequently, qualified reference to any of the attributes + or the group defined below will have the desired effect, e.g. + + <type . . .> + . . . + <attributeGroup ref="xml:specialAttrs"/> + + will define a type which will schema-validate an instance + element with any of those attributes + + + + In keeping with the XML Schema WG's standard versioning + policy, this schema document will persist at + http://www.w3.org/2001/03/xml.xsd. + At the date of issue it can also be found at + http://www.w3.org/2001/xml.xsd. + The schema document at that URI may however change in the future, + in order to remain compatible with the latest version of XML Schema + itself. In other words, if the XML Schema namespace changes, the version + of this document at + http://www.w3.org/2001/xml.xsd will change + accordingly; the version at + http://www.w3.org/2001/03/xml.xsd will not change. + + + + + + In due course, we should install the relevant ISO 2- and 3-letter + codes as the enumerated possible values . . . + + + + + + + + + + + + + + + See http://www.w3.org/TR/xmlbase/ for + information about this attribute. + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd new file mode 100644 index 0000000..a6de9d2 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-contentTypes.xsd @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd new file mode 100644 index 0000000..10e978b --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-coreProperties.xsd @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd new file mode 100644 index 0000000..4248bf7 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-digSig.xsd @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd new file mode 100644 index 0000000..5649746 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/ecma/fouth-edition/opc-relationships.xsd @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/mce/mc.xsd b/skills/document-skills/pptx/ooxml/schemas/mce/mc.xsd new file mode 100644 index 0000000..ef72545 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/mce/mc.xsd @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2010.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2010.xsd new file mode 100644 index 0000000..f65f777 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2010.xsd @@ -0,0 +1,560 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2012.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2012.xsd new file mode 100644 index 0000000..6b00755 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2012.xsd @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2018.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2018.xsd new file mode 100644 index 0000000..f321d33 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-2018.xsd @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cex-2018.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cex-2018.xsd new file mode 100644 index 0000000..364c6a9 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cex-2018.xsd @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cid-2016.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cid-2016.xsd new file mode 100644 index 0000000..fed9d15 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-cid-2016.xsd @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd new file mode 100644 index 0000000..680cf15 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-sdtdatahash-2020.xsd @@ -0,0 +1,4 @@ + + + + diff --git a/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-symex-2015.xsd b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-symex-2015.xsd new file mode 100644 index 0000000..89ada90 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/schemas/microsoft/wml-symex-2015.xsd @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/skills/document-skills/pptx/ooxml/scripts/pack.py b/skills/document-skills/pptx/ooxml/scripts/pack.py new file mode 100755 index 0000000..68bc088 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/pack.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +""" +Tool to pack a directory into a .docx, .pptx, or .xlsx file with XML formatting undone. + +Example usage: + python pack.py [--force] +""" + +import argparse +import shutil +import subprocess +import sys +import tempfile +import defusedxml.minidom +import zipfile +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description="Pack a directory into an Office file") + parser.add_argument("input_directory", help="Unpacked Office document directory") + parser.add_argument("output_file", help="Output Office file (.docx/.pptx/.xlsx)") + parser.add_argument("--force", action="store_true", help="Skip validation") + args = parser.parse_args() + + try: + success = pack_document( + args.input_directory, args.output_file, validate=not args.force + ) + + # Show warning if validation was skipped + if args.force: + print("Warning: Skipped validation, file may be corrupt", file=sys.stderr) + # Exit with error if validation failed + elif not success: + print("Contents would produce a corrupt file.", file=sys.stderr) + print("Please validate XML before repacking.", file=sys.stderr) + print("Use --force to skip validation and pack anyway.", file=sys.stderr) + sys.exit(1) + + except ValueError as e: + sys.exit(f"Error: {e}") + + +def pack_document(input_dir, output_file, validate=False): + """Pack a directory into an Office file (.docx/.pptx/.xlsx). + + Args: + input_dir: Path to unpacked Office document directory + output_file: Path to output Office file + validate: If True, validates with soffice (default: False) + + Returns: + bool: True if successful, False if validation failed + """ + input_dir = Path(input_dir) + output_file = Path(output_file) + + if not input_dir.is_dir(): + raise ValueError(f"{input_dir} is not a directory") + if output_file.suffix.lower() not in {".docx", ".pptx", ".xlsx"}: + raise ValueError(f"{output_file} must be a .docx, .pptx, or .xlsx file") + + # Work in temporary directory to avoid modifying original + with tempfile.TemporaryDirectory() as temp_dir: + temp_content_dir = Path(temp_dir) / "content" + shutil.copytree(input_dir, temp_content_dir) + + # Process XML files to remove pretty-printing whitespace + for pattern in ["*.xml", "*.rels"]: + for xml_file in temp_content_dir.rglob(pattern): + condense_xml(xml_file) + + # Create final Office file as zip archive + output_file.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(output_file, "w", zipfile.ZIP_DEFLATED) as zf: + for f in temp_content_dir.rglob("*"): + if f.is_file(): + zf.write(f, f.relative_to(temp_content_dir)) + + # Validate if requested + if validate: + if not validate_document(output_file): + output_file.unlink() # Delete the corrupt file + return False + + return True + + +def validate_document(doc_path): + """Validate document by converting to HTML with soffice.""" + # Determine the correct filter based on file extension + match doc_path.suffix.lower(): + case ".docx": + filter_name = "html:HTML" + case ".pptx": + filter_name = "html:impress_html_Export" + case ".xlsx": + filter_name = "html:HTML (StarCalc)" + + with tempfile.TemporaryDirectory() as temp_dir: + try: + result = subprocess.run( + [ + "soffice", + "--headless", + "--convert-to", + filter_name, + "--outdir", + temp_dir, + str(doc_path), + ], + capture_output=True, + timeout=10, + text=True, + ) + if not (Path(temp_dir) / f"{doc_path.stem}.html").exists(): + error_msg = result.stderr.strip() or "Document validation failed" + print(f"Validation error: {error_msg}", file=sys.stderr) + return False + return True + except FileNotFoundError: + print("Warning: soffice not found. Skipping validation.", file=sys.stderr) + return True + except subprocess.TimeoutExpired: + print("Validation error: Timeout during conversion", file=sys.stderr) + return False + except Exception as e: + print(f"Validation error: {e}", file=sys.stderr) + return False + + +def condense_xml(xml_file): + """Strip unnecessary whitespace and remove comments.""" + with open(xml_file, "r", encoding="utf-8") as f: + dom = defusedxml.minidom.parse(f) + + # Process each element to remove whitespace and comments + for element in dom.getElementsByTagName("*"): + # Skip w:t elements and their processing + if element.tagName.endswith(":t"): + continue + + # Remove whitespace-only text nodes and comment nodes + for child in list(element.childNodes): + if ( + child.nodeType == child.TEXT_NODE + and child.nodeValue + and child.nodeValue.strip() == "" + ) or child.nodeType == child.COMMENT_NODE: + element.removeChild(child) + + # Write back the condensed XML + with open(xml_file, "wb") as f: + f.write(dom.toxml(encoding="UTF-8")) + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/pptx/ooxml/scripts/unpack.py b/skills/document-skills/pptx/ooxml/scripts/unpack.py new file mode 100755 index 0000000..4938798 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/unpack.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Unpack and format XML contents of Office files (.docx, .pptx, .xlsx)""" + +import random +import sys +import defusedxml.minidom +import zipfile +from pathlib import Path + +# Get command line arguments +assert len(sys.argv) == 3, "Usage: python unpack.py " +input_file, output_dir = sys.argv[1], sys.argv[2] + +# Extract and format +output_path = Path(output_dir) +output_path.mkdir(parents=True, exist_ok=True) +zipfile.ZipFile(input_file).extractall(output_path) + +# Pretty print all XML files +xml_files = list(output_path.rglob("*.xml")) + list(output_path.rglob("*.rels")) +for xml_file in xml_files: + content = xml_file.read_text(encoding="utf-8") + dom = defusedxml.minidom.parseString(content) + xml_file.write_bytes(dom.toprettyxml(indent=" ", encoding="ascii")) + +# For .docx files, suggest an RSID for tracked changes +if input_file.endswith(".docx"): + suggested_rsid = "".join(random.choices("0123456789ABCDEF", k=8)) + print(f"Suggested RSID for edit session: {suggested_rsid}") diff --git a/skills/document-skills/pptx/ooxml/scripts/validate.py b/skills/document-skills/pptx/ooxml/scripts/validate.py new file mode 100755 index 0000000..508c589 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/validate.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +""" +Command line tool to validate Office document XML files against XSD schemas and tracked changes. + +Usage: + python validate.py --original +""" + +import argparse +import sys +from pathlib import Path + +from validation import DOCXSchemaValidator, PPTXSchemaValidator, RedliningValidator + + +def main(): + parser = argparse.ArgumentParser(description="Validate Office document XML files") + parser.add_argument( + "unpacked_dir", + help="Path to unpacked Office document directory", + ) + parser.add_argument( + "--original", + required=True, + help="Path to original file (.docx/.pptx/.xlsx)", + ) + parser.add_argument( + "-v", + "--verbose", + action="store_true", + help="Enable verbose output", + ) + args = parser.parse_args() + + # Validate paths + unpacked_dir = Path(args.unpacked_dir) + original_file = Path(args.original) + file_extension = original_file.suffix.lower() + assert unpacked_dir.is_dir(), f"Error: {unpacked_dir} is not a directory" + assert original_file.is_file(), f"Error: {original_file} is not a file" + assert file_extension in [".docx", ".pptx", ".xlsx"], ( + f"Error: {original_file} must be a .docx, .pptx, or .xlsx file" + ) + + # Run validations + match file_extension: + case ".docx": + validators = [DOCXSchemaValidator, RedliningValidator] + case ".pptx": + validators = [PPTXSchemaValidator] + case _: + print(f"Error: Validation not supported for file type {file_extension}") + sys.exit(1) + + # Run validators + success = True + for V in validators: + validator = V(unpacked_dir, original_file, verbose=args.verbose) + if not validator.validate(): + success = False + + if success: + print("All validations PASSED!") + + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/pptx/ooxml/scripts/validation/__init__.py b/skills/document-skills/pptx/ooxml/scripts/validation/__init__.py new file mode 100644 index 0000000..db092ec --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/validation/__init__.py @@ -0,0 +1,15 @@ +""" +Validation modules for Word document processing. +""" + +from .base import BaseSchemaValidator +from .docx import DOCXSchemaValidator +from .pptx import PPTXSchemaValidator +from .redlining import RedliningValidator + +__all__ = [ + "BaseSchemaValidator", + "DOCXSchemaValidator", + "PPTXSchemaValidator", + "RedliningValidator", +] diff --git a/skills/document-skills/pptx/ooxml/scripts/validation/base.py b/skills/document-skills/pptx/ooxml/scripts/validation/base.py new file mode 100644 index 0000000..0681b19 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/validation/base.py @@ -0,0 +1,951 @@ +""" +Base validator with common validation logic for document files. +""" + +import re +from pathlib import Path + +import lxml.etree + + +class BaseSchemaValidator: + """Base validator with common validation logic for document files.""" + + # Elements whose 'id' attributes must be unique within their file + # Format: element_name -> (attribute_name, scope) + # scope can be 'file' (unique within file) or 'global' (unique across all files) + UNIQUE_ID_REQUIREMENTS = { + # Word elements + "comment": ("id", "file"), # Comment IDs in comments.xml + "commentrangestart": ("id", "file"), # Must match comment IDs + "commentrangeend": ("id", "file"), # Must match comment IDs + "bookmarkstart": ("id", "file"), # Bookmark start IDs + "bookmarkend": ("id", "file"), # Bookmark end IDs + # Note: ins and del (track changes) can share IDs when part of same revision + # PowerPoint elements + "sldid": ("id", "file"), # Slide IDs in presentation.xml + "sldmasterid": ("id", "global"), # Slide master IDs must be globally unique + "sldlayoutid": ("id", "global"), # Slide layout IDs must be globally unique + "cm": ("authorid", "file"), # Comment author IDs + # Excel elements + "sheet": ("sheetid", "file"), # Sheet IDs in workbook.xml + "definedname": ("id", "file"), # Named range IDs + # Drawing/Shape elements (all formats) + "cxnsp": ("id", "file"), # Connection shape IDs + "sp": ("id", "file"), # Shape IDs + "pic": ("id", "file"), # Picture IDs + "grpsp": ("id", "file"), # Group shape IDs + } + + # Mapping of element names to expected relationship types + # Subclasses should override this with format-specific mappings + ELEMENT_RELATIONSHIP_TYPES = {} + + # Unified schema mappings for all Office document types + SCHEMA_MAPPINGS = { + # Document type specific schemas + "word": "ISO-IEC29500-4_2016/wml.xsd", # Word documents + "ppt": "ISO-IEC29500-4_2016/pml.xsd", # PowerPoint presentations + "xl": "ISO-IEC29500-4_2016/sml.xsd", # Excel spreadsheets + # Common file types + "[Content_Types].xml": "ecma/fouth-edition/opc-contentTypes.xsd", + "app.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd", + "core.xml": "ecma/fouth-edition/opc-coreProperties.xsd", + "custom.xml": "ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd", + ".rels": "ecma/fouth-edition/opc-relationships.xsd", + # Word-specific files + "people.xml": "microsoft/wml-2012.xsd", + "commentsIds.xml": "microsoft/wml-cid-2016.xsd", + "commentsExtensible.xml": "microsoft/wml-cex-2018.xsd", + "commentsExtended.xml": "microsoft/wml-2012.xsd", + # Chart files (common across document types) + "chart": "ISO-IEC29500-4_2016/dml-chart.xsd", + # Theme files (common across document types) + "theme": "ISO-IEC29500-4_2016/dml-main.xsd", + # Drawing and media files + "drawing": "ISO-IEC29500-4_2016/dml-main.xsd", + } + + # Unified namespace constants + MC_NAMESPACE = "http://schemas.openxmlformats.org/markup-compatibility/2006" + XML_NAMESPACE = "http://www.w3.org/XML/1998/namespace" + + # Common OOXML namespaces used across validators + PACKAGE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/relationships" + ) + OFFICE_RELATIONSHIPS_NAMESPACE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + ) + CONTENT_TYPES_NAMESPACE = ( + "http://schemas.openxmlformats.org/package/2006/content-types" + ) + + # Folders where we should clean ignorable namespaces + MAIN_CONTENT_FOLDERS = {"word", "ppt", "xl"} + + # All allowed OOXML namespaces (superset of all document types) + OOXML_NAMESPACES = { + "http://schemas.openxmlformats.org/officeDocument/2006/math", + "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "http://schemas.openxmlformats.org/schemaLibrary/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/main", + "http://schemas.openxmlformats.org/drawingml/2006/chart", + "http://schemas.openxmlformats.org/drawingml/2006/chartDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/diagram", + "http://schemas.openxmlformats.org/drawingml/2006/picture", + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing", + "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing", + "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + "http://schemas.openxmlformats.org/presentationml/2006/main", + "http://schemas.openxmlformats.org/spreadsheetml/2006/main", + "http://schemas.openxmlformats.org/officeDocument/2006/sharedTypes", + "http://www.w3.org/XML/1998/namespace", + } + + def __init__(self, unpacked_dir, original_file, verbose=False): + self.unpacked_dir = Path(unpacked_dir).resolve() + self.original_file = Path(original_file) + self.verbose = verbose + + # Set schemas directory + self.schemas_dir = Path(__file__).parent.parent.parent / "schemas" + + # Get all XML and .rels files + patterns = ["*.xml", "*.rels"] + self.xml_files = [ + f for pattern in patterns for f in self.unpacked_dir.rglob(pattern) + ] + + if not self.xml_files: + print(f"Warning: No XML files found in {self.unpacked_dir}") + + def validate(self): + """Run all validation checks and return True if all pass.""" + raise NotImplementedError("Subclasses must implement the validate method") + + def validate_xml(self): + """Validate that all XML files are well-formed.""" + errors = [] + + for xml_file in self.xml_files: + try: + # Try to parse the XML file + lxml.etree.parse(str(xml_file)) + except lxml.etree.XMLSyntaxError as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {e.lineno}: {e.msg}" + ) + except Exception as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Unexpected error: {str(e)}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} XML violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All XML files are well-formed") + return True + + def validate_namespaces(self): + """Validate that namespace prefixes in Ignorable attributes are declared.""" + errors = [] + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + declared = set(root.nsmap.keys()) - {None} # Exclude default namespace + + for attr_val in [ + v for k, v in root.attrib.items() if k.endswith("Ignorable") + ]: + undeclared = set(attr_val.split()) - declared + errors.extend( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Namespace '{ns}' in Ignorable but not declared" + for ns in undeclared + ) + except lxml.etree.XMLSyntaxError: + continue + + if errors: + print(f"FAILED - {len(errors)} namespace issues:") + for error in errors: + print(error) + return False + if self.verbose: + print("PASSED - All namespace prefixes properly declared") + return True + + def validate_unique_ids(self): + """Validate that specific IDs are unique according to OOXML requirements.""" + errors = [] + global_ids = {} # Track globally unique IDs across all files + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + file_ids = {} # Track IDs that must be unique within this file + + # Remove all mc:AlternateContent elements from the tree + mc_elements = root.xpath( + ".//mc:AlternateContent", namespaces={"mc": self.MC_NAMESPACE} + ) + for elem in mc_elements: + elem.getparent().remove(elem) + + # Now check IDs in the cleaned tree + for elem in root.iter(): + # Get the element name without namespace + tag = ( + elem.tag.split("}")[-1].lower() + if "}" in elem.tag + else elem.tag.lower() + ) + + # Check if this element type has ID uniqueness requirements + if tag in self.UNIQUE_ID_REQUIREMENTS: + attr_name, scope = self.UNIQUE_ID_REQUIREMENTS[tag] + + # Look for the specified attribute + id_value = None + for attr, value in elem.attrib.items(): + attr_local = ( + attr.split("}")[-1].lower() + if "}" in attr + else attr.lower() + ) + if attr_local == attr_name: + id_value = value + break + + if id_value is not None: + if scope == "global": + # Check global uniqueness + if id_value in global_ids: + prev_file, prev_line, prev_tag = global_ids[ + id_value + ] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Global ID '{id_value}' in <{tag}> " + f"already used in {prev_file} at line {prev_line} in <{prev_tag}>" + ) + else: + global_ids[id_value] = ( + xml_file.relative_to(self.unpacked_dir), + elem.sourceline, + tag, + ) + elif scope == "file": + # Check file-level uniqueness + key = (tag, attr_name) + if key not in file_ids: + file_ids[key] = {} + + if id_value in file_ids[key]: + prev_line = file_ids[key][id_value] + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: Duplicate {attr_name}='{id_value}' in <{tag}> " + f"(first occurrence at line {prev_line})" + ) + else: + file_ids[key][id_value] = elem.sourceline + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} ID uniqueness violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All required IDs are unique") + return True + + def validate_file_references(self): + """ + Validate that all .rels files properly reference files and that all files are referenced. + """ + errors = [] + + # Find all .rels files + rels_files = list(self.unpacked_dir.rglob("*.rels")) + + if not rels_files: + if self.verbose: + print("PASSED - No .rels files found") + return True + + # Get all files in the unpacked directory (excluding reference files) + all_files = [] + for file_path in self.unpacked_dir.rglob("*"): + if ( + file_path.is_file() + and file_path.name != "[Content_Types].xml" + and not file_path.name.endswith(".rels") + ): # This file is not referenced by .rels + all_files.append(file_path.resolve()) + + # Track all files that are referenced by any .rels file + all_referenced_files = set() + + if self.verbose: + print( + f"Found {len(rels_files)} .rels files and {len(all_files)} target files" + ) + + # Check each .rels file + for rels_file in rels_files: + try: + # Parse relationships file + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + # Get the directory where this .rels file is located + rels_dir = rels_file.parent + + # Find all relationships and their targets + referenced_files = set() + broken_refs = [] + + for rel in rels_root.findall( + ".//ns:Relationship", + namespaces={"ns": self.PACKAGE_RELATIONSHIPS_NAMESPACE}, + ): + target = rel.get("Target") + if target and not target.startswith( + ("http", "mailto:") + ): # Skip external URLs + # Resolve the target path relative to the .rels file location + if rels_file.name == ".rels": + # Root .rels file - targets are relative to unpacked_dir + target_path = self.unpacked_dir / target + else: + # Other .rels files - targets are relative to their parent's parent + # e.g., word/_rels/document.xml.rels -> targets relative to word/ + base_dir = rels_dir.parent + target_path = base_dir / target + + # Normalize the path and check if it exists + try: + target_path = target_path.resolve() + if target_path.exists() and target_path.is_file(): + referenced_files.add(target_path) + all_referenced_files.add(target_path) + else: + broken_refs.append((target, rel.sourceline)) + except (OSError, ValueError): + broken_refs.append((target, rel.sourceline)) + + # Report broken references + if broken_refs: + rel_path = rels_file.relative_to(self.unpacked_dir) + for broken_ref, line_num in broken_refs: + errors.append( + f" {rel_path}: Line {line_num}: Broken reference to {broken_ref}" + ) + + except Exception as e: + rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append(f" Error parsing {rel_path}: {e}") + + # Check for unreferenced files (files that exist but are not referenced anywhere) + unreferenced_files = set(all_files) - all_referenced_files + + if unreferenced_files: + for unref_file in sorted(unreferenced_files): + unref_rel_path = unref_file.relative_to(self.unpacked_dir) + errors.append(f" Unreferenced file: {unref_rel_path}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship validation errors:") + for error in errors: + print(error) + print( + "CRITICAL: These errors will cause the document to appear corrupt. " + + "Broken references MUST be fixed, " + + "and unreferenced files MUST be referenced or removed." + ) + return False + else: + if self.verbose: + print( + "PASSED - All references are valid and all files are properly referenced" + ) + return True + + def validate_all_relationship_ids(self): + """ + Validate that all r:id attributes in XML files reference existing IDs + in their corresponding .rels files, and optionally validate relationship types. + """ + import lxml.etree + + errors = [] + + # Process each XML file that might contain r:id references + for xml_file in self.xml_files: + # Skip .rels files themselves + if xml_file.suffix == ".rels": + continue + + # Determine the corresponding .rels file + # For dir/file.xml, it's dir/_rels/file.xml.rels + rels_dir = xml_file.parent / "_rels" + rels_file = rels_dir / f"{xml_file.name}.rels" + + # Skip if there's no corresponding .rels file (that's okay) + if not rels_file.exists(): + continue + + try: + # Parse the .rels file to get valid relationship IDs and their types + rels_root = lxml.etree.parse(str(rels_file)).getroot() + rid_to_type = {} + + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rid = rel.get("Id") + rel_type = rel.get("Type", "") + if rid: + # Check for duplicate rIds + if rid in rid_to_type: + rels_rel_path = rels_file.relative_to(self.unpacked_dir) + errors.append( + f" {rels_rel_path}: Line {rel.sourceline}: " + f"Duplicate relationship ID '{rid}' (IDs must be unique)" + ) + # Extract just the type name from the full URL + type_name = ( + rel_type.split("/")[-1] if "/" in rel_type else rel_type + ) + rid_to_type[rid] = type_name + + # Parse the XML file to find all r:id references + xml_root = lxml.etree.parse(str(xml_file)).getroot() + + # Find all elements with r:id attributes + for elem in xml_root.iter(): + # Check for r:id attribute (relationship ID) + rid_attr = elem.get(f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id") + if rid_attr: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + elem_name = ( + elem.tag.split("}")[-1] if "}" in elem.tag else elem.tag + ) + + # Check if the ID exists + if rid_attr not in rid_to_type: + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references non-existent relationship '{rid_attr}' " + f"(valid IDs: {', '.join(sorted(rid_to_type.keys())[:5])}{'...' if len(rid_to_type) > 5 else ''})" + ) + # Check if we have type expectations for this element + elif self.ELEMENT_RELATIONSHIP_TYPES: + expected_type = self._get_expected_relationship_type( + elem_name + ) + if expected_type: + actual_type = rid_to_type[rid_attr] + # Check if the actual type matches or contains the expected type + if expected_type not in actual_type.lower(): + errors.append( + f" {xml_rel_path}: Line {elem.sourceline}: " + f"<{elem_name}> references '{rid_attr}' which points to '{actual_type}' " + f"but should point to a '{expected_type}' relationship" + ) + + except Exception as e: + xml_rel_path = xml_file.relative_to(self.unpacked_dir) + errors.append(f" Error processing {xml_rel_path}: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} relationship ID reference errors:") + for error in errors: + print(error) + print("\nThese ID mismatches will cause the document to appear corrupt!") + return False + else: + if self.verbose: + print("PASSED - All relationship ID references are valid") + return True + + def _get_expected_relationship_type(self, element_name): + """ + Get the expected relationship type for an element. + First checks the explicit mapping, then tries pattern detection. + """ + # Normalize element name to lowercase + elem_lower = element_name.lower() + + # Check explicit mapping first + if elem_lower in self.ELEMENT_RELATIONSHIP_TYPES: + return self.ELEMENT_RELATIONSHIP_TYPES[elem_lower] + + # Try pattern detection for common patterns + # Pattern 1: Elements ending in "Id" often expect a relationship of the prefix type + if elem_lower.endswith("id") and len(elem_lower) > 2: + # e.g., "sldId" -> "sld", "sldMasterId" -> "sldMaster" + prefix = elem_lower[:-2] # Remove "id" + # Check if this might be a compound like "sldMasterId" + if prefix.endswith("master"): + return prefix.lower() + elif prefix.endswith("layout"): + return prefix.lower() + else: + # Simple case like "sldId" -> "slide" + # Common transformations + if prefix == "sld": + return "slide" + return prefix.lower() + + # Pattern 2: Elements ending in "Reference" expect a relationship of the prefix type + if elem_lower.endswith("reference") and len(elem_lower) > 9: + prefix = elem_lower[:-9] # Remove "reference" + return prefix.lower() + + return None + + def validate_content_types(self): + """Validate that all content files are properly declared in [Content_Types].xml.""" + errors = [] + + # Find [Content_Types].xml file + content_types_file = self.unpacked_dir / "[Content_Types].xml" + if not content_types_file.exists(): + print("FAILED - [Content_Types].xml file not found") + return False + + try: + # Parse and get all declared parts and extensions + root = lxml.etree.parse(str(content_types_file)).getroot() + declared_parts = set() + declared_extensions = set() + + # Get Override declarations (specific files) + for override in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Override" + ): + part_name = override.get("PartName") + if part_name is not None: + declared_parts.add(part_name.lstrip("/")) + + # Get Default declarations (by extension) + for default in root.findall( + f".//{{{self.CONTENT_TYPES_NAMESPACE}}}Default" + ): + extension = default.get("Extension") + if extension is not None: + declared_extensions.add(extension.lower()) + + # Root elements that require content type declaration + declarable_roots = { + "sld", + "sldLayout", + "sldMaster", + "presentation", # PowerPoint + "document", # Word + "workbook", + "worksheet", # Excel + "theme", # Common + } + + # Common media file extensions that should be declared + media_extensions = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "bmp": "image/bmp", + "tiff": "image/tiff", + "wmf": "image/x-wmf", + "emf": "image/x-emf", + } + + # Get all files in the unpacked directory + all_files = list(self.unpacked_dir.rglob("*")) + all_files = [f for f in all_files if f.is_file()] + + # Check all XML files for Override declarations + for xml_file in self.xml_files: + path_str = str(xml_file.relative_to(self.unpacked_dir)).replace( + "\\", "/" + ) + + # Skip non-content files + if any( + skip in path_str + for skip in [".rels", "[Content_Types]", "docProps/", "_rels/"] + ): + continue + + try: + root_tag = lxml.etree.parse(str(xml_file)).getroot().tag + root_name = root_tag.split("}")[-1] if "}" in root_tag else root_tag + + if root_name in declarable_roots and path_str not in declared_parts: + errors.append( + f" {path_str}: File with <{root_name}> root not declared in [Content_Types].xml" + ) + + except Exception: + continue # Skip unparseable files + + # Check all non-XML files for Default extension declarations + for file_path in all_files: + # Skip XML files and metadata files (already checked above) + if file_path.suffix.lower() in {".xml", ".rels"}: + continue + if file_path.name == "[Content_Types].xml": + continue + if "_rels" in file_path.parts or "docProps" in file_path.parts: + continue + + extension = file_path.suffix.lstrip(".").lower() + if extension and extension not in declared_extensions: + # Check if it's a known media extension that should be declared + if extension in media_extensions: + relative_path = file_path.relative_to(self.unpacked_dir) + errors.append( + f' {relative_path}: File with extension \'{extension}\' not declared in [Content_Types].xml - should add: ' + ) + + except Exception as e: + errors.append(f" Error parsing [Content_Types].xml: {e}") + + if errors: + print(f"FAILED - Found {len(errors)} content type declaration errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print( + "PASSED - All content files are properly declared in [Content_Types].xml" + ) + return True + + def validate_file_against_xsd(self, xml_file, verbose=False): + """Validate a single XML file against XSD schema, comparing with original. + + Args: + xml_file: Path to XML file to validate + verbose: Enable verbose output + + Returns: + tuple: (is_valid, new_errors_set) where is_valid is True/False/None (skipped) + """ + # Resolve both paths to handle symlinks + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + + # Validate current file + is_valid, current_errors = self._validate_single_file_xsd( + xml_file, unpacked_dir + ) + + if is_valid is None: + return None, set() # Skipped + elif is_valid: + return True, set() # Valid, no errors + + # Get errors from original file for this specific file + original_errors = self._get_original_file_errors(xml_file) + + # Compare with original (both are guaranteed to be sets here) + assert current_errors is not None + new_errors = current_errors - original_errors + + if new_errors: + if verbose: + relative_path = xml_file.relative_to(unpacked_dir) + print(f"FAILED - {relative_path}: {len(new_errors)} new error(s)") + for error in list(new_errors)[:3]: + truncated = error[:250] + "..." if len(error) > 250 else error + print(f" - {truncated}") + return False, new_errors + else: + # All errors existed in original + if verbose: + print( + f"PASSED - No new errors (original had {len(current_errors)} errors)" + ) + return True, set() + + def validate_against_xsd(self): + """Validate XML files against XSD schemas, showing only new errors compared to original.""" + new_errors = [] + original_error_count = 0 + valid_count = 0 + skipped_count = 0 + + for xml_file in self.xml_files: + relative_path = str(xml_file.relative_to(self.unpacked_dir)) + is_valid, new_file_errors = self.validate_file_against_xsd( + xml_file, verbose=False + ) + + if is_valid is None: + skipped_count += 1 + continue + elif is_valid and not new_file_errors: + valid_count += 1 + continue + elif is_valid: + # Had errors but all existed in original + original_error_count += 1 + valid_count += 1 + continue + + # Has new errors + new_errors.append(f" {relative_path}: {len(new_file_errors)} new error(s)") + for error in list(new_file_errors)[:3]: # Show first 3 errors + new_errors.append( + f" - {error[:250]}..." if len(error) > 250 else f" - {error}" + ) + + # Print summary + if self.verbose: + print(f"Validated {len(self.xml_files)} files:") + print(f" - Valid: {valid_count}") + print(f" - Skipped (no schema): {skipped_count}") + if original_error_count: + print(f" - With original errors (ignored): {original_error_count}") + print( + f" - With NEW errors: {len(new_errors) > 0 and len([e for e in new_errors if not e.startswith(' ')]) or 0}" + ) + + if new_errors: + print("\nFAILED - Found NEW validation errors:") + for error in new_errors: + print(error) + return False + else: + if self.verbose: + print("\nPASSED - No new XSD validation errors introduced") + return True + + def _get_schema_path(self, xml_file): + """Determine the appropriate schema path for an XML file.""" + # Check exact filename match + if xml_file.name in self.SCHEMA_MAPPINGS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.name] + + # Check .rels files + if xml_file.suffix == ".rels": + return self.schemas_dir / self.SCHEMA_MAPPINGS[".rels"] + + # Check chart files + if "charts/" in str(xml_file) and xml_file.name.startswith("chart"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["chart"] + + # Check theme files + if "theme/" in str(xml_file) and xml_file.name.startswith("theme"): + return self.schemas_dir / self.SCHEMA_MAPPINGS["theme"] + + # Check if file is in a main content folder and use appropriate schema + if xml_file.parent.name in self.MAIN_CONTENT_FOLDERS: + return self.schemas_dir / self.SCHEMA_MAPPINGS[xml_file.parent.name] + + return None + + def _clean_ignorable_namespaces(self, xml_doc): + """Remove attributes and elements not in allowed namespaces.""" + # Create a clean copy + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + # Remove attributes not in allowed namespaces + for elem in xml_copy.iter(): + attrs_to_remove = [] + + for attr in elem.attrib: + # Check if attribute is from a namespace other than allowed ones + if "{" in attr: + ns = attr.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + attrs_to_remove.append(attr) + + # Remove collected attributes + for attr in attrs_to_remove: + del elem.attrib[attr] + + # Remove elements not in allowed namespaces + self._remove_ignorable_elements(xml_copy) + + return lxml.etree.ElementTree(xml_copy) + + def _remove_ignorable_elements(self, root): + """Recursively remove all elements not in allowed namespaces.""" + elements_to_remove = [] + + # Find elements to remove + for elem in list(root): + # Skip non-element nodes (comments, processing instructions, etc.) + if not hasattr(elem, "tag") or callable(elem.tag): + continue + + tag_str = str(elem.tag) + if tag_str.startswith("{"): + ns = tag_str.split("}")[0][1:] + if ns not in self.OOXML_NAMESPACES: + elements_to_remove.append(elem) + continue + + # Recursively clean child elements + self._remove_ignorable_elements(elem) + + # Remove collected elements + for elem in elements_to_remove: + root.remove(elem) + + def _preprocess_for_mc_ignorable(self, xml_doc): + """Preprocess XML to handle mc:Ignorable attribute properly.""" + # Remove mc:Ignorable attributes before validation + root = xml_doc.getroot() + + # Remove mc:Ignorable attribute from root + if f"{{{self.MC_NAMESPACE}}}Ignorable" in root.attrib: + del root.attrib[f"{{{self.MC_NAMESPACE}}}Ignorable"] + + return xml_doc + + def _validate_single_file_xsd(self, xml_file, base_path): + """Validate a single XML file against XSD schema. Returns (is_valid, errors_set).""" + schema_path = self._get_schema_path(xml_file) + if not schema_path: + return None, None # Skip file + + try: + # Load schema + with open(schema_path, "rb") as xsd_file: + parser = lxml.etree.XMLParser() + xsd_doc = lxml.etree.parse( + xsd_file, parser=parser, base_url=str(schema_path) + ) + schema = lxml.etree.XMLSchema(xsd_doc) + + # Load and preprocess XML + with open(xml_file, "r") as f: + xml_doc = lxml.etree.parse(f) + + xml_doc, _ = self._remove_template_tags_from_text_nodes(xml_doc) + xml_doc = self._preprocess_for_mc_ignorable(xml_doc) + + # Clean ignorable namespaces if needed + relative_path = xml_file.relative_to(base_path) + if ( + relative_path.parts + and relative_path.parts[0] in self.MAIN_CONTENT_FOLDERS + ): + xml_doc = self._clean_ignorable_namespaces(xml_doc) + + # Validate + if schema.validate(xml_doc): + return True, set() + else: + errors = set() + for error in schema.error_log: + # Store normalized error message (without line numbers for comparison) + errors.add(error.message) + return False, errors + + except Exception as e: + return False, {str(e)} + + def _get_original_file_errors(self, xml_file): + """Get XSD validation errors from a single file in the original document. + + Args: + xml_file: Path to the XML file in unpacked_dir to check + + Returns: + set: Set of error messages from the original file + """ + import tempfile + import zipfile + + # Resolve both paths to handle symlinks (e.g., /var vs /private/var on macOS) + xml_file = Path(xml_file).resolve() + unpacked_dir = self.unpacked_dir.resolve() + relative_path = xml_file.relative_to(unpacked_dir) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Extract original file + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_path) + + # Find corresponding file in original + original_xml_file = temp_path / relative_path + + if not original_xml_file.exists(): + # File didn't exist in original, so no original errors + return set() + + # Validate the specific file in original + is_valid, errors = self._validate_single_file_xsd( + original_xml_file, temp_path + ) + return errors if errors else set() + + def _remove_template_tags_from_text_nodes(self, xml_doc): + """Remove template tags from XML text nodes and collect warnings. + + Template tags follow the pattern {{ ... }} and are used as placeholders + for content replacement. They should be removed from text content before + XSD validation while preserving XML structure. + + Returns: + tuple: (cleaned_xml_doc, warnings_list) + """ + warnings = [] + template_pattern = re.compile(r"\{\{[^}]*\}\}") + + # Create a copy of the document to avoid modifying the original + xml_string = lxml.etree.tostring(xml_doc, encoding="unicode") + xml_copy = lxml.etree.fromstring(xml_string) + + def process_text_content(text, content_type): + if not text: + return text + matches = list(template_pattern.finditer(text)) + if matches: + for match in matches: + warnings.append( + f"Found template tag in {content_type}: {match.group()}" + ) + return template_pattern.sub("", text) + return text + + # Process all text nodes in the document + for elem in xml_copy.iter(): + # Skip processing if this is a w:t element + if not hasattr(elem, "tag") or callable(elem.tag): + continue + tag_str = str(elem.tag) + if tag_str.endswith("}t") or tag_str == "t": + continue + + elem.text = process_text_content(elem.text, "text content") + elem.tail = process_text_content(elem.tail, "tail content") + + return lxml.etree.ElementTree(xml_copy), warnings + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/pptx/ooxml/scripts/validation/docx.py b/skills/document-skills/pptx/ooxml/scripts/validation/docx.py new file mode 100644 index 0000000..602c470 --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/validation/docx.py @@ -0,0 +1,274 @@ +""" +Validator for Word document XML files against XSD schemas. +""" + +import re +import tempfile +import zipfile + +import lxml.etree + +from .base import BaseSchemaValidator + + +class DOCXSchemaValidator(BaseSchemaValidator): + """Validator for Word document XML files against XSD schemas.""" + + # Word-specific namespace + WORD_2006_NAMESPACE = "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + + # Word-specific element to relationship type mappings + # Start with empty mapping - add specific cases as we discover them + ELEMENT_RELATIONSHIP_TYPES = {} + + def validate(self): + """Run all validation checks and return True if all pass.""" + # Test 0: XML well-formedness + if not self.validate_xml(): + return False + + # Test 1: Namespace declarations + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + # Test 2: Unique IDs + if not self.validate_unique_ids(): + all_valid = False + + # Test 3: Relationship and file reference validation + if not self.validate_file_references(): + all_valid = False + + # Test 4: Content type declarations + if not self.validate_content_types(): + all_valid = False + + # Test 5: XSD schema validation + if not self.validate_against_xsd(): + all_valid = False + + # Test 6: Whitespace preservation + if not self.validate_whitespace_preservation(): + all_valid = False + + # Test 7: Deletion validation + if not self.validate_deletions(): + all_valid = False + + # Test 8: Insertion validation + if not self.validate_insertions(): + all_valid = False + + # Test 9: Relationship ID reference validation + if not self.validate_all_relationship_ids(): + all_valid = False + + # Count and compare paragraphs + self.compare_paragraph_counts() + + return all_valid + + def validate_whitespace_preservation(self): + """ + Validate that w:t elements with whitespace have xml:space='preserve'. + """ + errors = [] + + for xml_file in self.xml_files: + # Only check document.xml files + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + # Find all w:t elements + for elem in root.iter(f"{{{self.WORD_2006_NAMESPACE}}}t"): + if elem.text: + text = elem.text + # Check if text starts or ends with whitespace + if re.match(r"^\s.*", text) or re.match(r".*\s$", text): + # Check if xml:space="preserve" attribute exists + xml_space_attr = f"{{{self.XML_NAMESPACE}}}space" + if ( + xml_space_attr not in elem.attrib + or elem.attrib[xml_space_attr] != "preserve" + ): + # Show a preview of the text + text_preview = ( + repr(text)[:50] + "..." + if len(repr(text)) > 50 + else repr(text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: w:t element with whitespace missing xml:space='preserve': {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} whitespace preservation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All whitespace is properly preserved") + return True + + def validate_deletions(self): + """ + Validate that w:t elements are not within w:del elements. + For some reason, XSD validation does not catch this, so we do it manually. + """ + errors = [] + + for xml_file in self.xml_files: + # Only check document.xml files + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + # Find all w:t elements that are descendants of w:del elements + namespaces = {"w": self.WORD_2006_NAMESPACE} + xpath_expression = ".//w:del//w:t" + problematic_t_elements = root.xpath( + xpath_expression, namespaces=namespaces + ) + for t_elem in problematic_t_elements: + if t_elem.text: + # Show a preview of the text + text_preview = ( + repr(t_elem.text)[:50] + "..." + if len(repr(t_elem.text)) > 50 + else repr(t_elem.text) + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {t_elem.sourceline}: found within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} deletion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:t elements found within w:del elements") + return True + + def count_paragraphs_in_unpacked(self): + """Count the number of paragraphs in the unpacked document.""" + count = 0 + + for xml_file in self.xml_files: + # Only check document.xml files + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + # Count all w:p elements + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + except Exception as e: + print(f"Error counting paragraphs in unpacked document: {e}") + + return count + + def count_paragraphs_in_original(self): + """Count the number of paragraphs in the original docx file.""" + count = 0 + + try: + # Create temporary directory to unpack original + with tempfile.TemporaryDirectory() as temp_dir: + # Unpack original docx + with zipfile.ZipFile(self.original_file, "r") as zip_ref: + zip_ref.extractall(temp_dir) + + # Parse document.xml + doc_xml_path = temp_dir + "/word/document.xml" + root = lxml.etree.parse(doc_xml_path).getroot() + + # Count all w:p elements + paragraphs = root.findall(f".//{{{self.WORD_2006_NAMESPACE}}}p") + count = len(paragraphs) + + except Exception as e: + print(f"Error counting paragraphs in original document: {e}") + + return count + + def validate_insertions(self): + """ + Validate that w:delText elements are not within w:ins elements. + w:delText is only allowed in w:ins if nested within a w:del. + """ + errors = [] + + for xml_file in self.xml_files: + if xml_file.name != "document.xml": + continue + + try: + root = lxml.etree.parse(str(xml_file)).getroot() + namespaces = {"w": self.WORD_2006_NAMESPACE} + + # Find w:delText in w:ins that are NOT within w:del + invalid_elements = root.xpath( + ".//w:ins//w:delText[not(ancestor::w:del)]", + namespaces=namespaces + ) + + for elem in invalid_elements: + text_preview = ( + repr(elem.text or "")[:50] + "..." + if len(repr(elem.text or "")) > 50 + else repr(elem.text or "") + ) + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: within : {text_preview}" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} insertion validation violations:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - No w:delText elements within w:ins elements") + return True + + def compare_paragraph_counts(self): + """Compare paragraph counts between original and new document.""" + original_count = self.count_paragraphs_in_original() + new_count = self.count_paragraphs_in_unpacked() + + diff = new_count - original_count + diff_str = f"+{diff}" if diff > 0 else str(diff) + print(f"\nParagraphs: {original_count} → {new_count} ({diff_str})") + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/pptx/ooxml/scripts/validation/pptx.py b/skills/document-skills/pptx/ooxml/scripts/validation/pptx.py new file mode 100644 index 0000000..66d5b1e --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/validation/pptx.py @@ -0,0 +1,315 @@ +""" +Validator for PowerPoint presentation XML files against XSD schemas. +""" + +import re + +from .base import BaseSchemaValidator + + +class PPTXSchemaValidator(BaseSchemaValidator): + """Validator for PowerPoint presentation XML files against XSD schemas.""" + + # PowerPoint presentation namespace + PRESENTATIONML_NAMESPACE = ( + "http://schemas.openxmlformats.org/presentationml/2006/main" + ) + + # PowerPoint-specific element to relationship type mappings + ELEMENT_RELATIONSHIP_TYPES = { + "sldid": "slide", + "sldmasterid": "slidemaster", + "notesmasterid": "notesmaster", + "sldlayoutid": "slidelayout", + "themeid": "theme", + "tablestyleid": "tablestyles", + } + + def validate(self): + """Run all validation checks and return True if all pass.""" + # Test 0: XML well-formedness + if not self.validate_xml(): + return False + + # Test 1: Namespace declarations + all_valid = True + if not self.validate_namespaces(): + all_valid = False + + # Test 2: Unique IDs + if not self.validate_unique_ids(): + all_valid = False + + # Test 3: UUID ID validation + if not self.validate_uuid_ids(): + all_valid = False + + # Test 4: Relationship and file reference validation + if not self.validate_file_references(): + all_valid = False + + # Test 5: Slide layout ID validation + if not self.validate_slide_layout_ids(): + all_valid = False + + # Test 6: Content type declarations + if not self.validate_content_types(): + all_valid = False + + # Test 7: XSD schema validation + if not self.validate_against_xsd(): + all_valid = False + + # Test 8: Notes slide reference validation + if not self.validate_notes_slide_references(): + all_valid = False + + # Test 9: Relationship ID reference validation + if not self.validate_all_relationship_ids(): + all_valid = False + + # Test 10: Duplicate slide layout references validation + if not self.validate_no_duplicate_slide_layouts(): + all_valid = False + + return all_valid + + def validate_uuid_ids(self): + """Validate that ID attributes that look like UUIDs contain only hex values.""" + import lxml.etree + + errors = [] + # UUID pattern: 8-4-4-4-12 hex digits with optional braces/hyphens + uuid_pattern = re.compile( + r"^[\{\(]?[0-9A-Fa-f]{8}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{4}-?[0-9A-Fa-f]{12}[\}\)]?$" + ) + + for xml_file in self.xml_files: + try: + root = lxml.etree.parse(str(xml_file)).getroot() + + # Check all elements for ID attributes + for elem in root.iter(): + for attr, value in elem.attrib.items(): + # Check if this is an ID attribute + attr_name = attr.split("}")[-1].lower() + if attr_name == "id" or attr_name.endswith("id"): + # Check if value looks like a UUID (has the right length and pattern structure) + if self._looks_like_uuid(value): + # Validate that it contains only hex characters in the right positions + if not uuid_pattern.match(value): + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: " + f"Line {elem.sourceline}: ID '{value}' appears to be a UUID but contains invalid hex characters" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {xml_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} UUID ID validation errors:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All UUID-like IDs contain valid hex values") + return True + + def _looks_like_uuid(self, value): + """Check if a value has the general structure of a UUID.""" + # Remove common UUID delimiters + clean_value = value.strip("{}()").replace("-", "") + # Check if it's 32 hex-like characters (could include invalid hex chars) + return len(clean_value) == 32 and all(c.isalnum() for c in clean_value) + + def validate_slide_layout_ids(self): + """Validate that sldLayoutId elements in slide masters reference valid slide layouts.""" + import lxml.etree + + errors = [] + + # Find all slide master files + slide_masters = list(self.unpacked_dir.glob("ppt/slideMasters/*.xml")) + + if not slide_masters: + if self.verbose: + print("PASSED - No slide masters found") + return True + + for slide_master in slide_masters: + try: + # Parse the slide master file + root = lxml.etree.parse(str(slide_master)).getroot() + + # Find the corresponding _rels file for this slide master + rels_file = slide_master.parent / "_rels" / f"{slide_master.name}.rels" + + if not rels_file.exists(): + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Missing relationships file: {rels_file.relative_to(self.unpacked_dir)}" + ) + continue + + # Parse the relationships file + rels_root = lxml.etree.parse(str(rels_file)).getroot() + + # Build a set of valid relationship IDs that point to slide layouts + valid_layout_rids = set() + for rel in rels_root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "slideLayout" in rel_type: + valid_layout_rids.add(rel.get("Id")) + + # Find all sldLayoutId elements in the slide master + for sld_layout_id in root.findall( + f".//{{{self.PRESENTATIONML_NAMESPACE}}}sldLayoutId" + ): + r_id = sld_layout_id.get( + f"{{{self.OFFICE_RELATIONSHIPS_NAMESPACE}}}id" + ) + layout_id = sld_layout_id.get("id") + + if r_id and r_id not in valid_layout_rids: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: " + f"Line {sld_layout_id.sourceline}: sldLayoutId with id='{layout_id}' " + f"references r:id='{r_id}' which is not found in slide layout relationships" + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {slide_master.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print(f"FAILED - Found {len(errors)} slide layout ID validation errors:") + for error in errors: + print(error) + print( + "Remove invalid references or add missing slide layouts to the relationships file." + ) + return False + else: + if self.verbose: + print("PASSED - All slide layout IDs reference valid slide layouts") + return True + + def validate_no_duplicate_slide_layouts(self): + """Validate that each slide has exactly one slideLayout reference.""" + import lxml.etree + + errors = [] + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + for rels_file in slide_rels_files: + try: + root = lxml.etree.parse(str(rels_file)).getroot() + + # Find all slideLayout relationships + layout_rels = [ + rel + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ) + if "slideLayout" in rel.get("Type", "") + ] + + if len(layout_rels) > 1: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: has {len(layout_rels)} slideLayout references" + ) + + except Exception as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + if errors: + print("FAILED - Found slides with duplicate slideLayout references:") + for error in errors: + print(error) + return False + else: + if self.verbose: + print("PASSED - All slides have exactly one slideLayout reference") + return True + + def validate_notes_slide_references(self): + """Validate that each notesSlide file is referenced by only one slide.""" + import lxml.etree + + errors = [] + notes_slide_references = {} # Track which slides reference each notesSlide + + # Find all slide relationship files + slide_rels_files = list(self.unpacked_dir.glob("ppt/slides/_rels/*.xml.rels")) + + if not slide_rels_files: + if self.verbose: + print("PASSED - No slide relationship files found") + return True + + for rels_file in slide_rels_files: + try: + # Parse the relationships file + root = lxml.etree.parse(str(rels_file)).getroot() + + # Find all notesSlide relationships + for rel in root.findall( + f".//{{{self.PACKAGE_RELATIONSHIPS_NAMESPACE}}}Relationship" + ): + rel_type = rel.get("Type", "") + if "notesSlide" in rel_type: + target = rel.get("Target", "") + if target: + # Normalize the target path to handle relative paths + normalized_target = target.replace("../", "") + + # Track which slide references this notesSlide + slide_name = rels_file.stem.replace( + ".xml", "" + ) # e.g., "slide1" + + if normalized_target not in notes_slide_references: + notes_slide_references[normalized_target] = [] + notes_slide_references[normalized_target].append( + (slide_name, rels_file) + ) + + except (lxml.etree.XMLSyntaxError, Exception) as e: + errors.append( + f" {rels_file.relative_to(self.unpacked_dir)}: Error: {e}" + ) + + # Check for duplicate references + for target, references in notes_slide_references.items(): + if len(references) > 1: + slide_names = [ref[0] for ref in references] + errors.append( + f" Notes slide '{target}' is referenced by multiple slides: {', '.join(slide_names)}" + ) + for slide_name, rels_file in references: + errors.append(f" - {rels_file.relative_to(self.unpacked_dir)}") + + if errors: + print( + f"FAILED - Found {len([e for e in errors if not e.startswith(' ')])} notes slide reference validation errors:" + ) + for error in errors: + print(error) + print("Each slide may optionally have its own slide file.") + return False + else: + if self.verbose: + print("PASSED - All notes slide references are unique") + return True + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/pptx/ooxml/scripts/validation/redlining.py b/skills/document-skills/pptx/ooxml/scripts/validation/redlining.py new file mode 100644 index 0000000..7ed425e --- /dev/null +++ b/skills/document-skills/pptx/ooxml/scripts/validation/redlining.py @@ -0,0 +1,279 @@ +""" +Validator for tracked changes in Word documents. +""" + +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +class RedliningValidator: + """Validator for tracked changes in Word documents.""" + + def __init__(self, unpacked_dir, original_docx, verbose=False): + self.unpacked_dir = Path(unpacked_dir) + self.original_docx = Path(original_docx) + self.verbose = verbose + self.namespaces = { + "w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main" + } + + def validate(self): + """Main validation method that returns True if valid, False otherwise.""" + # Verify unpacked directory exists and has correct structure + modified_file = self.unpacked_dir / "word" / "document.xml" + if not modified_file.exists(): + print(f"FAILED - Modified document.xml not found at {modified_file}") + return False + + # First, check if there are any tracked changes by Claude to validate + try: + import xml.etree.ElementTree as ET + + tree = ET.parse(modified_file) + root = tree.getroot() + + # Check for w:del or w:ins tags authored by Claude + del_elements = root.findall(".//w:del", self.namespaces) + ins_elements = root.findall(".//w:ins", self.namespaces) + + # Filter to only include changes by Claude + claude_del_elements = [ + elem + for elem in del_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude" + ] + claude_ins_elements = [ + elem + for elem in ins_elements + if elem.get(f"{{{self.namespaces['w']}}}author") == "Claude" + ] + + # Redlining validation is only needed if tracked changes by Claude have been used. + if not claude_del_elements and not claude_ins_elements: + if self.verbose: + print("PASSED - No tracked changes by Claude found.") + return True + + except Exception: + # If we can't parse the XML, continue with full validation + pass + + # Create temporary directory for unpacking original docx + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Unpack original docx + try: + with zipfile.ZipFile(self.original_docx, "r") as zip_ref: + zip_ref.extractall(temp_path) + except Exception as e: + print(f"FAILED - Error unpacking original docx: {e}") + return False + + original_file = temp_path / "word" / "document.xml" + if not original_file.exists(): + print( + f"FAILED - Original document.xml not found in {self.original_docx}" + ) + return False + + # Parse both XML files using xml.etree.ElementTree for redlining validation + try: + import xml.etree.ElementTree as ET + + modified_tree = ET.parse(modified_file) + modified_root = modified_tree.getroot() + original_tree = ET.parse(original_file) + original_root = original_tree.getroot() + except ET.ParseError as e: + print(f"FAILED - Error parsing XML files: {e}") + return False + + # Remove Claude's tracked changes from both documents + self._remove_claude_tracked_changes(original_root) + self._remove_claude_tracked_changes(modified_root) + + # Extract and compare text content + modified_text = self._extract_text_content(modified_root) + original_text = self._extract_text_content(original_root) + + if modified_text != original_text: + # Show detailed character-level differences for each paragraph + error_message = self._generate_detailed_diff( + original_text, modified_text + ) + print(error_message) + return False + + if self.verbose: + print("PASSED - All changes by Claude are properly tracked") + return True + + def _generate_detailed_diff(self, original_text, modified_text): + """Generate detailed word-level differences using git word diff.""" + error_parts = [ + "FAILED - Document text doesn't match after removing Claude's tracked changes", + "", + "Likely causes:", + " 1. Modified text inside another author's or tags", + " 2. Made edits without proper tracked changes", + " 3. Didn't nest inside when deleting another's insertion", + "", + "For pre-redlined documents, use correct patterns:", + " - To reject another's INSERTION: Nest inside their ", + " - To restore another's DELETION: Add new AFTER their ", + "", + ] + + # Show git word diff + git_diff = self._get_git_word_diff(original_text, modified_text) + if git_diff: + error_parts.extend(["Differences:", "============", git_diff]) + else: + error_parts.append("Unable to generate word diff (git not available)") + + return "\n".join(error_parts) + + def _get_git_word_diff(self, original_text, modified_text): + """Generate word diff using git with character-level precision.""" + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Create two files + original_file = temp_path / "original.txt" + modified_file = temp_path / "modified.txt" + + original_file.write_text(original_text, encoding="utf-8") + modified_file.write_text(modified_text, encoding="utf-8") + + # Try character-level diff first for precise differences + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "--word-diff-regex=.", # Character-by-character diff + "-U0", # Zero lines of context - show only changed lines + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + # Clean up the output - remove git diff header lines + lines = result.stdout.split("\n") + # Skip the header lines (diff --git, index, +++, ---, @@) + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + + if content_lines: + return "\n".join(content_lines) + + # Fallback to word-level diff if character-level is too verbose + result = subprocess.run( + [ + "git", + "diff", + "--word-diff=plain", + "-U0", # Zero lines of context + "--no-index", + str(original_file), + str(modified_file), + ], + capture_output=True, + text=True, + ) + + if result.stdout.strip(): + lines = result.stdout.split("\n") + content_lines = [] + in_content = False + for line in lines: + if line.startswith("@@"): + in_content = True + continue + if in_content and line.strip(): + content_lines.append(line) + return "\n".join(content_lines) + + except (subprocess.CalledProcessError, FileNotFoundError, Exception): + # Git not available or other error, return None to use fallback + pass + + return None + + def _remove_claude_tracked_changes(self, root): + """Remove tracked changes authored by Claude from the XML root.""" + ins_tag = f"{{{self.namespaces['w']}}}ins" + del_tag = f"{{{self.namespaces['w']}}}del" + author_attr = f"{{{self.namespaces['w']}}}author" + + # Remove w:ins elements + for parent in root.iter(): + to_remove = [] + for child in parent: + if child.tag == ins_tag and child.get(author_attr) == "Claude": + to_remove.append(child) + for elem in to_remove: + parent.remove(elem) + + # Unwrap content in w:del elements where author is "Claude" + deltext_tag = f"{{{self.namespaces['w']}}}delText" + t_tag = f"{{{self.namespaces['w']}}}t" + + for parent in root.iter(): + to_process = [] + for child in parent: + if child.tag == del_tag and child.get(author_attr) == "Claude": + to_process.append((child, list(parent).index(child))) + + # Process in reverse order to maintain indices + for del_elem, del_index in reversed(to_process): + # Convert w:delText to w:t before moving + for elem in del_elem.iter(): + if elem.tag == deltext_tag: + elem.tag = t_tag + + # Move all children of w:del to its parent before removing w:del + for child in reversed(list(del_elem)): + parent.insert(del_index, child) + parent.remove(del_elem) + + def _extract_text_content(self, root): + """Extract text content from Word XML, preserving paragraph structure. + + Empty paragraphs are skipped to avoid false positives when tracked + insertions add only structural elements without text content. + """ + p_tag = f"{{{self.namespaces['w']}}}p" + t_tag = f"{{{self.namespaces['w']}}}t" + + paragraphs = [] + for p_elem in root.findall(f".//{p_tag}"): + # Get all text elements within this paragraph + text_parts = [] + for t_elem in p_elem.findall(f".//{t_tag}"): + if t_elem.text: + text_parts.append(t_elem.text) + paragraph_text = "".join(text_parts) + # Skip empty paragraphs - they don't affect content validation + if paragraph_text: + paragraphs.append(paragraph_text) + + return "\n".join(paragraphs) + + +if __name__ == "__main__": + raise RuntimeError("This module should not be run directly.") diff --git a/skills/document-skills/pptx/scripts/html2pptx.js b/skills/document-skills/pptx/scripts/html2pptx.js new file mode 100755 index 0000000..437bf7c --- /dev/null +++ b/skills/document-skills/pptx/scripts/html2pptx.js @@ -0,0 +1,979 @@ +/** + * html2pptx - Convert HTML slide to pptxgenjs slide with positioned elements + * + * USAGE: + * const pptx = new pptxgen(); + * pptx.layout = 'LAYOUT_16x9'; // Must match HTML body dimensions + * + * const { slide, placeholders } = await html2pptx('slide.html', pptx); + * slide.addChart(pptx.charts.LINE, data, placeholders[0]); + * + * await pptx.writeFile('output.pptx'); + * + * FEATURES: + * - Converts HTML to PowerPoint with accurate positioning + * - Supports text, images, shapes, and bullet lists + * - Extracts placeholder elements (class="placeholder") with positions + * - Handles CSS gradients, borders, and margins + * + * VALIDATION: + * - Uses body width/height from HTML for viewport sizing + * - Throws error if HTML dimensions don't match presentation layout + * - Throws error if content overflows body (with overflow details) + * + * RETURNS: + * { slide, placeholders } where placeholders is an array of { id, x, y, w, h } + */ + +const { chromium } = require('playwright'); +const path = require('path'); +const sharp = require('sharp'); + +const PT_PER_PX = 0.75; +const PX_PER_IN = 96; +const EMU_PER_IN = 914400; + +// Helper: Get body dimensions and check for overflow +async function getBodyDimensions(page) { + const bodyDimensions = await page.evaluate(() => { + const body = document.body; + const style = window.getComputedStyle(body); + + return { + width: parseFloat(style.width), + height: parseFloat(style.height), + scrollWidth: body.scrollWidth, + scrollHeight: body.scrollHeight + }; + }); + + const errors = []; + const widthOverflowPx = Math.max(0, bodyDimensions.scrollWidth - bodyDimensions.width - 1); + const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1); + + const widthOverflowPt = widthOverflowPx * PT_PER_PX; + const heightOverflowPt = heightOverflowPx * PT_PER_PX; + + if (widthOverflowPt > 0 || heightOverflowPt > 0) { + const directions = []; + if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`); + if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`); + const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : ''; + errors.push(`HTML content overflows body by ${directions.join(' and ')}${reminder}`); + } + + return { ...bodyDimensions, errors }; +} + +// Helper: Validate dimensions match presentation layout +function validateDimensions(bodyDimensions, pres) { + const errors = []; + const widthInches = bodyDimensions.width / PX_PER_IN; + const heightInches = bodyDimensions.height / PX_PER_IN; + + if (pres.presLayout) { + const layoutWidth = pres.presLayout.width / EMU_PER_IN; + const layoutHeight = pres.presLayout.height / EMU_PER_IN; + + if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) { + errors.push( + `HTML dimensions (${widthInches.toFixed(1)}" × ${heightInches.toFixed(1)}") ` + + `don't match presentation layout (${layoutWidth.toFixed(1)}" × ${layoutHeight.toFixed(1)}")` + ); + } + } + return errors; +} + +function validateTextBoxPosition(slideData, bodyDimensions) { + const errors = []; + const slideHeightInches = bodyDimensions.height / PX_PER_IN; + const minBottomMargin = 0.5; // 0.5 inches from bottom + + for (const el of slideData.elements) { + // Check text elements (p, h1-h6, list) + if (['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'list'].includes(el.type)) { + const fontSize = el.style?.fontSize || 0; + const bottomEdge = el.position.y + el.position.h; + const distanceFromBottom = slideHeightInches - bottomEdge; + + if (fontSize > 12 && distanceFromBottom < minBottomMargin) { + const getText = () => { + if (typeof el.text === 'string') return el.text; + if (Array.isArray(el.text)) return el.text.find(t => t.text)?.text || ''; + if (Array.isArray(el.items)) return el.items.find(item => item.text)?.text || ''; + return ''; + }; + const textPrefix = getText().substring(0, 50) + (getText().length > 50 ? '...' : ''); + + errors.push( + `Text box "${textPrefix}" ends too close to bottom edge ` + + `(${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)` + ); + } + } + } + + return errors; +} + +// Helper: Add background to slide +async function addBackground(slideData, targetSlide, tmpDir) { + if (slideData.background.type === 'image' && slideData.background.path) { + let imagePath = slideData.background.path.startsWith('file://') + ? slideData.background.path.replace('file://', '') + : slideData.background.path; + targetSlide.background = { path: imagePath }; + } else if (slideData.background.type === 'color' && slideData.background.value) { + targetSlide.background = { color: slideData.background.value }; + } +} + +// Helper: Add elements to slide +function addElements(slideData, targetSlide, pres) { + for (const el of slideData.elements) { + if (el.type === 'image') { + let imagePath = el.src.startsWith('file://') ? el.src.replace('file://', '') : el.src; + targetSlide.addImage({ + path: imagePath, + x: el.position.x, + y: el.position.y, + w: el.position.w, + h: el.position.h + }); + } else if (el.type === 'line') { + targetSlide.addShape(pres.ShapeType.line, { + x: el.x1, + y: el.y1, + w: el.x2 - el.x1, + h: el.y2 - el.y1, + line: { color: el.color, width: el.width } + }); + } else if (el.type === 'shape') { + const shapeOptions = { + x: el.position.x, + y: el.position.y, + w: el.position.w, + h: el.position.h, + shape: el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect + }; + + if (el.shape.fill) { + shapeOptions.fill = { color: el.shape.fill }; + if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency; + } + if (el.shape.line) shapeOptions.line = el.shape.line; + if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius; + if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow; + + targetSlide.addText(el.text || '', shapeOptions); + } else if (el.type === 'list') { + const listOptions = { + x: el.position.x, + y: el.position.y, + w: el.position.w, + h: el.position.h, + fontSize: el.style.fontSize, + fontFace: el.style.fontFace, + color: el.style.color, + align: el.style.align, + valign: 'top', + lineSpacing: el.style.lineSpacing, + paraSpaceBefore: el.style.paraSpaceBefore, + paraSpaceAfter: el.style.paraSpaceAfter, + margin: el.style.margin + }; + if (el.style.margin) listOptions.margin = el.style.margin; + targetSlide.addText(el.items, listOptions); + } else { + // Check if text is single-line (height suggests one line) + const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2; + const isSingleLine = el.position.h <= lineHeight * 1.5; + + let adjustedX = el.position.x; + let adjustedW = el.position.w; + + // Make single-line text 2% wider to account for underestimate + if (isSingleLine) { + const widthIncrease = el.position.w * 0.02; + const align = el.style.align; + + if (align === 'center') { + // Center: expand both sides + adjustedX = el.position.x - (widthIncrease / 2); + adjustedW = el.position.w + widthIncrease; + } else if (align === 'right') { + // Right: expand to the left + adjustedX = el.position.x - widthIncrease; + adjustedW = el.position.w + widthIncrease; + } else { + // Left (default): expand to the right + adjustedW = el.position.w + widthIncrease; + } + } + + const textOptions = { + x: adjustedX, + y: el.position.y, + w: adjustedW, + h: el.position.h, + fontSize: el.style.fontSize, + fontFace: el.style.fontFace, + color: el.style.color, + bold: el.style.bold, + italic: el.style.italic, + underline: el.style.underline, + valign: 'top', + lineSpacing: el.style.lineSpacing, + paraSpaceBefore: el.style.paraSpaceBefore, + paraSpaceAfter: el.style.paraSpaceAfter, + inset: 0 // Remove default PowerPoint internal padding + }; + + if (el.style.align) textOptions.align = el.style.align; + if (el.style.margin) textOptions.margin = el.style.margin; + if (el.style.rotate !== undefined) textOptions.rotate = el.style.rotate; + if (el.style.transparency !== null && el.style.transparency !== undefined) textOptions.transparency = el.style.transparency; + + targetSlide.addText(el.text, textOptions); + } + } +} + +// Helper: Extract slide data from HTML page +async function extractSlideData(page) { + return await page.evaluate(() => { + const PT_PER_PX = 0.75; + const PX_PER_IN = 96; + + // Fonts that are single-weight and should not have bold applied + // (applying bold causes PowerPoint to use faux bold which makes text wider) + const SINGLE_WEIGHT_FONTS = ['impact']; + + // Helper: Check if a font should skip bold formatting + const shouldSkipBold = (fontFamily) => { + if (!fontFamily) return false; + const normalizedFont = fontFamily.toLowerCase().replace(/['"]/g, '').split(',')[0].trim(); + return SINGLE_WEIGHT_FONTS.includes(normalizedFont); + }; + + // Unit conversion helpers + const pxToInch = (px) => px / PX_PER_IN; + const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX; + const rgbToHex = (rgbStr) => { + // Handle transparent backgrounds by defaulting to white + if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF'; + + const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (!match) return 'FFFFFF'; + return match.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); + }; + + const extractAlpha = (rgbStr) => { + const match = rgbStr.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/); + if (!match || !match[4]) return null; + const alpha = parseFloat(match[4]); + return Math.round((1 - alpha) * 100); + }; + + const applyTextTransform = (text, textTransform) => { + if (textTransform === 'uppercase') return text.toUpperCase(); + if (textTransform === 'lowercase') return text.toLowerCase(); + if (textTransform === 'capitalize') { + return text.replace(/\b\w/g, c => c.toUpperCase()); + } + return text; + }; + + // Extract rotation angle from CSS transform and writing-mode + const getRotation = (transform, writingMode) => { + let angle = 0; + + // Handle writing-mode first + // PowerPoint: 90° = text rotated 90° clockwise (reads top to bottom, letters upright) + // PowerPoint: 270° = text rotated 270° clockwise (reads bottom to top, letters upright) + if (writingMode === 'vertical-rl') { + // vertical-rl alone = text reads top to bottom = 90° in PowerPoint + angle = 90; + } else if (writingMode === 'vertical-lr') { + // vertical-lr alone = text reads bottom to top = 270° in PowerPoint + angle = 270; + } + + // Then add any transform rotation + if (transform && transform !== 'none') { + // Try to match rotate() function + const rotateMatch = transform.match(/rotate\((-?\d+(?:\.\d+)?)deg\)/); + if (rotateMatch) { + angle += parseFloat(rotateMatch[1]); + } else { + // Browser may compute as matrix - extract rotation from matrix + const matrixMatch = transform.match(/matrix\(([^)]+)\)/); + if (matrixMatch) { + const values = matrixMatch[1].split(',').map(parseFloat); + // matrix(a, b, c, d, e, f) where rotation = atan2(b, a) + const matrixAngle = Math.atan2(values[1], values[0]) * (180 / Math.PI); + angle += Math.round(matrixAngle); + } + } + } + + // Normalize to 0-359 range + angle = angle % 360; + if (angle < 0) angle += 360; + + return angle === 0 ? null : angle; + }; + + // Get position/dimensions accounting for rotation + const getPositionAndSize = (el, rect, rotation) => { + if (rotation === null) { + return { x: rect.left, y: rect.top, w: rect.width, h: rect.height }; + } + + // For 90° or 270° rotations, swap width and height + // because PowerPoint applies rotation to the original (unrotated) box + const isVertical = rotation === 90 || rotation === 270; + + if (isVertical) { + // The browser shows us the rotated dimensions (tall box for vertical text) + // But PowerPoint needs the pre-rotation dimensions (wide box that will be rotated) + // So we swap: browser's height becomes PPT's width, browser's width becomes PPT's height + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + + return { + x: centerX - rect.height / 2, + y: centerY - rect.width / 2, + w: rect.height, + h: rect.width + }; + } + + // For other rotations, use element's offset dimensions + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + return { + x: centerX - el.offsetWidth / 2, + y: centerY - el.offsetHeight / 2, + w: el.offsetWidth, + h: el.offsetHeight + }; + }; + + // Parse CSS box-shadow into PptxGenJS shadow properties + const parseBoxShadow = (boxShadow) => { + if (!boxShadow || boxShadow === 'none') return null; + + // Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]" + // CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)" + + const insetMatch = boxShadow.match(/inset/); + + // IMPORTANT: PptxGenJS/PowerPoint doesn't properly support inset shadows + // Only process outer shadows to avoid file corruption + if (insetMatch) return null; + + // Extract color first (rgba or rgb at start) + const colorMatch = boxShadow.match(/rgba?\([^)]+\)/); + + // Extract numeric values (handles both px and pt units) + const parts = boxShadow.match(/([-\d.]+)(px|pt)/g); + + if (!parts || parts.length < 2) return null; + + const offsetX = parseFloat(parts[0]); + const offsetY = parseFloat(parts[1]); + const blur = parts.length > 2 ? parseFloat(parts[2]) : 0; + + // Calculate angle from offsets (in degrees, 0 = right, 90 = down) + let angle = 0; + if (offsetX !== 0 || offsetY !== 0) { + angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI); + if (angle < 0) angle += 360; + } + + // Calculate offset distance (hypotenuse) + const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX; + + // Extract opacity from rgba + let opacity = 0.5; + if (colorMatch) { + const opacityMatch = colorMatch[0].match(/[\d.]+\)$/); + if (opacityMatch) { + opacity = parseFloat(opacityMatch[0].replace(')', '')); + } + } + + return { + type: 'outer', + angle: Math.round(angle), + blur: blur * 0.75, // Convert to points + color: colorMatch ? rgbToHex(colorMatch[0]) : '000000', + offset: offset, + opacity + }; + }; + + // Parse inline formatting tags (, , , , , ) into text runs + const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => { + let prevNodeIsText = false; + + element.childNodes.forEach((node) => { + let textTransform = baseTextTransform; + + const isText = node.nodeType === Node.TEXT_NODE || node.tagName === 'BR'; + if (isText) { + const text = node.tagName === 'BR' ? '\n' : textTransform(node.textContent.replace(/\s+/g, ' ')); + const prevRun = runs[runs.length - 1]; + if (prevNodeIsText && prevRun) { + prevRun.text += text; + } else { + runs.push({ text, options: { ...baseOptions } }); + } + + } else if (node.nodeType === Node.ELEMENT_NODE && node.textContent.trim()) { + const options = { ...baseOptions }; + const computed = window.getComputedStyle(node); + + // Handle inline elements with computed styles + if (node.tagName === 'SPAN' || node.tagName === 'B' || node.tagName === 'STRONG' || node.tagName === 'I' || node.tagName === 'EM' || node.tagName === 'U') { + const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600; + if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true; + if (computed.fontStyle === 'italic') options.italic = true; + if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true; + if (computed.color && computed.color !== 'rgb(0, 0, 0)') { + options.color = rgbToHex(computed.color); + const transparency = extractAlpha(computed.color); + if (transparency !== null) options.transparency = transparency; + } + if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize); + + // Apply text-transform on the span element itself + if (computed.textTransform && computed.textTransform !== 'none') { + const transformStr = computed.textTransform; + textTransform = (text) => applyTextTransform(text, transformStr); + } + + // Validate: Check for margins on inline elements + if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) { + errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-left which is not supported in PowerPoint. Remove margin from inline elements.`); + } + if (computed.marginRight && parseFloat(computed.marginRight) > 0) { + errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-right which is not supported in PowerPoint. Remove margin from inline elements.`); + } + if (computed.marginTop && parseFloat(computed.marginTop) > 0) { + errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-top which is not supported in PowerPoint. Remove margin from inline elements.`); + } + if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) { + errors.push(`Inline element <${node.tagName.toLowerCase()}> has margin-bottom which is not supported in PowerPoint. Remove margin from inline elements.`); + } + + // Recursively process the child node. This will flatten nested spans into multiple runs. + parseInlineFormatting(node, options, runs, textTransform); + } + } + + prevNodeIsText = isText; + }); + + // Trim leading space from first run and trailing space from last run + if (runs.length > 0) { + runs[0].text = runs[0].text.replace(/^\s+/, ''); + runs[runs.length - 1].text = runs[runs.length - 1].text.replace(/\s+$/, ''); + } + + return runs.filter(r => r.text.length > 0); + }; + + // Extract background from body (image or color) + const body = document.body; + const bodyStyle = window.getComputedStyle(body); + const bgImage = bodyStyle.backgroundImage; + const bgColor = bodyStyle.backgroundColor; + + // Collect validation errors + const errors = []; + + // Validate: Check for CSS gradients + if (bgImage && (bgImage.includes('linear-gradient') || bgImage.includes('radial-gradient'))) { + errors.push( + 'CSS gradients are not supported. Use Sharp to rasterize gradients as PNG images first, ' + + 'then reference with background-image: url(\'gradient.png\')' + ); + } + + let background; + if (bgImage && bgImage !== 'none') { + // Extract URL from url("...") or url(...) + const urlMatch = bgImage.match(/url\(["']?([^"')]+)["']?\)/); + if (urlMatch) { + background = { + type: 'image', + path: urlMatch[1] + }; + } else { + background = { + type: 'color', + value: rgbToHex(bgColor) + }; + } + } else { + background = { + type: 'color', + value: rgbToHex(bgColor) + }; + } + + // Process all elements + const elements = []; + const placeholders = []; + const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI']; + const processed = new Set(); + + document.querySelectorAll('*').forEach((el) => { + if (processed.has(el)) return; + + // Validate text elements don't have backgrounds, borders, or shadows + if (textTags.includes(el.tagName)) { + const computed = window.getComputedStyle(el); + const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const hasBorder = (computed.borderWidth && parseFloat(computed.borderWidth) > 0) || + (computed.borderTopWidth && parseFloat(computed.borderTopWidth) > 0) || + (computed.borderRightWidth && parseFloat(computed.borderRightWidth) > 0) || + (computed.borderBottomWidth && parseFloat(computed.borderBottomWidth) > 0) || + (computed.borderLeftWidth && parseFloat(computed.borderLeftWidth) > 0); + const hasShadow = computed.boxShadow && computed.boxShadow !== 'none'; + + if (hasBg || hasBorder || hasShadow) { + errors.push( + `Text element <${el.tagName.toLowerCase()}> has ${hasBg ? 'background' : hasBorder ? 'border' : 'shadow'}. ` + + 'Backgrounds, borders, and shadows are only supported on
                      elements, not text elements.' + ); + return; + } + } + + // Extract placeholder elements (for charts, etc.) + if (el.className && el.className.includes('placeholder')) { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + errors.push( + `Placeholder "${el.id || 'unnamed'}" has ${rect.width === 0 ? 'width: 0' : 'height: 0'}. Check the layout CSS.` + ); + } else { + placeholders.push({ + id: el.id || `placeholder-${placeholders.length}`, + x: pxToInch(rect.left), + y: pxToInch(rect.top), + w: pxToInch(rect.width), + h: pxToInch(rect.height) + }); + } + processed.add(el); + return; + } + + // Extract images + if (el.tagName === 'IMG') { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + elements.push({ + type: 'image', + src: el.src, + position: { + x: pxToInch(rect.left), + y: pxToInch(rect.top), + w: pxToInch(rect.width), + h: pxToInch(rect.height) + } + }); + processed.add(el); + return; + } + } + + // Extract DIVs with backgrounds/borders as shapes + const isContainer = el.tagName === 'DIV' && !textTags.includes(el.tagName); + if (isContainer) { + const computed = window.getComputedStyle(el); + const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)'; + + // Validate: Check for unwrapped text content in DIV + for (const node of el.childNodes) { + if (node.nodeType === Node.TEXT_NODE) { + const text = node.textContent.trim(); + if (text) { + errors.push( + `DIV element contains unwrapped text "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}". ` + + 'All text must be wrapped in

                      ,

                      -

                      ,
                        , or
                          tags to appear in PowerPoint.' + ); + } + } + } + + // Check for background images on shapes + const bgImage = computed.backgroundImage; + if (bgImage && bgImage !== 'none') { + errors.push( + 'Background images on DIV elements are not supported. ' + + 'Use solid colors or borders for shapes, or use slide.addImage() in PptxGenJS to layer images.' + ); + return; + } + + // Check for borders - both uniform and partial + const borderTop = computed.borderTopWidth; + const borderRight = computed.borderRightWidth; + const borderBottom = computed.borderBottomWidth; + const borderLeft = computed.borderLeftWidth; + const borders = [borderTop, borderRight, borderBottom, borderLeft].map(b => parseFloat(b) || 0); + const hasBorder = borders.some(b => b > 0); + const hasUniformBorder = hasBorder && borders.every(b => b === borders[0]); + const borderLines = []; + + if (hasBorder && !hasUniformBorder) { + const rect = el.getBoundingClientRect(); + const x = pxToInch(rect.left); + const y = pxToInch(rect.top); + const w = pxToInch(rect.width); + const h = pxToInch(rect.height); + + // Collect lines to add after shape (inset by half the line width to center on edge) + if (parseFloat(borderTop) > 0) { + const widthPt = pxToPoints(borderTop); + const inset = (widthPt / 72) / 2; // Convert points to inches, then half + borderLines.push({ + type: 'line', + x1: x, y1: y + inset, x2: x + w, y2: y + inset, + width: widthPt, + color: rgbToHex(computed.borderTopColor) + }); + } + if (parseFloat(borderRight) > 0) { + const widthPt = pxToPoints(borderRight); + const inset = (widthPt / 72) / 2; + borderLines.push({ + type: 'line', + x1: x + w - inset, y1: y, x2: x + w - inset, y2: y + h, + width: widthPt, + color: rgbToHex(computed.borderRightColor) + }); + } + if (parseFloat(borderBottom) > 0) { + const widthPt = pxToPoints(borderBottom); + const inset = (widthPt / 72) / 2; + borderLines.push({ + type: 'line', + x1: x, y1: y + h - inset, x2: x + w, y2: y + h - inset, + width: widthPt, + color: rgbToHex(computed.borderBottomColor) + }); + } + if (parseFloat(borderLeft) > 0) { + const widthPt = pxToPoints(borderLeft); + const inset = (widthPt / 72) / 2; + borderLines.push({ + type: 'line', + x1: x + inset, y1: y, x2: x + inset, y2: y + h, + width: widthPt, + color: rgbToHex(computed.borderLeftColor) + }); + } + } + + if (hasBg || hasBorder) { + const rect = el.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + const shadow = parseBoxShadow(computed.boxShadow); + + // Only add shape if there's background or uniform border + if (hasBg || hasUniformBorder) { + elements.push({ + type: 'shape', + text: '', // Shape only - child text elements render on top + position: { + x: pxToInch(rect.left), + y: pxToInch(rect.top), + w: pxToInch(rect.width), + h: pxToInch(rect.height) + }, + shape: { + fill: hasBg ? rgbToHex(computed.backgroundColor) : null, + transparency: hasBg ? extractAlpha(computed.backgroundColor) : null, + line: hasUniformBorder ? { + color: rgbToHex(computed.borderColor), + width: pxToPoints(computed.borderWidth) + } : null, + // Convert border-radius to rectRadius (in inches) + // % values: 50%+ = circle (1), <50% = percentage of min dimension + // pt values: divide by 72 (72pt = 1 inch) + // px values: divide by 96 (96px = 1 inch) + rectRadius: (() => { + const radius = computed.borderRadius; + const radiusValue = parseFloat(radius); + if (radiusValue === 0) return 0; + + if (radius.includes('%')) { + if (radiusValue >= 50) return 1; + // Calculate percentage of smaller dimension + const minDim = Math.min(rect.width, rect.height); + return (radiusValue / 100) * pxToInch(minDim); + } + + if (radius.includes('pt')) return radiusValue / 72; + return radiusValue / PX_PER_IN; + })(), + shadow: shadow + } + }); + } + + // Add partial border lines + elements.push(...borderLines); + + processed.add(el); + return; + } + } + } + + // Extract bullet lists as single text block + if (el.tagName === 'UL' || el.tagName === 'OL') { + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + + const liElements = Array.from(el.querySelectorAll('li')); + const items = []; + const ulComputed = window.getComputedStyle(el); + const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft); + + // Split: margin-left for bullet position, indent for text position + // margin-left + indent = ul padding-left + const marginLeft = ulPaddingLeftPt * 0.5; + const textIndent = ulPaddingLeftPt * 0.5; + + liElements.forEach((li, idx) => { + const isLast = idx === liElements.length - 1; + const runs = parseInlineFormatting(li, { breakLine: false }); + // Clean manual bullets from first run + if (runs.length > 0) { + runs[0].text = runs[0].text.replace(/^[•\-\*▪▸]\s*/, ''); + runs[0].options.bullet = { indent: textIndent }; + } + // Set breakLine on last run + if (runs.length > 0 && !isLast) { + runs[runs.length - 1].options.breakLine = true; + } + items.push(...runs); + }); + + const computed = window.getComputedStyle(liElements[0] || el); + + elements.push({ + type: 'list', + items: items, + position: { + x: pxToInch(rect.left), + y: pxToInch(rect.top), + w: pxToInch(rect.width), + h: pxToInch(rect.height) + }, + style: { + fontSize: pxToPoints(computed.fontSize), + fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(), + color: rgbToHex(computed.color), + transparency: extractAlpha(computed.color), + align: computed.textAlign === 'start' ? 'left' : computed.textAlign, + lineSpacing: computed.lineHeight && computed.lineHeight !== 'normal' ? pxToPoints(computed.lineHeight) : null, + paraSpaceBefore: 0, + paraSpaceAfter: pxToPoints(computed.marginBottom), + // PptxGenJS margin array is [left, right, bottom, top] + margin: [marginLeft, 0, 0, 0] + } + }); + + liElements.forEach(li => processed.add(li)); + processed.add(el); + return; + } + + // Extract text elements (P, H1, H2, etc.) + if (!textTags.includes(el.tagName)) return; + + const rect = el.getBoundingClientRect(); + const text = el.textContent.trim(); + if (rect.width === 0 || rect.height === 0 || !text) return; + + // Validate: Check for manual bullet symbols in text elements (not in lists) + if (el.tagName !== 'LI' && /^[•\-\*▪▸○●◆◇■□]\s/.test(text.trimStart())) { + errors.push( + `Text element <${el.tagName.toLowerCase()}> starts with bullet symbol "${text.substring(0, 20)}...". ` + + 'Use
                            or
                              lists instead of manual bullet symbols.' + ); + return; + } + + const computed = window.getComputedStyle(el); + const rotation = getRotation(computed.transform, computed.writingMode); + const { x, y, w, h } = getPositionAndSize(el, rect, rotation); + + const baseStyle = { + fontSize: pxToPoints(computed.fontSize), + fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(), + color: rgbToHex(computed.color), + align: computed.textAlign === 'start' ? 'left' : computed.textAlign, + lineSpacing: pxToPoints(computed.lineHeight), + paraSpaceBefore: pxToPoints(computed.marginTop), + paraSpaceAfter: pxToPoints(computed.marginBottom), + // PptxGenJS margin array is [left, right, bottom, top] (not [top, right, bottom, left] as documented) + margin: [ + pxToPoints(computed.paddingLeft), + pxToPoints(computed.paddingRight), + pxToPoints(computed.paddingBottom), + pxToPoints(computed.paddingTop) + ] + }; + + const transparency = extractAlpha(computed.color); + if (transparency !== null) baseStyle.transparency = transparency; + + if (rotation !== null) baseStyle.rotate = rotation; + + const hasFormatting = el.querySelector('b, i, u, strong, em, span, br'); + + if (hasFormatting) { + // Text with inline formatting + const transformStr = computed.textTransform; + const runs = parseInlineFormatting(el, {}, [], (str) => applyTextTransform(str, transformStr)); + + // Adjust lineSpacing based on largest fontSize in runs + const adjustedStyle = { ...baseStyle }; + if (adjustedStyle.lineSpacing) { + const maxFontSize = Math.max( + adjustedStyle.fontSize, + ...runs.map(r => r.options?.fontSize || 0) + ); + if (maxFontSize > adjustedStyle.fontSize) { + const lineHeightMultiplier = adjustedStyle.lineSpacing / adjustedStyle.fontSize; + adjustedStyle.lineSpacing = maxFontSize * lineHeightMultiplier; + } + } + + elements.push({ + type: el.tagName.toLowerCase(), + text: runs, + position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) }, + style: adjustedStyle + }); + } else { + // Plain text - inherit CSS formatting + const textTransform = computed.textTransform; + const transformedText = applyTextTransform(text, textTransform); + + const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600; + + elements.push({ + type: el.tagName.toLowerCase(), + text: transformedText, + position: { x: pxToInch(x), y: pxToInch(y), w: pxToInch(w), h: pxToInch(h) }, + style: { + ...baseStyle, + bold: isBold && !shouldSkipBold(computed.fontFamily), + italic: computed.fontStyle === 'italic', + underline: computed.textDecoration.includes('underline') + } + }); + } + + processed.add(el); + }); + + return { background, elements, placeholders, errors }; + }); +} + +async function html2pptx(htmlFile, pres, options = {}) { + const { + tmpDir = process.env.TMPDIR || '/tmp', + slide = null + } = options; + + try { + // Use Chrome on macOS, default Chromium on Unix + const launchOptions = { env: { TMPDIR: tmpDir } }; + if (process.platform === 'darwin') { + launchOptions.channel = 'chrome'; + } + + const browser = await chromium.launch(launchOptions); + + let bodyDimensions; + let slideData; + + const filePath = path.isAbsolute(htmlFile) ? htmlFile : path.join(process.cwd(), htmlFile); + const validationErrors = []; + + try { + const page = await browser.newPage(); + page.on('console', (msg) => { + // Log the message text to your test runner's console + console.log(`Browser console: ${msg.text()}`); + }); + + await page.goto(`file://${filePath}`); + + bodyDimensions = await getBodyDimensions(page); + + await page.setViewportSize({ + width: Math.round(bodyDimensions.width), + height: Math.round(bodyDimensions.height) + }); + + slideData = await extractSlideData(page); + } finally { + await browser.close(); + } + + // Collect all validation errors + if (bodyDimensions.errors && bodyDimensions.errors.length > 0) { + validationErrors.push(...bodyDimensions.errors); + } + + const dimensionErrors = validateDimensions(bodyDimensions, pres); + if (dimensionErrors.length > 0) { + validationErrors.push(...dimensionErrors); + } + + const textBoxPositionErrors = validateTextBoxPosition(slideData, bodyDimensions); + if (textBoxPositionErrors.length > 0) { + validationErrors.push(...textBoxPositionErrors); + } + + if (slideData.errors && slideData.errors.length > 0) { + validationErrors.push(...slideData.errors); + } + + // Throw all errors at once if any exist + if (validationErrors.length > 0) { + const errorMessage = validationErrors.length === 1 + ? validationErrors[0] + : `Multiple validation errors found:\n${validationErrors.map((e, i) => ` ${i + 1}. ${e}`).join('\n')}`; + throw new Error(errorMessage); + } + + const targetSlide = slide || pres.addSlide(); + + await addBackground(slideData, targetSlide, tmpDir); + addElements(slideData, targetSlide, pres); + + return { slide: targetSlide, placeholders: slideData.placeholders }; + } catch (error) { + if (!error.message.startsWith(htmlFile)) { + throw new Error(`${htmlFile}: ${error.message}`); + } + throw error; + } +} + +module.exports = html2pptx; \ No newline at end of file diff --git a/skills/document-skills/pptx/scripts/inventory.py b/skills/document-skills/pptx/scripts/inventory.py new file mode 100755 index 0000000..edda390 --- /dev/null +++ b/skills/document-skills/pptx/scripts/inventory.py @@ -0,0 +1,1020 @@ +#!/usr/bin/env python3 +""" +Extract structured text content from PowerPoint presentations. + +This module provides functionality to: +- Extract all text content from PowerPoint shapes +- Preserve paragraph formatting (alignment, bullets, fonts, spacing) +- Handle nested GroupShapes recursively with correct absolute positions +- Sort shapes by visual position on slides +- Filter out slide numbers and non-content placeholders +- Export to JSON with clean, structured data + +Classes: + ParagraphData: Represents a text paragraph with formatting + ShapeData: Represents a shape with position and text content + +Main Functions: + extract_text_inventory: Extract all text from a presentation + save_inventory: Save extracted data to JSON + +Usage: + python inventory.py input.pptx output.json +""" + +import argparse +import json +import platform +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Union + +from PIL import Image, ImageDraw, ImageFont +from pptx import Presentation +from pptx.enum.text import PP_ALIGN +from pptx.shapes.base import BaseShape + +# Type aliases for cleaner signatures +JsonValue = Union[str, int, float, bool, None] +ParagraphDict = Dict[str, JsonValue] +ShapeDict = Dict[ + str, Union[str, float, bool, List[ParagraphDict], List[str], Dict[str, Any], None] +] +InventoryData = Dict[ + str, Dict[str, "ShapeData"] +] # Dict of slide_id -> {shape_id -> ShapeData} +InventoryDict = Dict[str, Dict[str, ShapeDict]] # JSON-serializable inventory + + +def main(): + """Main entry point for command-line usage.""" + parser = argparse.ArgumentParser( + description="Extract text inventory from PowerPoint with proper GroupShape support.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python inventory.py presentation.pptx inventory.json + Extracts text inventory with correct absolute positions for grouped shapes + + python inventory.py presentation.pptx inventory.json --issues-only + Extracts only text shapes that have overflow or overlap issues + +The output JSON includes: + - All text content organized by slide and shape + - Correct absolute positions for shapes in groups + - Visual position and size in inches + - Paragraph properties and formatting + - Issue detection: text overflow and shape overlaps + """, + ) + + parser.add_argument("input", help="Input PowerPoint file (.pptx)") + parser.add_argument("output", help="Output JSON file for inventory") + parser.add_argument( + "--issues-only", + action="store_true", + help="Include only text shapes that have overflow or overlap issues", + ) + + args = parser.parse_args() + + input_path = Path(args.input) + if not input_path.exists(): + print(f"Error: Input file not found: {args.input}") + sys.exit(1) + + if not input_path.suffix.lower() == ".pptx": + print("Error: Input must be a PowerPoint file (.pptx)") + sys.exit(1) + + try: + print(f"Extracting text inventory from: {args.input}") + if args.issues_only: + print( + "Filtering to include only text shapes with issues (overflow/overlap)" + ) + inventory = extract_text_inventory(input_path, issues_only=args.issues_only) + + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + save_inventory(inventory, output_path) + + print(f"Output saved to: {args.output}") + + # Report statistics + total_slides = len(inventory) + total_shapes = sum(len(shapes) for shapes in inventory.values()) + if args.issues_only: + if total_shapes > 0: + print( + f"Found {total_shapes} text elements with issues in {total_slides} slides" + ) + else: + print("No issues discovered") + else: + print( + f"Found text in {total_slides} slides with {total_shapes} text elements" + ) + + except Exception as e: + print(f"Error processing presentation: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +@dataclass +class ShapeWithPosition: + """A shape with its absolute position on the slide.""" + + shape: BaseShape + absolute_left: int # in EMUs + absolute_top: int # in EMUs + + +class ParagraphData: + """Data structure for paragraph properties extracted from a PowerPoint paragraph.""" + + def __init__(self, paragraph: Any): + """Initialize from a PowerPoint paragraph object. + + Args: + paragraph: The PowerPoint paragraph object + """ + self.text: str = paragraph.text.strip() + self.bullet: bool = False + self.level: Optional[int] = None + self.alignment: Optional[str] = None + self.space_before: Optional[float] = None + self.space_after: Optional[float] = None + self.font_name: Optional[str] = None + self.font_size: Optional[float] = None + self.bold: Optional[bool] = None + self.italic: Optional[bool] = None + self.underline: Optional[bool] = None + self.color: Optional[str] = None + self.theme_color: Optional[str] = None + self.line_spacing: Optional[float] = None + + # Check for bullet formatting + if ( + hasattr(paragraph, "_p") + and paragraph._p is not None + and paragraph._p.pPr is not None + ): + pPr = paragraph._p.pPr + ns = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + if ( + pPr.find(f"{ns}buChar") is not None + or pPr.find(f"{ns}buAutoNum") is not None + ): + self.bullet = True + if hasattr(paragraph, "level"): + self.level = paragraph.level + + # Add alignment if not LEFT (default) + if hasattr(paragraph, "alignment") and paragraph.alignment is not None: + alignment_map = { + PP_ALIGN.CENTER: "CENTER", + PP_ALIGN.RIGHT: "RIGHT", + PP_ALIGN.JUSTIFY: "JUSTIFY", + } + if paragraph.alignment in alignment_map: + self.alignment = alignment_map[paragraph.alignment] + + # Add spacing properties if set + if hasattr(paragraph, "space_before") and paragraph.space_before: + self.space_before = paragraph.space_before.pt + if hasattr(paragraph, "space_after") and paragraph.space_after: + self.space_after = paragraph.space_after.pt + + # Extract font properties from first run + if paragraph.runs: + first_run = paragraph.runs[0] + if hasattr(first_run, "font"): + font = first_run.font + if font.name: + self.font_name = font.name + if font.size: + self.font_size = font.size.pt + if font.bold is not None: + self.bold = font.bold + if font.italic is not None: + self.italic = font.italic + if font.underline is not None: + self.underline = font.underline + + # Handle color - both RGB and theme colors + try: + # Try RGB color first + if font.color.rgb: + self.color = str(font.color.rgb) + except (AttributeError, TypeError): + # Fall back to theme color + try: + if font.color.theme_color: + self.theme_color = font.color.theme_color.name + except (AttributeError, TypeError): + pass + + # Add line spacing if set + if hasattr(paragraph, "line_spacing") and paragraph.line_spacing is not None: + if hasattr(paragraph.line_spacing, "pt"): + self.line_spacing = round(paragraph.line_spacing.pt, 2) + else: + # Multiplier - convert to points + font_size = self.font_size if self.font_size else 12.0 + self.line_spacing = round(paragraph.line_spacing * font_size, 2) + + def to_dict(self) -> ParagraphDict: + """Convert to dictionary for JSON serialization, excluding None values.""" + result: ParagraphDict = {"text": self.text} + + # Add optional fields only if they have values + if self.bullet: + result["bullet"] = self.bullet + if self.level is not None: + result["level"] = self.level + if self.alignment: + result["alignment"] = self.alignment + if self.space_before is not None: + result["space_before"] = self.space_before + if self.space_after is not None: + result["space_after"] = self.space_after + if self.font_name: + result["font_name"] = self.font_name + if self.font_size is not None: + result["font_size"] = self.font_size + if self.bold is not None: + result["bold"] = self.bold + if self.italic is not None: + result["italic"] = self.italic + if self.underline is not None: + result["underline"] = self.underline + if self.color: + result["color"] = self.color + if self.theme_color: + result["theme_color"] = self.theme_color + if self.line_spacing is not None: + result["line_spacing"] = self.line_spacing + + return result + + +class ShapeData: + """Data structure for shape properties extracted from a PowerPoint shape.""" + + @staticmethod + def emu_to_inches(emu: int) -> float: + """Convert EMUs (English Metric Units) to inches.""" + return emu / 914400.0 + + @staticmethod + def inches_to_pixels(inches: float, dpi: int = 96) -> int: + """Convert inches to pixels at given DPI.""" + return int(inches * dpi) + + @staticmethod + def get_font_path(font_name: str) -> Optional[str]: + """Get the font file path for a given font name. + + Args: + font_name: Name of the font (e.g., 'Arial', 'Calibri') + + Returns: + Path to the font file, or None if not found + """ + system = platform.system() + + # Common font file variations to try + font_variations = [ + font_name, + font_name.lower(), + font_name.replace(" ", ""), + font_name.replace(" ", "-"), + ] + + # Define font directories and extensions by platform + if system == "Darwin": # macOS + font_dirs = [ + "/System/Library/Fonts/", + "/Library/Fonts/", + "~/Library/Fonts/", + ] + extensions = [".ttf", ".otf", ".ttc", ".dfont"] + else: # Linux + font_dirs = [ + "/usr/share/fonts/truetype/", + "/usr/local/share/fonts/", + "~/.fonts/", + ] + extensions = [".ttf", ".otf"] + + # Try to find the font file + from pathlib import Path + + for font_dir in font_dirs: + font_dir_path = Path(font_dir).expanduser() + if not font_dir_path.exists(): + continue + + # First try exact matches + for variant in font_variations: + for ext in extensions: + font_path = font_dir_path / f"{variant}{ext}" + if font_path.exists(): + return str(font_path) + + # Then try fuzzy matching - find files containing the font name + try: + for file_path in font_dir_path.iterdir(): + if file_path.is_file(): + file_name_lower = file_path.name.lower() + font_name_lower = font_name.lower().replace(" ", "") + if font_name_lower in file_name_lower and any( + file_name_lower.endswith(ext) for ext in extensions + ): + return str(file_path) + except (OSError, PermissionError): + continue + + return None + + @staticmethod + def get_slide_dimensions(slide: Any) -> tuple[Optional[int], Optional[int]]: + """Get slide dimensions from slide object. + + Args: + slide: Slide object + + Returns: + Tuple of (width_emu, height_emu) or (None, None) if not found + """ + try: + prs = slide.part.package.presentation_part.presentation + return prs.slide_width, prs.slide_height + except (AttributeError, TypeError): + return None, None + + @staticmethod + def get_default_font_size(shape: BaseShape, slide_layout: Any) -> Optional[float]: + """Extract default font size from slide layout for a placeholder shape. + + Args: + shape: Placeholder shape + slide_layout: Slide layout containing the placeholder definition + + Returns: + Default font size in points, or None if not found + """ + try: + if not hasattr(shape, "placeholder_format"): + return None + + shape_type = shape.placeholder_format.type # type: ignore + for layout_placeholder in slide_layout.placeholders: + if layout_placeholder.placeholder_format.type == shape_type: + # Find first defRPr element with sz (size) attribute + for elem in layout_placeholder.element.iter(): + if "defRPr" in elem.tag and (sz := elem.get("sz")): + return float(sz) / 100.0 # Convert EMUs to points + break + except Exception: + pass + return None + + def __init__( + self, + shape: BaseShape, + absolute_left: Optional[int] = None, + absolute_top: Optional[int] = None, + slide: Optional[Any] = None, + ): + """Initialize from a PowerPoint shape object. + + Args: + shape: The PowerPoint shape object (should be pre-validated) + absolute_left: Absolute left position in EMUs (for shapes in groups) + absolute_top: Absolute top position in EMUs (for shapes in groups) + slide: Optional slide object to get dimensions and layout information + """ + self.shape = shape # Store reference to original shape + self.shape_id: str = "" # Will be set after sorting + + # Get slide dimensions from slide object + self.slide_width_emu, self.slide_height_emu = ( + self.get_slide_dimensions(slide) if slide else (None, None) + ) + + # Get placeholder type if applicable + self.placeholder_type: Optional[str] = None + self.default_font_size: Optional[float] = None + if hasattr(shape, "is_placeholder") and shape.is_placeholder: # type: ignore + if shape.placeholder_format and shape.placeholder_format.type: # type: ignore + self.placeholder_type = ( + str(shape.placeholder_format.type).split(".")[-1].split(" ")[0] # type: ignore + ) + + # Get default font size from layout + if slide and hasattr(slide, "slide_layout"): + self.default_font_size = self.get_default_font_size( + shape, slide.slide_layout + ) + + # Get position information + # Use absolute positions if provided (for shapes in groups), otherwise use shape's position + left_emu = ( + absolute_left + if absolute_left is not None + else (shape.left if hasattr(shape, "left") else 0) + ) + top_emu = ( + absolute_top + if absolute_top is not None + else (shape.top if hasattr(shape, "top") else 0) + ) + + self.left: float = round(self.emu_to_inches(left_emu), 2) # type: ignore + self.top: float = round(self.emu_to_inches(top_emu), 2) # type: ignore + self.width: float = round( + self.emu_to_inches(shape.width if hasattr(shape, "width") else 0), + 2, # type: ignore + ) + self.height: float = round( + self.emu_to_inches(shape.height if hasattr(shape, "height") else 0), + 2, # type: ignore + ) + + # Store EMU positions for overflow calculations + self.left_emu = left_emu + self.top_emu = top_emu + self.width_emu = shape.width if hasattr(shape, "width") else 0 + self.height_emu = shape.height if hasattr(shape, "height") else 0 + + # Calculate overflow status + self.frame_overflow_bottom: Optional[float] = None + self.slide_overflow_right: Optional[float] = None + self.slide_overflow_bottom: Optional[float] = None + self.overlapping_shapes: Dict[ + str, float + ] = {} # Dict of shape_id -> overlap area in sq inches + self.warnings: List[str] = [] + self._estimate_frame_overflow() + self._calculate_slide_overflow() + self._detect_bullet_issues() + + @property + def paragraphs(self) -> List[ParagraphData]: + """Calculate paragraphs from the shape's text frame.""" + if not self.shape or not hasattr(self.shape, "text_frame"): + return [] + + paragraphs = [] + for paragraph in self.shape.text_frame.paragraphs: # type: ignore + if paragraph.text.strip(): + paragraphs.append(ParagraphData(paragraph)) + return paragraphs + + def _get_default_font_size(self) -> int: + """Get default font size from theme text styles or use conservative default.""" + try: + if not ( + hasattr(self.shape, "part") and hasattr(self.shape.part, "slide_layout") + ): + return 14 + + slide_master = self.shape.part.slide_layout.slide_master # type: ignore + if not hasattr(slide_master, "element"): + return 14 + + # Determine theme style based on placeholder type + style_name = "bodyStyle" # Default + if self.placeholder_type and "TITLE" in self.placeholder_type: + style_name = "titleStyle" + + # Find font size in theme styles + for child in slide_master.element.iter(): + tag = child.tag.split("}")[-1] if "}" in child.tag else child.tag + if tag == style_name: + for elem in child.iter(): + if "sz" in elem.attrib: + return int(elem.attrib["sz"]) // 100 + except Exception: + pass + + return 14 # Conservative default for body text + + def _get_usable_dimensions(self, text_frame) -> Tuple[int, int]: + """Get usable width and height in pixels after accounting for margins.""" + # Default PowerPoint margins in inches + margins = {"top": 0.05, "bottom": 0.05, "left": 0.1, "right": 0.1} + + # Override with actual margins if set + if hasattr(text_frame, "margin_top") and text_frame.margin_top: + margins["top"] = self.emu_to_inches(text_frame.margin_top) + if hasattr(text_frame, "margin_bottom") and text_frame.margin_bottom: + margins["bottom"] = self.emu_to_inches(text_frame.margin_bottom) + if hasattr(text_frame, "margin_left") and text_frame.margin_left: + margins["left"] = self.emu_to_inches(text_frame.margin_left) + if hasattr(text_frame, "margin_right") and text_frame.margin_right: + margins["right"] = self.emu_to_inches(text_frame.margin_right) + + # Calculate usable area + usable_width = self.width - margins["left"] - margins["right"] + usable_height = self.height - margins["top"] - margins["bottom"] + + # Convert to pixels + return ( + self.inches_to_pixels(usable_width), + self.inches_to_pixels(usable_height), + ) + + def _wrap_text_line(self, line: str, max_width_px: int, draw, font) -> List[str]: + """Wrap a single line of text to fit within max_width_px.""" + if not line: + return [""] + + # Use textlength for efficient width calculation + if draw.textlength(line, font=font) <= max_width_px: + return [line] + + # Need to wrap - split into words + wrapped = [] + words = line.split(" ") + current_line = "" + + for word in words: + test_line = current_line + (" " if current_line else "") + word + if draw.textlength(test_line, font=font) <= max_width_px: + current_line = test_line + else: + if current_line: + wrapped.append(current_line) + current_line = word + + if current_line: + wrapped.append(current_line) + + return wrapped + + def _estimate_frame_overflow(self) -> None: + """Estimate if text overflows the shape bounds using PIL text measurement.""" + if not self.shape or not hasattr(self.shape, "text_frame"): + return + + text_frame = self.shape.text_frame # type: ignore + if not text_frame or not text_frame.paragraphs: + return + + # Get usable dimensions after accounting for margins + usable_width_px, usable_height_px = self._get_usable_dimensions(text_frame) + if usable_width_px <= 0 or usable_height_px <= 0: + return + + # Set up PIL for text measurement + dummy_img = Image.new("RGB", (1, 1)) + draw = ImageDraw.Draw(dummy_img) + + # Get default font size from placeholder or use conservative estimate + default_font_size = self._get_default_font_size() + + # Calculate total height of all paragraphs + total_height_px = 0 + + for para_idx, paragraph in enumerate(text_frame.paragraphs): + if not paragraph.text.strip(): + continue + + para_data = ParagraphData(paragraph) + + # Load font for this paragraph + font_name = para_data.font_name or "Arial" + font_size = int(para_data.font_size or default_font_size) + + font = None + font_path = self.get_font_path(font_name) + if font_path: + try: + font = ImageFont.truetype(font_path, size=font_size) + except Exception: + font = ImageFont.load_default() + else: + font = ImageFont.load_default() + + # Wrap all lines in this paragraph + all_wrapped_lines = [] + for line in paragraph.text.split("\n"): + wrapped = self._wrap_text_line(line, usable_width_px, draw, font) + all_wrapped_lines.extend(wrapped) + + if all_wrapped_lines: + # Calculate line height + if para_data.line_spacing: + # Custom line spacing explicitly set + line_height_px = para_data.line_spacing * 96 / 72 + else: + # PowerPoint default single spacing (1.0x font size) + line_height_px = font_size * 96 / 72 + + # Add space_before (except first paragraph) + if para_idx > 0 and para_data.space_before: + total_height_px += para_data.space_before * 96 / 72 + + # Add paragraph text height + total_height_px += len(all_wrapped_lines) * line_height_px + + # Add space_after + if para_data.space_after: + total_height_px += para_data.space_after * 96 / 72 + + # Check for overflow (ignore negligible overflows <= 0.05") + if total_height_px > usable_height_px: + overflow_px = total_height_px - usable_height_px + overflow_inches = round(overflow_px / 96.0, 2) + if overflow_inches > 0.05: # Only report significant overflows + self.frame_overflow_bottom = overflow_inches + + def _calculate_slide_overflow(self) -> None: + """Calculate if shape overflows the slide boundaries.""" + if self.slide_width_emu is None or self.slide_height_emu is None: + return + + # Check right overflow (ignore negligible overflows <= 0.01") + right_edge_emu = self.left_emu + self.width_emu + if right_edge_emu > self.slide_width_emu: + overflow_emu = right_edge_emu - self.slide_width_emu + overflow_inches = round(self.emu_to_inches(overflow_emu), 2) + if overflow_inches > 0.01: # Only report significant overflows + self.slide_overflow_right = overflow_inches + + # Check bottom overflow (ignore negligible overflows <= 0.01") + bottom_edge_emu = self.top_emu + self.height_emu + if bottom_edge_emu > self.slide_height_emu: + overflow_emu = bottom_edge_emu - self.slide_height_emu + overflow_inches = round(self.emu_to_inches(overflow_emu), 2) + if overflow_inches > 0.01: # Only report significant overflows + self.slide_overflow_bottom = overflow_inches + + def _detect_bullet_issues(self) -> None: + """Detect bullet point formatting issues in paragraphs.""" + if not self.shape or not hasattr(self.shape, "text_frame"): + return + + text_frame = self.shape.text_frame # type: ignore + if not text_frame or not text_frame.paragraphs: + return + + # Common bullet symbols that indicate manual bullets + bullet_symbols = ["•", "●", "○"] + + for paragraph in text_frame.paragraphs: + text = paragraph.text.strip() + # Check for manual bullet symbols + if text and any(text.startswith(symbol + " ") for symbol in bullet_symbols): + self.warnings.append( + "manual_bullet_symbol: use proper bullet formatting" + ) + break + + @property + def has_any_issues(self) -> bool: + """Check if shape has any issues (overflow, overlap, or warnings).""" + return ( + self.frame_overflow_bottom is not None + or self.slide_overflow_right is not None + or self.slide_overflow_bottom is not None + or len(self.overlapping_shapes) > 0 + or len(self.warnings) > 0 + ) + + def to_dict(self) -> ShapeDict: + """Convert to dictionary for JSON serialization.""" + result: ShapeDict = { + "left": self.left, + "top": self.top, + "width": self.width, + "height": self.height, + } + + # Add optional fields if present + if self.placeholder_type: + result["placeholder_type"] = self.placeholder_type + + if self.default_font_size: + result["default_font_size"] = self.default_font_size + + # Add overflow information only if there is overflow + overflow_data = {} + + # Add frame overflow if present + if self.frame_overflow_bottom is not None: + overflow_data["frame"] = {"overflow_bottom": self.frame_overflow_bottom} + + # Add slide overflow if present + slide_overflow = {} + if self.slide_overflow_right is not None: + slide_overflow["overflow_right"] = self.slide_overflow_right + if self.slide_overflow_bottom is not None: + slide_overflow["overflow_bottom"] = self.slide_overflow_bottom + if slide_overflow: + overflow_data["slide"] = slide_overflow + + # Only add overflow field if there is overflow + if overflow_data: + result["overflow"] = overflow_data + + # Add overlap field if there are overlapping shapes + if self.overlapping_shapes: + result["overlap"] = {"overlapping_shapes": self.overlapping_shapes} + + # Add warnings field if there are warnings + if self.warnings: + result["warnings"] = self.warnings + + # Add paragraphs after placeholder_type + result["paragraphs"] = [para.to_dict() for para in self.paragraphs] + + return result + + +def is_valid_shape(shape: BaseShape) -> bool: + """Check if a shape contains meaningful text content.""" + # Must have a text frame with content + if not hasattr(shape, "text_frame") or not shape.text_frame: # type: ignore + return False + + text = shape.text_frame.text.strip() # type: ignore + if not text: + return False + + # Skip slide numbers and numeric footers + if hasattr(shape, "is_placeholder") and shape.is_placeholder: # type: ignore + if shape.placeholder_format and shape.placeholder_format.type: # type: ignore + placeholder_type = ( + str(shape.placeholder_format.type).split(".")[-1].split(" ")[0] # type: ignore + ) + if placeholder_type == "SLIDE_NUMBER": + return False + if placeholder_type == "FOOTER" and text.isdigit(): + return False + + return True + + +def collect_shapes_with_absolute_positions( + shape: BaseShape, parent_left: int = 0, parent_top: int = 0 +) -> List[ShapeWithPosition]: + """Recursively collect all shapes with valid text, calculating absolute positions. + + For shapes within groups, their positions are relative to the group. + This function calculates the absolute position on the slide by accumulating + parent group offsets. + + Args: + shape: The shape to process + parent_left: Accumulated left offset from parent groups (in EMUs) + parent_top: Accumulated top offset from parent groups (in EMUs) + + Returns: + List of ShapeWithPosition objects with absolute positions + """ + if hasattr(shape, "shapes"): # GroupShape + result = [] + # Get this group's position + group_left = shape.left if hasattr(shape, "left") else 0 + group_top = shape.top if hasattr(shape, "top") else 0 + + # Calculate absolute position for this group + abs_group_left = parent_left + group_left + abs_group_top = parent_top + group_top + + # Process children with accumulated offsets + for child in shape.shapes: # type: ignore + result.extend( + collect_shapes_with_absolute_positions( + child, abs_group_left, abs_group_top + ) + ) + return result + + # Regular shape - check if it has valid text + if is_valid_shape(shape): + # Calculate absolute position + shape_left = shape.left if hasattr(shape, "left") else 0 + shape_top = shape.top if hasattr(shape, "top") else 0 + + return [ + ShapeWithPosition( + shape=shape, + absolute_left=parent_left + shape_left, + absolute_top=parent_top + shape_top, + ) + ] + + return [] + + +def sort_shapes_by_position(shapes: List[ShapeData]) -> List[ShapeData]: + """Sort shapes by visual position (top-to-bottom, left-to-right). + + Shapes within 0.5 inches vertically are considered on the same row. + """ + if not shapes: + return shapes + + # Sort by top position first + shapes = sorted(shapes, key=lambda s: (s.top, s.left)) + + # Group shapes by row (within 0.5 inches vertically) + result = [] + row = [shapes[0]] + row_top = shapes[0].top + + for shape in shapes[1:]: + if abs(shape.top - row_top) <= 0.5: + row.append(shape) + else: + # Sort current row by left position and add to result + result.extend(sorted(row, key=lambda s: s.left)) + row = [shape] + row_top = shape.top + + # Don't forget the last row + result.extend(sorted(row, key=lambda s: s.left)) + return result + + +def calculate_overlap( + rect1: Tuple[float, float, float, float], + rect2: Tuple[float, float, float, float], + tolerance: float = 0.05, +) -> Tuple[bool, float]: + """Calculate if and how much two rectangles overlap. + + Args: + rect1: (left, top, width, height) of first rectangle in inches + rect2: (left, top, width, height) of second rectangle in inches + tolerance: Minimum overlap in inches to consider as overlapping (default: 0.05") + + Returns: + Tuple of (overlaps, overlap_area) where: + - overlaps: True if rectangles overlap by more than tolerance + - overlap_area: Area of overlap in square inches + """ + left1, top1, w1, h1 = rect1 + left2, top2, w2, h2 = rect2 + + # Calculate overlap dimensions + overlap_width = min(left1 + w1, left2 + w2) - max(left1, left2) + overlap_height = min(top1 + h1, top2 + h2) - max(top1, top2) + + # Check if there's meaningful overlap (more than tolerance) + if overlap_width > tolerance and overlap_height > tolerance: + # Calculate overlap area in square inches + overlap_area = overlap_width * overlap_height + return True, round(overlap_area, 2) + + return False, 0 + + +def detect_overlaps(shapes: List[ShapeData]) -> None: + """Detect overlapping shapes and update their overlapping_shapes dictionaries. + + This function requires each ShapeData to have its shape_id already set. + It modifies the shapes in-place, adding shape IDs with overlap areas in square inches. + + Args: + shapes: List of ShapeData objects with shape_id attributes set + """ + n = len(shapes) + + # Compare each pair of shapes + for i in range(n): + for j in range(i + 1, n): + shape1 = shapes[i] + shape2 = shapes[j] + + # Ensure shape IDs are set + assert shape1.shape_id, f"Shape at index {i} has no shape_id" + assert shape2.shape_id, f"Shape at index {j} has no shape_id" + + rect1 = (shape1.left, shape1.top, shape1.width, shape1.height) + rect2 = (shape2.left, shape2.top, shape2.width, shape2.height) + + overlaps, overlap_area = calculate_overlap(rect1, rect2) + + if overlaps: + # Add shape IDs with overlap area in square inches + shape1.overlapping_shapes[shape2.shape_id] = overlap_area + shape2.overlapping_shapes[shape1.shape_id] = overlap_area + + +def extract_text_inventory( + pptx_path: Path, prs: Optional[Any] = None, issues_only: bool = False +) -> InventoryData: + """Extract text content from all slides in a PowerPoint presentation. + + Args: + pptx_path: Path to the PowerPoint file + prs: Optional Presentation object to use. If not provided, will load from pptx_path. + issues_only: If True, only include shapes that have overflow or overlap issues + + Returns a nested dictionary: {slide-N: {shape-N: ShapeData}} + Shapes are sorted by visual position (top-to-bottom, left-to-right). + The ShapeData objects contain the full shape information and can be + converted to dictionaries for JSON serialization using to_dict(). + """ + if prs is None: + prs = Presentation(str(pptx_path)) + inventory: InventoryData = {} + + for slide_idx, slide in enumerate(prs.slides): + # Collect all valid shapes from this slide with absolute positions + shapes_with_positions = [] + for shape in slide.shapes: # type: ignore + shapes_with_positions.extend(collect_shapes_with_absolute_positions(shape)) + + if not shapes_with_positions: + continue + + # Convert to ShapeData with absolute positions and slide reference + shape_data_list = [ + ShapeData( + swp.shape, + swp.absolute_left, + swp.absolute_top, + slide, + ) + for swp in shapes_with_positions + ] + + # Sort by visual position and assign stable IDs in one step + sorted_shapes = sort_shapes_by_position(shape_data_list) + for idx, shape_data in enumerate(sorted_shapes): + shape_data.shape_id = f"shape-{idx}" + + # Detect overlaps using the stable shape IDs + if len(sorted_shapes) > 1: + detect_overlaps(sorted_shapes) + + # Filter for issues only if requested (after overlap detection) + if issues_only: + sorted_shapes = [sd for sd in sorted_shapes if sd.has_any_issues] + + if not sorted_shapes: + continue + + # Create slide inventory using the stable shape IDs + inventory[f"slide-{slide_idx}"] = { + shape_data.shape_id: shape_data for shape_data in sorted_shapes + } + + return inventory + + +def get_inventory_as_dict(pptx_path: Path, issues_only: bool = False) -> InventoryDict: + """Extract text inventory and return as JSON-serializable dictionaries. + + This is a convenience wrapper around extract_text_inventory that returns + dictionaries instead of ShapeData objects, useful for testing and direct + JSON serialization. + + Args: + pptx_path: Path to the PowerPoint file + issues_only: If True, only include shapes that have overflow or overlap issues + + Returns: + Nested dictionary with all data serialized for JSON + """ + inventory = extract_text_inventory(pptx_path, issues_only=issues_only) + + # Convert ShapeData objects to dictionaries + dict_inventory: InventoryDict = {} + for slide_key, shapes in inventory.items(): + dict_inventory[slide_key] = { + shape_key: shape_data.to_dict() for shape_key, shape_data in shapes.items() + } + + return dict_inventory + + +def save_inventory(inventory: InventoryData, output_path: Path) -> None: + """Save inventory to JSON file with proper formatting. + + Converts ShapeData objects to dictionaries for JSON serialization. + """ + # Convert ShapeData objects to dictionaries + json_inventory: InventoryDict = {} + for slide_key, shapes in inventory.items(): + json_inventory[slide_key] = { + shape_key: shape_data.to_dict() for shape_key, shape_data in shapes.items() + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(json_inventory, f, indent=2, ensure_ascii=False) + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/pptx/scripts/rearrange.py b/skills/document-skills/pptx/scripts/rearrange.py new file mode 100755 index 0000000..2519911 --- /dev/null +++ b/skills/document-skills/pptx/scripts/rearrange.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +""" +Rearrange PowerPoint slides based on a sequence of indices. + +Usage: + python rearrange.py template.pptx output.pptx 0,34,34,50,52 + +This will create output.pptx using slides from template.pptx in the specified order. +Slides can be repeated (e.g., 34 appears twice). +""" + +import argparse +import shutil +import sys +from copy import deepcopy +from pathlib import Path + +import six +from pptx import Presentation + + +def main(): + parser = argparse.ArgumentParser( + description="Rearrange PowerPoint slides based on a sequence of indices.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python rearrange.py template.pptx output.pptx 0,34,34,50,52 + Creates output.pptx using slides 0, 34 (twice), 50, and 52 from template.pptx + + python rearrange.py template.pptx output.pptx 5,3,1,2,4 + Creates output.pptx with slides reordered as specified + +Note: Slide indices are 0-based (first slide is 0, second is 1, etc.) + """, + ) + + parser.add_argument("template", help="Path to template PPTX file") + parser.add_argument("output", help="Path for output PPTX file") + parser.add_argument( + "sequence", help="Comma-separated sequence of slide indices (0-based)" + ) + + args = parser.parse_args() + + # Parse the slide sequence + try: + slide_sequence = [int(x.strip()) for x in args.sequence.split(",")] + except ValueError: + print( + "Error: Invalid sequence format. Use comma-separated integers (e.g., 0,34,34,50,52)" + ) + sys.exit(1) + + # Check template exists + template_path = Path(args.template) + if not template_path.exists(): + print(f"Error: Template file not found: {args.template}") + sys.exit(1) + + # Create output directory if needed + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + try: + rearrange_presentation(template_path, output_path, slide_sequence) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + except Exception as e: + print(f"Error processing presentation: {e}") + sys.exit(1) + + +def duplicate_slide(pres, index): + """Duplicate a slide in the presentation.""" + source = pres.slides[index] + + # Use source's layout to preserve formatting + new_slide = pres.slides.add_slide(source.slide_layout) + + # Collect all image and media relationships from the source slide + image_rels = {} + for rel_id, rel in six.iteritems(source.part.rels): + if "image" in rel.reltype or "media" in rel.reltype: + image_rels[rel_id] = rel + + # CRITICAL: Clear placeholder shapes to avoid duplicates + for shape in new_slide.shapes: + sp = shape.element + sp.getparent().remove(sp) + + # Copy all shapes from source + for shape in source.shapes: + el = shape.element + new_el = deepcopy(el) + new_slide.shapes._spTree.insert_element_before(new_el, "p:extLst") + + # Handle picture shapes - need to update the blip reference + # Look for all blip elements (they can be in pic or other contexts) + # Using the element's own xpath method without namespaces argument + blips = new_el.xpath(".//a:blip[@r:embed]") + for blip in blips: + old_rId = blip.get( + "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed" + ) + if old_rId in image_rels: + # Create a new relationship in the destination slide for this image + old_rel = image_rels[old_rId] + # get_or_add returns the rId directly, or adds and returns new rId + new_rId = new_slide.part.rels.get_or_add( + old_rel.reltype, old_rel._target + ) + # Update the blip's embed reference to use the new relationship ID + blip.set( + "{http://schemas.openxmlformats.org/officeDocument/2006/relationships}embed", + new_rId, + ) + + # Copy any additional image/media relationships that might be referenced elsewhere + for rel_id, rel in image_rels.items(): + try: + new_slide.part.rels.get_or_add(rel.reltype, rel._target) + except Exception: + pass # Relationship might already exist + + return new_slide + + +def delete_slide(pres, index): + """Delete a slide from the presentation.""" + rId = pres.slides._sldIdLst[index].rId + pres.part.drop_rel(rId) + del pres.slides._sldIdLst[index] + + +def reorder_slides(pres, slide_index, target_index): + """Move a slide from one position to another.""" + slides = pres.slides._sldIdLst + + # Remove slide element from current position + slide_element = slides[slide_index] + slides.remove(slide_element) + + # Insert at target position + slides.insert(target_index, slide_element) + + +def rearrange_presentation(template_path, output_path, slide_sequence): + """ + Create a new presentation with slides from template in specified order. + + Args: + template_path: Path to template PPTX file + output_path: Path for output PPTX file + slide_sequence: List of slide indices (0-based) to include + """ + # Copy template to preserve dimensions and theme + if template_path != output_path: + shutil.copy2(template_path, output_path) + prs = Presentation(output_path) + else: + prs = Presentation(template_path) + + total_slides = len(prs.slides) + + # Validate indices + for idx in slide_sequence: + if idx < 0 or idx >= total_slides: + raise ValueError(f"Slide index {idx} out of range (0-{total_slides - 1})") + + # Track original slides and their duplicates + slide_map = [] # List of actual slide indices for final presentation + duplicated = {} # Track duplicates: original_idx -> [duplicate_indices] + + # Step 1: DUPLICATE repeated slides + print(f"Processing {len(slide_sequence)} slides from template...") + for i, template_idx in enumerate(slide_sequence): + if template_idx in duplicated and duplicated[template_idx]: + # Already duplicated this slide, use the duplicate + slide_map.append(duplicated[template_idx].pop(0)) + print(f" [{i}] Using duplicate of slide {template_idx}") + elif slide_sequence.count(template_idx) > 1 and template_idx not in duplicated: + # First occurrence of a repeated slide - create duplicates + slide_map.append(template_idx) + duplicates = [] + count = slide_sequence.count(template_idx) - 1 + print( + f" [{i}] Using original slide {template_idx}, creating {count} duplicate(s)" + ) + for _ in range(count): + duplicate_slide(prs, template_idx) + duplicates.append(len(prs.slides) - 1) + duplicated[template_idx] = duplicates + else: + # Unique slide or first occurrence already handled, use original + slide_map.append(template_idx) + print(f" [{i}] Using original slide {template_idx}") + + # Step 2: DELETE unwanted slides (work backwards) + slides_to_keep = set(slide_map) + print(f"\nDeleting {len(prs.slides) - len(slides_to_keep)} unused slides...") + for i in range(len(prs.slides) - 1, -1, -1): + if i not in slides_to_keep: + delete_slide(prs, i) + # Update slide_map indices after deletion + slide_map = [idx - 1 if idx > i else idx for idx in slide_map] + + # Step 3: REORDER to final sequence + print(f"Reordering {len(slide_map)} slides to final sequence...") + for target_pos in range(len(slide_map)): + # Find which slide should be at target_pos + current_pos = slide_map[target_pos] + if current_pos != target_pos: + reorder_slides(prs, current_pos, target_pos) + # Update slide_map: the move shifts other slides + for i in range(len(slide_map)): + if slide_map[i] > current_pos and slide_map[i] <= target_pos: + slide_map[i] -= 1 + elif slide_map[i] < current_pos and slide_map[i] >= target_pos: + slide_map[i] += 1 + slide_map[target_pos] = target_pos + + # Save the presentation + prs.save(output_path) + print(f"\nSaved rearranged presentation to: {output_path}") + print(f"Final presentation has {len(prs.slides)} slides") + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/pptx/scripts/replace.py b/skills/document-skills/pptx/scripts/replace.py new file mode 100755 index 0000000..8f7a8b1 --- /dev/null +++ b/skills/document-skills/pptx/scripts/replace.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +"""Apply text replacements to PowerPoint presentation. + +Usage: + python replace.py + +The replacements JSON should have the structure output by inventory.py. +ALL text shapes identified by inventory.py will have their text cleared +unless "paragraphs" is specified in the replacements for that shape. +""" + +import json +import sys +from pathlib import Path +from typing import Any, Dict, List + +from inventory import InventoryData, extract_text_inventory +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.dml import MSO_THEME_COLOR +from pptx.enum.text import PP_ALIGN +from pptx.oxml.xmlchemy import OxmlElement +from pptx.util import Pt + + +def clear_paragraph_bullets(paragraph): + """Clear bullet formatting from a paragraph.""" + pPr = paragraph._element.get_or_add_pPr() + + # Remove existing bullet elements + for child in list(pPr): + if ( + child.tag.endswith("buChar") + or child.tag.endswith("buNone") + or child.tag.endswith("buAutoNum") + or child.tag.endswith("buFont") + ): + pPr.remove(child) + + return pPr + + +def apply_paragraph_properties(paragraph, para_data: Dict[str, Any]): + """Apply formatting properties to a paragraph.""" + # Get the text but don't set it on paragraph directly yet + text = para_data.get("text", "") + + # Get or create paragraph properties + pPr = clear_paragraph_bullets(paragraph) + + # Handle bullet formatting + if para_data.get("bullet", False): + level = para_data.get("level", 0) + paragraph.level = level + + # Calculate font-proportional indentation + font_size = para_data.get("font_size", 18.0) + level_indent_emu = int((font_size * (1.6 + level * 1.6)) * 12700) + hanging_indent_emu = int(-font_size * 0.8 * 12700) + + # Set indentation + pPr.attrib["marL"] = str(level_indent_emu) + pPr.attrib["indent"] = str(hanging_indent_emu) + + # Add bullet character + buChar = OxmlElement("a:buChar") + buChar.set("char", "•") + pPr.append(buChar) + + # Default to left alignment for bullets if not specified + if "alignment" not in para_data: + paragraph.alignment = PP_ALIGN.LEFT + else: + # Remove indentation for non-bullet text + pPr.attrib["marL"] = "0" + pPr.attrib["indent"] = "0" + + # Add buNone element + buNone = OxmlElement("a:buNone") + pPr.insert(0, buNone) + + # Apply alignment + if "alignment" in para_data: + alignment_map = { + "LEFT": PP_ALIGN.LEFT, + "CENTER": PP_ALIGN.CENTER, + "RIGHT": PP_ALIGN.RIGHT, + "JUSTIFY": PP_ALIGN.JUSTIFY, + } + if para_data["alignment"] in alignment_map: + paragraph.alignment = alignment_map[para_data["alignment"]] + + # Apply spacing + if "space_before" in para_data: + paragraph.space_before = Pt(para_data["space_before"]) + if "space_after" in para_data: + paragraph.space_after = Pt(para_data["space_after"]) + if "line_spacing" in para_data: + paragraph.line_spacing = Pt(para_data["line_spacing"]) + + # Apply run-level formatting + if not paragraph.runs: + run = paragraph.add_run() + run.text = text + else: + run = paragraph.runs[0] + run.text = text + + # Apply font properties + apply_font_properties(run, para_data) + + +def apply_font_properties(run, para_data: Dict[str, Any]): + """Apply font properties to a text run.""" + if "bold" in para_data: + run.font.bold = para_data["bold"] + if "italic" in para_data: + run.font.italic = para_data["italic"] + if "underline" in para_data: + run.font.underline = para_data["underline"] + if "font_size" in para_data: + run.font.size = Pt(para_data["font_size"]) + if "font_name" in para_data: + run.font.name = para_data["font_name"] + + # Apply color - prefer RGB, fall back to theme_color + if "color" in para_data: + color_hex = para_data["color"].lstrip("#") + if len(color_hex) == 6: + r = int(color_hex[0:2], 16) + g = int(color_hex[2:4], 16) + b = int(color_hex[4:6], 16) + run.font.color.rgb = RGBColor(r, g, b) + elif "theme_color" in para_data: + # Get theme color by name (e.g., "DARK_1", "ACCENT_1") + theme_name = para_data["theme_color"] + try: + run.font.color.theme_color = getattr(MSO_THEME_COLOR, theme_name) + except AttributeError: + print(f" WARNING: Unknown theme color name '{theme_name}'") + + +def detect_frame_overflow(inventory: InventoryData) -> Dict[str, Dict[str, float]]: + """Detect text overflow in shapes (text exceeding shape bounds). + + Returns dict of slide_key -> shape_key -> overflow_inches. + Only includes shapes that have text overflow. + """ + overflow_map = {} + + for slide_key, shapes_dict in inventory.items(): + for shape_key, shape_data in shapes_dict.items(): + # Check for frame overflow (text exceeding shape bounds) + if shape_data.frame_overflow_bottom is not None: + if slide_key not in overflow_map: + overflow_map[slide_key] = {} + overflow_map[slide_key][shape_key] = shape_data.frame_overflow_bottom + + return overflow_map + + +def validate_replacements(inventory: InventoryData, replacements: Dict) -> List[str]: + """Validate that all shapes in replacements exist in inventory. + + Returns list of error messages. + """ + errors = [] + + for slide_key, shapes_data in replacements.items(): + if not slide_key.startswith("slide-"): + continue + + # Check if slide exists + if slide_key not in inventory: + errors.append(f"Slide '{slide_key}' not found in inventory") + continue + + # Check each shape + for shape_key in shapes_data.keys(): + if shape_key not in inventory[slide_key]: + # Find shapes without replacements defined and show their content + unused_with_content = [] + for k in inventory[slide_key].keys(): + if k not in shapes_data: + shape_data = inventory[slide_key][k] + # Get text from paragraphs as preview + paragraphs = shape_data.paragraphs + if paragraphs and paragraphs[0].text: + first_text = paragraphs[0].text[:50] + if len(paragraphs[0].text) > 50: + first_text += "..." + unused_with_content.append(f"{k} ('{first_text}')") + else: + unused_with_content.append(k) + + errors.append( + f"Shape '{shape_key}' not found on '{slide_key}'. " + f"Shapes without replacements: {', '.join(sorted(unused_with_content)) if unused_with_content else 'none'}" + ) + + return errors + + +def check_duplicate_keys(pairs): + """Check for duplicate keys when loading JSON.""" + result = {} + for key, value in pairs: + if key in result: + raise ValueError(f"Duplicate key found in JSON: '{key}'") + result[key] = value + return result + + +def apply_replacements(pptx_file: str, json_file: str, output_file: str): + """Apply text replacements from JSON to PowerPoint presentation.""" + + # Load presentation + prs = Presentation(pptx_file) + + # Get inventory of all text shapes (returns ShapeData objects) + # Pass prs to use same Presentation instance + inventory = extract_text_inventory(Path(pptx_file), prs) + + # Detect text overflow in original presentation + original_overflow = detect_frame_overflow(inventory) + + # Load replacement data with duplicate key detection + with open(json_file, "r") as f: + replacements = json.load(f, object_pairs_hook=check_duplicate_keys) + + # Validate replacements + errors = validate_replacements(inventory, replacements) + if errors: + print("ERROR: Invalid shapes in replacement JSON:") + for error in errors: + print(f" - {error}") + print("\nPlease check the inventory and update your replacement JSON.") + print( + "You can regenerate the inventory with: python inventory.py " + ) + raise ValueError(f"Found {len(errors)} validation error(s)") + + # Track statistics + shapes_processed = 0 + shapes_cleared = 0 + shapes_replaced = 0 + + # Process each slide from inventory + for slide_key, shapes_dict in inventory.items(): + if not slide_key.startswith("slide-"): + continue + + slide_index = int(slide_key.split("-")[1]) + + if slide_index >= len(prs.slides): + print(f"Warning: Slide {slide_index} not found") + continue + + # Process each shape from inventory + for shape_key, shape_data in shapes_dict.items(): + shapes_processed += 1 + + # Get the shape directly from ShapeData + shape = shape_data.shape + if not shape: + print(f"Warning: {shape_key} has no shape reference") + continue + + # ShapeData already validates text_frame in __init__ + text_frame = shape.text_frame # type: ignore + + text_frame.clear() # type: ignore + shapes_cleared += 1 + + # Check for replacement paragraphs + replacement_shape_data = replacements.get(slide_key, {}).get(shape_key, {}) + if "paragraphs" not in replacement_shape_data: + continue + + shapes_replaced += 1 + + # Add replacement paragraphs + for i, para_data in enumerate(replacement_shape_data["paragraphs"]): + if i == 0: + p = text_frame.paragraphs[0] # type: ignore + else: + p = text_frame.add_paragraph() # type: ignore + + apply_paragraph_properties(p, para_data) + + # Check for issues after replacements + # Save to a temporary file and reload to avoid modifying the presentation during inventory + # (extract_text_inventory accesses font.color which adds empty elements) + import tempfile + + with tempfile.NamedTemporaryFile(suffix=".pptx", delete=False) as tmp: + tmp_path = Path(tmp.name) + prs.save(str(tmp_path)) + + try: + updated_inventory = extract_text_inventory(tmp_path) + updated_overflow = detect_frame_overflow(updated_inventory) + finally: + tmp_path.unlink() # Clean up temp file + + # Check if any text overflow got worse + overflow_errors = [] + for slide_key, shape_overflows in updated_overflow.items(): + for shape_key, new_overflow in shape_overflows.items(): + # Get original overflow (0 if there was no overflow before) + original = original_overflow.get(slide_key, {}).get(shape_key, 0.0) + + # Error if overflow increased + if new_overflow > original + 0.01: # Small tolerance for rounding + increase = new_overflow - original + overflow_errors.append( + f'{slide_key}/{shape_key}: overflow worsened by {increase:.2f}" ' + f'(was {original:.2f}", now {new_overflow:.2f}")' + ) + + # Collect warnings from updated shapes + warnings = [] + for slide_key, shapes_dict in updated_inventory.items(): + for shape_key, shape_data in shapes_dict.items(): + if shape_data.warnings: + for warning in shape_data.warnings: + warnings.append(f"{slide_key}/{shape_key}: {warning}") + + # Fail if there are any issues + if overflow_errors or warnings: + print("\nERROR: Issues detected in replacement output:") + if overflow_errors: + print("\nText overflow worsened:") + for error in overflow_errors: + print(f" - {error}") + if warnings: + print("\nFormatting warnings:") + for warning in warnings: + print(f" - {warning}") + print("\nPlease fix these issues before saving.") + raise ValueError( + f"Found {len(overflow_errors)} overflow error(s) and {len(warnings)} warning(s)" + ) + + # Save the presentation + prs.save(output_file) + + # Report results + print(f"Saved updated presentation to: {output_file}") + print(f"Processed {len(prs.slides)} slides") + print(f" - Shapes processed: {shapes_processed}") + print(f" - Shapes cleared: {shapes_cleared}") + print(f" - Shapes replaced: {shapes_replaced}") + + +def main(): + """Main entry point for command-line usage.""" + if len(sys.argv) != 4: + print(__doc__) + sys.exit(1) + + input_pptx = Path(sys.argv[1]) + replacements_json = Path(sys.argv[2]) + output_pptx = Path(sys.argv[3]) + + if not input_pptx.exists(): + print(f"Error: Input file '{input_pptx}' not found") + sys.exit(1) + + if not replacements_json.exists(): + print(f"Error: Replacements JSON file '{replacements_json}' not found") + sys.exit(1) + + try: + apply_replacements(str(input_pptx), str(replacements_json), str(output_pptx)) + except Exception as e: + print(f"Error applying replacements: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/pptx/scripts/thumbnail.py b/skills/document-skills/pptx/scripts/thumbnail.py new file mode 100755 index 0000000..5c7fdf1 --- /dev/null +++ b/skills/document-skills/pptx/scripts/thumbnail.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +""" +Create thumbnail grids from PowerPoint presentation slides. + +Creates a grid layout of slide thumbnails with configurable columns (max 6). +Each grid contains up to cols×(cols+1) images. For presentations with more +slides, multiple numbered grid files are created automatically. + +The program outputs the names of all files created. + +Output: +- Single grid: {prefix}.jpg (if slides fit in one grid) +- Multiple grids: {prefix}-1.jpg, {prefix}-2.jpg, etc. + +Grid limits by column count: +- 3 cols: max 12 slides per grid (3×4) +- 4 cols: max 20 slides per grid (4×5) +- 5 cols: max 30 slides per grid (5×6) [default] +- 6 cols: max 42 slides per grid (6×7) + +Usage: + python thumbnail.py input.pptx [output_prefix] [--cols N] [--outline-placeholders] + +Examples: + python thumbnail.py presentation.pptx + # Creates: thumbnails.jpg (using default prefix) + # Outputs: + # Created 1 grid(s): + # - thumbnails.jpg + + python thumbnail.py large-deck.pptx grid --cols 4 + # Creates: grid-1.jpg, grid-2.jpg, grid-3.jpg + # Outputs: + # Created 3 grid(s): + # - grid-1.jpg + # - grid-2.jpg + # - grid-3.jpg + + python thumbnail.py template.pptx analysis --outline-placeholders + # Creates thumbnail grids with red outlines around text placeholders +""" + +import argparse +import subprocess +import sys +import tempfile +from pathlib import Path + +from inventory import extract_text_inventory +from PIL import Image, ImageDraw, ImageFont +from pptx import Presentation + +# Constants +THUMBNAIL_WIDTH = 300 # Fixed thumbnail width in pixels +CONVERSION_DPI = 100 # DPI for PDF to image conversion +MAX_COLS = 6 # Maximum number of columns +DEFAULT_COLS = 5 # Default number of columns +JPEG_QUALITY = 95 # JPEG compression quality + +# Grid layout constants +GRID_PADDING = 20 # Padding between thumbnails +BORDER_WIDTH = 2 # Border width around thumbnails +FONT_SIZE_RATIO = 0.12 # Font size as fraction of thumbnail width +LABEL_PADDING_RATIO = 0.4 # Label padding as fraction of font size + + +def main(): + parser = argparse.ArgumentParser( + description="Create thumbnail grids from PowerPoint slides." + ) + parser.add_argument("input", help="Input PowerPoint file (.pptx)") + parser.add_argument( + "output_prefix", + nargs="?", + default="thumbnails", + help="Output prefix for image files (default: thumbnails, will create prefix.jpg or prefix-N.jpg)", + ) + parser.add_argument( + "--cols", + type=int, + default=DEFAULT_COLS, + help=f"Number of columns (default: {DEFAULT_COLS}, max: {MAX_COLS})", + ) + parser.add_argument( + "--outline-placeholders", + action="store_true", + help="Outline text placeholders with a colored border", + ) + + args = parser.parse_args() + + # Validate columns + cols = min(args.cols, MAX_COLS) + if args.cols > MAX_COLS: + print(f"Warning: Columns limited to {MAX_COLS} (requested {args.cols})") + + # Validate input + input_path = Path(args.input) + if not input_path.exists() or input_path.suffix.lower() != ".pptx": + print(f"Error: Invalid PowerPoint file: {args.input}") + sys.exit(1) + + # Construct output path (always JPG) + output_path = Path(f"{args.output_prefix}.jpg") + + print(f"Processing: {args.input}") + + try: + with tempfile.TemporaryDirectory() as temp_dir: + # Get placeholder regions if outlining is enabled + placeholder_regions = None + slide_dimensions = None + if args.outline_placeholders: + print("Extracting placeholder regions...") + placeholder_regions, slide_dimensions = get_placeholder_regions( + input_path + ) + if placeholder_regions: + print(f"Found placeholders on {len(placeholder_regions)} slides") + + # Convert slides to images + slide_images = convert_to_images(input_path, Path(temp_dir), CONVERSION_DPI) + if not slide_images: + print("Error: No slides found") + sys.exit(1) + + print(f"Found {len(slide_images)} slides") + + # Create grids (max cols×(cols+1) images per grid) + grid_files = create_grids( + slide_images, + cols, + THUMBNAIL_WIDTH, + output_path, + placeholder_regions, + slide_dimensions, + ) + + # Print saved files + print(f"Created {len(grid_files)} grid(s):") + for grid_file in grid_files: + print(f" - {grid_file}") + + except Exception as e: + print(f"Error: {e}") + sys.exit(1) + + +def create_hidden_slide_placeholder(size): + """Create placeholder image for hidden slides.""" + img = Image.new("RGB", size, color="#F0F0F0") + draw = ImageDraw.Draw(img) + line_width = max(5, min(size) // 100) + draw.line([(0, 0), size], fill="#CCCCCC", width=line_width) + draw.line([(size[0], 0), (0, size[1])], fill="#CCCCCC", width=line_width) + return img + + +def get_placeholder_regions(pptx_path): + """Extract ALL text regions from the presentation. + + Returns a tuple of (placeholder_regions, slide_dimensions). + text_regions is a dict mapping slide indices to lists of text regions. + Each region is a dict with 'left', 'top', 'width', 'height' in inches. + slide_dimensions is a tuple of (width_inches, height_inches). + """ + prs = Presentation(str(pptx_path)) + inventory = extract_text_inventory(pptx_path, prs) + placeholder_regions = {} + + # Get actual slide dimensions in inches (EMU to inches conversion) + slide_width_inches = (prs.slide_width or 9144000) / 914400.0 + slide_height_inches = (prs.slide_height or 5143500) / 914400.0 + + for slide_key, shapes in inventory.items(): + # Extract slide index from "slide-N" format + slide_idx = int(slide_key.split("-")[1]) + regions = [] + + for shape_key, shape_data in shapes.items(): + # The inventory only contains shapes with text, so all shapes should be highlighted + regions.append( + { + "left": shape_data.left, + "top": shape_data.top, + "width": shape_data.width, + "height": shape_data.height, + } + ) + + if regions: + placeholder_regions[slide_idx] = regions + + return placeholder_regions, (slide_width_inches, slide_height_inches) + + +def convert_to_images(pptx_path, temp_dir, dpi): + """Convert PowerPoint to images via PDF, handling hidden slides.""" + # Detect hidden slides + print("Analyzing presentation...") + prs = Presentation(str(pptx_path)) + total_slides = len(prs.slides) + + # Find hidden slides (1-based indexing for display) + hidden_slides = { + idx + 1 + for idx, slide in enumerate(prs.slides) + if slide.element.get("show") == "0" + } + + print(f"Total slides: {total_slides}") + if hidden_slides: + print(f"Hidden slides: {sorted(hidden_slides)}") + + pdf_path = temp_dir / f"{pptx_path.stem}.pdf" + + # Convert to PDF + print("Converting to PDF...") + result = subprocess.run( + [ + "soffice", + "--headless", + "--convert-to", + "pdf", + "--outdir", + str(temp_dir), + str(pptx_path), + ], + capture_output=True, + text=True, + ) + if result.returncode != 0 or not pdf_path.exists(): + raise RuntimeError("PDF conversion failed") + + # Convert PDF to images + print(f"Converting to images at {dpi} DPI...") + result = subprocess.run( + ["pdftoppm", "-jpeg", "-r", str(dpi), str(pdf_path), str(temp_dir / "slide")], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError("Image conversion failed") + + visible_images = sorted(temp_dir.glob("slide-*.jpg")) + + # Create full list with placeholders for hidden slides + all_images = [] + visible_idx = 0 + + # Get placeholder dimensions from first visible slide + if visible_images: + with Image.open(visible_images[0]) as img: + placeholder_size = img.size + else: + placeholder_size = (1920, 1080) + + for slide_num in range(1, total_slides + 1): + if slide_num in hidden_slides: + # Create placeholder image for hidden slide + placeholder_path = temp_dir / f"hidden-{slide_num:03d}.jpg" + placeholder_img = create_hidden_slide_placeholder(placeholder_size) + placeholder_img.save(placeholder_path, "JPEG") + all_images.append(placeholder_path) + else: + # Use the actual visible slide image + if visible_idx < len(visible_images): + all_images.append(visible_images[visible_idx]) + visible_idx += 1 + + return all_images + + +def create_grids( + image_paths, + cols, + width, + output_path, + placeholder_regions=None, + slide_dimensions=None, +): + """Create multiple thumbnail grids from slide images, max cols×(cols+1) images per grid.""" + # Maximum images per grid is cols × (cols + 1) for better proportions + max_images_per_grid = cols * (cols + 1) + grid_files = [] + + print( + f"Creating grids with {cols} columns (max {max_images_per_grid} images per grid)" + ) + + # Split images into chunks + for chunk_idx, start_idx in enumerate( + range(0, len(image_paths), max_images_per_grid) + ): + end_idx = min(start_idx + max_images_per_grid, len(image_paths)) + chunk_images = image_paths[start_idx:end_idx] + + # Create grid for this chunk + grid = create_grid( + chunk_images, cols, width, start_idx, placeholder_regions, slide_dimensions + ) + + # Generate output filename + if len(image_paths) <= max_images_per_grid: + # Single grid - use base filename without suffix + grid_filename = output_path + else: + # Multiple grids - insert index before extension with dash + stem = output_path.stem + suffix = output_path.suffix + grid_filename = output_path.parent / f"{stem}-{chunk_idx + 1}{suffix}" + + # Save grid + grid_filename.parent.mkdir(parents=True, exist_ok=True) + grid.save(str(grid_filename), quality=JPEG_QUALITY) + grid_files.append(str(grid_filename)) + + return grid_files + + +def create_grid( + image_paths, + cols, + width, + start_slide_num=0, + placeholder_regions=None, + slide_dimensions=None, +): + """Create thumbnail grid from slide images with optional placeholder outlining.""" + font_size = int(width * FONT_SIZE_RATIO) + label_padding = int(font_size * LABEL_PADDING_RATIO) + + # Get dimensions + with Image.open(image_paths[0]) as img: + aspect = img.height / img.width + height = int(width * aspect) + + # Calculate grid size + rows = (len(image_paths) + cols - 1) // cols + grid_w = cols * width + (cols + 1) * GRID_PADDING + grid_h = rows * (height + font_size + label_padding * 2) + (rows + 1) * GRID_PADDING + + # Create grid + grid = Image.new("RGB", (grid_w, grid_h), "white") + draw = ImageDraw.Draw(grid) + + # Load font with size based on thumbnail width + try: + # Use Pillow's default font with size + font = ImageFont.load_default(size=font_size) + except Exception: + # Fall back to basic default font if size parameter not supported + font = ImageFont.load_default() + + # Place thumbnails + for i, img_path in enumerate(image_paths): + row, col = i // cols, i % cols + x = col * width + (col + 1) * GRID_PADDING + y_base = ( + row * (height + font_size + label_padding * 2) + (row + 1) * GRID_PADDING + ) + + # Add label with actual slide number + label = f"{start_slide_num + i}" + bbox = draw.textbbox((0, 0), label, font=font) + text_w = bbox[2] - bbox[0] + draw.text( + (x + (width - text_w) // 2, y_base + label_padding), + label, + fill="black", + font=font, + ) + + # Add thumbnail below label with proportional spacing + y_thumbnail = y_base + label_padding + font_size + label_padding + + with Image.open(img_path) as img: + # Get original dimensions before thumbnail + orig_w, orig_h = img.size + + # Apply placeholder outlines if enabled + if placeholder_regions and (start_slide_num + i) in placeholder_regions: + # Convert to RGBA for transparency support + if img.mode != "RGBA": + img = img.convert("RGBA") + + # Get the regions for this slide + regions = placeholder_regions[start_slide_num + i] + + # Calculate scale factors using actual slide dimensions + if slide_dimensions: + slide_width_inches, slide_height_inches = slide_dimensions + else: + # Fallback: estimate from image size at CONVERSION_DPI + slide_width_inches = orig_w / CONVERSION_DPI + slide_height_inches = orig_h / CONVERSION_DPI + + x_scale = orig_w / slide_width_inches + y_scale = orig_h / slide_height_inches + + # Create a highlight overlay + overlay = Image.new("RGBA", img.size, (255, 255, 255, 0)) + overlay_draw = ImageDraw.Draw(overlay) + + # Highlight each placeholder region + for region in regions: + # Convert from inches to pixels in the original image + px_left = int(region["left"] * x_scale) + px_top = int(region["top"] * y_scale) + px_width = int(region["width"] * x_scale) + px_height = int(region["height"] * y_scale) + + # Draw highlight outline with red color and thick stroke + # Using a bright red outline instead of fill + stroke_width = max( + 5, min(orig_w, orig_h) // 150 + ) # Thicker proportional stroke width + overlay_draw.rectangle( + [(px_left, px_top), (px_left + px_width, px_top + px_height)], + outline=(255, 0, 0, 255), # Bright red, fully opaque + width=stroke_width, + ) + + # Composite the overlay onto the image using alpha blending + img = Image.alpha_composite(img, overlay) + # Convert back to RGB for JPEG saving + img = img.convert("RGB") + + img.thumbnail((width, height), Image.Resampling.LANCZOS) + w, h = img.size + tx = x + (width - w) // 2 + ty = y_thumbnail + (height - h) // 2 + grid.paste(img, (tx, ty)) + + # Add border + if BORDER_WIDTH > 0: + draw.rectangle( + [ + (tx - BORDER_WIDTH, ty - BORDER_WIDTH), + (tx + w + BORDER_WIDTH - 1, ty + h + BORDER_WIDTH - 1), + ], + outline="gray", + width=BORDER_WIDTH, + ) + + return grid + + +if __name__ == "__main__": + main() diff --git a/skills/document-skills/xlsx/LICENSE.txt b/skills/document-skills/xlsx/LICENSE.txt new file mode 100644 index 0000000..c55ab42 --- /dev/null +++ b/skills/document-skills/xlsx/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/skills/document-skills/xlsx/SKILL.md b/skills/document-skills/xlsx/SKILL.md new file mode 100644 index 0000000..22db189 --- /dev/null +++ b/skills/document-skills/xlsx/SKILL.md @@ -0,0 +1,289 @@ +--- +name: xlsx +description: "Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas" +license: Proprietary. LICENSE.txt has complete terms +--- + +# Requirements for Outputs + +## All Excel files + +### Zero Formula Errors +- Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?) + +### Preserve Existing Templates (when updating templates) +- Study and EXACTLY match existing format, style, and conventions when modifying files +- Never impose standardized formatting on files with established patterns +- Existing template conventions ALWAYS override these guidelines + +## Financial models + +### Color Coding Standards +Unless otherwise stated by the user or existing template + +#### Industry-Standard Color Conventions +- **Blue text (RGB: 0,0,255)**: Hardcoded inputs, and numbers users will change for scenarios +- **Black text (RGB: 0,0,0)**: ALL formulas and calculations +- **Green text (RGB: 0,128,0)**: Links pulling from other worksheets within same workbook +- **Red text (RGB: 255,0,0)**: External links to other files +- **Yellow background (RGB: 255,255,0)**: Key assumptions needing attention or cells that need to be updated + +### Number Formatting Standards + +#### Required Format Rules +- **Years**: Format as text strings (e.g., "2024" not "2,024") +- **Currency**: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)") +- **Zeros**: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-") +- **Percentages**: Default to 0.0% format (one decimal) +- **Multiples**: Format as 0.0x for valuation multiples (EV/EBITDA, P/E) +- **Negative numbers**: Use parentheses (123) not minus -123 + +### Formula Construction Rules + +#### Assumptions Placement +- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells +- Use cell references instead of hardcoded values in formulas +- Example: Use =B5*(1+$B$6) instead of =B5*1.05 + +#### Formula Error Prevention +- Verify all cell references are correct +- Check for off-by-one errors in ranges +- Ensure consistent formulas across all projection periods +- Test with edge cases (zero values, negative numbers) +- Verify no unintended circular references + +#### Documentation Requirements for Hardcodes +- Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]" +- Examples: + - "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]" + - "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]" + - "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity" + - "Source: FactSet, 8/20/2025, Consensus Estimates Screen" + +# XLSX creation, editing, and analysis + +## Overview + +A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks. + +## Important Requirements + +**LibreOffice Required for Formula Recalculation**: You can assume LibreOffice is installed for recalculating formula values using the `recalc.py` script. The script automatically configures LibreOffice on first run + +## Reading and analyzing data + +### Data analysis with pandas +For data analysis, visualization, and basic operations, use **pandas** which provides powerful data manipulation capabilities: + +```python +import pandas as pd + +# Read Excel +df = pd.read_excel('file.xlsx') # Default: first sheet +all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict + +# Analyze +df.head() # Preview data +df.info() # Column info +df.describe() # Statistics + +# Write Excel +df.to_excel('output.xlsx', index=False) +``` + +## Excel File Workflows + +## CRITICAL: Use Formulas, Not Hardcoded Values + +**Always use Excel formulas instead of calculating values in Python and hardcoding them.** This ensures the spreadsheet remains dynamic and updateable. + +### ❌ WRONG - Hardcoding Calculated Values +```python +# Bad: Calculating in Python and hardcoding result +total = df['Sales'].sum() +sheet['B10'] = total # Hardcodes 5000 + +# Bad: Computing growth rate in Python +growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue'] +sheet['C5'] = growth # Hardcodes 0.15 + +# Bad: Python calculation for average +avg = sum(values) / len(values) +sheet['D20'] = avg # Hardcodes 42.5 +``` + +### ✅ CORRECT - Using Excel Formulas +```python +# Good: Let Excel calculate the sum +sheet['B10'] = '=SUM(B2:B9)' + +# Good: Growth rate as Excel formula +sheet['C5'] = '=(C4-C2)/C2' + +# Good: Average using Excel function +sheet['D20'] = '=AVERAGE(D2:D19)' +``` + +This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes. + +## Common Workflow +1. **Choose tool**: pandas for data, openpyxl for formulas/formatting +2. **Create/Load**: Create new workbook or load existing file +3. **Modify**: Add/edit data, formulas, and formatting +4. **Save**: Write to file +5. **Recalculate formulas (MANDATORY IF USING FORMULAS)**: Use the recalc.py script + ```bash + python recalc.py output.xlsx + ``` +6. **Verify and fix any errors**: + - The script returns JSON with error details + - If `status` is `errors_found`, check `error_summary` for specific error types and locations + - Fix the identified errors and recalculate again + - Common errors to fix: + - `#REF!`: Invalid cell references + - `#DIV/0!`: Division by zero + - `#VALUE!`: Wrong data type in formula + - `#NAME?`: Unrecognized formula name + +### Creating new Excel files + +```python +# Using openpyxl for formulas and formatting +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment + +wb = Workbook() +sheet = wb.active + +# Add data +sheet['A1'] = 'Hello' +sheet['B1'] = 'World' +sheet.append(['Row', 'of', 'data']) + +# Add formula +sheet['B2'] = '=SUM(A1:A10)' + +# Formatting +sheet['A1'].font = Font(bold=True, color='FF0000') +sheet['A1'].fill = PatternFill('solid', start_color='FFFF00') +sheet['A1'].alignment = Alignment(horizontal='center') + +# Column width +sheet.column_dimensions['A'].width = 20 + +wb.save('output.xlsx') +``` + +### Editing existing Excel files + +```python +# Using openpyxl to preserve formulas and formatting +from openpyxl import load_workbook + +# Load existing file +wb = load_workbook('existing.xlsx') +sheet = wb.active # or wb['SheetName'] for specific sheet + +# Working with multiple sheets +for sheet_name in wb.sheetnames: + sheet = wb[sheet_name] + print(f"Sheet: {sheet_name}") + +# Modify cells +sheet['A1'] = 'New Value' +sheet.insert_rows(2) # Insert row at position 2 +sheet.delete_cols(3) # Delete column 3 + +# Add new sheet +new_sheet = wb.create_sheet('NewSheet') +new_sheet['A1'] = 'Data' + +wb.save('modified.xlsx') +``` + +## Recalculating formulas + +Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided `recalc.py` script to recalculate formulas: + +```bash +python recalc.py [timeout_seconds] +``` + +Example: +```bash +python recalc.py output.xlsx 30 +``` + +The script: +- Automatically sets up LibreOffice macro on first run +- Recalculates all formulas in all sheets +- Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.) +- Returns JSON with detailed error locations and counts +- Works on both Linux and macOS + +## Formula Verification Checklist + +Quick checks to ensure formulas work correctly: + +### Essential Verification +- [ ] **Test 2-3 sample references**: Verify they pull correct values before building full model +- [ ] **Column mapping**: Confirm Excel columns match (e.g., column 64 = BL, not BK) +- [ ] **Row offset**: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6) + +### Common Pitfalls +- [ ] **NaN handling**: Check for null values with `pd.notna()` +- [ ] **Far-right columns**: FY data often in columns 50+ +- [ ] **Multiple matches**: Search all occurrences, not just first +- [ ] **Division by zero**: Check denominators before using `/` in formulas (#DIV/0!) +- [ ] **Wrong references**: Verify all cell references point to intended cells (#REF!) +- [ ] **Cross-sheet references**: Use correct format (Sheet1!A1) for linking sheets + +### Formula Testing Strategy +- [ ] **Start small**: Test formulas on 2-3 cells before applying broadly +- [ ] **Verify dependencies**: Check all cells referenced in formulas exist +- [ ] **Test edge cases**: Include zero, negative, and very large values + +### Interpreting recalc.py Output +The script returns JSON with error details: +```json +{ + "status": "success", // or "errors_found" + "total_errors": 0, // Total error count + "total_formulas": 42, // Number of formulas in file + "error_summary": { // Only present if errors found + "#REF!": { + "count": 2, + "locations": ["Sheet1!B5", "Sheet1!C10"] + } + } +} +``` + +## Best Practices + +### Library Selection +- **pandas**: Best for data analysis, bulk operations, and simple data export +- **openpyxl**: Best for complex formatting, formulas, and Excel-specific features + +### Working with openpyxl +- Cell indices are 1-based (row=1, column=1 refers to cell A1) +- Use `data_only=True` to read calculated values: `load_workbook('file.xlsx', data_only=True)` +- **Warning**: If opened with `data_only=True` and saved, formulas are replaced with values and permanently lost +- For large files: Use `read_only=True` for reading or `write_only=True` for writing +- Formulas are preserved but not evaluated - use recalc.py to update values + +### Working with pandas +- Specify data types to avoid inference issues: `pd.read_excel('file.xlsx', dtype={'id': str})` +- For large files, read specific columns: `pd.read_excel('file.xlsx', usecols=['A', 'C', 'E'])` +- Handle dates properly: `pd.read_excel('file.xlsx', parse_dates=['date_column'])` + +## Code Style Guidelines +**IMPORTANT**: When generating Python code for Excel operations: +- Write minimal, concise Python code without unnecessary comments +- Avoid verbose variable names and redundant operations +- Avoid unnecessary print statements + +**For Excel files themselves**: +- Add comments to cells with complex formulas or important assumptions +- Document data sources for hardcoded values +- Include notes for key calculations and model sections \ No newline at end of file diff --git a/skills/document-skills/xlsx/recalc.py b/skills/document-skills/xlsx/recalc.py new file mode 100644 index 0000000..102e157 --- /dev/null +++ b/skills/document-skills/xlsx/recalc.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +""" +Excel Formula Recalculation Script +Recalculates all formulas in an Excel file using LibreOffice +""" + +import json +import sys +import subprocess +import os +import platform +from pathlib import Path +from openpyxl import load_workbook + + +def setup_libreoffice_macro(): + """Setup LibreOffice macro for recalculation if not already configured""" + if platform.system() == 'Darwin': + macro_dir = os.path.expanduser('~/Library/Application Support/LibreOffice/4/user/basic/Standard') + else: + macro_dir = os.path.expanduser('~/.config/libreoffice/4/user/basic/Standard') + + macro_file = os.path.join(macro_dir, 'Module1.xba') + + if os.path.exists(macro_file): + with open(macro_file, 'r') as f: + if 'RecalculateAndSave' in f.read(): + return True + + if not os.path.exists(macro_dir): + subprocess.run(['soffice', '--headless', '--terminate_after_init'], + capture_output=True, timeout=10) + os.makedirs(macro_dir, exist_ok=True) + + macro_content = ''' + + + Sub RecalculateAndSave() + ThisComponent.calculateAll() + ThisComponent.store() + ThisComponent.close(True) + End Sub +''' + + try: + with open(macro_file, 'w') as f: + f.write(macro_content) + return True + except Exception: + return False + + +def recalc(filename, timeout=30): + """ + Recalculate formulas in Excel file and report any errors + + Args: + filename: Path to Excel file + timeout: Maximum time to wait for recalculation (seconds) + + Returns: + dict with error locations and counts + """ + if not Path(filename).exists(): + return {'error': f'File {filename} does not exist'} + + abs_path = str(Path(filename).absolute()) + + if not setup_libreoffice_macro(): + return {'error': 'Failed to setup LibreOffice macro'} + + cmd = [ + 'soffice', '--headless', '--norestore', + 'vnd.sun.star.script:Standard.Module1.RecalculateAndSave?language=Basic&location=application', + abs_path + ] + + # Handle timeout command differences between Linux and macOS + if platform.system() != 'Windows': + timeout_cmd = 'timeout' if platform.system() == 'Linux' else None + if platform.system() == 'Darwin': + # Check if gtimeout is available on macOS + try: + subprocess.run(['gtimeout', '--version'], capture_output=True, timeout=1, check=False) + timeout_cmd = 'gtimeout' + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + if timeout_cmd: + cmd = [timeout_cmd, str(timeout)] + cmd + + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode != 0 and result.returncode != 124: # 124 is timeout exit code + error_msg = result.stderr or 'Unknown error during recalculation' + if 'Module1' in error_msg or 'RecalculateAndSave' not in error_msg: + return {'error': 'LibreOffice macro not configured properly'} + else: + return {'error': error_msg} + + # Check for Excel errors in the recalculated file - scan ALL cells + try: + wb = load_workbook(filename, data_only=True) + + excel_errors = ['#VALUE!', '#DIV/0!', '#REF!', '#NAME?', '#NULL!', '#NUM!', '#N/A'] + error_details = {err: [] for err in excel_errors} + total_errors = 0 + + for sheet_name in wb.sheetnames: + ws = wb[sheet_name] + # Check ALL rows and columns - no limits + for row in ws.iter_rows(): + for cell in row: + if cell.value is not None and isinstance(cell.value, str): + for err in excel_errors: + if err in cell.value: + location = f"{sheet_name}!{cell.coordinate}" + error_details[err].append(location) + total_errors += 1 + break + + wb.close() + + # Build result summary + result = { + 'status': 'success' if total_errors == 0 else 'errors_found', + 'total_errors': total_errors, + 'error_summary': {} + } + + # Add non-empty error categories + for err_type, locations in error_details.items(): + if locations: + result['error_summary'][err_type] = { + 'count': len(locations), + 'locations': locations[:20] # Show up to 20 locations + } + + # Add formula count for context - also check ALL cells + wb_formulas = load_workbook(filename, data_only=False) + formula_count = 0 + for sheet_name in wb_formulas.sheetnames: + ws = wb_formulas[sheet_name] + for row in ws.iter_rows(): + for cell in row: + if cell.value and isinstance(cell.value, str) and cell.value.startswith('='): + formula_count += 1 + wb_formulas.close() + + result['total_formulas'] = formula_count + + return result + + except Exception as e: + return {'error': str(e)} + + +def main(): + if len(sys.argv) < 2: + print("Usage: python recalc.py [timeout_seconds]") + print("\nRecalculates all formulas in an Excel file using LibreOffice") + print("\nReturns JSON with error details:") + print(" - status: 'success' or 'errors_found'") + print(" - total_errors: Total number of Excel errors found") + print(" - total_formulas: Number of formulas in the file") + print(" - error_summary: Breakdown by error type with locations") + print(" - #VALUE!, #DIV/0!, #REF!, #NAME?, #NULL!, #NUM!, #N/A") + sys.exit(1) + + filename = sys.argv[1] + timeout = int(sys.argv[2]) if len(sys.argv) > 2 else 30 + + result = recalc(filename, timeout) + print(json.dumps(result, indent=2)) + + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/skills/google-adk-python/SKILL.md b/skills/google-adk-python/SKILL.md new file mode 100644 index 0000000..b387bab --- /dev/null +++ b/skills/google-adk-python/SKILL.md @@ -0,0 +1,237 @@ +# Google ADK Python Skill + +You are an expert guide for Google's Agent Development Kit (ADK) Python - an open-source, code-first toolkit for building, evaluating, and deploying AI agents. + +## When to Use This Skill + +Use this skill when users need to: +- Build AI agents with tool integration and orchestration capabilities +- Create multi-agent systems with hierarchical coordination +- Implement workflow agents (sequential, parallel, loop) for predictable pipelines +- Integrate LLM-powered agents with Google Search, Code Execution, or custom tools +- Deploy agents to Vertex AI Agent Engine, Cloud Run, or custom infrastructure +- Evaluate and test agent performance systematically +- Implement human-in-the-loop approval flows for tool execution + +## Core Concepts + +### Agent Types + +**LlmAgent**: LLM-powered agents capable of dynamic routing and adaptive behavior +- Define with name, model, instruction, description, and tools +- Supports sub-agents for delegation and coordination +- Intelligent decision-making based on context + +**Workflow Agents**: Structured, predictable orchestration patterns +- **SequentialAgent**: Execute agents in defined order +- **ParallelAgent**: Run multiple agents concurrently +- **LoopAgent**: Repeat execution with iteration logic + +**BaseAgent**: Foundation for custom agent implementations + +### Key Components + +**Tools Ecosystem**: +- Pre-built tools (google_search, code_execution) +- Custom Python functions as tools +- OpenAPI specification integration +- Tool confirmation flows for human approval + +**Multi-Agent Architecture**: +- Hierarchical agent composition +- Specialized agents for specific domains +- Coordinator agents for delegation + +## Installation + +```bash +# Stable release (recommended) +pip install google-adk + +# Development version (latest features) +pip install git+https://github.com/google/adk-python.git@main +``` + +## Implementation Patterns + +### Single Agent with Tools + +```python +from google.adk.agents import LlmAgent +from google.adk.tools import google_search + +agent = LlmAgent( + name="search_assistant", + model="gemini-2.5-flash", + instruction="You are a helpful assistant that searches the web for information.", + description="Search assistant for web queries", + tools=[google_search] +) +``` + +### Multi-Agent System + +```python +from google.adk.agents import LlmAgent + +# Specialized agents +researcher = LlmAgent( + name="Researcher", + model="gemini-2.5-flash", + instruction="Research topics thoroughly using web search.", + tools=[google_search] +) + +writer = LlmAgent( + name="Writer", + model="gemini-2.5-flash", + instruction="Write clear, engaging content based on research.", +) + +# Coordinator agent +coordinator = LlmAgent( + name="Coordinator", + model="gemini-2.5-flash", + instruction="Delegate tasks to researcher and writer agents.", + sub_agents=[researcher, writer] +) +``` + +### Custom Tool Creation + +```python +from google.adk.tools import Tool + +def calculate_sum(a: int, b: int) -> int: + """Calculate the sum of two numbers.""" + return a + b + +# Convert function to tool +sum_tool = Tool.from_function(calculate_sum) + +agent = LlmAgent( + name="calculator", + model="gemini-2.5-flash", + tools=[sum_tool] +) +``` + +### Sequential Workflow + +```python +from google.adk.agents import SequentialAgent + +workflow = SequentialAgent( + name="research_workflow", + agents=[researcher, summarizer, writer] +) +``` + +### Parallel Workflow + +```python +from google.adk.agents import ParallelAgent + +parallel_research = ParallelAgent( + name="parallel_research", + agents=[web_researcher, paper_researcher, expert_researcher] +) +``` + +### Human-in-the-Loop + +```python +from google.adk.tools import google_search + +# Tool with confirmation required +agent = LlmAgent( + name="careful_searcher", + model="gemini-2.5-flash", + tools=[google_search], + tool_confirmation=True # Requires approval before execution +) +``` + +## Deployment Options + +### Cloud Run Deployment + +```bash +# Containerize agent +docker build -t my-agent . + +# Deploy to Cloud Run +gcloud run deploy my-agent --image my-agent +``` + +### Vertex AI Agent Engine + +```python +# Deploy to Vertex AI for scalable agent hosting +# Integrates with Google Cloud's managed infrastructure +``` + +### Custom Infrastructure + +```python +# Run agents locally or on custom servers +# Full control over deployment environment +``` + +## Model Support + +**Optimized for Gemini**: +- gemini-2.5-flash +- gemini-2.5-pro +- gemini-1.5-flash +- gemini-1.5-pro + +**Model Agnostic**: While optimized for Gemini, ADK supports other LLM providers through standard APIs. + +## Best Practices + +1. **Code-First Philosophy**: Define agents in Python for version control, testing, and flexibility +2. **Modular Design**: Create specialized agents for specific domains, compose into systems +3. **Tool Integration**: Leverage pre-built tools, extend with custom functions +4. **Evaluation**: Test agents systematically against test cases +5. **Safety**: Implement confirmation flows for sensitive operations +6. **Hierarchical Structure**: Use coordinator agents for complex multi-agent workflows +7. **Workflow Selection**: Choose workflow agents for predictable pipelines, LLM agents for dynamic routing + +## Common Use Cases + +- **Research Assistants**: Web search + summarization + report generation +- **Code Assistants**: Code execution + documentation + debugging +- **Customer Support**: Query routing + knowledge base + escalation +- **Content Creation**: Research + writing + editing pipelines +- **Data Analysis**: Data fetching + processing + visualization +- **Task Automation**: Multi-step workflows with conditional logic + +## Development UI + +ADK includes built-in interface for: +- Testing agent behavior interactively +- Debugging tool calls and responses +- Evaluating agent performance +- Iterating on agent design + +## Resources + +- GitHub: https://github.com/google/adk-python +- Documentation: https://google.github.io/adk-docs/ +- llms.txt: https://raw.githubusercontent.com/google/adk-python/refs/heads/main/llms.txt + +## Implementation Workflow + +When implementing ADK-based agents: + +1. **Define Requirements**: Identify agent capabilities and tools needed +2. **Choose Architecture**: Single agent, multi-agent, or workflow-based +3. **Select Tools**: Pre-built, custom functions, or OpenAPI integrations +4. **Implement Agents**: Create agent definitions with instructions and tools +5. **Test Locally**: Use development UI for iteration +6. **Add Evaluation**: Create test cases for systematic validation +7. **Deploy**: Choose Cloud Run, Vertex AI, or custom infrastructure +8. **Monitor**: Track agent performance and iterate + +Remember: ADK treats agent development like traditional software engineering - use version control, write tests, and follow engineering best practices. \ No newline at end of file diff --git a/skills/mcp-builder/LICENSE.txt b/skills/mcp-builder/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/skills/mcp-builder/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/skills/mcp-builder/SKILL.md b/skills/mcp-builder/SKILL.md new file mode 100644 index 0000000..c9ef8a2 --- /dev/null +++ b/skills/mcp-builder/SKILL.md @@ -0,0 +1,328 @@ +--- +name: mcp-builder +description: Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK). +license: Complete terms in LICENSE.txt +--- + +# MCP Server Development Guide + +## Overview + +To create high-quality MCP (Model Context Protocol) servers that enable LLMs to effectively interact with external services, use this skill. An MCP server provides tools that allow LLMs to access external services and APIs. The quality of an MCP server is measured by how well it enables LLMs to accomplish real-world tasks using the tools provided. + +--- + +# Process + +## 🚀 High-Level Workflow + +Creating a high-quality MCP server involves four main phases: + +### Phase 1: Deep Research and Planning + +#### 1.1 Understand Agent-Centric Design Principles + +Before diving into implementation, understand how to design tools for AI agents by reviewing these principles: + +**Build for Workflows, Not Just API Endpoints:** +- Don't simply wrap existing API endpoints - build thoughtful, high-impact workflow tools +- Consolidate related operations (e.g., `schedule_event` that both checks availability and creates event) +- Focus on tools that enable complete tasks, not just individual API calls +- Consider what workflows agents actually need to accomplish + +**Optimize for Limited Context:** +- Agents have constrained context windows - make every token count +- Return high-signal information, not exhaustive data dumps +- Provide "concise" vs "detailed" response format options +- Default to human-readable identifiers over technical codes (names over IDs) +- Consider the agent's context budget as a scarce resource + +**Design Actionable Error Messages:** +- Error messages should guide agents toward correct usage patterns +- Suggest specific next steps: "Try using filter='active_only' to reduce results" +- Make errors educational, not just diagnostic +- Help agents learn proper tool usage through clear feedback + +**Follow Natural Task Subdivisions:** +- Tool names should reflect how humans think about tasks +- Group related tools with consistent prefixes for discoverability +- Design tools around natural workflows, not just API structure + +**Use Evaluation-Driven Development:** +- Create realistic evaluation scenarios early +- Let agent feedback drive tool improvements +- Prototype quickly and iterate based on actual agent performance + +#### 1.3 Study MCP Protocol Documentation + +**Fetch the latest MCP protocol documentation:** + +Use WebFetch to load: `https://modelcontextprotocol.io/llms-full.txt` + +This comprehensive document contains the complete MCP specification and guidelines. + +#### 1.4 Study Framework Documentation + +**Load and read the following reference files:** + +- **MCP Best Practices**: [📋 View Best Practices](./reference/mcp_best_practices.md) - Core guidelines for all MCP servers + +**For Python implementations, also load:** +- **Python SDK Documentation**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- [🐍 Python Implementation Guide](./reference/python_mcp_server.md) - Python-specific best practices and examples + +**For Node/TypeScript implementations, also load:** +- **TypeScript SDK Documentation**: Use WebFetch to load `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` +- [⚡ TypeScript Implementation Guide](./reference/node_mcp_server.md) - Node/TypeScript-specific best practices and examples + +#### 1.5 Exhaustively Study API Documentation + +To integrate a service, read through **ALL** available API documentation: +- Official API reference documentation +- Authentication and authorization requirements +- Rate limiting and pagination patterns +- Error responses and status codes +- Available endpoints and their parameters +- Data models and schemas + +**To gather comprehensive information, use web search and the WebFetch tool as needed.** + +#### 1.6 Create a Comprehensive Implementation Plan + +Based on your research, create a detailed plan that includes: + +**Tool Selection:** +- List the most valuable endpoints/operations to implement +- Prioritize tools that enable the most common and important use cases +- Consider which tools work together to enable complex workflows + +**Shared Utilities and Helpers:** +- Identify common API request patterns +- Plan pagination helpers +- Design filtering and formatting utilities +- Plan error handling strategies + +**Input/Output Design:** +- Define input validation models (Pydantic for Python, Zod for TypeScript) +- Design consistent response formats (e.g., JSON or Markdown), and configurable levels of detail (e.g., Detailed or Concise) +- Plan for large-scale usage (thousands of users/resources) +- Implement character limits and truncation strategies (e.g., 25,000 tokens) + +**Error Handling Strategy:** +- Plan graceful failure modes +- Design clear, actionable, LLM-friendly, natural language error messages which prompt further action +- Consider rate limiting and timeout scenarios +- Handle authentication and authorization errors + +--- + +### Phase 2: Implementation + +Now that you have a comprehensive plan, begin implementation following language-specific best practices. + +#### 2.1 Set Up Project Structure + +**For Python:** +- Create a single `.py` file or organize into modules if complex (see [🐍 Python Guide](./reference/python_mcp_server.md)) +- Use the MCP Python SDK for tool registration +- Define Pydantic models for input validation + +**For Node/TypeScript:** +- Create proper project structure (see [⚡ TypeScript Guide](./reference/node_mcp_server.md)) +- Set up `package.json` and `tsconfig.json` +- Use MCP TypeScript SDK +- Define Zod schemas for input validation + +#### 2.2 Implement Core Infrastructure First + +**To begin implementation, create shared utilities before implementing tools:** +- API request helper functions +- Error handling utilities +- Response formatting functions (JSON and Markdown) +- Pagination helpers +- Authentication/token management + +#### 2.3 Implement Tools Systematically + +For each tool in the plan: + +**Define Input Schema:** +- Use Pydantic (Python) or Zod (TypeScript) for validation +- Include proper constraints (min/max length, regex patterns, min/max values, ranges) +- Provide clear, descriptive field descriptions +- Include diverse examples in field descriptions + +**Write Comprehensive Docstrings/Descriptions:** +- One-line summary of what the tool does +- Detailed explanation of purpose and functionality +- Explicit parameter types with examples +- Complete return type schema +- Usage examples (when to use, when not to use) +- Error handling documentation, which outlines how to proceed given specific errors + +**Implement Tool Logic:** +- Use shared utilities to avoid code duplication +- Follow async/await patterns for all I/O +- Implement proper error handling +- Support multiple response formats (JSON and Markdown) +- Respect pagination parameters +- Check character limits and truncate appropriately + +**Add Tool Annotations:** +- `readOnlyHint`: true (for read-only operations) +- `destructiveHint`: false (for non-destructive operations) +- `idempotentHint`: true (if repeated calls have same effect) +- `openWorldHint`: true (if interacting with external systems) + +#### 2.4 Follow Language-Specific Best Practices + +**At this point, load the appropriate language guide:** + +**For Python: Load [🐍 Python Implementation Guide](./reference/python_mcp_server.md) and ensure the following:** +- Using MCP Python SDK with proper tool registration +- Pydantic v2 models with `model_config` +- Type hints throughout +- Async/await for all I/O operations +- Proper imports organization +- Module-level constants (CHARACTER_LIMIT, API_BASE_URL) + +**For Node/TypeScript: Load [⚡ TypeScript Implementation Guide](./reference/node_mcp_server.md) and ensure the following:** +- Using `server.registerTool` properly +- Zod schemas with `.strict()` +- TypeScript strict mode enabled +- No `any` types - use proper types +- Explicit Promise return types +- Build process configured (`npm run build`) + +--- + +### Phase 3: Review and Refine + +After initial implementation: + +#### 3.1 Code Quality Review + +To ensure quality, review the code for: +- **DRY Principle**: No duplicated code between tools +- **Composability**: Shared logic extracted into functions +- **Consistency**: Similar operations return similar formats +- **Error Handling**: All external calls have error handling +- **Type Safety**: Full type coverage (Python type hints, TypeScript types) +- **Documentation**: Every tool has comprehensive docstrings/descriptions + +#### 3.2 Test and Build + +**Important:** MCP servers are long-running processes that wait for requests over stdio/stdin or sse/http. Running them directly in your main process (e.g., `python server.py` or `node dist/index.js`) will cause your process to hang indefinitely. + +**Safe ways to test the server:** +- Use the evaluation harness (see Phase 4) - recommended approach +- Run the server in tmux to keep it outside your main process +- Use a timeout when testing: `timeout 5s python server.py` + +**For Python:** +- Verify Python syntax: `python -m py_compile your_server.py` +- Check imports work correctly by reviewing the file +- To manually test: Run server in tmux, then test with evaluation harness in main process +- Or use the evaluation harness directly (it manages the server for stdio transport) + +**For Node/TypeScript:** +- Run `npm run build` and ensure it completes without errors +- Verify dist/index.js is created +- To manually test: Run server in tmux, then test with evaluation harness in main process +- Or use the evaluation harness directly (it manages the server for stdio transport) + +#### 3.3 Use Quality Checklist + +To verify implementation quality, load the appropriate checklist from the language-specific guide: +- Python: see "Quality Checklist" in [🐍 Python Guide](./reference/python_mcp_server.md) +- Node/TypeScript: see "Quality Checklist" in [⚡ TypeScript Guide](./reference/node_mcp_server.md) + +--- + +### Phase 4: Create Evaluations + +After implementing your MCP server, create comprehensive evaluations to test its effectiveness. + +**Load [✅ Evaluation Guide](./reference/evaluation.md) for complete evaluation guidelines.** + +#### 4.1 Understand Evaluation Purpose + +Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions. + +#### 4.2 Create 10 Evaluation Questions + +To create effective evaluations, follow the process outlined in the evaluation guide: + +1. **Tool Inspection**: List available tools and understand their capabilities +2. **Content Exploration**: Use READ-ONLY operations to explore available data +3. **Question Generation**: Create 10 complex, realistic questions +4. **Answer Verification**: Solve each question yourself to verify answers + +#### 4.3 Evaluation Requirements + +Each question must be: +- **Independent**: Not dependent on other questions +- **Read-only**: Only non-destructive operations required +- **Complex**: Requiring multiple tool calls and deep exploration +- **Realistic**: Based on real use cases humans would care about +- **Verifiable**: Single, clear answer that can be verified by string comparison +- **Stable**: Answer won't change over time + +#### 4.4 Output Format + +Create an XML file with this structure: + +```xml + + + Find discussions about AI model launches with animal codenames. One model needed a specific safety designation that uses the format ASL-X. What number X was being determined for the model named after a spotted wild cat? + 3 + + + +``` + +--- + +# Reference Files + +## 📚 Documentation Library + +Load these resources as needed during development: + +### Core MCP Documentation (Load First) +- **MCP Protocol**: Fetch from `https://modelcontextprotocol.io/llms-full.txt` - Complete MCP specification +- [📋 MCP Best Practices](./reference/mcp_best_practices.md) - Universal MCP guidelines including: + - Server and tool naming conventions + - Response format guidelines (JSON vs Markdown) + - Pagination best practices + - Character limits and truncation strategies + - Tool development guidelines + - Security and error handling standards + +### SDK Documentation (Load During Phase 1/2) +- **Python SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` +- **TypeScript SDK**: Fetch from `https://raw.githubusercontent.com/modelcontextprotocol/typescript-sdk/main/README.md` + +### Language-Specific Implementation Guides (Load During Phase 2) +- [🐍 Python Implementation Guide](./reference/python_mcp_server.md) - Complete Python/FastMCP guide with: + - Server initialization patterns + - Pydantic model examples + - Tool registration with `@mcp.tool` + - Complete working examples + - Quality checklist + +- [⚡ TypeScript Implementation Guide](./reference/node_mcp_server.md) - Complete TypeScript guide with: + - Project structure + - Zod schema patterns + - Tool registration with `server.registerTool` + - Complete working examples + - Quality checklist + +### Evaluation Guide (Load During Phase 4) +- [✅ Evaluation Guide](./reference/evaluation.md) - Complete evaluation creation guide with: + - Question creation guidelines + - Answer verification strategies + - XML format specifications + - Example questions and answers + - Running an evaluation with the provided scripts diff --git a/skills/mcp-builder/reference/evaluation.md b/skills/mcp-builder/reference/evaluation.md new file mode 100644 index 0000000..87e9bb7 --- /dev/null +++ b/skills/mcp-builder/reference/evaluation.md @@ -0,0 +1,602 @@ +# MCP Server Evaluation Guide + +## Overview + +This document provides guidance on creating comprehensive evaluations for MCP servers. Evaluations test whether LLMs can effectively use your MCP server to answer realistic, complex questions using only the tools provided. + +--- + +## Quick Reference + +### Evaluation Requirements +- Create 10 human-readable questions +- Questions must be READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE +- Each question requires multiple tool calls (potentially dozens) +- Answers must be single, verifiable values +- Answers must be STABLE (won't change over time) + +### Output Format +```xml + + + Your question here + Single verifiable answer + + +``` + +--- + +## Purpose of Evaluations + +The measure of quality of an MCP server is NOT how well or comprehensively the server implements tools, but how well these implementations (input/output schemas, docstrings/descriptions, functionality) enable LLMs with no other context and access ONLY to the MCP servers to answer realistic and difficult questions. + +## Evaluation Overview + +Create 10 human-readable questions requiring ONLY READ-ONLY, INDEPENDENT, NON-DESTRUCTIVE, and IDEMPOTENT operations to answer. Each question should be: +- Realistic +- Clear and concise +- Unambiguous +- Complex, requiring potentially dozens of tool calls or steps +- Answerable with a single, verifiable value that you identify in advance + +## Question Guidelines + +### Core Requirements + +1. **Questions MUST be independent** + - Each question should NOT depend on the answer to any other question + - Should not assume prior write operations from processing another question + +2. **Questions MUST require ONLY NON-DESTRUCTIVE AND IDEMPOTENT tool use** + - Should not instruct or require modifying state to arrive at the correct answer + +3. **Questions must be REALISTIC, CLEAR, CONCISE, and COMPLEX** + - Must require another LLM to use multiple (potentially dozens of) tools or steps to answer + +### Complexity and Depth + +4. **Questions must require deep exploration** + - Consider multi-hop questions requiring multiple sub-questions and sequential tool calls + - Each step should benefit from information found in previous questions + +5. **Questions may require extensive paging** + - May need paging through multiple pages of results + - May require querying old data (1-2 years out-of-date) to find niche information + - The questions must be DIFFICULT + +6. **Questions must require deep understanding** + - Rather than surface-level knowledge + - May pose complex ideas as True/False questions requiring evidence + - May use multiple-choice format where LLM must search different hypotheses + +7. **Questions must not be solvable with straightforward keyword search** + - Do not include specific keywords from the target content + - Use synonyms, related concepts, or paraphrases + - Require multiple searches, analyzing multiple related items, extracting context, then deriving the answer + +### Tool Testing + +8. **Questions should stress-test tool return values** + - May elicit tools returning large JSON objects or lists, overwhelming the LLM + - Should require understanding multiple modalities of data: + - IDs and names + - Timestamps and datetimes (months, days, years, seconds) + - File IDs, names, extensions, and mimetypes + - URLs, GIDs, etc. + - Should probe the tool's ability to return all useful forms of data + +9. **Questions should MOSTLY reflect real human use cases** + - The kinds of information retrieval tasks that HUMANS assisted by an LLM would care about + +10. **Questions may require dozens of tool calls** + - This challenges LLMs with limited context + - Encourages MCP server tools to reduce information returned + +11. **Include ambiguous questions** + - May be ambiguous OR require difficult decisions on which tools to call + - Force the LLM to potentially make mistakes or misinterpret + - Ensure that despite AMBIGUITY, there is STILL A SINGLE VERIFIABLE ANSWER + +### Stability + +12. **Questions must be designed so the answer DOES NOT CHANGE** + - Do not ask questions that rely on "current state" which is dynamic + - For example, do not count: + - Number of reactions to a post + - Number of replies to a thread + - Number of members in a channel + +13. **DO NOT let the MCP server RESTRICT the kinds of questions you create** + - Create challenging and complex questions + - Some may not be solvable with the available MCP server tools + - Questions may require specific output formats (datetime vs. epoch time, JSON vs. MARKDOWN) + - Questions may require dozens of tool calls to complete + +## Answer Guidelines + +### Verification + +1. **Answers must be VERIFIABLE via direct string comparison** + - If the answer can be re-written in many formats, clearly specify the output format in the QUESTION + - Examples: "Use YYYY/MM/DD.", "Respond True or False.", "Answer A, B, C, or D and nothing else." + - Answer should be a single VERIFIABLE value such as: + - User ID, user name, display name, first name, last name + - Channel ID, channel name + - Message ID, string + - URL, title + - Numerical quantity + - Timestamp, datetime + - Boolean (for True/False questions) + - Email address, phone number + - File ID, file name, file extension + - Multiple choice answer + - Answers must not require special formatting or complex, structured output + - Answer will be verified using DIRECT STRING COMPARISON + +### Readability + +2. **Answers should generally prefer HUMAN-READABLE formats** + - Examples: names, first name, last name, datetime, file name, message string, URL, yes/no, true/false, a/b/c/d + - Rather than opaque IDs (though IDs are acceptable) + - The VAST MAJORITY of answers should be human-readable + +### Stability + +3. **Answers must be STABLE/STATIONARY** + - Look at old content (e.g., conversations that have ended, projects that have launched, questions answered) + - Create QUESTIONS based on "closed" concepts that will always return the same answer + - Questions may ask to consider a fixed time window to insulate from non-stationary answers + - Rely on context UNLIKELY to change + - Example: if finding a paper name, be SPECIFIC enough so answer is not confused with papers published later + +4. **Answers must be CLEAR and UNAMBIGUOUS** + - Questions must be designed so there is a single, clear answer + - Answer can be derived from using the MCP server tools + +### Diversity + +5. **Answers must be DIVERSE** + - Answer should be a single VERIFIABLE value in diverse modalities and formats + - User concept: user ID, user name, display name, first name, last name, email address, phone number + - Channel concept: channel ID, channel name, channel topic + - Message concept: message ID, message string, timestamp, month, day, year + +6. **Answers must NOT be complex structures** + - Not a list of values + - Not a complex object + - Not a list of IDs or strings + - Not natural language text + - UNLESS the answer can be straightforwardly verified using DIRECT STRING COMPARISON + - And can be realistically reproduced + - It should be unlikely that an LLM would return the same list in any other order or format + +## Evaluation Process + +### Step 1: Documentation Inspection + +Read the documentation of the target API to understand: +- Available endpoints and functionality +- If ambiguity exists, fetch additional information from the web +- Parallelize this step AS MUCH AS POSSIBLE +- Ensure each subagent is ONLY examining documentation from the file system or on the web + +### Step 2: Tool Inspection + +List the tools available in the MCP server: +- Inspect the MCP server directly +- Understand input/output schemas, docstrings, and descriptions +- WITHOUT calling the tools themselves at this stage + +### Step 3: Developing Understanding + +Repeat steps 1 & 2 until you have a good understanding: +- Iterate multiple times +- Think about the kinds of tasks you want to create +- Refine your understanding +- At NO stage should you READ the code of the MCP server implementation itself +- Use your intuition and understanding to create reasonable, realistic, but VERY challenging tasks + +### Step 4: Read-Only Content Inspection + +After understanding the API and tools, USE the MCP server tools: +- Inspect content using READ-ONLY and NON-DESTRUCTIVE operations ONLY +- Goal: identify specific content (e.g., users, channels, messages, projects, tasks) for creating realistic questions +- Should NOT call any tools that modify state +- Will NOT read the code of the MCP server implementation itself +- Parallelize this step with individual sub-agents pursuing independent explorations +- Ensure each subagent is only performing READ-ONLY, NON-DESTRUCTIVE, and IDEMPOTENT operations +- BE CAREFUL: SOME TOOLS may return LOTS OF DATA which would cause you to run out of CONTEXT +- Make INCREMENTAL, SMALL, AND TARGETED tool calls for exploration +- In all tool call requests, use the `limit` parameter to limit results (<10) +- Use pagination + +### Step 5: Task Generation + +After inspecting the content, create 10 human-readable questions: +- An LLM should be able to answer these with the MCP server +- Follow all question and answer guidelines above + +## Output Format + +Each QA pair consists of a question and an answer. The output should be an XML file with this structure: + +```xml + + + Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name? + Website Redesign + + + Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username. + sarah_dev + + + Look for pull requests that modified files in the /api directory and were merged between January 1 and January 31, 2024. How many different contributors worked on these PRs? + 7 + + + Find the repository with the most stars that was created before 2023. What is the repository name? + data-pipeline + + +``` + +## Evaluation Examples + +### Good Questions + +**Example 1: Multi-hop question requiring deep exploration (GitHub MCP)** +```xml + + Find the repository that was archived in Q3 2023 and had previously been the most forked project in the organization. What was the primary programming language used in that repository? + Python + +``` + +This question is good because: +- Requires multiple searches to find archived repositories +- Needs to identify which had the most forks before archival +- Requires examining repository details for the language +- Answer is a simple, verifiable value +- Based on historical (closed) data that won't change + +**Example 2: Requires understanding context without keyword matching (Project Management MCP)** +```xml + + Locate the initiative focused on improving customer onboarding that was completed in late 2023. The project lead created a retrospective document after completion. What was the lead's role title at that time? + Product Manager + +``` + +This question is good because: +- Doesn't use specific project name ("initiative focused on improving customer onboarding") +- Requires finding completed projects from specific timeframe +- Needs to identify the project lead and their role +- Requires understanding context from retrospective documents +- Answer is human-readable and stable +- Based on completed work (won't change) + +**Example 3: Complex aggregation requiring multiple steps (Issue Tracker MCP)** +```xml + + Among all bugs reported in January 2024 that were marked as critical priority, which assignee resolved the highest percentage of their assigned bugs within 48 hours? Provide the assignee's username. + alex_eng + +``` + +This question is good because: +- Requires filtering bugs by date, priority, and status +- Needs to group by assignee and calculate resolution rates +- Requires understanding timestamps to determine 48-hour windows +- Tests pagination (potentially many bugs to process) +- Answer is a single username +- Based on historical data from specific time period + +**Example 4: Requires synthesis across multiple data types (CRM MCP)** +```xml + + Find the account that upgraded from the Starter to Enterprise plan in Q4 2023 and had the highest annual contract value. What industry does this account operate in? + Healthcare + +``` + +This question is good because: +- Requires understanding subscription tier changes +- Needs to identify upgrade events in specific timeframe +- Requires comparing contract values +- Must access account industry information +- Answer is simple and verifiable +- Based on completed historical transactions + +### Poor Questions + +**Example 1: Answer changes over time** +```xml + + How many open issues are currently assigned to the engineering team? + 47 + +``` + +This question is poor because: +- The answer will change as issues are created, closed, or reassigned +- Not based on stable/stationary data +- Relies on "current state" which is dynamic + +**Example 2: Too easy with keyword search** +```xml + + Find the pull request with title "Add authentication feature" and tell me who created it. + developer123 + +``` + +This question is poor because: +- Can be solved with a straightforward keyword search for exact title +- Doesn't require deep exploration or understanding +- No synthesis or analysis needed + +**Example 3: Ambiguous answer format** +```xml + + List all the repositories that have Python as their primary language. + repo1, repo2, repo3, data-pipeline, ml-tools + +``` + +This question is poor because: +- Answer is a list that could be returned in any order +- Difficult to verify with direct string comparison +- LLM might format differently (JSON array, comma-separated, newline-separated) +- Better to ask for a specific aggregate (count) or superlative (most stars) + +## Verification Process + +After creating evaluations: + +1. **Examine the XML file** to understand the schema +2. **Load each task instruction** and in parallel using the MCP server and tools, identify the correct answer by attempting to solve the task YOURSELF +3. **Flag any operations** that require WRITE or DESTRUCTIVE operations +4. **Accumulate all CORRECT answers** and replace any incorrect answers in the document +5. **Remove any ``** that require WRITE or DESTRUCTIVE operations + +Remember to parallelize solving tasks to avoid running out of context, then accumulate all answers and make changes to the file at the end. + +## Tips for Creating Quality Evaluations + +1. **Think Hard and Plan Ahead** before generating tasks +2. **Parallelize Where Opportunity Arises** to speed up the process and manage context +3. **Focus on Realistic Use Cases** that humans would actually want to accomplish +4. **Create Challenging Questions** that test the limits of the MCP server's capabilities +5. **Ensure Stability** by using historical data and closed concepts +6. **Verify Answers** by solving the questions yourself using the MCP server tools +7. **Iterate and Refine** based on what you learn during the process + +--- + +# Running Evaluations + +After creating your evaluation file, you can use the provided evaluation harness to test your MCP server. + +## Setup + +1. **Install Dependencies** + + ```bash + pip install -r scripts/requirements.txt + ``` + + Or install manually: + ```bash + pip install anthropic mcp + ``` + +2. **Set API Key** + + ```bash + export ANTHROPIC_API_KEY=your_api_key_here + ``` + +## Evaluation File Format + +Evaluation files use XML format with `` elements: + +```xml + + + Find the project created in Q2 2024 with the highest number of completed tasks. What is the project name? + Website Redesign + + + Search for issues labeled as "bug" that were closed in March 2024. Which user closed the most issues? Provide their username. + sarah_dev + + +``` + +## Running Evaluations + +The evaluation script (`scripts/evaluation.py`) supports three transport types: + +**Important:** +- **stdio transport**: The evaluation script automatically launches and manages the MCP server process for you. Do not run the server manually. +- **sse/http transports**: You must start the MCP server separately before running the evaluation. The script connects to the already-running server at the specified URL. + +### 1. Local STDIO Server + +For locally-run MCP servers (script launches the server automatically): + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + evaluation.xml +``` + +With environment variables: +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_mcp_server.py \ + -e API_KEY=abc123 \ + -e DEBUG=true \ + evaluation.xml +``` + +### 2. Server-Sent Events (SSE) + +For SSE-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t sse \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + -H "X-Custom-Header: value" \ + evaluation.xml +``` + +### 3. HTTP (Streamable HTTP) + +For HTTP-based MCP servers (you must start the server first): + +```bash +python scripts/evaluation.py \ + -t http \ + -u https://example.com/mcp \ + -H "Authorization: Bearer token123" \ + evaluation.xml +``` + +## Command-Line Options + +``` +usage: evaluation.py [-h] [-t {stdio,sse,http}] [-m MODEL] [-c COMMAND] + [-a ARGS [ARGS ...]] [-e ENV [ENV ...]] [-u URL] + [-H HEADERS [HEADERS ...]] [-o OUTPUT] + eval_file + +positional arguments: + eval_file Path to evaluation XML file + +optional arguments: + -h, --help Show help message + -t, --transport Transport type: stdio, sse, or http (default: stdio) + -m, --model Claude model to use (default: claude-3-7-sonnet-20250219) + -o, --output Output file for report (default: print to stdout) + +stdio options: + -c, --command Command to run MCP server (e.g., python, node) + -a, --args Arguments for the command (e.g., server.py) + -e, --env Environment variables in KEY=VALUE format + +sse/http options: + -u, --url MCP server URL + -H, --header HTTP headers in 'Key: Value' format +``` + +## Output + +The evaluation script generates a detailed report including: + +- **Summary Statistics**: + - Accuracy (correct/total) + - Average task duration + - Average tool calls per task + - Total tool calls + +- **Per-Task Results**: + - Prompt and expected response + - Actual response from the agent + - Whether the answer was correct (✅/❌) + - Duration and tool call details + - Agent's summary of its approach + - Agent's feedback on the tools + +### Save Report to File + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a my_server.py \ + -o evaluation_report.md \ + evaluation.xml +``` + +## Complete Example Workflow + +Here's a complete example of creating and running an evaluation: + +1. **Create your evaluation file** (`my_evaluation.xml`): + +```xml + + + Find the user who created the most issues in January 2024. What is their username? + alice_developer + + + Among all pull requests merged in Q1 2024, which repository had the highest number? Provide the repository name. + backend-api + + + Find the project that was completed in December 2023 and had the longest duration from start to finish. How many days did it take? + 127 + + +``` + +2. **Install dependencies**: + +```bash +pip install -r scripts/requirements.txt +export ANTHROPIC_API_KEY=your_api_key +``` + +3. **Run evaluation**: + +```bash +python scripts/evaluation.py \ + -t stdio \ + -c python \ + -a github_mcp_server.py \ + -e GITHUB_TOKEN=ghp_xxx \ + -o github_eval_report.md \ + my_evaluation.xml +``` + +4. **Review the report** in `github_eval_report.md` to: + - See which questions passed/failed + - Read the agent's feedback on your tools + - Identify areas for improvement + - Iterate on your MCP server design + +## Troubleshooting + +### Connection Errors + +If you get connection errors: +- **STDIO**: Verify the command and arguments are correct +- **SSE/HTTP**: Check the URL is accessible and headers are correct +- Ensure any required API keys are set in environment variables or headers + +### Low Accuracy + +If many evaluations fail: +- Review the agent's feedback for each task +- Check if tool descriptions are clear and comprehensive +- Verify input parameters are well-documented +- Consider whether tools return too much or too little data +- Ensure error messages are actionable + +### Timeout Issues + +If tasks are timing out: +- Use a more capable model (e.g., `claude-3-7-sonnet-20250219`) +- Check if tools are returning too much data +- Verify pagination is working correctly +- Consider simplifying complex questions \ No newline at end of file diff --git a/skills/mcp-builder/reference/mcp_best_practices.md b/skills/mcp-builder/reference/mcp_best_practices.md new file mode 100644 index 0000000..db42af7 --- /dev/null +++ b/skills/mcp-builder/reference/mcp_best_practices.md @@ -0,0 +1,915 @@ +# MCP Server Development Best Practices and Guidelines + +## Overview + +This document compiles essential best practices and guidelines for building Model Context Protocol (MCP) servers. It covers naming conventions, tool design, response formats, pagination, error handling, security, and compliance requirements. + +--- + +## Quick Reference + +### Server Naming +- **Python**: `{service}_mcp` (e.g., `slack_mcp`) +- **Node/TypeScript**: `{service}-mcp-server` (e.g., `slack-mcp-server`) + +### Tool Naming +- Use snake_case with service prefix +- Format: `{service}_{action}_{resource}` +- Example: `slack_send_message`, `github_create_issue` + +### Response Formats +- Support both JSON and Markdown formats +- JSON for programmatic processing +- Markdown for human readability + +### Pagination +- Always respect `limit` parameter +- Return `has_more`, `next_offset`, `total_count` +- Default to 20-50 items + +### Character Limits +- Set CHARACTER_LIMIT constant (typically 25,000) +- Truncate gracefully with clear messages +- Provide guidance on filtering + +--- + +## Table of Contents +1. Server Naming Conventions +2. Tool Naming and Design +3. Response Format Guidelines +4. Pagination Best Practices +5. Character Limits and Truncation +6. Tool Development Best Practices +7. Transport Best Practices +8. Testing Requirements +9. OAuth and Security Best Practices +10. Resource Management Best Practices +11. Prompt Management Best Practices +12. Error Handling Standards +13. Documentation Requirements +14. Compliance and Monitoring + +--- + +## 1. Server Naming Conventions + +Follow these standardized naming patterns for MCP servers: + +**Python**: Use format `{service}_mcp` (lowercase with underscores) +- Examples: `slack_mcp`, `github_mcp`, `jira_mcp`, `stripe_mcp` + +**Node/TypeScript**: Use format `{service}-mcp-server` (lowercase with hyphens) +- Examples: `slack-mcp-server`, `github-mcp-server`, `jira-mcp-server` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +--- + +## 2. Tool Naming and Design + +### Tool Naming Best Practices + +1. **Use snake_case**: `search_users`, `create_project`, `get_channel_info` +2. **Include service prefix**: Anticipate that your MCP server may be used alongside other MCP servers + - Use `slack_send_message` instead of just `send_message` + - Use `github_create_issue` instead of just `create_issue` + - Use `asana_list_tasks` instead of just `list_tasks` +3. **Be action-oriented**: Start with verbs (get, list, search, create, etc.) +4. **Be specific**: Avoid generic names that could conflict with other servers +5. **Maintain consistency**: Use consistent naming patterns within your server + +### Tool Design Guidelines + +- Tool descriptions must narrowly and unambiguously describe functionality +- Descriptions must precisely match actual functionality +- Should not create confusion with other MCP servers +- Should provide tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- Keep tool operations focused and atomic + +--- + +## 3. Response Format Guidelines + +All tools that return data should support multiple formats for flexibility: + +### JSON Format (`response_format="json"`) +- Machine-readable structured data +- Include all available fields and metadata +- Consistent field names and types +- Suitable for programmatic processing +- Use for when LLMs need to process data further + +### Markdown Format (`response_format="markdown"`, typically default) +- Human-readable formatted text +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format (e.g., "2024-01-15 10:30:00 UTC" instead of epoch) +- Show display names with IDs in parentheses (e.g., "@john.doe (U123456)") +- Omit verbose metadata (e.g., show only one profile image URL, not all sizes) +- Group related information logically +- Use for when presenting information to users + +--- + +## 4. Pagination Best Practices + +For tools that list resources: + +- **Always respect the `limit` parameter**: Never load all results when a limit is specified +- **Implement pagination**: Use `offset` or cursor-based pagination +- **Return pagination metadata**: Include `has_more`, `next_offset`/`next_cursor`, `total_count` +- **Never load all results into memory**: Especially important for large datasets +- **Default to reasonable limits**: 20-50 items is typical +- **Include clear pagination info in responses**: Make it easy for LLMs to request more data + +Example pagination response structure: +```json +{ + "total": 150, + "count": 20, + "offset": 0, + "items": [...], + "has_more": true, + "next_offset": 20 +} +``` + +--- + +## 5. Character Limits and Truncation + +To prevent overwhelming responses with too much data: + +- **Define CHARACTER_LIMIT constant**: Typically 25,000 characters at module level +- **Check response size before returning**: Measure the final response length +- **Truncate gracefully with clear indicators**: Let the LLM know data was truncated +- **Provide guidance on filtering**: Suggest how to use parameters to reduce results +- **Include truncation metadata**: Show what was truncated and how to get more + +Example truncation handling: +```python +CHARACTER_LIMIT = 25000 + +if len(result) > CHARACTER_LIMIT: + truncated_data = data[:max(1, len(data) // 2)] + response["truncated"] = True + response["truncation_message"] = ( + f"Response truncated from {len(data)} to {len(truncated_data)} items. " + f"Use 'offset' parameter or add filters to see more results." + ) +``` + +--- + +## 6. Transport Options + +MCP servers support multiple transport mechanisms for different deployment scenarios: + +### Stdio Transport + +**Best for**: Command-line tools, local integrations, subprocess execution + +**Characteristics**: +- Standard input/output stream communication +- Simple setup, no network configuration needed +- Runs as a subprocess of the client +- Ideal for desktop applications and CLI tools + +**Use when**: +- Building tools for local development environments +- Integrating with desktop applications (e.g., Claude Desktop) +- Creating command-line utilities +- Single-user, single-session scenarios + +### HTTP Transport + +**Best for**: Web services, remote access, multi-client scenarios + +**Characteristics**: +- Request-response pattern over HTTP +- Supports multiple simultaneous clients +- Can be deployed as a web service +- Requires network configuration and security considerations + +**Use when**: +- Serving multiple clients simultaneously +- Deploying as a cloud service +- Integration with web applications +- Need for load balancing or scaling + +### Server-Sent Events (SSE) Transport + +**Best for**: Real-time updates, push notifications, streaming data + +**Characteristics**: +- One-way server-to-client streaming over HTTP +- Enables real-time updates without polling +- Long-lived connections for continuous data flow +- Built on standard HTTP infrastructure + +**Use when**: +- Clients need real-time data updates +- Implementing push notifications +- Streaming logs or monitoring data +- Progressive result delivery for long operations + +### Transport Selection Criteria + +| Criterion | Stdio | HTTP | SSE | +|-----------|-------|------|-----| +| **Deployment** | Local | Remote | Remote | +| **Clients** | Single | Multiple | Multiple | +| **Communication** | Bidirectional | Request-Response | Server-Push | +| **Complexity** | Low | Medium | Medium-High | +| **Real-time** | No | No | Yes | + +--- + +## 7. Tool Development Best Practices + +### General Guidelines +1. Tool names should be descriptive and action-oriented +2. Use parameter validation with detailed JSON schemas +3. Include examples in tool descriptions +4. Implement proper error handling and validation +5. Use progress reporting for long operations +6. Keep tool operations focused and atomic +7. Document expected return value structures +8. Implement proper timeouts +9. Consider rate limiting for resource-intensive operations +10. Log tool usage for debugging and monitoring + +### Security Considerations for Tools + +#### Input Validation +- Validate all parameters against schema +- Sanitize file paths and system commands +- Validate URLs and external identifiers +- Check parameter sizes and ranges +- Prevent command injection + +#### Access Control +- Implement authentication where needed +- Use appropriate authorization checks +- Audit tool usage +- Rate limit requests +- Monitor for abuse + +#### Error Handling +- Don't expose internal errors to clients +- Log security-relevant errors +- Handle timeouts appropriately +- Clean up resources after errors +- Validate return values + +### Tool Annotations +- Provide readOnlyHint and destructiveHint annotations +- Remember annotations are hints, not security guarantees +- Clients should not make security-critical decisions based solely on annotations + +--- + +## 8. Transport Best Practices + +### General Transport Guidelines +1. Handle connection lifecycle properly +2. Implement proper error handling +3. Use appropriate timeout values +4. Implement connection state management +5. Clean up resources on disconnection + +### Security Best Practices for Transport +- Follow security considerations for DNS rebinding attacks +- Implement proper authentication mechanisms +- Validate message formats +- Handle malformed messages gracefully + +### Stdio Transport Specific +- Local MCP servers should NOT log to stdout (interferes with protocol) +- Use stderr for logging messages +- Handle standard I/O streams properly + +--- + +## 9. Testing Requirements + +A comprehensive testing strategy should cover: + +### Functional Testing +- Verify correct execution with valid/invalid inputs + +### Integration Testing +- Test interaction with external systems + +### Security Testing +- Validate auth, input sanitization, rate limiting + +### Performance Testing +- Check behavior under load, timeouts + +### Error Handling +- Ensure proper error reporting and cleanup + +--- + +## 10. OAuth and Security Best Practices + +### Authentication and Authorization + +MCP servers that connect to external services should implement proper authentication: + +**OAuth 2.1 Implementation:** +- Use secure OAuth 2.1 with certificates from recognized authorities +- Validate access tokens before processing requests +- Only accept tokens specifically intended for your server +- Reject tokens without proper audience claims +- Never pass through tokens received from MCP clients + +**API Key Management:** +- Store API keys in environment variables, never in code +- Validate keys on server startup +- Provide clear error messages when authentication fails +- Use secure transmission for sensitive credentials + +### Input Validation and Security + +**Always validate inputs:** +- Sanitize file paths to prevent directory traversal +- Validate URLs and external identifiers +- Check parameter sizes and ranges +- Prevent command injection in system calls +- Use schema validation (Pydantic/Zod) for all inputs + +**Error handling security:** +- Don't expose internal errors to clients +- Log security-relevant errors server-side +- Provide helpful but not revealing error messages +- Clean up resources after errors + +### Privacy and Data Protection + +**Data collection principles:** +- Only collect data strictly necessary for functionality +- Don't collect extraneous conversation data +- Don't collect PII unless explicitly required for the tool's purpose +- Provide clear information about what data is accessed + +**Data transmission:** +- Don't send data to servers outside your organization without disclosure +- Use secure transmission (HTTPS) for all network communication +- Validate certificates for external services + +--- + +## 11. Resource Management Best Practices + +1. Only suggest necessary resources +2. Use clear, descriptive names for roots +3. Handle resource boundaries properly +4. Respect client control over resources +5. Use model-controlled primitives (tools) for automatic data exposure + +--- + +## 12. Prompt Management Best Practices + +- Clients should show users proposed prompts +- Users should be able to modify or reject prompts +- Clients should show users completions +- Users should be able to modify or reject completions +- Consider costs when using sampling + +--- + +## 13. Error Handling Standards + +- Use standard JSON-RPC error codes +- Report tool errors within result objects (not protocol-level) +- Provide helpful, specific error messages +- Don't expose internal implementation details +- Clean up resources properly on errors + +--- + +## 14. Documentation Requirements + +- Provide clear documentation of all tools and capabilities +- Include working examples (at least 3 per major feature) +- Document security considerations +- Specify required permissions and access levels +- Document rate limits and performance characteristics + +--- + +## 15. Compliance and Monitoring + +- Implement logging for debugging and monitoring +- Track tool usage patterns +- Monitor for potential abuse +- Maintain audit trails for security-relevant operations +- Be prepared for ongoing compliance reviews + +--- + +## Summary + +These best practices represent the comprehensive guidelines for building secure, efficient, and compliant MCP servers that work well within the ecosystem. Developers should follow these guidelines to ensure their MCP servers meet the standards for inclusion in the MCP directory and provide a safe, reliable experience for users. + + +---------- + + +# Tools + +> Enable LLMs to perform actions through your server + +Tools are a powerful primitive in the Model Context Protocol (MCP) that enable servers to expose executable functionality to clients. Through tools, LLMs can interact with external systems, perform computations, and take actions in the real world. + + + Tools are designed to be **model-controlled**, meaning that tools are exposed from servers to clients with the intention of the AI model being able to automatically invoke them (with a human in the loop to grant approval). + + +## Overview + +Tools in MCP allow servers to expose executable functions that can be invoked by clients and used by LLMs to perform actions. Key aspects of tools include: + +* **Discovery**: Clients can obtain a list of available tools by sending a `tools/list` request +* **Invocation**: Tools are called using the `tools/call` request, where servers perform the requested operation and return results +* **Flexibility**: Tools can range from simple calculations to complex API interactions + +Like [resources](/docs/concepts/resources), tools are identified by unique names and can include descriptions to guide their usage. However, unlike resources, tools represent dynamic operations that can modify state or interact with external systems. + +## Tool definition structure + +Each tool is defined with the following structure: + +```typescript +{ + name: string; // Unique identifier for the tool + description?: string; // Human-readable description + inputSchema: { // JSON Schema for the tool's parameters + type: "object", + properties: { ... } // Tool-specific parameters + }, + annotations?: { // Optional hints about tool behavior + title?: string; // Human-readable title for the tool + readOnlyHint?: boolean; // If true, the tool does not modify its environment + destructiveHint?: boolean; // If true, the tool may perform destructive updates + idempotentHint?: boolean; // If true, repeated calls with same args have no additional effect + openWorldHint?: boolean; // If true, tool interacts with external entities + } +} +``` + +## Implementing tools + +Here's an example of implementing a basic tool in an MCP server: + + + + ```typescript + const server = new Server({ + name: "example-server", + version: "1.0.0" + }, { + capabilities: { + tools: {} + } + }); + + // Define available tools + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [{ + name: "calculate_sum", + description: "Add two numbers together", + inputSchema: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" } + }, + required: ["a", "b"] + } + }] + }; + }); + + // Handle tool execution + server.setRequestHandler(CallToolRequestSchema, async (request) => { + if (request.params.name === "calculate_sum") { + const { a, b } = request.params.arguments; + return { + content: [ + { + type: "text", + text: String(a + b) + } + ] + }; + } + throw new Error("Tool not found"); + }); + ``` + + + + ```python + app = Server("example-server") + + @app.list_tools() + async def list_tools() -> list[types.Tool]: + return [ + types.Tool( + name="calculate_sum", + description="Add two numbers together", + inputSchema={ + "type": "object", + "properties": { + "a": {"type": "number"}, + "b": {"type": "number"} + }, + "required": ["a", "b"] + } + ) + ] + + @app.call_tool() + async def call_tool( + name: str, + arguments: dict + ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: + if name == "calculate_sum": + a = arguments["a"] + b = arguments["b"] + result = a + b + return [types.TextContent(type="text", text=str(result))] + raise ValueError(f"Tool not found: {name}") + ``` + + + +## Example tool patterns + +Here are some examples of types of tools that a server could provide: + +### System operations + +Tools that interact with the local system: + +```typescript +{ + name: "execute_command", + description: "Run a shell command", + inputSchema: { + type: "object", + properties: { + command: { type: "string" }, + args: { type: "array", items: { type: "string" } } + } + } +} +``` + +### API integrations + +Tools that wrap external APIs: + +```typescript +{ + name: "github_create_issue", + description: "Create a GitHub issue", + inputSchema: { + type: "object", + properties: { + title: { type: "string" }, + body: { type: "string" }, + labels: { type: "array", items: { type: "string" } } + } + } +} +``` + +### Data processing + +Tools that transform or analyze data: + +```typescript +{ + name: "analyze_csv", + description: "Analyze a CSV file", + inputSchema: { + type: "object", + properties: { + filepath: { type: "string" }, + operations: { + type: "array", + items: { + enum: ["sum", "average", "count"] + } + } + } + } +} +``` + +## Best practices + +When implementing tools: + +1. Provide clear, descriptive names and descriptions +2. Use detailed JSON Schema definitions for parameters +3. Include examples in tool descriptions to demonstrate how the model should use them +4. Implement proper error handling and validation +5. Use progress reporting for long operations +6. Keep tool operations focused and atomic +7. Document expected return value structures +8. Implement proper timeouts +9. Consider rate limiting for resource-intensive operations +10. Log tool usage for debugging and monitoring + +### Tool name conflicts + +MCP client applications and MCP server proxies may encounter tool name conflicts when building their own tool lists. For example, two connected MCP servers `web1` and `web2` may both expose a tool named `search_web`. + +Applications may disambiguiate tools with one of the following strategies (among others; not an exhaustive list): + +* Concatenating a unique, user-defined server name with the tool name, e.g. `web1___search_web` and `web2___search_web`. This strategy may be preferable when unique server names are already provided by the user in a configuration file. +* Generating a random prefix for the tool name, e.g. `jrwxs___search_web` and `6cq52___search_web`. This strategy may be preferable in server proxies where user-defined unique names are not available. +* Using the server URI as a prefix for the tool name, e.g. `web1.example.com:search_web` and `web2.example.com:search_web`. This strategy may be suitable when working with remote MCP servers. + +Note that the server-provided name from the initialization flow is not guaranteed to be unique and is not generally suitable for disambiguation purposes. + +## Security considerations + +When exposing tools: + +### Input validation + +* Validate all parameters against the schema +* Sanitize file paths and system commands +* Validate URLs and external identifiers +* Check parameter sizes and ranges +* Prevent command injection + +### Access control + +* Implement authentication where needed +* Use appropriate authorization checks +* Audit tool usage +* Rate limit requests +* Monitor for abuse + +### Error handling + +* Don't expose internal errors to clients +* Log security-relevant errors +* Handle timeouts appropriately +* Clean up resources after errors +* Validate return values + +## Tool discovery and updates + +MCP supports dynamic tool discovery: + +1. Clients can list available tools at any time +2. Servers can notify clients when tools change using `notifications/tools/list_changed` +3. Tools can be added or removed during runtime +4. Tool definitions can be updated (though this should be done carefully) + +## Error handling + +Tool errors should be reported within the result object, not as MCP protocol-level errors. This allows the LLM to see and potentially handle the error. When a tool encounters an error: + +1. Set `isError` to `true` in the result +2. Include error details in the `content` array + +Here's an example of proper error handling for tools: + + + + ```typescript + try { + // Tool operation + const result = performOperation(); + return { + content: [ + { + type: "text", + text: `Operation successful: ${result}` + } + ] + }; + } catch (error) { + return { + isError: true, + content: [ + { + type: "text", + text: `Error: ${error.message}` + } + ] + }; + } + ``` + + + + ```python + try: + # Tool operation + result = perform_operation() + return types.CallToolResult( + content=[ + types.TextContent( + type="text", + text=f"Operation successful: {result}" + ) + ] + ) + except Exception as error: + return types.CallToolResult( + isError=True, + content=[ + types.TextContent( + type="text", + text=f"Error: {str(error)}" + ) + ] + ) + ``` + + + +This approach allows the LLM to see that an error occurred and potentially take corrective action or request human intervention. + +## Tool annotations + +Tool annotations provide additional metadata about a tool's behavior, helping clients understand how to present and manage tools. These annotations are hints that describe the nature and impact of a tool, but should not be relied upon for security decisions. + +### Purpose of tool annotations + +Tool annotations serve several key purposes: + +1. Provide UX-specific information without affecting model context +2. Help clients categorize and present tools appropriately +3. Convey information about a tool's potential side effects +4. Assist in developing intuitive interfaces for tool approval + +### Available tool annotations + +The MCP specification defines the following annotations for tools: + +| Annotation | Type | Default | Description | +| ----------------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `title` | string | - | A human-readable title for the tool, useful for UI display | +| `readOnlyHint` | boolean | false | If true, indicates the tool does not modify its environment | +| `destructiveHint` | boolean | true | If true, the tool may perform destructive updates (only meaningful when `readOnlyHint` is false) | +| `idempotentHint` | boolean | false | If true, calling the tool repeatedly with the same arguments has no additional effect (only meaningful when `readOnlyHint` is false) | +| `openWorldHint` | boolean | true | If true, the tool may interact with an "open world" of external entities | + +### Example usage + +Here's how to define tools with annotations for different scenarios: + +```typescript +// A read-only search tool +{ + name: "web_search", + description: "Search the web for information", + inputSchema: { + type: "object", + properties: { + query: { type: "string" } + }, + required: ["query"] + }, + annotations: { + title: "Web Search", + readOnlyHint: true, + openWorldHint: true + } +} + +// A destructive file deletion tool +{ + name: "delete_file", + description: "Delete a file from the filesystem", + inputSchema: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + }, + annotations: { + title: "Delete File", + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false + } +} + +// A non-destructive database record creation tool +{ + name: "create_record", + description: "Create a new record in the database", + inputSchema: { + type: "object", + properties: { + table: { type: "string" }, + data: { type: "object" } + }, + required: ["table", "data"] + }, + annotations: { + title: "Create Database Record", + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false + } +} +``` + +### Integrating annotations in server implementation + + + + ```typescript + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { + tools: [{ + name: "calculate_sum", + description: "Add two numbers together", + inputSchema: { + type: "object", + properties: { + a: { type: "number" }, + b: { type: "number" } + }, + required: ["a", "b"] + }, + annotations: { + title: "Calculate Sum", + readOnlyHint: true, + openWorldHint: false + } + }] + }; + }); + ``` + + + + ```python + from mcp.server.fastmcp import FastMCP + + mcp = FastMCP("example-server") + + @mcp.tool( + annotations={ + "title": "Calculate Sum", + "readOnlyHint": True, + "openWorldHint": False + } + ) + async def calculate_sum(a: float, b: float) -> str: + """Add two numbers together. + + Args: + a: First number to add + b: Second number to add + """ + result = a + b + return str(result) + ``` + + + +### Best practices for tool annotations + +1. **Be accurate about side effects**: Clearly indicate whether a tool modifies its environment and whether those modifications are destructive. + +2. **Use descriptive titles**: Provide human-friendly titles that clearly describe the tool's purpose. + +3. **Indicate idempotency properly**: Mark tools as idempotent only if repeated calls with the same arguments truly have no additional effect. + +4. **Set appropriate open/closed world hints**: Indicate whether a tool interacts with a closed system (like a database) or an open system (like the web). + +5. **Remember annotations are hints**: All properties in ToolAnnotations are hints and not guaranteed to provide a faithful description of tool behavior. Clients should never make security-critical decisions based solely on annotations. + +## Testing tools + +A comprehensive testing strategy for MCP tools should cover: + +* **Functional testing**: Verify tools execute correctly with valid inputs and handle invalid inputs appropriately +* **Integration testing**: Test tool interaction with external systems using both real and mocked dependencies +* **Security testing**: Validate authentication, authorization, input sanitization, and rate limiting +* **Performance testing**: Check behavior under load, timeout handling, and resource cleanup +* **Error handling**: Ensure tools properly report errors through the MCP protocol and clean up resources diff --git a/skills/mcp-builder/reference/node_mcp_server.md b/skills/mcp-builder/reference/node_mcp_server.md new file mode 100644 index 0000000..e66a35b --- /dev/null +++ b/skills/mcp-builder/reference/node_mcp_server.md @@ -0,0 +1,916 @@ +# Node/TypeScript MCP Server Implementation Guide + +## Overview + +This document provides Node/TypeScript-specific best practices and examples for implementing MCP servers using the MCP TypeScript SDK. It covers project structure, server setup, tool registration patterns, input validation with Zod, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import axios, { AxiosError } from "axios"; +``` + +### Server Initialization +```typescript +const server = new McpServer({ + name: "service-mcp-server", + version: "1.0.0" +}); +``` + +### Tool Registration Pattern +```typescript +server.registerTool("tool_name", {...config}, async (params) => { + // Implementation +}); +``` + +--- + +## MCP TypeScript SDK + +The official MCP TypeScript SDK provides: +- `McpServer` class for server initialization +- `registerTool` method for tool registration +- Zod schema integration for runtime input validation +- Type-safe tool handler implementations + +See the MCP SDK documentation in the references for complete details. + +## Server Naming Convention + +Node/TypeScript MCP servers must follow this naming pattern: +- **Format**: `{service}-mcp-server` (lowercase with hyphens) +- **Examples**: `github-mcp-server`, `jira-mcp-server`, `stripe-mcp-server` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Project Structure + +Create the following structure for Node/TypeScript MCP servers: + +``` +{service}-mcp-server/ +├── package.json +├── tsconfig.json +├── README.md +├── src/ +│ ├── index.ts # Main entry point with McpServer initialization +│ ├── types.ts # TypeScript type definitions and interfaces +│ ├── tools/ # Tool implementations (one file per domain) +│ ├── services/ # API clients and shared utilities +│ ├── schemas/ # Zod validation schemas +│ └── constants.ts # Shared constants (API_URL, CHARACTER_LIMIT, etc.) +└── dist/ # Built JavaScript files (entry point: dist/index.js) +``` + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure + +Tools are registered using the `registerTool` method with the following requirements: +- Use Zod schemas for runtime input validation and type safety +- The `description` field must be explicitly provided - JSDoc comments are NOT automatically extracted +- Explicitly provide `title`, `description`, `inputSchema`, and `annotations` +- The `inputSchema` must be a Zod schema object (not a JSON schema) +- Type all parameters and return values explicitly + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +const server = new McpServer({ + name: "example-mcp", + version: "1.0.0" +}); + +// Zod schema for input validation +const UserSearchInputSchema = z.object({ + query: z.string() + .min(2, "Query must be at least 2 characters") + .max(200, "Query must not exceed 200 characters") + .describe("Search string to match against names/emails"), + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}).strict(); + +// Type definition from Zod schema +type UserSearchInput = z.infer; + +server.registerTool( + "example_search_users", + { + title: "Search Example Users", + description: `Search for users in the Example system by name, email, or team. + +This tool searches across all user profiles in the Example platform, supporting partial matches and various search filters. It does NOT create or modify users, only searches existing ones. + +Args: + - query (string): Search string to match against names/emails + - limit (number): Maximum results to return, between 1-100 (default: 20) + - offset (number): Number of results to skip for pagination (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + For JSON format: Structured data with schema: + { + "total": number, // Total number of matches found + "count": number, // Number of results in this response + "offset": number, // Current pagination offset + "users": [ + { + "id": string, // User ID (e.g., "U123456789") + "name": string, // Full name (e.g., "John Doe") + "email": string, // Email address + "team": string, // Team name (optional) + "active": boolean // Whether user is active + } + ], + "has_more": boolean, // Whether more results are available + "next_offset": number // Offset for next page (if has_more is true) + } + +Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + +Error Handling: + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "No users found matching ''" if search returns empty`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } + }, + async (params: UserSearchInput) => { + try { + // Input validation is handled by Zod schema + // Make API request using validated parameters + const data = await makeApiRequest( + "users/search", + "GET", + undefined, + { + q: params.query, + limit: params.limit, + offset: params.offset + } + ); + + const users = data.users || []; + const total = data.total || 0; + + if (!users.length) { + return { + content: [{ + type: "text", + text: `No users found matching '${params.query}'` + }] + }; + } + + // Format response based on requested format + let result: string; + + if (params.response_format === ResponseFormat.MARKDOWN) { + // Human-readable markdown format + const lines: string[] = [`# User Search Results: '${params.query}'`, ""]; + lines.push(`Found ${total} users (showing ${users.length})`); + lines.push(""); + + for (const user of users) { + lines.push(`## ${user.name} (${user.id})`); + lines.push(`- **Email**: ${user.email}`); + if (user.team) { + lines.push(`- **Team**: ${user.team}`); + } + lines.push(""); + } + + result = lines.join("\n"); + + } else { + // Machine-readable JSON format + const response: any = { + total, + count: users.length, + offset: params.offset, + users: users.map((user: any) => ({ + id: user.id, + name: user.name, + email: user.email, + ...(user.team ? { team: user.team } : {}), + active: user.active ?? true + })) + }; + + // Add pagination info if there are more results + if (total > params.offset + users.length) { + response.has_more = true; + response.next_offset = params.offset + users.length; + } + + result = JSON.stringify(response, null, 2); + } + + return { + content: [{ + type: "text", + text: result + }] + }; + } catch (error) { + return { + content: [{ + type: "text", + text: handleApiError(error) + }] + }; + } + } +); +``` + +## Zod Schemas for Input Validation + +Zod provides runtime type validation: + +```typescript +import { z } from "zod"; + +// Basic schema with validation +const CreateUserSchema = z.object({ + name: z.string() + .min(1, "Name is required") + .max(100, "Name must not exceed 100 characters"), + email: z.string() + .email("Invalid email format"), + age: z.number() + .int("Age must be a whole number") + .min(0, "Age cannot be negative") + .max(150, "Age cannot be greater than 150") +}).strict(); // Use .strict() to forbid extra fields + +// Enums +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +const SearchSchema = z.object({ + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format") +}); + +// Optional fields with defaults +const PaginationSchema = z.object({ + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip") +}); +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```typescript +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +const inputSchema = z.object({ + query: z.string(), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}); +``` + +**Markdown format**: +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format +- Show display names with IDs in parentheses +- Omit verbose metadata +- Group related information logically + +**JSON format**: +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```typescript +const ListSchema = z.object({ + limit: z.number().int().min(1).max(100).default(20), + offset: z.number().int().min(0).default(0) +}); + +async function listItems(params: z.infer) { + const data = await apiRequest(params.limit, params.offset); + + const response = { + total: data.total, + count: data.items.length, + offset: params.offset, + items: data.items, + has_more: data.total > params.offset + data.items.length, + next_offset: data.total > params.offset + data.items.length + ? params.offset + data.items.length + : undefined + }; + + return JSON.stringify(response, null, 2); +} +``` + +## Character Limits and Truncation + +Add a CHARACTER_LIMIT constant to prevent overwhelming responses: + +```typescript +// At module level in constants.ts +export const CHARACTER_LIMIT = 25000; // Maximum response size in characters + +async function searchTool(params: SearchInput) { + let result = generateResponse(data); + + // Check character limit and truncate if needed + if (result.length > CHARACTER_LIMIT) { + const truncatedData = data.slice(0, Math.max(1, data.length / 2)); + response.data = truncatedData; + response.truncated = true; + response.truncation_message = + `Response truncated from ${data.length} to ${truncatedData.length} items. ` + + `Use 'offset' parameter or add filters to see more results.`; + result = JSON.stringify(response, null, 2); + } + + return result; +} +``` + +## Error Handling + +Provide clear, actionable error messages: + +```typescript +import axios, { AxiosError } from "axios"; + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return "Error: Resource not found. Please check the ID is correct."; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return "Error: Rate limit exceeded. Please wait before making more requests."; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out. Please try again."; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```typescript +// Shared API request function +async function makeApiRequest( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + return response.data; + } catch (error) { + throw error; + } +} +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```typescript +// Good: Async network request +async function fetchData(resourceId: string): Promise { + const response = await axios.get(`${API_URL}/resource/${resourceId}`); + return response.data; +} + +// Bad: Promise chains +function fetchData(resourceId: string): Promise { + return axios.get(`${API_URL}/resource/${resourceId}`) + .then(response => response.data); // Harder to read and maintain +} +``` + +## TypeScript Best Practices + +1. **Use Strict TypeScript**: Enable strict mode in tsconfig.json +2. **Define Interfaces**: Create clear interface definitions for all data structures +3. **Avoid `any`**: Use proper types or `unknown` instead of `any` +4. **Zod for Runtime Validation**: Use Zod schemas to validate external data +5. **Type Guards**: Create type guard functions for complex type checking +6. **Error Handling**: Always use try-catch with proper error type checking +7. **Null Safety**: Use optional chaining (`?.`) and nullish coalescing (`??`) + +```typescript +// Good: Type-safe with Zod and interfaces +interface UserResponse { + id: string; + name: string; + email: string; + team?: string; + active: boolean; +} + +const UserSchema = z.object({ + id: z.string(), + name: z.string(), + email: z.string().email(), + team: z.string().optional(), + active: z.boolean() +}); + +type User = z.infer; + +async function getUser(id: string): Promise { + const data = await apiCall(`/users/${id}`); + return UserSchema.parse(data); // Runtime validation +} + +// Bad: Using any +async function getUser(id: string): Promise { + return await apiCall(`/users/${id}`); // No type safety +} +``` + +## Package Configuration + +### package.json + +```json +{ + "name": "{service}-mcp-server", + "version": "1.0.0", + "description": "MCP server for {Service} API integration", + "type": "module", + "main": "dist/index.js", + "scripts": { + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts", + "build": "tsc", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.6.1", + "axios": "^1.7.9", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.19.2", + "typescript": "^5.7.2" + } +} +``` + +### tsconfig.json + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "Node16", + "moduleResolution": "Node16", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +## Complete Example + +```typescript +#!/usr/bin/env node +/** + * MCP Server for Example Service. + * + * This server provides tools to interact with Example API, including user search, + * project management, and data export capabilities. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import axios, { AxiosError } from "axios"; + +// Constants +const API_BASE_URL = "https://api.example.com/v1"; +const CHARACTER_LIMIT = 25000; + +// Enums +enum ResponseFormat { + MARKDOWN = "markdown", + JSON = "json" +} + +// Zod schemas +const UserSearchInputSchema = z.object({ + query: z.string() + .min(2, "Query must be at least 2 characters") + .max(200, "Query must not exceed 200 characters") + .describe("Search string to match against names/emails"), + limit: z.number() + .int() + .min(1) + .max(100) + .default(20) + .describe("Maximum results to return"), + offset: z.number() + .int() + .min(0) + .default(0) + .describe("Number of results to skip for pagination"), + response_format: z.nativeEnum(ResponseFormat) + .default(ResponseFormat.MARKDOWN) + .describe("Output format: 'markdown' for human-readable or 'json' for machine-readable") +}).strict(); + +type UserSearchInput = z.infer; + +// Shared utility functions +async function makeApiRequest( + endpoint: string, + method: "GET" | "POST" | "PUT" | "DELETE" = "GET", + data?: any, + params?: any +): Promise { + try { + const response = await axios({ + method, + url: `${API_BASE_URL}/${endpoint}`, + data, + params, + timeout: 30000, + headers: { + "Content-Type": "application/json", + "Accept": "application/json" + } + }); + return response.data; + } catch (error) { + throw error; + } +} + +function handleApiError(error: unknown): string { + if (error instanceof AxiosError) { + if (error.response) { + switch (error.response.status) { + case 404: + return "Error: Resource not found. Please check the ID is correct."; + case 403: + return "Error: Permission denied. You don't have access to this resource."; + case 429: + return "Error: Rate limit exceeded. Please wait before making more requests."; + default: + return `Error: API request failed with status ${error.response.status}`; + } + } else if (error.code === "ECONNABORTED") { + return "Error: Request timed out. Please try again."; + } + } + return `Error: Unexpected error occurred: ${error instanceof Error ? error.message : String(error)}`; +} + +// Create MCP server instance +const server = new McpServer({ + name: "example-mcp", + version: "1.0.0" +}); + +// Register tools +server.registerTool( + "example_search_users", + { + title: "Search Example Users", + description: `[Full description as shown above]`, + inputSchema: UserSearchInputSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + } + }, + async (params: UserSearchInput) => { + // Implementation as shown above + } +); + +// Main function +async function main() { + // Verify environment variables if needed + if (!process.env.EXAMPLE_API_KEY) { + console.error("ERROR: EXAMPLE_API_KEY environment variable is required"); + process.exit(1); + } + + // Create transport + const transport = new StdioServerTransport(); + + // Connect server to transport + await server.connect(transport); + + console.error("Example MCP server running via stdio"); +} + +// Run the server +main().catch((error) => { + console.error("Server error:", error); + process.exit(1); +}); +``` + +--- + +## Advanced MCP Features + +### Resource Registration + +Expose data as resources for efficient, URI-based access: + +```typescript +import { ResourceTemplate } from "@modelcontextprotocol/sdk/types.js"; + +// Register a resource with URI template +server.registerResource( + { + uri: "file://documents/{name}", + name: "Document Resource", + description: "Access documents by name", + mimeType: "text/plain" + }, + async (uri: string) => { + // Extract parameter from URI + const match = uri.match(/^file:\/\/documents\/(.+)$/); + if (!match) { + throw new Error("Invalid URI format"); + } + + const documentName = match[1]; + const content = await loadDocument(documentName); + + return { + contents: [{ + uri, + mimeType: "text/plain", + text: content + }] + }; + } +); + +// List available resources dynamically +server.registerResourceList(async () => { + const documents = await getAvailableDocuments(); + return { + resources: documents.map(doc => ({ + uri: `file://documents/${doc.name}`, + name: doc.name, + mimeType: "text/plain", + description: doc.description + })) + }; +}); +``` + +**When to use Resources vs Tools:** +- **Resources**: For data access with simple URI-based parameters +- **Tools**: For complex operations requiring validation and business logic +- **Resources**: When data is relatively static or template-based +- **Tools**: When operations have side effects or complex workflows + +### Multiple Transport Options + +The TypeScript SDK supports different transport mechanisms: + +```typescript +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js"; + +// Stdio transport (default - for CLI tools) +const stdioTransport = new StdioServerTransport(); +await server.connect(stdioTransport); + +// SSE transport (for real-time web updates) +const sseTransport = new SSEServerTransport("/message", response); +await server.connect(sseTransport); + +// HTTP transport (for web services) +// Configure based on your HTTP framework integration +``` + +**Transport selection guide:** +- **Stdio**: Command-line tools, subprocess integration, local development +- **HTTP**: Web services, remote access, multiple simultaneous clients +- **SSE**: Real-time updates, server-push notifications, web dashboards + +### Notification Support + +Notify clients when server state changes: + +```typescript +// Notify when tools list changes +server.notification({ + method: "notifications/tools/list_changed" +}); + +// Notify when resources change +server.notification({ + method: "notifications/resources/list_changed" +}); +``` + +Use notifications sparingly - only when server capabilities genuinely change. + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +## Building and Running + +Always build your TypeScript code before running: + +```bash +# Build the project +npm run build + +# Run the server +npm start + +# Development with auto-reload +npm run dev +``` + +Always ensure `npm run build` completes successfully before considering the implementation complete. + +## Quality Checklist + +Before finalizing your Node/TypeScript MCP server implementation, ensure: + +### Strategic Design +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools registered using `registerTool` with complete configuration +- [ ] All tools include `title`, `description`, `inputSchema`, and `annotations` +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Zod schemas for runtime input validation with `.strict()` enforcement +- [ ] All Zod schemas have proper constraints and descriptive error messages +- [ ] All tools have comprehensive descriptions with explicit input/output types +- [ ] Descriptions include return value examples and complete schema documentation +- [ ] Error messages are clear, actionable, and educational + +### TypeScript Quality +- [ ] TypeScript interfaces are defined for all data structures +- [ ] Strict TypeScript is enabled in tsconfig.json +- [ ] No use of `any` type - use `unknown` or proper types instead +- [ ] All async functions have explicit Promise return types +- [ ] Error handling uses proper type guards (e.g., `axios.isAxiosError`, `z.ZodError`) + +### Advanced Features (where applicable) +- [ ] Resources registered for appropriate data endpoints +- [ ] Appropriate transport configured (stdio, HTTP, SSE) +- [ ] Notifications implemented for dynamic server capabilities +- [ ] Type-safe with SDK interfaces + +### Project Configuration +- [ ] Package.json includes all necessary dependencies +- [ ] Build script produces working JavaScript in dist/ directory +- [ ] Main entry point is properly configured as dist/index.js +- [ ] Server name follows format: `{service}-mcp-server` +- [ ] tsconfig.json properly configured with strict mode + +### Code Quality +- [ ] Pagination is properly implemented where applicable +- [ ] Large responses check CHARACTER_LIMIT constant and truncate with clear messages +- [ ] Filtering options are provided for potentially large result sets +- [ ] All network operations handle timeouts and connection errors gracefully +- [ ] Common functionality is extracted into reusable functions +- [ ] Return types are consistent across similar operations + +### Testing and Build +- [ ] `npm run build` completes successfully without errors +- [ ] dist/index.js created and executable +- [ ] Server runs: `node dist/index.js --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected \ No newline at end of file diff --git a/skills/mcp-builder/reference/python_mcp_server.md b/skills/mcp-builder/reference/python_mcp_server.md new file mode 100644 index 0000000..38fa3a1 --- /dev/null +++ b/skills/mcp-builder/reference/python_mcp_server.md @@ -0,0 +1,752 @@ +# Python MCP Server Implementation Guide + +## Overview + +This document provides Python-specific best practices and examples for implementing MCP servers using the MCP Python SDK. It covers server setup, tool registration patterns, input validation with Pydantic, error handling, and complete working examples. + +--- + +## Quick Reference + +### Key Imports +```python +from mcp.server.fastmcp import FastMCP +from pydantic import BaseModel, Field, field_validator, ConfigDict +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +``` + +### Server Initialization +```python +mcp = FastMCP("service_mcp") +``` + +### Tool Registration Pattern +```python +@mcp.tool(name="tool_name", annotations={...}) +async def tool_function(params: InputModel) -> str: + # Implementation + pass +``` + +--- + +## MCP Python SDK and FastMCP + +The official MCP Python SDK provides FastMCP, a high-level framework for building MCP servers. It provides: +- Automatic description and inputSchema generation from function signatures and docstrings +- Pydantic model integration for input validation +- Decorator-based tool registration with `@mcp.tool` + +**For complete SDK documentation, use WebFetch to load:** +`https://raw.githubusercontent.com/modelcontextprotocol/python-sdk/main/README.md` + +## Server Naming Convention + +Python MCP servers must follow this naming pattern: +- **Format**: `{service}_mcp` (lowercase with underscores) +- **Examples**: `github_mcp`, `jira_mcp`, `stripe_mcp` + +The name should be: +- General (not tied to specific features) +- Descriptive of the service/API being integrated +- Easy to infer from the task description +- Without version numbers or dates + +## Tool Implementation + +### Tool Naming + +Use snake_case for tool names (e.g., "search_users", "create_project", "get_channel_info") with clear, action-oriented names. + +**Avoid Naming Conflicts**: Include the service context to prevent overlaps: +- Use "slack_send_message" instead of just "send_message" +- Use "github_create_issue" instead of just "create_issue" +- Use "asana_list_tasks" instead of just "list_tasks" + +### Tool Structure with FastMCP + +Tools are defined using the `@mcp.tool` decorator with Pydantic models for input validation: + +```python +from pydantic import BaseModel, Field, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Define Pydantic model for input validation +class ServiceToolInput(BaseModel): + '''Input model for service tool operation.''' + model_config = ConfigDict( + str_strip_whitespace=True, # Auto-strip whitespace from strings + validate_assignment=True, # Validate on assignment + extra='forbid' # Forbid extra fields + ) + + param1: str = Field(..., description="First parameter description (e.g., 'user123', 'project-abc')", min_length=1, max_length=100) + param2: Optional[int] = Field(default=None, description="Optional integer parameter with constraints", ge=0, le=1000) + tags: Optional[List[str]] = Field(default_factory=list, description="List of tags to apply", max_items=10) + +@mcp.tool( + name="service_tool_name", + annotations={ + "title": "Human-Readable Tool Title", + "readOnlyHint": True, # Tool does not modify environment + "destructiveHint": False, # Tool does not perform destructive operations + "idempotentHint": True, # Repeated calls have no additional effect + "openWorldHint": False # Tool does not interact with external entities + } +) +async def service_tool_name(params: ServiceToolInput) -> str: + '''Tool description automatically becomes the 'description' field. + + This tool performs a specific operation on the service. It validates all inputs + using the ServiceToolInput Pydantic model before processing. + + Args: + params (ServiceToolInput): Validated input parameters containing: + - param1 (str): First parameter description + - param2 (Optional[int]): Optional parameter with default + - tags (Optional[List[str]]): List of tags + + Returns: + str: JSON-formatted response containing operation results + ''' + # Implementation here + pass +``` + +## Pydantic v2 Key Features + +- Use `model_config` instead of nested `Config` class +- Use `field_validator` instead of deprecated `validator` +- Use `model_dump()` instead of deprecated `dict()` +- Validators require `@classmethod` decorator +- Type hints are required for validator methods + +```python +from pydantic import BaseModel, Field, field_validator, ConfigDict + +class CreateUserInput(BaseModel): + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + name: str = Field(..., description="User's full name", min_length=1, max_length=100) + email: str = Field(..., description="User's email address", pattern=r'^[\w\.-]+@[\w\.-]+\.\w+$') + age: int = Field(..., description="User's age", ge=0, le=150) + + @field_validator('email') + @classmethod + def validate_email(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Email cannot be empty") + return v.lower() +``` + +## Response Format Options + +Support multiple output formats for flexibility: + +```python +from enum import Enum + +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +class UserSearchInput(BaseModel): + query: str = Field(..., description="Search query") + response_format: ResponseFormat = Field( + default=ResponseFormat.MARKDOWN, + description="Output format: 'markdown' for human-readable or 'json' for machine-readable" + ) +``` + +**Markdown format**: +- Use headers, lists, and formatting for clarity +- Convert timestamps to human-readable format (e.g., "2024-01-15 10:30:00 UTC" instead of epoch) +- Show display names with IDs in parentheses (e.g., "@john.doe (U123456)") +- Omit verbose metadata (e.g., show only one profile image URL, not all sizes) +- Group related information logically + +**JSON format**: +- Return complete, structured data suitable for programmatic processing +- Include all available fields and metadata +- Use consistent field names and types + +## Pagination Implementation + +For tools that list resources: + +```python +class ListInput(BaseModel): + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + +async def list_items(params: ListInput) -> str: + # Make API request with pagination + data = await api_request(limit=params.limit, offset=params.offset) + + # Return pagination info + response = { + "total": data["total"], + "count": len(data["items"]), + "offset": params.offset, + "items": data["items"], + "has_more": data["total"] > params.offset + len(data["items"]), + "next_offset": params.offset + len(data["items"]) if data["total"] > params.offset + len(data["items"]) else None + } + return json.dumps(response, indent=2) +``` + +## Character Limits and Truncation + +Add a CHARACTER_LIMIT constant to prevent overwhelming responses: + +```python +# At module level +CHARACTER_LIMIT = 25000 # Maximum response size in characters + +async def search_tool(params: SearchInput) -> str: + result = generate_response(data) + + # Check character limit and truncate if needed + if len(result) > CHARACTER_LIMIT: + # Truncate data and add notice + truncated_data = data[:max(1, len(data) // 2)] + response["data"] = truncated_data + response["truncated"] = True + response["truncation_message"] = ( + f"Response truncated from {len(data)} to {len(truncated_data)} items. " + f"Use 'offset' parameter or add filters to see more results." + ) + result = json.dumps(response, indent=2) + + return result +``` + +## Error Handling + +Provide clear, actionable error messages: + +```python +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" +``` + +## Shared Utilities + +Extract common functionality into reusable functions: + +```python +# Shared API request function +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() +``` + +## Async/Await Best Practices + +Always use async/await for network requests and I/O operations: + +```python +# Good: Async network request +async def fetch_data(resource_id: str) -> dict: + async with httpx.AsyncClient() as client: + response = await client.get(f"{API_URL}/resource/{resource_id}") + response.raise_for_status() + return response.json() + +# Bad: Synchronous request +def fetch_data(resource_id: str) -> dict: + response = requests.get(f"{API_URL}/resource/{resource_id}") # Blocks + return response.json() +``` + +## Type Hints + +Use type hints throughout: + +```python +from typing import Optional, List, Dict, Any + +async def get_user(user_id: str) -> Dict[str, Any]: + data = await fetch_user(user_id) + return {"id": data["id"], "name": data["name"]} +``` + +## Tool Docstrings + +Every tool must have comprehensive docstrings with explicit type information: + +```python +async def search_users(params: UserSearchInput) -> str: + ''' + Search for users in the Example system by name, email, or team. + + This tool searches across all user profiles in the Example platform, + supporting partial matches and various search filters. It does NOT + create or modify users, only searches existing ones. + + Args: + params (UserSearchInput): Validated input parameters containing: + - query (str): Search string to match against names/emails (e.g., "john", "@example.com", "team:marketing") + - limit (Optional[int]): Maximum results to return, between 1-100 (default: 20) + - offset (Optional[int]): Number of results to skip for pagination (default: 0) + + Returns: + str: JSON-formatted string containing search results with the following schema: + + Success response: + { + "total": int, # Total number of matches found + "count": int, # Number of results in this response + "offset": int, # Current pagination offset + "users": [ + { + "id": str, # User ID (e.g., "U123456789") + "name": str, # Full name (e.g., "John Doe") + "email": str, # Email address (e.g., "john@example.com") + "team": str # Team name (e.g., "Marketing") - optional + } + ] + } + + Error response: + "Error: " or "No users found matching ''" + + Examples: + - Use when: "Find all marketing team members" -> params with query="team:marketing" + - Use when: "Search for John's account" -> params with query="john" + - Don't use when: You need to create a user (use example_create_user instead) + - Don't use when: You have a user ID and need full details (use example_get_user instead) + + Error Handling: + - Input validation errors are handled by Pydantic model + - Returns "Error: Rate limit exceeded" if too many requests (429 status) + - Returns "Error: Invalid API authentication" if API key is invalid (401 status) + - Returns formatted list of results or "No users found matching 'query'" + ''' +``` + +## Complete Example + +See below for a complete Python MCP server example: + +```python +#!/usr/bin/env python3 +''' +MCP Server for Example Service. + +This server provides tools to interact with Example API, including user search, +project management, and data export capabilities. +''' + +from typing import Optional, List, Dict, Any +from enum import Enum +import httpx +from pydantic import BaseModel, Field, field_validator, ConfigDict +from mcp.server.fastmcp import FastMCP + +# Initialize the MCP server +mcp = FastMCP("example_mcp") + +# Constants +API_BASE_URL = "https://api.example.com/v1" +CHARACTER_LIMIT = 25000 # Maximum response size in characters + +# Enums +class ResponseFormat(str, Enum): + '''Output format for tool responses.''' + MARKDOWN = "markdown" + JSON = "json" + +# Pydantic Models for Input Validation +class UserSearchInput(BaseModel): + '''Input model for user search operations.''' + model_config = ConfigDict( + str_strip_whitespace=True, + validate_assignment=True + ) + + query: str = Field(..., description="Search string to match against names/emails", min_length=2, max_length=200) + limit: Optional[int] = Field(default=20, description="Maximum results to return", ge=1, le=100) + offset: Optional[int] = Field(default=0, description="Number of results to skip for pagination", ge=0) + response_format: ResponseFormat = Field(default=ResponseFormat.MARKDOWN, description="Output format") + + @field_validator('query') + @classmethod + def validate_query(cls, v: str) -> str: + if not v.strip(): + raise ValueError("Query cannot be empty or whitespace only") + return v.strip() + +# Shared utility functions +async def _make_api_request(endpoint: str, method: str = "GET", **kwargs) -> dict: + '''Reusable function for all API calls.''' + async with httpx.AsyncClient() as client: + response = await client.request( + method, + f"{API_BASE_URL}/{endpoint}", + timeout=30.0, + **kwargs + ) + response.raise_for_status() + return response.json() + +def _handle_api_error(e: Exception) -> str: + '''Consistent error formatting across all tools.''' + if isinstance(e, httpx.HTTPStatusError): + if e.response.status_code == 404: + return "Error: Resource not found. Please check the ID is correct." + elif e.response.status_code == 403: + return "Error: Permission denied. You don't have access to this resource." + elif e.response.status_code == 429: + return "Error: Rate limit exceeded. Please wait before making more requests." + return f"Error: API request failed with status {e.response.status_code}" + elif isinstance(e, httpx.TimeoutException): + return "Error: Request timed out. Please try again." + return f"Error: Unexpected error occurred: {type(e).__name__}" + +# Tool definitions +@mcp.tool( + name="example_search_users", + annotations={ + "title": "Search Example Users", + "readOnlyHint": True, + "destructiveHint": False, + "idempotentHint": True, + "openWorldHint": True + } +) +async def example_search_users(params: UserSearchInput) -> str: + '''Search for users in the Example system by name, email, or team. + + [Full docstring as shown above] + ''' + try: + # Make API request using validated parameters + data = await _make_api_request( + "users/search", + params={ + "q": params.query, + "limit": params.limit, + "offset": params.offset + } + ) + + users = data.get("users", []) + total = data.get("total", 0) + + if not users: + return f"No users found matching '{params.query}'" + + # Format response based on requested format + if params.response_format == ResponseFormat.MARKDOWN: + lines = [f"# User Search Results: '{params.query}'", ""] + lines.append(f"Found {total} users (showing {len(users)})") + lines.append("") + + for user in users: + lines.append(f"## {user['name']} ({user['id']})") + lines.append(f"- **Email**: {user['email']}") + if user.get('team'): + lines.append(f"- **Team**: {user['team']}") + lines.append("") + + return "\n".join(lines) + + else: + # Machine-readable JSON format + import json + response = { + "total": total, + "count": len(users), + "offset": params.offset, + "users": users + } + return json.dumps(response, indent=2) + + except Exception as e: + return _handle_api_error(e) + +if __name__ == "__main__": + mcp.run() +``` + +--- + +## Advanced FastMCP Features + +### Context Parameter Injection + +FastMCP can automatically inject a `Context` parameter into tools for advanced capabilities like logging, progress reporting, resource reading, and user interaction: + +```python +from mcp.server.fastmcp import FastMCP, Context + +mcp = FastMCP("example_mcp") + +@mcp.tool() +async def advanced_search(query: str, ctx: Context) -> str: + '''Advanced tool with context access for logging and progress.''' + + # Report progress for long operations + await ctx.report_progress(0.25, "Starting search...") + + # Log information for debugging + await ctx.log_info("Processing query", {"query": query, "timestamp": datetime.now()}) + + # Perform search + results = await search_api(query) + await ctx.report_progress(0.75, "Formatting results...") + + # Access server configuration + server_name = ctx.fastmcp.name + + return format_results(results) + +@mcp.tool() +async def interactive_tool(resource_id: str, ctx: Context) -> str: + '''Tool that can request additional input from users.''' + + # Request sensitive information when needed + api_key = await ctx.elicit( + prompt="Please provide your API key:", + input_type="password" + ) + + # Use the provided key + return await api_call(resource_id, api_key) +``` + +**Context capabilities:** +- `ctx.report_progress(progress, message)` - Report progress for long operations +- `ctx.log_info(message, data)` / `ctx.log_error()` / `ctx.log_debug()` - Logging +- `ctx.elicit(prompt, input_type)` - Request input from users +- `ctx.fastmcp.name` - Access server configuration +- `ctx.read_resource(uri)` - Read MCP resources + +### Resource Registration + +Expose data as resources for efficient, template-based access: + +```python +@mcp.resource("file://documents/{name}") +async def get_document(name: str) -> str: + '''Expose documents as MCP resources. + + Resources are useful for static or semi-static data that doesn't + require complex parameters. They use URI templates for flexible access. + ''' + document_path = f"./docs/{name}" + with open(document_path, "r") as f: + return f.read() + +@mcp.resource("config://settings/{key}") +async def get_setting(key: str, ctx: Context) -> str: + '''Expose configuration as resources with context.''' + settings = await load_settings() + return json.dumps(settings.get(key, {})) +``` + +**When to use Resources vs Tools:** +- **Resources**: For data access with simple parameters (URI templates) +- **Tools**: For complex operations with validation and business logic + +### Structured Output Types + +FastMCP supports multiple return types beyond strings: + +```python +from typing import TypedDict +from dataclasses import dataclass +from pydantic import BaseModel + +# TypedDict for structured returns +class UserData(TypedDict): + id: str + name: str + email: str + +@mcp.tool() +async def get_user_typed(user_id: str) -> UserData: + '''Returns structured data - FastMCP handles serialization.''' + return {"id": user_id, "name": "John Doe", "email": "john@example.com"} + +# Pydantic models for complex validation +class DetailedUser(BaseModel): + id: str + name: str + email: str + created_at: datetime + metadata: Dict[str, Any] + +@mcp.tool() +async def get_user_detailed(user_id: str) -> DetailedUser: + '''Returns Pydantic model - automatically generates schema.''' + user = await fetch_user(user_id) + return DetailedUser(**user) +``` + +### Lifespan Management + +Initialize resources that persist across requests: + +```python +from contextlib import asynccontextmanager + +@asynccontextmanager +async def app_lifespan(): + '''Manage resources that live for the server's lifetime.''' + # Initialize connections, load config, etc. + db = await connect_to_database() + config = load_configuration() + + # Make available to all tools + yield {"db": db, "config": config} + + # Cleanup on shutdown + await db.close() + +mcp = FastMCP("example_mcp", lifespan=app_lifespan) + +@mcp.tool() +async def query_data(query: str, ctx: Context) -> str: + '''Access lifespan resources through context.''' + db = ctx.request_context.lifespan_state["db"] + results = await db.query(query) + return format_results(results) +``` + +### Multiple Transport Options + +FastMCP supports different transport mechanisms: + +```python +# Default: Stdio transport (for CLI tools) +if __name__ == "__main__": + mcp.run() + +# HTTP transport (for web services) +if __name__ == "__main__": + mcp.run(transport="streamable_http", port=8000) + +# SSE transport (for real-time updates) +if __name__ == "__main__": + mcp.run(transport="sse", port=8000) +``` + +**Transport selection:** +- **Stdio**: Command-line tools, subprocess integration +- **HTTP**: Web services, remote access, multiple clients +- **SSE**: Real-time updates, push notifications + +--- + +## Code Best Practices + +### Code Composability and Reusability + +Your implementation MUST prioritize composability and code reuse: + +1. **Extract Common Functionality**: + - Create reusable helper functions for operations used across multiple tools + - Build shared API clients for HTTP requests instead of duplicating code + - Centralize error handling logic in utility functions + - Extract business logic into dedicated functions that can be composed + - Extract shared markdown or JSON field selection & formatting functionality + +2. **Avoid Duplication**: + - NEVER copy-paste similar code between tools + - If you find yourself writing similar logic twice, extract it into a function + - Common operations like pagination, filtering, field selection, and formatting should be shared + - Authentication/authorization logic should be centralized + +### Python-Specific Best Practices + +1. **Use Type Hints**: Always include type annotations for function parameters and return values +2. **Pydantic Models**: Define clear Pydantic models for all input validation +3. **Avoid Manual Validation**: Let Pydantic handle input validation with constraints +4. **Proper Imports**: Group imports (standard library, third-party, local) +5. **Error Handling**: Use specific exception types (httpx.HTTPStatusError, not generic Exception) +6. **Async Context Managers**: Use `async with` for resources that need cleanup +7. **Constants**: Define module-level constants in UPPER_CASE + +## Quality Checklist + +Before finalizing your Python MCP server implementation, ensure: + +### Strategic Design +- [ ] Tools enable complete workflows, not just API endpoint wrappers +- [ ] Tool names reflect natural task subdivisions +- [ ] Response formats optimize for agent context efficiency +- [ ] Human-readable identifiers used where appropriate +- [ ] Error messages guide agents toward correct usage + +### Implementation Quality +- [ ] FOCUSED IMPLEMENTATION: Most important and valuable tools implemented +- [ ] All tools have descriptive names and documentation +- [ ] Return types are consistent across similar operations +- [ ] Error handling is implemented for all external calls +- [ ] Server name follows format: `{service}_mcp` +- [ ] All network operations use async/await +- [ ] Common functionality is extracted into reusable functions +- [ ] Error messages are clear, actionable, and educational +- [ ] Outputs are properly validated and formatted + +### Tool Configuration +- [ ] All tools implement 'name' and 'annotations' in the decorator +- [ ] Annotations correctly set (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) +- [ ] All tools use Pydantic BaseModel for input validation with Field() definitions +- [ ] All Pydantic Fields have explicit types and descriptions with constraints +- [ ] All tools have comprehensive docstrings with explicit input/output types +- [ ] Docstrings include complete schema structure for dict/JSON returns +- [ ] Pydantic models handle input validation (no manual validation needed) + +### Advanced Features (where applicable) +- [ ] Context injection used for logging, progress, or elicitation +- [ ] Resources registered for appropriate data endpoints +- [ ] Lifespan management implemented for persistent connections +- [ ] Structured output types used (TypedDict, Pydantic models) +- [ ] Appropriate transport configured (stdio, HTTP, SSE) + +### Code Quality +- [ ] File includes proper imports including Pydantic imports +- [ ] Pagination is properly implemented where applicable +- [ ] Large responses check CHARACTER_LIMIT and truncate with clear messages +- [ ] Filtering options are provided for potentially large result sets +- [ ] All async functions are properly defined with `async def` +- [ ] HTTP client usage follows async patterns with proper context managers +- [ ] Type hints are used throughout the code +- [ ] Constants are defined at module level in UPPER_CASE + +### Testing +- [ ] Server runs successfully: `python your_server.py --help` +- [ ] All imports resolve correctly +- [ ] Sample tool calls work as expected +- [ ] Error scenarios handled gracefully \ No newline at end of file diff --git a/skills/mcp-builder/scripts/connections.py b/skills/mcp-builder/scripts/connections.py new file mode 100644 index 0000000..ffcd0da --- /dev/null +++ b/skills/mcp-builder/scripts/connections.py @@ -0,0 +1,151 @@ +"""Lightweight connection handling for MCP servers.""" + +from abc import ABC, abstractmethod +from contextlib import AsyncExitStack +from typing import Any + +from mcp import ClientSession, StdioServerParameters +from mcp.client.sse import sse_client +from mcp.client.stdio import stdio_client +from mcp.client.streamable_http import streamablehttp_client + + +class MCPConnection(ABC): + """Base class for MCP server connections.""" + + def __init__(self): + self.session = None + self._stack = None + + @abstractmethod + def _create_context(self): + """Create the connection context based on connection type.""" + + async def __aenter__(self): + """Initialize MCP server connection.""" + self._stack = AsyncExitStack() + await self._stack.__aenter__() + + try: + ctx = self._create_context() + result = await self._stack.enter_async_context(ctx) + + if len(result) == 2: + read, write = result + elif len(result) == 3: + read, write, _ = result + else: + raise ValueError(f"Unexpected context result: {result}") + + session_ctx = ClientSession(read, write) + self.session = await self._stack.enter_async_context(session_ctx) + await self.session.initialize() + return self + except BaseException: + await self._stack.__aexit__(None, None, None) + raise + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Clean up MCP server connection resources.""" + if self._stack: + await self._stack.__aexit__(exc_type, exc_val, exc_tb) + self.session = None + self._stack = None + + async def list_tools(self) -> list[dict[str, Any]]: + """Retrieve available tools from the MCP server.""" + response = await self.session.list_tools() + return [ + { + "name": tool.name, + "description": tool.description, + "input_schema": tool.inputSchema, + } + for tool in response.tools + ] + + async def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> Any: + """Call a tool on the MCP server with provided arguments.""" + result = await self.session.call_tool(tool_name, arguments=arguments) + return result.content + + +class MCPConnectionStdio(MCPConnection): + """MCP connection using standard input/output.""" + + def __init__(self, command: str, args: list[str] = None, env: dict[str, str] = None): + super().__init__() + self.command = command + self.args = args or [] + self.env = env + + def _create_context(self): + return stdio_client( + StdioServerParameters(command=self.command, args=self.args, env=self.env) + ) + + +class MCPConnectionSSE(MCPConnection): + """MCP connection using Server-Sent Events.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return sse_client(url=self.url, headers=self.headers) + + +class MCPConnectionHTTP(MCPConnection): + """MCP connection using Streamable HTTP.""" + + def __init__(self, url: str, headers: dict[str, str] = None): + super().__init__() + self.url = url + self.headers = headers or {} + + def _create_context(self): + return streamablehttp_client(url=self.url, headers=self.headers) + + +def create_connection( + transport: str, + command: str = None, + args: list[str] = None, + env: dict[str, str] = None, + url: str = None, + headers: dict[str, str] = None, +) -> MCPConnection: + """Factory function to create the appropriate MCP connection. + + Args: + transport: Connection type ("stdio", "sse", or "http") + command: Command to run (stdio only) + args: Command arguments (stdio only) + env: Environment variables (stdio only) + url: Server URL (sse and http only) + headers: HTTP headers (sse and http only) + + Returns: + MCPConnection instance + """ + transport = transport.lower() + + if transport == "stdio": + if not command: + raise ValueError("Command is required for stdio transport") + return MCPConnectionStdio(command=command, args=args, env=env) + + elif transport == "sse": + if not url: + raise ValueError("URL is required for sse transport") + return MCPConnectionSSE(url=url, headers=headers) + + elif transport in ["http", "streamable_http", "streamable-http"]: + if not url: + raise ValueError("URL is required for http transport") + return MCPConnectionHTTP(url=url, headers=headers) + + else: + raise ValueError(f"Unsupported transport type: {transport}. Use 'stdio', 'sse', or 'http'") diff --git a/skills/mcp-builder/scripts/evaluation.py b/skills/mcp-builder/scripts/evaluation.py new file mode 100644 index 0000000..4177856 --- /dev/null +++ b/skills/mcp-builder/scripts/evaluation.py @@ -0,0 +1,373 @@ +"""MCP Server Evaluation Harness + +This script evaluates MCP servers by running test questions against them using Claude. +""" + +import argparse +import asyncio +import json +import re +import sys +import time +import traceback +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any + +from anthropic import Anthropic + +from connections import create_connection + +EVALUATION_PROMPT = """You are an AI assistant with access to tools. + +When given a task, you MUST: +1. Use the available tools to complete the task +2. Provide summary of each step in your approach, wrapped in tags +3. Provide feedback on the tools provided, wrapped in tags +4. Provide your final response, wrapped in tags + +Summary Requirements: +- In your tags, you must explain: + - The steps you took to complete the task + - Which tools you used, in what order, and why + - The inputs you provided to each tool + - The outputs you received from each tool + - A summary for how you arrived at the response + +Feedback Requirements: +- In your tags, provide constructive feedback on the tools: + - Comment on tool names: Are they clear and descriptive? + - Comment on input parameters: Are they well-documented? Are required vs optional parameters clear? + - Comment on descriptions: Do they accurately describe what the tool does? + - Comment on any errors encountered during tool usage: Did the tool fail to execute? Did the tool return too many tokens? + - Identify specific areas for improvement and explain WHY they would help + - Be specific and actionable in your suggestions + +Response Requirements: +- Your response should be concise and directly address what was asked +- Always wrap your final response in tags +- If you cannot solve the task return NOT_FOUND +- For numeric responses, provide just the number +- For IDs, provide just the ID +- For names or text, provide the exact text requested +- Your response should go last""" + + +def parse_evaluation_file(file_path: Path) -> list[dict[str, Any]]: + """Parse XML evaluation file with qa_pair elements.""" + try: + tree = ET.parse(file_path) + root = tree.getroot() + evaluations = [] + + for qa_pair in root.findall(".//qa_pair"): + question_elem = qa_pair.find("question") + answer_elem = qa_pair.find("answer") + + if question_elem is not None and answer_elem is not None: + evaluations.append({ + "question": (question_elem.text or "").strip(), + "answer": (answer_elem.text or "").strip(), + }) + + return evaluations + except Exception as e: + print(f"Error parsing evaluation file {file_path}: {e}") + return [] + + +def extract_xml_content(text: str, tag: str) -> str | None: + """Extract content from XML tags.""" + pattern = rf"<{tag}>(.*?)" + matches = re.findall(pattern, text, re.DOTALL) + return matches[-1].strip() if matches else None + + +async def agent_loop( + client: Anthropic, + model: str, + question: str, + tools: list[dict[str, Any]], + connection: Any, +) -> tuple[str, dict[str, Any]]: + """Run the agent loop with MCP tools.""" + messages = [{"role": "user", "content": question}] + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + + messages.append({"role": "assistant", "content": response.content}) + + tool_metrics = {} + + while response.stop_reason == "tool_use": + tool_use = next(block for block in response.content if block.type == "tool_use") + tool_name = tool_use.name + tool_input = tool_use.input + + tool_start_ts = time.time() + try: + tool_result = await connection.call_tool(tool_name, tool_input) + tool_response = json.dumps(tool_result) if isinstance(tool_result, (dict, list)) else str(tool_result) + except Exception as e: + tool_response = f"Error executing tool {tool_name}: {str(e)}\n" + tool_response += traceback.format_exc() + tool_duration = time.time() - tool_start_ts + + if tool_name not in tool_metrics: + tool_metrics[tool_name] = {"count": 0, "durations": []} + tool_metrics[tool_name]["count"] += 1 + tool_metrics[tool_name]["durations"].append(tool_duration) + + messages.append({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": tool_use.id, + "content": tool_response, + }] + }) + + response = await asyncio.to_thread( + client.messages.create, + model=model, + max_tokens=4096, + system=EVALUATION_PROMPT, + messages=messages, + tools=tools, + ) + messages.append({"role": "assistant", "content": response.content}) + + response_text = next( + (block.text for block in response.content if hasattr(block, "text")), + None, + ) + return response_text, tool_metrics + + +async def evaluate_single_task( + client: Anthropic, + model: str, + qa_pair: dict[str, Any], + tools: list[dict[str, Any]], + connection: Any, + task_index: int, +) -> dict[str, Any]: + """Evaluate a single QA pair with the given tools.""" + start_time = time.time() + + print(f"Task {task_index + 1}: Running task with question: {qa_pair['question']}") + response, tool_metrics = await agent_loop(client, model, qa_pair["question"], tools, connection) + + response_value = extract_xml_content(response, "response") + summary = extract_xml_content(response, "summary") + feedback = extract_xml_content(response, "feedback") + + duration_seconds = time.time() - start_time + + return { + "question": qa_pair["question"], + "expected": qa_pair["answer"], + "actual": response_value, + "score": int(response_value == qa_pair["answer"]) if response_value else 0, + "total_duration": duration_seconds, + "tool_calls": tool_metrics, + "num_tool_calls": sum(len(metrics["durations"]) for metrics in tool_metrics.values()), + "summary": summary, + "feedback": feedback, + } + + +REPORT_HEADER = """ +# Evaluation Report + +## Summary + +- **Accuracy**: {correct}/{total} ({accuracy:.1f}%) +- **Average Task Duration**: {average_duration_s:.2f}s +- **Average Tool Calls per Task**: {average_tool_calls:.2f} +- **Total Tool Calls**: {total_tool_calls} + +--- +""" + +TASK_TEMPLATE = """ +### Task {task_num} + +**Question**: {question} +**Ground Truth Answer**: `{expected_answer}` +**Actual Answer**: `{actual_answer}` +**Correct**: {correct_indicator} +**Duration**: {total_duration:.2f}s +**Tool Calls**: {tool_calls} + +**Summary** +{summary} + +**Feedback** +{feedback} + +--- +""" + + +async def run_evaluation( + eval_path: Path, + connection: Any, + model: str = "claude-3-7-sonnet-20250219", +) -> str: + """Run evaluation with MCP server tools.""" + print("🚀 Starting Evaluation") + + client = Anthropic() + + tools = await connection.list_tools() + print(f"📋 Loaded {len(tools)} tools from MCP server") + + qa_pairs = parse_evaluation_file(eval_path) + print(f"📋 Loaded {len(qa_pairs)} evaluation tasks") + + results = [] + for i, qa_pair in enumerate(qa_pairs): + print(f"Processing task {i + 1}/{len(qa_pairs)}") + result = await evaluate_single_task(client, model, qa_pair, tools, connection, i) + results.append(result) + + correct = sum(r["score"] for r in results) + accuracy = (correct / len(results)) * 100 if results else 0 + average_duration_s = sum(r["total_duration"] for r in results) / len(results) if results else 0 + average_tool_calls = sum(r["num_tool_calls"] for r in results) / len(results) if results else 0 + total_tool_calls = sum(r["num_tool_calls"] for r in results) + + report = REPORT_HEADER.format( + correct=correct, + total=len(results), + accuracy=accuracy, + average_duration_s=average_duration_s, + average_tool_calls=average_tool_calls, + total_tool_calls=total_tool_calls, + ) + + report += "".join([ + TASK_TEMPLATE.format( + task_num=i + 1, + question=qa_pair["question"], + expected_answer=qa_pair["answer"], + actual_answer=result["actual"] or "N/A", + correct_indicator="✅" if result["score"] else "❌", + total_duration=result["total_duration"], + tool_calls=json.dumps(result["tool_calls"], indent=2), + summary=result["summary"] or "N/A", + feedback=result["feedback"] or "N/A", + ) + for i, (qa_pair, result) in enumerate(zip(qa_pairs, results)) + ]) + + return report + + +def parse_headers(header_list: list[str]) -> dict[str, str]: + """Parse header strings in format 'Key: Value' into a dictionary.""" + headers = {} + if not header_list: + return headers + + for header in header_list: + if ":" in header: + key, value = header.split(":", 1) + headers[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed header: {header}") + return headers + + +def parse_env_vars(env_list: list[str]) -> dict[str, str]: + """Parse environment variable strings in format 'KEY=VALUE' into a dictionary.""" + env = {} + if not env_list: + return env + + for env_var in env_list: + if "=" in env_var: + key, value = env_var.split("=", 1) + env[key.strip()] = value.strip() + else: + print(f"Warning: Ignoring malformed environment variable: {env_var}") + return env + + +async def main(): + parser = argparse.ArgumentParser( + description="Evaluate MCP servers using test questions", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Evaluate a local stdio MCP server + python evaluation.py -t stdio -c python -a my_server.py eval.xml + + # Evaluate an SSE MCP server + python evaluation.py -t sse -u https://example.com/mcp -H "Authorization: Bearer token" eval.xml + + # Evaluate an HTTP MCP server with custom model + python evaluation.py -t http -u https://example.com/mcp -m claude-3-5-sonnet-20241022 eval.xml + """, + ) + + parser.add_argument("eval_file", type=Path, help="Path to evaluation XML file") + parser.add_argument("-t", "--transport", choices=["stdio", "sse", "http"], default="stdio", help="Transport type (default: stdio)") + parser.add_argument("-m", "--model", default="claude-3-7-sonnet-20250219", help="Claude model to use (default: claude-3-7-sonnet-20250219)") + + stdio_group = parser.add_argument_group("stdio options") + stdio_group.add_argument("-c", "--command", help="Command to run MCP server (stdio only)") + stdio_group.add_argument("-a", "--args", nargs="+", help="Arguments for the command (stdio only)") + stdio_group.add_argument("-e", "--env", nargs="+", help="Environment variables in KEY=VALUE format (stdio only)") + + remote_group = parser.add_argument_group("sse/http options") + remote_group.add_argument("-u", "--url", help="MCP server URL (sse/http only)") + remote_group.add_argument("-H", "--header", nargs="+", dest="headers", help="HTTP headers in 'Key: Value' format (sse/http only)") + + parser.add_argument("-o", "--output", type=Path, help="Output file for evaluation report (default: stdout)") + + args = parser.parse_args() + + if not args.eval_file.exists(): + print(f"Error: Evaluation file not found: {args.eval_file}") + sys.exit(1) + + headers = parse_headers(args.headers) if args.headers else None + env_vars = parse_env_vars(args.env) if args.env else None + + try: + connection = create_connection( + transport=args.transport, + command=args.command, + args=args.args, + env=env_vars, + url=args.url, + headers=headers, + ) + except ValueError as e: + print(f"Error: {e}") + sys.exit(1) + + print(f"🔗 Connecting to MCP server via {args.transport}...") + + async with connection: + print("✅ Connected successfully") + report = await run_evaluation(args.eval_file, connection, args.model) + + if args.output: + args.output.write_text(report) + print(f"\n✅ Report saved to {args.output}") + else: + print("\n" + report) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/skills/mcp-builder/scripts/example_evaluation.xml b/skills/mcp-builder/scripts/example_evaluation.xml new file mode 100644 index 0000000..41e4459 --- /dev/null +++ b/skills/mcp-builder/scripts/example_evaluation.xml @@ -0,0 +1,22 @@ + + + Calculate the compound interest on $10,000 invested at 5% annual interest rate, compounded monthly for 3 years. What is the final amount in dollars (rounded to 2 decimal places)? + 11614.72 + + + A projectile is launched at a 45-degree angle with an initial velocity of 50 m/s. Calculate the total distance (in meters) it has traveled from the launch point after 2 seconds, assuming g=9.8 m/s². Round to 2 decimal places. + 87.25 + + + A sphere has a volume of 500 cubic meters. Calculate its surface area in square meters. Round to 2 decimal places. + 304.65 + + + Calculate the population standard deviation of this dataset: [12, 15, 18, 22, 25, 30, 35]. Round to 2 decimal places. + 7.61 + + + Calculate the pH of a solution with a hydrogen ion concentration of 3.5 × 10^-5 M. Round to 2 decimal places. + 4.46 + + diff --git a/skills/mcp-builder/scripts/requirements.txt b/skills/mcp-builder/scripts/requirements.txt new file mode 100644 index 0000000..e73e5d1 --- /dev/null +++ b/skills/mcp-builder/scripts/requirements.txt @@ -0,0 +1,2 @@ +anthropic>=0.39.0 +mcp>=1.1.0 diff --git a/skills/mcp-management/README.md b/skills/mcp-management/README.md new file mode 100644 index 0000000..a98c592 --- /dev/null +++ b/skills/mcp-management/README.md @@ -0,0 +1,219 @@ +# MCP Management Skill + +Intelligent management and execution of Model Context Protocol (MCP) servers. + +## Overview + +This skill enables Claude to discover, analyze, and execute MCP server capabilities without polluting the main context window. Perfect for context-efficient MCP integration using subagent-based architecture. + +## Features + +- **Multi-Server Management**: Connect to multiple MCP servers from single config +- **Intelligent Tool Discovery**: Analyze which tools are relevant for specific tasks +- **Progressive Disclosure**: Load only necessary tool definitions +- **Execution Engine**: Call MCP tools with proper parameter handling +- **Context Efficiency**: Delegate MCP operations to `mcp-manager` subagent + +## Quick Start + +### 1. Install Dependencies + +```bash +cd .claude/skills/mcp-management/scripts +npm install +``` + +### 2. Configure MCP Servers + +Create `.claude/.mcp.json`: + +```json +{ + "mcpServers": { + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + }, + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/allowed/path"] + } + } +} +``` + +See `.claude/.mcp.json.example` for more examples. + +### 3. Test Connection + +```bash +cd .claude/skills/mcp-management/scripts +npx ts-node cli.ts list-tools +``` + +## Usage Patterns + +### Pattern 1: Discover Available Tools + +```bash +npx ts-node scripts/cli.ts list-tools +npx ts-node scripts/cli.ts list-prompts +npx ts-node scripts/cli.ts list-resources +``` + +### Pattern 2: LLM-Driven Tool Selection + +The LLM reads `assets/tools.json` and intelligently selects tools. No separate analysis command needed - the LLM's understanding of context and intent is superior to keyword matching. + +### Pattern 3: Execute MCP Tools + +```bash +npx ts-node scripts/cli.ts call-tool memory add '{"key":"name","value":"Alice"}' +``` + +### Pattern 4: Use with Subagent + +In main Claude conversation: + +``` +User: "I need to search the web and save results" +Main Agent: [Spawns mcp-manager subagent] +mcp-manager: Discovers brave-search + memory tools, reports back +Main Agent: Uses recommended tools for implementation +``` + +## Architecture + +``` +Main Agent (Claude) + ↓ (delegates MCP tasks) +mcp-manager Subagent + ↓ (uses skill) +mcp-management Skill + ↓ (connects via) +MCP Servers (memory, filesystem, etc.) +``` + +**Benefits**: +- Main agent context stays clean +- MCP discovery happens in isolated subagent context +- Only relevant tool definitions loaded when needed +- Reduced token usage + +## File Structure + +``` +mcp-management/ +├── SKILL.md # Skill definition +├── README.md # This file +├── scripts/ +│ ├── mcp-client.ts # Core MCP client manager +│ ├── analyze-tools.ts # Intelligent tool selection +│ ├── cli.ts # Command-line interface +│ ├── package.json # Dependencies +│ ├── tsconfig.json # TypeScript config +│ └── .env.example # Environment template +└── references/ + ├── mcp-protocol.md # MCP protocol reference + └── configuration.md # Config guide +``` + +## Scripts Reference + +### mcp-client.ts + +Core client manager class: +- Load config from `.claude/.mcp.json` +- Connect to multiple MCP servers +- List/execute tools, prompts, resources +- Lifecycle management + +### cli.ts + +Command-line interface: +- `list-tools` - Show all tools and save to assets/tools.json +- `list-prompts` - Show all prompts +- `list-resources` - Show all resources +- `call-tool ` - Execute tool + +**Note**: Tool analysis is performed by the LLM reading `assets/tools.json`, which provides better context understanding than algorithmic matching. + +## Configuration + +### Environment Variables + +Scripts check for variables in this order: + +1. `process.env` (runtime) +2. `.claude/skills/mcp-management/.env` +3. `.claude/skills/.env` +4. `.claude/.env` + +### MCP Config Format + +```json +{ + "mcpServers": { + "server-name": { + "command": "executable", // Required + "args": ["arg1", "arg2"], // Required + "env": { // Optional + "VAR": "value", + "API_KEY": "${ENV_VAR}" // Reference env vars + } + } + } +} +``` + +## Common MCP Servers + +Install with `npx`: + +- `@modelcontextprotocol/server-memory` - Key-value storage +- `@modelcontextprotocol/server-filesystem` - File operations +- `@modelcontextprotocol/server-brave-search` - Web search +- `@modelcontextprotocol/server-puppeteer` - Browser automation +- `@modelcontextprotocol/server-fetch` - HTTP requests + +## Integration with mcp-manager Agent + +The `mcp-manager` agent (`.claude/agents/mcp-manager.md`) uses this skill to: + +1. **Discover**: Connect to MCP servers, list capabilities +2. **Analyze**: Filter relevant tools for tasks +3. **Execute**: Call MCP tools on behalf of main agent +4. **Report**: Send concise results back to main agent + +This architecture keeps main context clean and enables efficient MCP integration. + +## Troubleshooting + +### "Config not found" + +Ensure `.claude/.mcp.json` exists and is valid JSON. + +### "Server connection failed" + +Check: +- Server command is installed (`npx` packages installed?) +- Server args are correct +- Environment variables are set + +### "Tool not found" + +List available tools first: +```bash +npx ts-node scripts/cli.ts list-tools +``` + +## Resources + +- [MCP Specification](https://modelcontextprotocol.io/specification/latest) +- [MCP TypeScript SDK](https://github.com/modelcontextprotocol/typescript-sdk) +- [Official MCP Servers](https://github.com/modelcontextprotocol/servers) +- [Skill References](./references/) + +## License + +MIT diff --git a/skills/mcp-management/SKILL.md b/skills/mcp-management/SKILL.md new file mode 100644 index 0000000..d933354 --- /dev/null +++ b/skills/mcp-management/SKILL.md @@ -0,0 +1,176 @@ +--- +name: mcp-management +description: Manage Model Context Protocol (MCP) servers - discover, analyze, and execute tools/prompts/resources from configured MCP servers. Use when working with MCP integrations, need to discover available MCP capabilities, filter MCP tools for specific tasks, execute MCP tools programmatically, access MCP prompts/resources, or implement MCP client functionality. Supports intelligent tool selection, multi-server management, and context-efficient capability discovery. +--- + +# MCP Management + +Skill for managing and interacting with Model Context Protocol (MCP) servers. + +## Overview + +MCP is an open protocol enabling AI agents to connect to external tools and data sources. This skill provides scripts and utilities to discover, analyze, and execute MCP capabilities from configured servers without polluting the main context window. + +**Key Benefits**: +- Progressive disclosure of MCP capabilities (load only what's needed) +- Intelligent tool/prompt/resource selection based on task requirements +- Multi-server management from single config file +- Context-efficient: subagents handle MCP discovery and execution +- Persistent tool catalog: automatically saves discovered tools to JSON for fast reference + +## When to Use This Skill + +Use this skill when: +1. **Discovering MCP Capabilities**: Need to list available tools/prompts/resources from configured servers +2. **Task-Based Tool Selection**: Analyzing which MCP tools are relevant for a specific task +3. **Executing MCP Tools**: Calling MCP tools programmatically with proper parameter handling +4. **MCP Integration**: Building or debugging MCP client implementations +5. **Context Management**: Avoiding context pollution by delegating MCP operations to subagents + +## Core Capabilities + +### 1. Configuration Management + +MCP servers configured in `.claude/.mcp.json`. + +**Gemini CLI Integration** (recommended): Create symlink to `.gemini/settings.json`: +```bash +mkdir -p .gemini && ln -sf .claude/.mcp.json .gemini/settings.json +``` + +See [references/configuration.md](references/configuration.md) and [references/gemini-cli-integration.md](references/gemini-cli-integration.md). + +### 2. Capability Discovery + +```bash +npx tsx scripts/cli.ts list-tools # Saves to assets/tools.json +npx tsx scripts/cli.ts list-prompts +npx tsx scripts/cli.ts list-resources +``` + +Aggregates capabilities from multiple servers with server identification. + +### 3. Intelligent Tool Analysis + +LLM analyzes `assets/tools.json` directly - better than keyword matching algorithms. + +### 4. Tool Execution + +**Primary: Gemini CLI** (if available) +```bash +gemini -y -m gemini-2.5-flash -p "Take a screenshot of https://example.com" +``` + +**Secondary: Direct Scripts** +```bash +npx tsx scripts/cli.ts call-tool memory create_entities '{"entities":[...]}' +``` + +**Fallback: mcp-manager Subagent** + +See [references/gemini-cli-integration.md](references/gemini-cli-integration.md) for complete examples. + +## Implementation Patterns + +### Pattern 1: Gemini CLI Auto-Execution (Primary) + +Use Gemini CLI for automatic tool discovery and execution. See [references/gemini-cli-integration.md](references/gemini-cli-integration.md) for complete guide. + +**Quick Example**: +```bash +gemini -y -m gemini-2.5-flash -p "Take a screenshot of https://example.com" +``` + +**Benefits**: Automatic tool discovery, natural language execution, faster than subagent orchestration. + +### Pattern 2: Subagent-Based Execution (Fallback) + +Use `mcp-manager` agent when Gemini CLI unavailable. Subagent discovers tools, selects relevant ones, executes tasks, reports back. + +**Benefit**: Main context stays clean, only relevant tool definitions loaded when needed. + +### Pattern 3: LLM-Driven Tool Selection + +LLM reads `assets/tools.json`, intelligently selects relevant tools using context understanding, synonyms, and intent recognition. + +### Pattern 4: Multi-Server Orchestration + +Coordinate tools across multiple servers. Each tool knows its source server for proper routing. + +## Scripts Reference + +### scripts/mcp-client.ts + +Core MCP client manager class. Handles: +- Config loading from `.claude/.mcp.json` +- Connecting to multiple MCP servers +- Listing tools/prompts/resources across all servers +- Executing tools with proper error handling +- Connection lifecycle management + +### scripts/cli.ts + +Command-line interface for MCP operations. Commands: +- `list-tools` - Display all tools and save to `assets/tools.json` +- `list-prompts` - Display all prompts +- `list-resources` - Display all resources +- `call-tool ` - Execute a tool + +**Note**: `list-tools` persists complete tool catalog to `assets/tools.json` with full schemas for fast reference, offline browsing, and version control. + +## Quick Start + +**Method 1: Gemini CLI** (recommended) +```bash +npm install -g gemini-cli +mkdir -p .gemini && ln -sf .claude/.mcp.json .gemini/settings.json +gemini -y -m gemini-2.5-flash -p "Take a screenshot of https://example.com" +``` + +**Method 2: Scripts** +```bash +cd .claude/skills/mcp-management/scripts && npm install +npx tsx cli.ts list-tools # Saves to assets/tools.json +npx tsx cli.ts call-tool memory create_entities '{"entities":[...]}' +``` + +**Method 3: mcp-manager Subagent** + +See [references/gemini-cli-integration.md](references/gemini-cli-integration.md) for complete guide. + +## Technical Details + +See [references/mcp-protocol.md](references/mcp-protocol.md) for: +- JSON-RPC protocol details +- Message types and formats +- Error codes and handling +- Transport mechanisms (stdio, HTTP+SSE) +- Best practices + +## Integration Strategy + +### Execution Priority + +1. **Gemini CLI** (Primary): Fast, automatic, intelligent tool selection + - Check: `command -v gemini` + - Execute: `gemini -y -m gemini-2.5-flash -p ""` + - Best for: All tasks when available + +2. **Direct CLI Scripts** (Secondary): Manual tool specification + - Use when: Need specific tool/server control + - Execute: `npx tsx scripts/cli.ts call-tool ` + +3. **mcp-manager Subagent** (Fallback): Context-efficient delegation + - Use when: Gemini unavailable or failed + - Keeps main context clean + +### Integration with Agents + +The `mcp-manager` agent uses this skill to: +- Check Gemini CLI availability first +- Execute via `gemini` command if available +- Fallback to direct script execution +- Discover MCP capabilities without loading into main context +- Report results back to main agent + +This keeps main agent context clean and enables efficient MCP integration. \ No newline at end of file diff --git a/skills/mcp-management/assets/tools.json b/skills/mcp-management/assets/tools.json new file mode 100644 index 0000000..27e9563 --- /dev/null +++ b/skills/mcp-management/assets/tools.json @@ -0,0 +1,3044 @@ +[ + { + "serverName": "memory", + "name": "create_entities", + "description": "Create multiple new entities in the knowledge graph", + "inputSchema": { + "type": "object", + "properties": { + "entities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the entity" + }, + "entityType": { + "type": "string", + "description": "The type of the entity" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents associated with the entity" + } + }, + "required": [ + "name", + "entityType", + "observations" + ], + "additionalProperties": false + } + } + }, + "required": [ + "entities" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "create_relations", + "description": "Create multiple new relations between entities in the knowledge graph. Relations should be in active voice", + "inputSchema": { + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + } + } + }, + "required": [ + "relations" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "add_observations", + "description": "Add new observations to existing entities in the knowledge graph", + "inputSchema": { + "type": "object", + "properties": { + "observations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The name of the entity to add the observations to" + }, + "contents": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observation contents to add" + } + }, + "required": [ + "entityName", + "contents" + ], + "additionalProperties": false + } + } + }, + "required": [ + "observations" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "delete_entities", + "description": "Delete multiple entities and their associated relations from the knowledge graph", + "inputSchema": { + "type": "object", + "properties": { + "entityNames": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of entity names to delete" + } + }, + "required": [ + "entityNames" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "delete_observations", + "description": "Delete specific observations from entities in the knowledge graph", + "inputSchema": { + "type": "object", + "properties": { + "deletions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entityName": { + "type": "string", + "description": "The name of the entity containing the observations" + }, + "observations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of observations to delete" + } + }, + "required": [ + "entityName", + "observations" + ], + "additionalProperties": false + } + } + }, + "required": [ + "deletions" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "delete_relations", + "description": "Delete multiple relations from the knowledge graph", + "inputSchema": { + "type": "object", + "properties": { + "relations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "from": { + "type": "string", + "description": "The name of the entity where the relation starts" + }, + "to": { + "type": "string", + "description": "The name of the entity where the relation ends" + }, + "relationType": { + "type": "string", + "description": "The type of the relation" + } + }, + "required": [ + "from", + "to", + "relationType" + ], + "additionalProperties": false + }, + "description": "An array of relations to delete" + } + }, + "required": [ + "relations" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "read_graph", + "description": "Read the entire knowledge graph", + "inputSchema": { + "type": "object", + "properties": {}, + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "search_nodes", + "description": "Search for nodes in the knowledge graph based on a query", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query to match against entity names, types, and observation content" + } + }, + "required": [ + "query" + ], + "additionalProperties": false + } + }, + { + "serverName": "memory", + "name": "open_nodes", + "description": "Open specific nodes in the knowledge graph by their names", + "inputSchema": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + }, + "description": "An array of entity names to retrieve" + } + }, + "required": [ + "names" + ], + "additionalProperties": false + } + }, + { + "serverName": "human-mcp", + "name": "eyes_analyze", + "description": "Understand images, videos, and GIFs with AI vision", + "inputSchema": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "File path, URL, or image to analyze" + }, + "focus": { + "type": "string", + "description": "What to focus on in the analysis" + }, + "detail": { + "type": "string", + "enum": [ + "quick", + "detailed" + ], + "default": "detailed", + "description": "Analysis depth" + } + }, + "required": [ + "source" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "eyes_compare", + "description": "Find differences between images", + "inputSchema": { + "type": "object", + "properties": { + "image1": { + "type": "string", + "description": "First image path or URL" + }, + "image2": { + "type": "string", + "description": "Second image path or URL" + }, + "focus": { + "type": "string", + "enum": [ + "differences", + "similarities", + "layout", + "content" + ], + "default": "differences", + "description": "What to compare" + } + }, + "required": [ + "image1", + "image2" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "eyes_read_document", + "description": "Extract text and data from documents", + "inputSchema": { + "type": "object", + "properties": { + "document": { + "type": "string", + "description": "Document path or URL" + }, + "pages": { + "type": "string", + "default": "all", + "description": "Page range (e.g., '1-5' or 'all')" + }, + "extract": { + "type": "string", + "enum": [ + "text", + "tables", + "both" + ], + "default": "both", + "description": "What to extract" + } + }, + "required": [ + "document" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "eyes_summarize_document", + "description": "Create summaries from documents", + "inputSchema": { + "type": "object", + "properties": { + "document": { + "type": "string", + "description": "Document path or URL" + }, + "length": { + "type": "string", + "enum": [ + "brief", + "medium", + "detailed" + ], + "default": "medium", + "description": "Summary length" + }, + "focus": { + "type": "string", + "description": "Specific topics to focus on" + } + }, + "required": [ + "document" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_gen_image", + "description": "Generate images from text descriptions using Gemini Imagen API", + "inputSchema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Text description of the image to generate" + }, + "model": { + "type": "string", + "enum": [ + "gemini-2.5-flash-image-preview" + ], + "default": "gemini-2.5-flash-image-preview", + "description": "Image generation model" + }, + "output_format": { + "type": "string", + "enum": [ + "base64", + "url" + ], + "default": "base64", + "description": "Output format for the generated image" + }, + "negative_prompt": { + "type": "string", + "description": "Text describing what should NOT be in the image" + }, + "style": { + "type": "string", + "enum": [ + "photorealistic", + "artistic", + "cartoon", + "sketch", + "digital_art" + ], + "description": "Style of the generated image" + }, + "aspect_ratio": { + "type": "string", + "enum": [ + "1:1", + "16:9", + "9:16", + "4:3", + "3:4" + ], + "default": "1:1", + "description": "Aspect ratio of the generated image" + }, + "seed": { + "type": "number", + "description": "Random seed for reproducible generation" + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_gen_video", + "description": "Generate videos from text descriptions using Gemini Veo 3.0 API", + "inputSchema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Text description of the video to generate" + }, + "model": { + "type": "string", + "enum": [ + "veo-3.0-generate-001" + ], + "default": "veo-3.0-generate-001", + "description": "Video generation model" + }, + "duration": { + "type": "string", + "enum": [ + "4s", + "8s", + "12s" + ], + "default": "4s", + "description": "Duration of the generated video" + }, + "output_format": { + "type": "string", + "enum": [ + "mp4", + "webm" + ], + "default": "mp4", + "description": "Output format for the generated video" + }, + "aspect_ratio": { + "type": "string", + "enum": [ + "1:1", + "16:9", + "9:16", + "4:3", + "3:4" + ], + "default": "16:9", + "description": "Aspect ratio of the generated video" + }, + "fps": { + "type": "integer", + "minimum": 1, + "maximum": 60, + "default": 24, + "description": "Frames per second" + }, + "image_input": { + "type": "string", + "description": "Base64 encoded image or image URL to use as starting frame" + }, + "style": { + "type": "string", + "enum": [ + "realistic", + "cinematic", + "artistic", + "cartoon", + "animation" + ], + "description": "Style of the generated video" + }, + "camera_movement": { + "type": "string", + "enum": [ + "static", + "pan_left", + "pan_right", + "zoom_in", + "zoom_out", + "dolly_forward", + "dolly_backward" + ], + "description": "Camera movement type" + }, + "seed": { + "type": "number", + "description": "Random seed for reproducible generation" + } + }, + "required": [ + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_image_to_video", + "description": "Generate videos from images and text descriptions using Gemini Imagen + Veo 3.0 APIs", + "inputSchema": { + "type": "object", + "properties": { + "prompt": { + "type": "string", + "description": "Text description of the video animation" + }, + "image_input": { + "type": "string", + "description": "Base64 encoded image or image URL to use as starting frame" + }, + "model": { + "type": "string", + "enum": [ + "veo-3.0-generate-001" + ], + "default": "veo-3.0-generate-001", + "description": "Video generation model" + }, + "duration": { + "type": "string", + "enum": [ + "4s", + "8s", + "12s" + ], + "default": "4s", + "description": "Duration of the generated video" + }, + "output_format": { + "type": "string", + "enum": [ + "mp4", + "webm" + ], + "default": "mp4", + "description": "Output format for the generated video" + }, + "aspect_ratio": { + "type": "string", + "enum": [ + "1:1", + "16:9", + "9:16", + "4:3", + "3:4" + ], + "default": "16:9", + "description": "Aspect ratio of the generated video" + }, + "fps": { + "type": "integer", + "minimum": 1, + "maximum": 60, + "default": 24, + "description": "Frames per second" + }, + "style": { + "type": "string", + "enum": [ + "realistic", + "cinematic", + "artistic", + "cartoon", + "animation" + ], + "description": "Style of the generated video" + }, + "camera_movement": { + "type": "string", + "enum": [ + "static", + "pan_left", + "pan_right", + "zoom_in", + "zoom_out", + "dolly_forward", + "dolly_backward" + ], + "description": "Camera movement type" + }, + "seed": { + "type": "number", + "description": "Random seed for reproducible generation" + } + }, + "required": [ + "prompt", + "image_input" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_edit_image", + "description": "Edit images using AI with text-based instructions for inpainting, outpainting, style transfer, object manipulation, and composition. No masks required - just describe what you want to change.", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "inpaint", + "outpaint", + "style_transfer", + "object_manipulation", + "multi_image_compose" + ], + "description": "Type of image editing operation to perform" + }, + "input_image": { + "type": "string", + "description": "Base64 encoded image or file path to the input image" + }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Text description of the desired edit" + }, + "mask_image": { + "type": "string", + "description": "Base64 encoded mask image for inpainting (white = edit area, black = keep)" + }, + "mask_prompt": { + "type": "string", + "description": "Text description of the area to mask for editing" + }, + "expand_direction": { + "type": "string", + "enum": [ + "all", + "left", + "right", + "top", + "bottom", + "horizontal", + "vertical" + ], + "description": "Direction to expand the image" + }, + "expansion_ratio": { + "type": "number", + "minimum": 0.1, + "maximum": 3, + "default": 1.5, + "description": "How much to expand the image (1.0 = no expansion)" + }, + "style_image": { + "type": "string", + "description": "Base64 encoded reference image for style transfer" + }, + "style_strength": { + "type": "number", + "minimum": 0.1, + "maximum": 1, + "default": 0.7, + "description": "Strength of style application" + }, + "target_object": { + "type": "string", + "description": "Description of the object to manipulate" + }, + "manipulation_type": { + "type": "string", + "enum": [ + "move", + "resize", + "remove", + "replace", + "duplicate" + ], + "description": "Type of object manipulation" + }, + "target_position": { + "type": "string", + "description": "New position for the object (e.g., 'center', 'top-left')" + }, + "secondary_images": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of base64 encoded images for composition" + }, + "composition_layout": { + "type": "string", + "enum": [ + "blend", + "collage", + "overlay", + "side_by_side" + ], + "description": "How to combine multiple images" + }, + "blend_mode": { + "type": "string", + "enum": [ + "normal", + "multiply", + "screen", + "overlay", + "soft_light" + ], + "description": "Blending mode for image composition" + }, + "negative_prompt": { + "type": "string", + "description": "What to avoid in the edited image" + }, + "strength": { + "type": "number", + "minimum": 0.1, + "maximum": 1, + "default": 0.8, + "description": "Strength of the editing effect" + }, + "guidance_scale": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 7.5, + "description": "How closely to follow the prompt" + }, + "seed": { + "type": "integer", + "minimum": 0, + "description": "Random seed for reproducible results" + }, + "output_format": { + "type": "string", + "enum": [ + "base64", + "url" + ], + "default": "base64", + "description": "Output format for the edited image" + }, + "quality": { + "type": "string", + "enum": [ + "draft", + "standard", + "high" + ], + "default": "standard", + "description": "Quality level of the editing" + } + }, + "required": [ + "operation", + "input_image", + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_inpaint_image", + "description": "Add or modify specific areas of an image using natural language descriptions. No mask required - just describe what to change and where.", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Base64 encoded image or file path to the input image" + }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Text description of what to add or change in the image" + }, + "mask_image": { + "type": "string", + "description": "(Optional) Base64 encoded mask image - not used by Gemini but kept for compatibility" + }, + "mask_prompt": { + "type": "string", + "description": "Text description of WHERE in the image to make changes (e.g., 'the empty space beside the cat', 'the top-left corner')" + }, + "negative_prompt": { + "type": "string", + "description": "What to avoid in the edited area" + }, + "strength": { + "type": "number", + "minimum": 0.1, + "maximum": 1, + "default": 0.8, + "description": "Strength of the editing effect" + }, + "guidance_scale": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 7.5, + "description": "How closely to follow the prompt" + }, + "seed": { + "type": "integer", + "minimum": 0, + "description": "Random seed for reproducible results" + }, + "output_format": { + "type": "string", + "enum": [ + "base64", + "url" + ], + "default": "base64", + "description": "Output format" + }, + "quality": { + "type": "string", + "enum": [ + "draft", + "standard", + "high" + ], + "default": "standard", + "description": "Quality level" + } + }, + "required": [ + "input_image", + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_outpaint_image", + "description": "Expand an image beyond its original borders in specified directions", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Base64 encoded image or file path to the input image" + }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Text description of what to add in the expanded areas" + }, + "expand_direction": { + "type": "string", + "enum": [ + "all", + "left", + "right", + "top", + "bottom", + "horizontal", + "vertical" + ], + "default": "all", + "description": "Direction to expand the image" + }, + "expansion_ratio": { + "type": "number", + "minimum": 0.1, + "maximum": 3, + "default": 1.5, + "description": "How much to expand the image (1.0 = no expansion)" + }, + "negative_prompt": { + "type": "string", + "description": "What to avoid in the expanded areas" + }, + "strength": { + "type": "number", + "minimum": 0.1, + "maximum": 1, + "default": 0.8, + "description": "Strength of the editing effect" + }, + "guidance_scale": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 7.5, + "description": "How closely to follow the prompt" + }, + "seed": { + "type": "integer", + "minimum": 0, + "description": "Random seed for reproducible results" + }, + "output_format": { + "type": "string", + "enum": [ + "base64", + "url" + ], + "default": "base64", + "description": "Output format" + }, + "quality": { + "type": "string", + "enum": [ + "draft", + "standard", + "high" + ], + "default": "standard", + "description": "Quality level" + } + }, + "required": [ + "input_image", + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_style_transfer_image", + "description": "Transfer the style from one image to another using AI", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Base64 encoded image or file path to the input image" + }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Text description of the desired style" + }, + "style_image": { + "type": "string", + "description": "Base64 encoded reference image for style transfer" + }, + "style_strength": { + "type": "number", + "minimum": 0.1, + "maximum": 1, + "default": 0.7, + "description": "Strength of style application" + }, + "negative_prompt": { + "type": "string", + "description": "What style elements to avoid" + }, + "guidance_scale": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 7.5, + "description": "How closely to follow the prompt" + }, + "seed": { + "type": "integer", + "minimum": 0, + "description": "Random seed for reproducible results" + }, + "output_format": { + "type": "string", + "enum": [ + "base64", + "url" + ], + "default": "base64", + "description": "Output format" + }, + "quality": { + "type": "string", + "enum": [ + "draft", + "standard", + "high" + ], + "default": "standard", + "description": "Quality level" + } + }, + "required": [ + "input_image", + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "gemini_compose_images", + "description": "Combine multiple images into a single composition using AI", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Base64 encoded primary image" + }, + "secondary_images": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of base64 encoded secondary images to compose" + }, + "prompt": { + "type": "string", + "minLength": 1, + "description": "Text description of how to compose the images" + }, + "composition_layout": { + "type": "string", + "enum": [ + "blend", + "collage", + "overlay", + "side_by_side" + ], + "default": "blend", + "description": "How to combine the images" + }, + "blend_mode": { + "type": "string", + "enum": [ + "normal", + "multiply", + "screen", + "overlay", + "soft_light" + ], + "default": "normal", + "description": "Blending mode for image composition" + }, + "negative_prompt": { + "type": "string", + "description": "What to avoid in the composition" + }, + "strength": { + "type": "number", + "minimum": 0.1, + "maximum": 1, + "default": 0.8, + "description": "Strength of the composition effect" + }, + "guidance_scale": { + "type": "number", + "minimum": 1, + "maximum": 20, + "default": 7.5, + "description": "How closely to follow the prompt" + }, + "seed": { + "type": "integer", + "minimum": 0, + "description": "Random seed for reproducible results" + }, + "output_format": { + "type": "string", + "enum": [ + "base64", + "url" + ], + "default": "base64", + "description": "Output format" + }, + "quality": { + "type": "string", + "enum": [ + "draft", + "standard", + "high" + ], + "default": "standard", + "description": "Quality level" + } + }, + "required": [ + "input_image", + "secondary_images", + "prompt" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "jimp_crop_image", + "description": "Crop an image using Jimp with various modes (manual, center, aspect ratio, etc.)", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Input image - supports file paths, URLs, or base64 data URIs" + }, + "mode": { + "type": "string", + "enum": [ + "manual", + "center", + "top_left", + "top_right", + "bottom_left", + "bottom_right", + "aspect_ratio" + ], + "default": "manual", + "description": "Crop mode" + }, + "x": { + "type": "integer", + "minimum": 0, + "description": "X coordinate for crop start (manual mode)" + }, + "y": { + "type": "integer", + "minimum": 0, + "description": "Y coordinate for crop start (manual mode)" + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Width of crop region" + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Height of crop region" + }, + "aspect_ratio": { + "type": "string", + "description": "Aspect ratio (e.g., '16:9', '4:3')" + }, + "output_format": { + "type": "string", + "enum": [ + "png", + "jpeg", + "bmp" + ], + "default": "png", + "description": "Output image format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100)" + } + }, + "required": [ + "input_image" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "jimp_resize_image", + "description": "Resize an image using Jimp with various algorithms and options", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Input image - supports file paths, URLs, or base64 data URIs" + }, + "width": { + "type": "integer", + "minimum": 1, + "description": "Target width in pixels" + }, + "height": { + "type": "integer", + "minimum": 1, + "description": "Target height in pixels" + }, + "scale": { + "type": "number", + "minimum": 0.01, + "maximum": 10, + "description": "Scale factor (e.g., 0.5 for 50%, 2.0 for 200%)" + }, + "maintain_aspect_ratio": { + "type": "boolean", + "default": true, + "description": "Maintain aspect ratio when resizing" + }, + "algorithm": { + "type": "string", + "enum": [ + "nearestNeighbor", + "bilinear", + "bicubic", + "hermite", + "bezier" + ], + "default": "bilinear", + "description": "Resize algorithm" + }, + "output_format": { + "type": "string", + "enum": [ + "png", + "jpeg", + "bmp" + ], + "default": "png", + "description": "Output image format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100)" + } + }, + "required": [ + "input_image" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "jimp_rotate_image", + "description": "Rotate an image using Jimp by any angle", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Input image - supports file paths, URLs, or base64 data URIs" + }, + "angle": { + "type": "number", + "description": "Rotation angle in degrees (positive = clockwise, negative = counter-clockwise)" + }, + "background_color": { + "type": "string", + "description": "Background color for areas outside the rotated image (CSS color format, e.g., '#ffffff', 'white')" + }, + "output_format": { + "type": "string", + "enum": [ + "png", + "jpeg", + "bmp" + ], + "default": "png", + "description": "Output image format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100)" + } + }, + "required": [ + "input_image", + "angle" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "jimp_mask_image", + "description": "Apply a grayscale alpha mask to an image using Jimp (black pixels = transparent, white pixels = opaque)", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Input image to apply mask to - supports file paths, URLs, or base64 data URIs" + }, + "mask_image": { + "type": "string", + "description": "Grayscale mask image (black = transparent, white = opaque) - supports file paths, URLs, or base64 data URIs" + }, + "output_format": { + "type": "string", + "enum": [ + "png", + "jpeg", + "bmp" + ], + "default": "png", + "description": "Output image format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100)" + } + }, + "required": [ + "input_image", + "mask_image" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "rmbg_remove_background", + "description": "Remove background from an image using AI-powered background removal", + "inputSchema": { + "type": "object", + "properties": { + "input_image": { + "type": "string", + "description": "Input image - supports file paths, URLs, or base64 data URIs" + }, + "quality": { + "type": "string", + "enum": [ + "fast", + "balanced", + "high" + ], + "default": "balanced", + "description": "Processing quality (fast = quick but less accurate, high = slower but more accurate)" + }, + "output_format": { + "type": "string", + "enum": [ + "png", + "jpeg" + ], + "default": "png", + "description": "Output image format (PNG preserves transparency, JPEG requires background color)" + }, + "background_color": { + "type": "string", + "description": "Background color for JPEG output (CSS color format, e.g., '#ffffff', 'white')" + }, + "jpeg_quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 85, + "description": "JPEG quality (0-100)" + } + }, + "required": [ + "input_image" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "playwright_screenshot_fullpage", + "description": "Capture full page screenshot including scrollable content using Playwright", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL of the webpage to capture" + }, + "format": { + "type": "string", + "enum": [ + "png", + "jpeg" + ], + "default": "png", + "description": "Screenshot format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100), only applicable for jpeg format" + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 120000, + "default": 30000, + "description": "Navigation timeout in milliseconds" + }, + "wait_until": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle" + ], + "default": "networkidle", + "description": "When to consider navigation successful" + }, + "viewport": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": 320, + "maximum": 3840, + "default": 1920 + }, + "height": { + "type": "integer", + "minimum": 240, + "maximum": 2160, + "default": 1080 + } + }, + "additionalProperties": false, + "description": "Viewport dimensions" + } + }, + "required": [ + "url" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "playwright_screenshot_viewport", + "description": "Capture viewport screenshot (visible area only) using Playwright", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL of the webpage to capture" + }, + "format": { + "type": "string", + "enum": [ + "png", + "jpeg" + ], + "default": "png", + "description": "Screenshot format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100), only applicable for jpeg format" + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 120000, + "default": 30000, + "description": "Navigation timeout in milliseconds" + }, + "wait_until": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle" + ], + "default": "networkidle", + "description": "When to consider navigation successful" + }, + "viewport": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": 320, + "maximum": 3840, + "default": 1920 + }, + "height": { + "type": "integer", + "minimum": 240, + "maximum": 2160, + "default": 1080 + } + }, + "additionalProperties": false, + "description": "Viewport dimensions" + } + }, + "required": [ + "url" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "playwright_screenshot_element", + "description": "Capture screenshot of specific element using Playwright", + "inputSchema": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "URL of the webpage to capture" + }, + "selector": { + "type": "string", + "minLength": 1, + "description": "CSS selector, text content, or role of the element to capture" + }, + "selector_type": { + "type": "string", + "enum": [ + "css", + "text", + "role" + ], + "default": "css", + "description": "Type of selector (css, text, or role)" + }, + "format": { + "type": "string", + "enum": [ + "png", + "jpeg" + ], + "default": "png", + "description": "Screenshot format" + }, + "quality": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "description": "JPEG quality (0-100), only applicable for jpeg format" + }, + "timeout": { + "type": "integer", + "minimum": 1000, + "maximum": 120000, + "default": 30000, + "description": "Navigation and element wait timeout in milliseconds" + }, + "wait_until": { + "type": "string", + "enum": [ + "load", + "domcontentloaded", + "networkidle" + ], + "default": "networkidle", + "description": "When to consider navigation successful" + }, + "viewport": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": 320, + "maximum": 3840, + "default": 1920 + }, + "height": { + "type": "integer", + "minimum": 240, + "maximum": 2160, + "default": 1080 + } + }, + "additionalProperties": false, + "description": "Viewport dimensions" + }, + "wait_for_selector": { + "type": "boolean", + "default": true, + "description": "Wait for the selector to be visible before capturing" + } + }, + "required": [ + "url", + "selector" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "mouth_speak", + "description": "Generate speech from text using Gemini Speech Generation API with voice customization", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 32000, + "description": "Text to convert to speech (max 32k tokens)" + }, + "voice": { + "type": "string", + "enum": [ + "Astrid", + "Charon", + "Fenrir", + "Kore", + "Odin", + "Puck", + "Sage", + "Vox", + "Zephyr", + "Aoede", + "Apollo", + "Elektra", + "Iris", + "Nemesis", + "Perseus", + "Selene", + "Thalia", + "Argus", + "Ares", + "Demeter", + "Dione", + "Echo", + "Eros", + "Hephaestus", + "Hermes", + "Hyperion", + "Iapetus", + "Kronos", + "Leto", + "Maia", + "Mnemosyne" + ], + "default": "Zephyr", + "description": "Voice to use for speech generation" + }, + "model": { + "type": "string", + "enum": [ + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts" + ], + "default": "gemini-2.5-flash-preview-tts", + "description": "Speech generation model" + }, + "language": { + "type": "string", + "enum": [ + "ar-EG", + "de-DE", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "ru-RU", + "nl-NL", + "pl-PL", + "th-TH", + "tr-TR", + "vi-VN", + "ro-RO", + "uk-UA", + "bn-BD", + "en-IN", + "mr-IN", + "ta-IN", + "te-IN" + ], + "default": "en-US", + "description": "Language for speech generation" + }, + "output_format": { + "type": "string", + "enum": [ + "wav", + "base64", + "url" + ], + "default": "base64", + "description": "Output format for generated audio" + }, + "style_prompt": { + "type": "string", + "description": "Natural language prompt to control speaking style" + } + }, + "required": [ + "text" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "mouth_narrate", + "description": "Generate narration for long-form content with chapter breaks and style control", + "inputSchema": { + "type": "object", + "properties": { + "content": { + "type": "string", + "minLength": 1, + "description": "Long-form content to narrate" + }, + "voice": { + "type": "string", + "enum": [ + "Astrid", + "Charon", + "Fenrir", + "Kore", + "Odin", + "Puck", + "Sage", + "Vox", + "Zephyr", + "Aoede", + "Apollo", + "Elektra", + "Iris", + "Nemesis", + "Perseus", + "Selene", + "Thalia", + "Argus", + "Ares", + "Demeter", + "Dione", + "Echo", + "Eros", + "Hephaestus", + "Hermes", + "Hyperion", + "Iapetus", + "Kronos", + "Leto", + "Maia", + "Mnemosyne" + ], + "default": "Sage", + "description": "Voice to use for narration" + }, + "model": { + "type": "string", + "enum": [ + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts" + ], + "default": "gemini-2.5-pro-preview-tts", + "description": "Speech generation model" + }, + "language": { + "type": "string", + "enum": [ + "ar-EG", + "de-DE", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "ru-RU", + "nl-NL", + "pl-PL", + "th-TH", + "tr-TR", + "vi-VN", + "ro-RO", + "uk-UA", + "bn-BD", + "en-IN", + "mr-IN", + "ta-IN", + "te-IN" + ], + "default": "en-US", + "description": "Language for narration" + }, + "output_format": { + "type": "string", + "enum": [ + "wav", + "base64", + "url" + ], + "default": "base64", + "description": "Output format for generated audio" + }, + "narration_style": { + "type": "string", + "enum": [ + "professional", + "casual", + "educational", + "storytelling" + ], + "default": "professional", + "description": "Narration style" + }, + "chapter_breaks": { + "type": "boolean", + "default": false, + "description": "Add pauses between chapters/sections" + }, + "max_chunk_size": { + "type": "number", + "default": 8000, + "description": "Maximum characters per audio chunk" + } + }, + "required": [ + "content" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "mouth_explain", + "description": "Generate spoken explanations of code with technical analysis", + "inputSchema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "description": "Code to explain" + }, + "language": { + "type": "string", + "enum": [ + "ar-EG", + "de-DE", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "ru-RU", + "nl-NL", + "pl-PL", + "th-TH", + "tr-TR", + "vi-VN", + "ro-RO", + "uk-UA", + "bn-BD", + "en-IN", + "mr-IN", + "ta-IN", + "te-IN" + ], + "default": "en-US", + "description": "Language for explanation" + }, + "programming_language": { + "type": "string", + "description": "Programming language of the code" + }, + "voice": { + "type": "string", + "enum": [ + "Astrid", + "Charon", + "Fenrir", + "Kore", + "Odin", + "Puck", + "Sage", + "Vox", + "Zephyr", + "Aoede", + "Apollo", + "Elektra", + "Iris", + "Nemesis", + "Perseus", + "Selene", + "Thalia", + "Argus", + "Ares", + "Demeter", + "Dione", + "Echo", + "Eros", + "Hephaestus", + "Hermes", + "Hyperion", + "Iapetus", + "Kronos", + "Leto", + "Maia", + "Mnemosyne" + ], + "default": "Apollo", + "description": "Voice to use for explanation" + }, + "model": { + "type": "string", + "enum": [ + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts" + ], + "default": "gemini-2.5-pro-preview-tts", + "description": "Speech generation model" + }, + "output_format": { + "type": "string", + "enum": [ + "wav", + "base64", + "url" + ], + "default": "base64", + "description": "Output format for generated audio" + }, + "explanation_level": { + "type": "string", + "enum": [ + "beginner", + "intermediate", + "advanced" + ], + "default": "intermediate", + "description": "Technical level of explanation" + }, + "include_examples": { + "type": "boolean", + "default": true, + "description": "Include examples in explanation" + } + }, + "required": [ + "code" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "mouth_customize", + "description": "Test different voices and styles to find the best fit for your content", + "inputSchema": { + "type": "object", + "properties": { + "text": { + "type": "string", + "minLength": 1, + "maxLength": 1000, + "description": "Sample text to test voice customization" + }, + "voice": { + "type": "string", + "enum": [ + "Astrid", + "Charon", + "Fenrir", + "Kore", + "Odin", + "Puck", + "Sage", + "Vox", + "Zephyr", + "Aoede", + "Apollo", + "Elektra", + "Iris", + "Nemesis", + "Perseus", + "Selene", + "Thalia", + "Argus", + "Ares", + "Demeter", + "Dione", + "Echo", + "Eros", + "Hephaestus", + "Hermes", + "Hyperion", + "Iapetus", + "Kronos", + "Leto", + "Maia", + "Mnemosyne" + ], + "description": "Base voice to customize" + }, + "model": { + "type": "string", + "enum": [ + "gemini-2.5-flash-preview-tts", + "gemini-2.5-pro-preview-tts" + ], + "default": "gemini-2.5-flash-preview-tts", + "description": "Speech generation model" + }, + "language": { + "type": "string", + "enum": [ + "ar-EG", + "de-DE", + "en-US", + "es-US", + "fr-FR", + "hi-IN", + "id-ID", + "it-IT", + "ja-JP", + "ko-KR", + "pt-BR", + "ru-RU", + "nl-NL", + "pl-PL", + "th-TH", + "tr-TR", + "vi-VN", + "ro-RO", + "uk-UA", + "bn-BD", + "en-IN", + "mr-IN", + "ta-IN", + "te-IN" + ], + "default": "en-US", + "description": "Language for speech generation" + }, + "output_format": { + "type": "string", + "enum": [ + "wav", + "base64", + "url" + ], + "default": "base64", + "description": "Output format for generated audio" + }, + "style_variations": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Array of different style prompts to test" + }, + "compare_voices": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "Astrid", + "Charon", + "Fenrir", + "Kore", + "Odin", + "Puck", + "Sage", + "Vox", + "Zephyr", + "Aoede", + "Apollo", + "Elektra", + "Iris", + "Nemesis", + "Perseus", + "Selene", + "Thalia", + "Argus", + "Ares", + "Demeter", + "Dione", + "Echo", + "Eros", + "Hephaestus", + "Hermes", + "Hyperion", + "Iapetus", + "Kronos", + "Leto", + "Maia", + "Mnemosyne" + ] + }, + "description": "Additional voices to compare with the main voice" + } + }, + "required": [ + "text", + "voice" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "mcp__reasoning__sequentialthinking", + "description": "Advanced sequential thinking for complex problems with thought revision and branching", + "inputSchema": { + "type": "object", + "properties": { + "thought": { + "type": "string", + "description": "Your current thinking step" + }, + "nextThoughtNeeded": { + "type": "boolean", + "description": "Whether another thought step is needed" + }, + "thoughtNumber": { + "type": "integer", + "minimum": 1, + "description": "Current thought number" + }, + "totalThoughts": { + "type": "integer", + "minimum": 1, + "description": "Estimated total thoughts needed" + }, + "sessionId": { + "type": "string", + "description": "Thinking session ID (auto-generated if not provided)" + }, + "problem": { + "type": "string", + "description": "The problem to think through (required for new sessions)" + }, + "isRevision": { + "type": "boolean", + "default": false, + "description": "Whether this revises previous thinking" + }, + "revisesThought": { + "type": "integer", + "minimum": 1, + "description": "Which thought is being reconsidered" + }, + "branchId": { + "type": "string", + "description": "Branch identifier" + }, + "branchFromThought": { + "type": "integer", + "minimum": 1, + "description": "Branching point thought number" + } + }, + "required": [ + "thought", + "nextThoughtNeeded", + "thoughtNumber", + "totalThoughts" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "brain_analyze_simple", + "description": "Fast pattern-based analysis using proven frameworks", + "inputSchema": { + "type": "object", + "properties": { + "problem": { + "type": "string", + "description": "The problem or situation to analyze" + }, + "pattern": { + "type": "string", + "enum": [ + "problem_solving", + "root_cause", + "pros_cons", + "swot", + "cause_effect" + ], + "description": "Analysis framework to use" + }, + "context": { + "type": "string", + "description": "Additional context or background information" + } + }, + "required": [ + "problem", + "pattern" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "brain_patterns_info", + "description": "List available reasoning patterns and their descriptions", + "inputSchema": { + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Specific pattern to get info about (optional)" + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "human-mcp", + "name": "brain_reflect_enhanced", + "description": "AI-powered reflection for complex analysis improvement", + "inputSchema": { + "type": "object", + "properties": { + "originalAnalysis": { + "type": "string", + "minLength": 50, + "maxLength": 5000, + "description": "The analysis or reasoning to reflect on" + }, + "focusAreas": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "assumptions", + "logic_gaps", + "alternative_approaches", + "evidence_quality", + "bias_detection", + "completeness" + ] + }, + "minItems": 1, + "maxItems": 3, + "description": "Specific aspects to focus reflection on" + }, + "improvementGoal": { + "type": "string", + "description": "Primary goal for improvement" + }, + "detailLevel": { + "type": "string", + "enum": [ + "concise", + "detailed" + ], + "default": "detailed", + "description": "Level of analysis detail" + } + }, + "required": [ + "originalAnalysis", + "focusAreas" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "chrome-devtools", + "name": "click", + "description": "Clicks on the provided element", + "inputSchema": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "The uid of an element on the page from the page content snapshot" + }, + "dblClick": { + "type": "boolean", + "description": "Set to true for double clicks. Default is false." + } + }, + "required": [ + "uid" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "chrome-devtools", + "name": "close_page", + "description": "Closes the page by its index. The last open page cannot be closed.", + "inputSchema": { + "type": "object", + "properties": { + "pageIdx": { + "type": "number", + "description": "The index of the page to close. Call list_pages to list pages." + } + }, + "required": [ + "pageIdx" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "chrome-devtools", + "name": "drag", + "description": "Drag an element onto another element", + "inputSchema": { + "type": "object", + "properties": { + "from_uid": { + "type": "string", + "description": "The uid of the element to drag" + }, + "to_uid": { + "type": "string", + "description": "The uid of the element to drop into" + } + }, + "required": [ + "from_uid", + "to_uid" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "chrome-devtools", + "name": "emulate", + "description": "Emulates various features on the selected page.", + "inputSchema": { + "type": "object", + "properties": { + "networkConditions": { + "type": "string", + "enum": [ + "No emulation", + "Offline", + "Slow 3G", + "Fast 3G", + "Slow 4G", + "Fast 4G" + ], + "description": "Throttle network. Set to \"No emulation\" to disable. If omitted, conditions remain unchanged." + }, + "cpuThrottlingRate": { + "type": "number", + "minimum": 1, + "maximum": 20, + "description": "Represents the CPU slowdown factor. Set the rate to 1 to disable throttling. If omitted, throttling remains unchanged." + } + }, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "chrome-devtools", + "name": "evaluate_script", + "description": "Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON\nso returned values have to JSON-serializable.", + "inputSchema": { + "type": "object", + "properties": { + "function": { + "type": "string", + "description": "A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch(\"example.com\")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n" + }, + "args": { + "type": "array", + "items": { + "type": "object", + "properties": { + "uid": { + "type": "string", + "description": "The uid of an element on the page from the page content snapshot" + } + }, + "required": [ + "uid" + ], + "additionalProperties": false + }, + "description": "An optional list of arguments to pass to the function." + } + }, + "required": [ + "function" + ], + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "serverName": "chrome-devtools", + "name": "fill", + "description": "Type text into a input, text area or select an option from a + {% for variant in product.variants %} + + {% endfor %} + + + + {% endform %} +
                      +
                      +``` + +### Sections + +Reusable content blocks (`sections/product-grid.liquid`): + +```liquid +
                      + {% for product in section.settings.collection.products %} + + {% endfor %} +
                      + +{% schema %} +{ + "name": "Product Grid", + "settings": [ + { + "type": "collection", + "id": "collection", + "label": "Collection" + }, + { + "type": "range", + "id": "products_per_row", + "min": 2, + "max": 5, + "step": 1, + "default": 4, + "label": "Products per row" + } + ], + "presets": [ + { + "name": "Product Grid" + } + ] +} +{% endschema %} +``` + +### Snippets + +Small reusable components (`snippets/product-card.liquid`): + +```liquid + +``` + +Include snippet: +```liquid +{% render 'product-card', product: product %} +``` + +## Development Workflow + +### Setup + +```bash +# Initialize new theme +shopify theme init + +# Choose Dawn (reference theme) or blank +``` + +### Local Development + +```bash +# Start local server +shopify theme dev + +# Preview at http://localhost:9292 +# Changes auto-sync to development theme +``` + +### Pull Theme + +```bash +# Pull live theme +shopify theme pull --live + +# Pull specific theme +shopify theme pull --theme=123456789 + +# Pull only templates +shopify theme pull --only=templates +``` + +### Push Theme + +```bash +# Push to development theme +shopify theme push --development + +# Create new unpublished theme +shopify theme push --unpublished + +# Push specific files +shopify theme push --only=sections,snippets +``` + +### Theme Check + +Lint theme code: +```bash +shopify theme check +shopify theme check --auto-correct +``` + +## Common Patterns + +### Product Form with Variants + +```liquid +{% form 'product', product %} + {% unless product.has_only_default_variant %} + {% for option in product.options_with_values %} +
                      + + +
                      + {% endfor %} + {% endunless %} + + + + + +{% endform %} +``` + +### Pagination + +```liquid +{% paginate collection.products by 12 %} + {% for product in collection.products %} + {% render 'product-card', product: product %} + {% endfor %} + + {% if paginate.pages > 1 %} + + {% endif %} +{% endpaginate %} +``` + +### Cart AJAX + +```javascript +// Add to cart +fetch('/cart/add.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: variantId, + quantity: 1 + }) +}) +.then(res => res.json()) +.then(item => console.log('Added:', item)); + +// Get cart +fetch('/cart.js') + .then(res => res.json()) + .then(cart => console.log('Cart:', cart)); + +// Update cart +fetch('/cart/change.js', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: lineItemKey, + quantity: 2 + }) +}) +.then(res => res.json()); +``` + +## Metafields in Themes + +Access custom data: + +```liquid +{{ product.metafields.custom.care_instructions }} +{{ product.metafields.custom.material.value }} + +{% if product.metafields.custom.featured %} + Featured +{% endif %} +``` + +## Best Practices + +**Performance:** +- Optimize images (use appropriate sizes) +- Minimize Liquid logic complexity +- Use lazy loading for images +- Defer non-critical JavaScript + +**Accessibility:** +- Use semantic HTML +- Include alt text for images +- Support keyboard navigation +- Ensure sufficient color contrast + +**SEO:** +- Use descriptive page titles +- Include meta descriptions +- Structure content with headings +- Implement schema markup + +**Code Quality:** +- Follow Shopify theme guidelines +- Use consistent naming conventions +- Comment complex logic +- Keep sections focused and reusable + +## Resources + +- Theme Development: https://shopify.dev/docs/themes +- Liquid Reference: https://shopify.dev/docs/api/liquid +- Dawn Theme: https://github.com/Shopify/dawn +- Theme Check: https://shopify.dev/docs/themes/tools/theme-check diff --git a/skills/shopify/scripts/requirements.txt b/skills/shopify/scripts/requirements.txt new file mode 100644 index 0000000..4613a2b --- /dev/null +++ b/skills/shopify/scripts/requirements.txt @@ -0,0 +1,19 @@ +# Shopify Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This script requires the Shopify CLI tool +# Install Shopify CLI: +# npm install -g @shopify/cli @shopify/theme +# or via Homebrew (macOS): +# brew tap shopify/shopify +# brew install shopify-cli +# +# Authenticate with: +# shopify auth login diff --git a/skills/shopify/scripts/shopify_init.py b/skills/shopify/scripts/shopify_init.py new file mode 100644 index 0000000..aa3547e --- /dev/null +++ b/skills/shopify/scripts/shopify_init.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +""" +Shopify Project Initialization Script + +Interactive script to scaffold Shopify apps, extensions, or themes. +Supports environment variable loading from multiple locations. +""" + +import os +import sys +import json +import subprocess +from pathlib import Path +from typing import Dict, Optional, List +from dataclasses import dataclass + + +@dataclass +class EnvConfig: + """Environment configuration container.""" + shopify_api_key: Optional[str] = None + shopify_api_secret: Optional[str] = None + shop_domain: Optional[str] = None + scopes: Optional[str] = None + + +class EnvLoader: + """Load environment variables from multiple sources in priority order.""" + + @staticmethod + def load_env_file(filepath: Path) -> Dict[str, str]: + """ + Load environment variables from .env file. + + Args: + filepath: Path to .env file + + Returns: + Dictionary of environment variables + """ + env_vars = {} + if not filepath.exists(): + return env_vars + + try: + with open(filepath, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_vars[key.strip()] = value.strip().strip('"').strip("'") + except Exception as e: + print(f"Warning: Failed to load {filepath}: {e}") + + return env_vars + + @staticmethod + def get_env_paths(skill_dir: Path) -> List[Path]: + """ + Get list of .env file paths in priority order. + + Priority: process.env > skill/.env > skills/.env > .claude/.env + + Args: + skill_dir: Path to skill directory + + Returns: + List of .env file paths + """ + paths = [] + + # skill/.env + skill_env = skill_dir / '.env' + if skill_env.exists(): + paths.append(skill_env) + + # skills/.env + skills_env = skill_dir.parent / '.env' + if skills_env.exists(): + paths.append(skills_env) + + # .claude/.env + claude_env = skill_dir.parent.parent / '.env' + if claude_env.exists(): + paths.append(claude_env) + + return paths + + @staticmethod + def load_config(skill_dir: Path) -> EnvConfig: + """ + Load configuration from environment variables. + + Priority: process.env > skill/.env > skills/.env > .claude/.env + + Args: + skill_dir: Path to skill directory + + Returns: + EnvConfig object + """ + config = EnvConfig() + + # Load from .env files (reverse priority order) + for env_path in reversed(EnvLoader.get_env_paths(skill_dir)): + env_vars = EnvLoader.load_env_file(env_path) + if 'SHOPIFY_API_KEY' in env_vars: + config.shopify_api_key = env_vars['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in env_vars: + config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in env_vars: + config.shop_domain = env_vars['SHOP_DOMAIN'] + if 'SCOPES' in env_vars: + config.scopes = env_vars['SCOPES'] + + # Override with process environment (highest priority) + if 'SHOPIFY_API_KEY' in os.environ: + config.shopify_api_key = os.environ['SHOPIFY_API_KEY'] + if 'SHOPIFY_API_SECRET' in os.environ: + config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET'] + if 'SHOP_DOMAIN' in os.environ: + config.shop_domain = os.environ['SHOP_DOMAIN'] + if 'SCOPES' in os.environ: + config.scopes = os.environ['SCOPES'] + + return config + + +class ShopifyInitializer: + """Initialize Shopify projects.""" + + def __init__(self, config: EnvConfig): + """ + Initialize ShopifyInitializer. + + Args: + config: Environment configuration + """ + self.config = config + + def prompt(self, message: str, default: Optional[str] = None) -> str: + """ + Prompt user for input. + + Args: + message: Prompt message + default: Default value + + Returns: + User input or default + """ + if default: + message = f"{message} [{default}]" + user_input = input(f"{message}: ").strip() + return user_input if user_input else (default or '') + + def select_option(self, message: str, options: List[str]) -> str: + """ + Prompt user to select from options. + + Args: + message: Prompt message + options: List of options + + Returns: + Selected option + """ + print(f"\n{message}") + for i, option in enumerate(options, 1): + print(f"{i}. {option}") + + while True: + try: + choice = int(input("Select option: ").strip()) + if 1 <= choice <= len(options): + return options[choice - 1] + print(f"Please select 1-{len(options)}") + except (ValueError, KeyboardInterrupt): + print("Invalid input") + + def check_cli_installed(self) -> bool: + """ + Check if Shopify CLI is installed. + + Returns: + True if installed, False otherwise + """ + try: + result = subprocess.run( + ['shopify', 'version'], + capture_output=True, + text=True, + timeout=5 + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None: + """ + Create shopify.app.toml configuration file. + + Args: + project_dir: Project directory + app_name: Application name + scopes: Access scopes + """ + config_content = f"""# Shopify App Configuration +name = "{app_name}" +client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}" +application_url = "https://your-app.com" +embedded = true + +[build] +automatically_update_urls_on_dev = true +dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}" + +[access_scopes] +scopes = "{scopes}" + +[webhooks] +api_version = "2025-01" + +[[webhooks.subscriptions]] +topics = ["app/uninstalled"] +uri = "/webhooks/app/uninstalled" + +[webhooks.privacy_compliance] +customer_data_request_url = "/webhooks/gdpr/data-request" +customer_deletion_url = "/webhooks/gdpr/customer-deletion" +shop_deletion_url = "/webhooks/gdpr/shop-deletion" +""" + config_path = project_dir / 'shopify.app.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None: + """ + Create shopify.extension.toml configuration file. + + Args: + project_dir: Project directory + extension_name: Extension name + extension_type: Extension type + """ + target_map = { + 'checkout': 'purchase.checkout.block.render', + 'admin_action': 'admin.product-details.action.render', + 'admin_block': 'admin.product-details.block.render', + 'pos': 'pos.home.tile.render' + } + + config_content = f"""name = "{extension_name}" +type = "ui_extension" +handle = "{extension_name.lower().replace(' ', '-')}" + +[extension_points] +api_version = "2025-01" + +[[extension_points.targets]] +target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}" + +[capabilities] +network_access = true +api_access = true +""" + config_path = project_dir / 'shopify.extension.toml' + config_path.write_text(config_content) + print(f"✓ Created {config_path}") + + def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None: + """ + Create README.md file. + + Args: + project_dir: Project directory + project_type: Project type (app/extension/theme) + project_name: Project name + """ + content = f"""# {project_name} + +Shopify {project_type.capitalize()} project. + +## Setup + +```bash +# Install dependencies +npm install + +# Start development +shopify {project_type} dev +``` + +## Deployment + +```bash +# Deploy to Shopify +shopify {project_type} deploy +``` + +## Resources + +- [Shopify Documentation](https://shopify.dev/docs) +- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli) +""" + readme_path = project_dir / 'README.md' + readme_path.write_text(content) + print(f"✓ Created {readme_path}") + + def init_app(self) -> None: + """Initialize Shopify app project.""" + print("\n=== Shopify App Initialization ===\n") + + app_name = self.prompt("App name", "my-shopify-app") + scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products") + + project_dir = Path.cwd() / app_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating app in {project_dir}...") + + self.create_app_config(project_dir, app_name, scopes) + self.create_readme(project_dir, "app", app_name) + + # Create basic package.json + package_json = { + "name": app_name.lower().replace(' ', '-'), + "version": "1.0.0", + "scripts": { + "dev": "shopify app dev", + "deploy": "shopify app deploy" + } + } + (project_dir / 'package.json').write_text(json.dumps(package_json, indent=2)) + print(f"✓ Created package.json") + + print(f"\n✓ App '{app_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {app_name}") + print(f" npm install") + print(f" shopify app dev") + + def init_extension(self) -> None: + """Initialize Shopify extension project.""" + print("\n=== Shopify Extension Initialization ===\n") + + extension_types = ['checkout', 'admin_action', 'admin_block', 'pos'] + extension_type = self.select_option("Select extension type", extension_types) + + extension_name = self.prompt("Extension name", "my-extension") + + project_dir = Path.cwd() / extension_name + project_dir.mkdir(exist_ok=True) + + print(f"\nCreating extension in {project_dir}...") + + self.create_extension_config(project_dir, extension_name, extension_type) + self.create_readme(project_dir, "extension", extension_name) + + print(f"\n✓ Extension '{extension_name}' initialized successfully!") + print(f"\nNext steps:") + print(f" cd {extension_name}") + print(f" shopify app dev") + + def init_theme(self) -> None: + """Initialize Shopify theme project.""" + print("\n=== Shopify Theme Initialization ===\n") + + theme_name = self.prompt("Theme name", "my-theme") + + print(f"\nInitializing theme '{theme_name}'...") + print("\nRecommended: Use 'shopify theme init' for full theme scaffolding") + print(f"\nRun: shopify theme init {theme_name}") + + def run(self) -> None: + """Run interactive initialization.""" + print("=" * 60) + print("Shopify Project Initializer") + print("=" * 60) + + # Check CLI + if not self.check_cli_installed(): + print("\n⚠ Shopify CLI not found!") + print("Install: npm install -g @shopify/cli@latest") + sys.exit(1) + + # Select project type + project_types = ['app', 'extension', 'theme'] + project_type = self.select_option("Select project type", project_types) + + # Initialize based on type + if project_type == 'app': + self.init_app() + elif project_type == 'extension': + self.init_extension() + elif project_type == 'theme': + self.init_theme() + + +def main() -> None: + """Main entry point.""" + try: + # Get skill directory + script_dir = Path(__file__).parent + skill_dir = script_dir.parent + + # Load configuration + config = EnvLoader.load_config(skill_dir) + + # Initialize project + initializer = ShopifyInitializer(config) + initializer.run() + + except KeyboardInterrupt: + print("\n\nAborted.") + sys.exit(0) + except Exception as e: + print(f"\n✗ Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/skills/shopify/scripts/tests/test_shopify_init.py b/skills/shopify/scripts/tests/test_shopify_init.py new file mode 100644 index 0000000..6c68cf2 --- /dev/null +++ b/skills/shopify/scripts/tests/test_shopify_init.py @@ -0,0 +1,385 @@ +""" +Tests for shopify_init.py + +Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing +""" + +import os +import sys +import json +import pytest +import subprocess +from pathlib import Path +from unittest.mock import Mock, patch, mock_open, MagicMock + +# Add parent directory to path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer + + +class TestEnvLoader: + """Test EnvLoader class.""" + + def test_load_env_file_success(self, tmp_path): + """Test loading valid .env file.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY=test_key +SHOPIFY_API_SECRET=test_secret +SHOP_DOMAIN=test.myshopify.com +# Comment line +SCOPES=read_products,write_products +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + assert result['SHOP_DOMAIN'] == 'test.myshopify.com' + assert result['SCOPES'] == 'read_products,write_products' + + def test_load_env_file_with_quotes(self, tmp_path): + """Test loading .env file with quoted values.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +SHOPIFY_API_KEY="test_key" +SHOPIFY_API_SECRET='test_secret' +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['SHOPIFY_API_KEY'] == 'test_key' + assert result['SHOPIFY_API_SECRET'] == 'test_secret' + + def test_load_env_file_nonexistent(self, tmp_path): + """Test loading non-existent .env file.""" + result = EnvLoader.load_env_file(tmp_path / "nonexistent.env") + assert result == {} + + def test_load_env_file_invalid_format(self, tmp_path): + """Test loading .env file with invalid lines.""" + env_file = tmp_path / ".env" + env_file.write_text(""" +VALID_KEY=value +INVALID_LINE_NO_EQUALS +ANOTHER_VALID=test +""") + + result = EnvLoader.load_env_file(env_file) + + assert result['VALID_KEY'] == 'value' + assert result['ANOTHER_VALID'] == 'test' + assert 'INVALID_LINE_NO_EQUALS' not in result + + def test_get_env_paths(self, tmp_path): + """Test getting .env file paths.""" + # Create directory structure + claude_dir = tmp_path / ".claude" + skills_dir = claude_dir / "skills" + skill_dir = skills_dir / "shopify" + + skill_dir.mkdir(parents=True) + + # Create .env files + (skill_dir / ".env").write_text("SKILL=1") + (skills_dir / ".env").write_text("SKILLS=1") + (claude_dir / ".env").write_text("CLAUDE=1") + + paths = EnvLoader.get_env_paths(skill_dir) + + assert len(paths) == 3 + assert skill_dir / ".env" in paths + assert skills_dir / ".env" in paths + assert claude_dir / ".env" in paths + + def test_load_config_priority(self, tmp_path, monkeypatch): + """Test configuration loading priority.""" + skill_dir = tmp_path / "skill" + skills_dir = tmp_path + claude_dir = tmp_path.parent + + skill_dir.mkdir(parents=True) + + # Create .env files with different values + (skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key") + (skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com") + + # Override with process env + monkeypatch.setenv("SHOPIFY_API_KEY", "process_key") + + config = EnvLoader.load_config(skill_dir) + + # Process env should win + assert config.shopify_api_key == "process_key" + # Shop domain from skills/.env + assert config.shop_domain == "skills.myshopify.com" + + def test_load_config_no_files(self, tmp_path): + """Test configuration loading with no .env files.""" + config = EnvLoader.load_config(tmp_path) + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + +class TestShopifyInitializer: + """Test ShopifyInitializer class.""" + + @pytest.fixture + def config(self): + """Create test config.""" + return EnvConfig( + shopify_api_key="test_key", + shopify_api_secret="test_secret", + shop_domain="test.myshopify.com", + scopes="read_products,write_products" + ) + + @pytest.fixture + def initializer(self, config): + """Create initializer instance.""" + return ShopifyInitializer(config) + + def test_prompt_with_default(self, initializer): + """Test prompt with default value.""" + with patch('builtins.input', return_value=''): + result = initializer.prompt("Test", "default_value") + assert result == "default_value" + + def test_prompt_with_input(self, initializer): + """Test prompt with user input.""" + with patch('builtins.input', return_value='user_input'): + result = initializer.prompt("Test", "default_value") + assert result == "user_input" + + def test_select_option_valid(self, initializer): + """Test select option with valid choice.""" + options = ['app', 'extension', 'theme'] + with patch('builtins.input', return_value='2'): + result = initializer.select_option("Choose", options) + assert result == 'extension' + + def test_select_option_invalid_then_valid(self, initializer): + """Test select option with invalid then valid choice.""" + options = ['app', 'extension'] + with patch('builtins.input', side_effect=['5', 'invalid', '1']): + result = initializer.select_option("Choose", options) + assert result == 'app' + + def test_check_cli_installed_success(self, initializer): + """Test CLI installed check - success.""" + mock_result = Mock() + mock_result.returncode = 0 + + with patch('subprocess.run', return_value=mock_result): + assert initializer.check_cli_installed() is True + + def test_check_cli_installed_failure(self, initializer): + """Test CLI installed check - failure.""" + with patch('subprocess.run', side_effect=FileNotFoundError): + assert initializer.check_cli_installed() is False + + def test_create_app_config(self, initializer, tmp_path): + """Test creating app configuration file.""" + initializer.create_app_config(tmp_path, "test-app", "read_products") + + config_file = tmp_path / "shopify.app.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-app"' in content + assert 'scopes = "read_products"' in content + assert 'client_id = "test_key"' in content + + def test_create_extension_config(self, initializer, tmp_path): + """Test creating extension configuration file.""" + initializer.create_extension_config(tmp_path, "test-ext", "checkout") + + config_file = tmp_path / "shopify.extension.toml" + assert config_file.exists() + + content = config_file.read_text() + assert 'name = "test-ext"' in content + assert 'purchase.checkout.block.render' in content + + def test_create_extension_config_admin_action(self, initializer, tmp_path): + """Test creating admin action extension config.""" + initializer.create_extension_config(tmp_path, "admin-ext", "admin_action") + + config_file = tmp_path / "shopify.extension.toml" + content = config_file.read_text() + assert 'admin.product-details.action.render' in content + + def test_create_readme(self, initializer, tmp_path): + """Test creating README file.""" + initializer.create_readme(tmp_path, "app", "Test App") + + readme_file = tmp_path / "README.md" + assert readme_file.exists() + + content = readme_file.read_text() + assert '# Test App' in content + assert 'shopify app dev' in content + + @patch('builtins.input') + @patch('builtins.print') + def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test app initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs + mock_input.side_effect = ['my-app', 'read_products,write_products'] + + initializer.init_app() + + # Check directory created + app_dir = tmp_path / "my-app" + assert app_dir.exists() + + # Check files created + assert (app_dir / "shopify.app.toml").exists() + assert (app_dir / "README.md").exists() + assert (app_dir / "package.json").exists() + + # Check package.json content + package_json = json.loads((app_dir / "package.json").read_text()) + assert package_json['name'] == 'my-app' + assert 'dev' in package_json['scripts'] + + @patch('builtins.input') + @patch('builtins.print') + def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch): + """Test extension initialization.""" + monkeypatch.chdir(tmp_path) + + # Mock user inputs: type selection (1 = checkout), name + mock_input.side_effect = ['1', 'my-extension'] + + initializer.init_extension() + + # Check directory and files created + ext_dir = tmp_path / "my-extension" + assert ext_dir.exists() + assert (ext_dir / "shopify.extension.toml").exists() + assert (ext_dir / "README.md").exists() + + @patch('builtins.input') + @patch('builtins.print') + def test_init_theme(self, mock_print, mock_input, initializer): + """Test theme initialization.""" + mock_input.return_value = 'my-theme' + + # Should just print instructions + initializer.init_theme() + + # Verify print was called (instructions shown) + assert mock_print.called + + @patch('builtins.print') + def test_run_no_cli(self, mock_print, initializer): + """Test run when CLI not installed.""" + with patch.object(initializer, 'check_cli_installed', return_value=False): + with pytest.raises(SystemExit) as exc_info: + initializer.run() + assert exc_info.value.code == 1 + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_app') + @patch('builtins.input') + @patch('builtins.print') + def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer): + """Test run with app selection.""" + mock_input.return_value = '1' # Select app + + initializer.run() + + mock_init_app.assert_called_once() + + @patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True) + @patch.object(ShopifyInitializer, 'init_extension') + @patch('builtins.input') + @patch('builtins.print') + def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer): + """Test run with extension selection.""" + mock_input.return_value = '2' # Select extension + + initializer.run() + + mock_init_ext.assert_called_once() + + +class TestMain: + """Test main function.""" + + @patch('shopify_init.ShopifyInitializer') + @patch('shopify_init.EnvLoader') + def test_main_success(self, mock_loader, mock_initializer): + """Test main function success path.""" + from shopify_init import main + + mock_config = Mock() + mock_loader.load_config.return_value = mock_config + + mock_init_instance = Mock() + mock_initializer.return_value = mock_init_instance + + with patch('builtins.print'): + main() + + mock_init_instance.run.assert_called_once() + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_keyboard_interrupt(self, mock_exit, mock_initializer): + """Test main function with keyboard interrupt.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = KeyboardInterrupt + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(0) + + @patch('shopify_init.ShopifyInitializer') + @patch('sys.exit') + def test_main_exception(self, mock_exit, mock_initializer): + """Test main function with exception.""" + from shopify_init import main + + mock_initializer.return_value.run.side_effect = Exception("Test error") + + with patch('builtins.print'): + main() + + mock_exit.assert_called_with(1) + + +class TestEnvConfig: + """Test EnvConfig dataclass.""" + + def test_env_config_defaults(self): + """Test EnvConfig default values.""" + config = EnvConfig() + + assert config.shopify_api_key is None + assert config.shopify_api_secret is None + assert config.shop_domain is None + assert config.scopes is None + + def test_env_config_with_values(self): + """Test EnvConfig with values.""" + config = EnvConfig( + shopify_api_key="key", + shopify_api_secret="secret", + shop_domain="test.myshopify.com", + scopes="read_products" + ) + + assert config.shopify_api_key == "key" + assert config.shopify_api_secret == "secret" + assert config.shop_domain == "test.myshopify.com" + assert config.scopes == "read_products" diff --git a/skills/skill-creator/LICENSE.txt b/skills/skill-creator/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/skills/skill-creator/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/skills/skill-creator/SKILL.md b/skills/skill-creator/SKILL.md new file mode 100644 index 0000000..7ce82af --- /dev/null +++ b/skills/skill-creator/SKILL.md @@ -0,0 +1,237 @@ +--- +name: skill-creator +description: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. +license: Complete terms in LICENSE.txt +--- + +# Skill Creator + +This skill provides guidance for creating effective skills. + +## About Skills + +Skills are modular, self-contained packages that extend Claude's capabilities by providing +specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific +domains or tasks—they transform Claude from a general-purpose agent into a specialized agent +equipped with procedural knowledge that no model can fully possess. + +### What Skills Provide + +1. Specialized workflows - Multi-step procedures for specific domains +2. Tool integrations - Instructions for working with specific file formats or APIs +3. Domain expertise - Company-specific knowledge, schemas, business logic +4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks + +### Anatomy of a Skill + +Every skill consists of a required SKILL.md file and optional bundled resources: + +``` +skill-name/ +├── SKILL.md (required) +│ ├── YAML frontmatter metadata (required) +│ │ ├── name: (required) +│ │ └── description: (required) +│ └── Markdown instructions (required) +└── Bundled Resources (optional) + ├── scripts/ - Executable code (Python/Bash/etc.) + ├── references/ - Documentation intended to be loaded into context as needed + └── assets/ - Files used in output (templates, icons, fonts, etc.) +``` + +#### Requirements (important) + +- Skill should be combined into specific topics, for example: `cloudflare`, `cloudflare-r2`, `cloudflare-workers`, `docker`, `gcloud` should be combined into `devops` +- `SKILL.md` should be **less than 200 lines** and include the references of related markdown files and scripts. +- Each script or referenced markdown file should be also **less than 200 lines**, remember that you can always split them into multiple files (**progressive disclosure** principle). +- Descriptions in metadata of `SKILL.md` files should be both concise and still contains enough usecases of the references and scripts, this will help skills can be activated automatically during the implementation process of Claude Code. +- **Referenced markdowns**: + - Sacrifice grammar for the sake of concision when writing these files. + - Can reference other markdown files or scripts as well. +- **Referenced scripts**: + - Prefer nodejs or python scripts instead of bash script, because bash scripts are not well-supported on Windows. + - If you're going to write python scripts, make sure you have `requirements.txt` + - Make sure scripts respect `.env` file follow this order: `process.env` > `.claude/skills/${SKILL}/.env` > `.claude/skills/.env` > `.claude/.env` + - Create `.env.example` file to show the required environment variables. + - Always write tests for these scripts. + +**Why?** +Better **context engineering**: inspired from **progressive disclosure** technique of Agent Skills, when agent skills are activated, Claude Code will consider to load only relevant files into the context, instead of reading all long `SKILL.md` as before. + +#### SKILL.md (required) + +**File name:** `SKILL.md` (uppercase) +**File size:** Under 200 lines, if you need more, plit it to multiple files in `references` folder. + +**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when Claude will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. "This skill should be used when..." instead of "Use this skill when..."). + +#### Bundled Resources (optional) + +##### Scripts (`scripts/`) + +Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten. + +- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed +- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks +- **Benefits**: Token efficient, deterministic, may be executed without loading into context +- **Note**: Scripts may still need to be read by Claude for patching or environment-specific adjustments + +##### References (`references/`) + +Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking. + +- **When to include**: For documentation that Claude should reference while working +- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications +- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides +- **Benefits**: Keeps SKILL.md lean, loaded only when Claude determines it's needed +- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md +- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files. + +##### Assets (`assets/`) + +Files not intended to be loaded into context, but rather used within the output Claude produces. + +- **When to include**: When the skill needs files that will be used in the final output +- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography +- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified +- **Benefits**: Separates output resources from documentation, enables Claude to use files without loading them into context + +### Progressive Disclosure Design Principle + +Skills use a three-level loading system to manage context efficiently: + +1. **Metadata (name + description)** - Always in context (~100 words) +2. **SKILL.md body** - When skill triggers (<5k words) +3. **Bundled resources** - As needed by Claude (Unlimited*) + +*Unlimited because scripts can be executed without reading into context window. + +## Skill Creation Process + +To create a skill, follow the "Skill Creation Process" in order, skipping steps only if there is a clear reason why they are not applicable. + +### Step 1: Understanding the Skill with Concrete Examples + +Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill. + +To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback. + +For example, when building an image-editor skill, relevant questions include: + +- "What functionality should the image-editor skill support? Editing, rotating, anything else?" +- "Can you give some examples of how this skill would be used?" +- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?" +- "What would a user say that should trigger this skill?" + +To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness. + +Conclude this step when there is a clear sense of the functionality the skill should support. + +### Step 2: Planning the Reusable Skill Contents + +To turn concrete examples into an effective skill, analyze each example by: + +1. Considering how to execute on the example from scratch +2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly + +Example: When building a `pdf-editor` skill to handle queries like "Help me rotate this PDF," the analysis shows: + +1. Rotating a PDF requires re-writing the same code each time +2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill + +Example: When designing a `frontend-webapp-builder` skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows: + +1. Writing a frontend webapp requires the same boilerplate HTML/React each time +2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill + +Example: When building a `big-query` skill to handle queries like "How many users have logged in today?" the analysis shows: + +1. Querying BigQuery requires re-discovering the table schemas and relationships each time +2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill + +To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets. + +### Step 3: Initializing the Skill + +At this point, it is time to actually create the skill. + +Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step. + +When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. + +Usage: + +```bash +scripts/init_skill.py --path +``` + +The script: + +- Creates the skill directory at the specified path +- Generates a SKILL.md template with proper frontmatter and TODO placeholders +- Creates example resource directories: `scripts/`, `references/`, and `assets/` +- Adds example files in each directory that can be customized or deleted + +After initialization, customize or remove the generated SKILL.md and example files as needed. + +### Step 4: Edit the Skill + +When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Focus on including information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively. + +#### Start with Reusable Skill Contents + +To begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`. + +Also, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them. + +#### Update SKILL.md + +**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., "To accomplish X, do Y" rather than "You should do X" or "If you need to do X"). This maintains consistency and clarity for AI consumption. + +To complete SKILL.md, answer the following questions: + +1. What is the purpose of the skill, in a few sentences? +2. When should the skill be used? +3. In practice, how should Claude use the skill? All reusable skill contents developed above should be referenced so that Claude knows how to use them. + +### Step 5: Packaging a Skill + +Once the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements: + +```bash +scripts/package_skill.py +``` + +Optional output directory specification: + +```bash +scripts/package_skill.py ./dist +``` + +The packaging script will: + +1. **Validate** the skill automatically, checking: + - YAML frontmatter format and required fields + - Skill naming conventions and directory structure + - Description completeness and quality + - File organization and resource references + +2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution. + +If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. + +### Step 6: Iterate + +After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed. + +**Iteration workflow:** +1. Use the skill on real tasks +2. Notice struggles or inefficiencies +3. Identify how SKILL.md or bundled resources should be updated +4. Implement changes and test again + +## References +- [Agent Skills](https://docs.claude.com/en/docs/claude-code/skills.md) +- [Agent Skills Spec](.claude/skills/agent_skills_spec.md) +- [Agent Skills Overview](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/overview.md) +- [Best Practices](https://docs.claude.com/en/docs/agents-and-tools/agent-skills/best-practices.md) \ No newline at end of file diff --git a/skills/skill-creator/scripts/init_skill.py b/skills/skill-creator/scripts/init_skill.py new file mode 100755 index 0000000..329ad4e --- /dev/null +++ b/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,303 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-api-helper --path skills/private + init_skill.py custom-skill --path /custom/location +""" + +import sys +from pathlib import Path + + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" → "Reading" → "Creating" → "Editing" +- Structure: ## Overview → ## Workflow Decision Tree → ## Step 1 → ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" → "Merge PDFs" → "Split PDFs" → "Extract Text" +- Structure: ## Overview → ## Quick Start → ## Task Category 1 → ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" → "Colors" → "Typography" → "Features" +- Structure: ## Overview → ## Guidelines → ## Specifications → ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" → numbered capability list +- Structure: ## Overview → ## Core Capabilities → ### 1. Feature → ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources + +This skill includes example resource directories that demonstrate how to organize different types of bundled resources: + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Claude for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Claude's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Claude should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Claude produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Any unneeded directories can be deleted.** Not every skill requires all three types of resources. +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Claude produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return ' '.join(word.capitalize() for word in skill_name.split('-')) + + +def init_skill(skill_name, path): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"❌ Error: Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"✅ Created skill directory: {skill_dir}") + except Exception as e: + print(f"❌ Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format( + skill_name=skill_name, + skill_title=skill_title + ) + + skill_md_path = skill_dir / 'SKILL.md' + try: + skill_md_path.write_text(skill_content) + print("✅ Created SKILL.md") + except Exception as e: + print(f"❌ Error creating SKILL.md: {e}") + return None + + # Create resource directories with example files + try: + # Create scripts/ directory with example script + scripts_dir = skill_dir / 'scripts' + scripts_dir.mkdir(exist_ok=True) + example_script = scripts_dir / 'example.py' + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("✅ Created scripts/example.py") + + # Create references/ directory with example reference doc + references_dir = skill_dir / 'references' + references_dir.mkdir(exist_ok=True) + example_reference = references_dir / 'api_reference.md' + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("✅ Created references/api_reference.md") + + # Create assets/ directory with example asset placeholder + assets_dir = skill_dir / 'assets' + assets_dir.mkdir(exist_ok=True) + example_asset = assets_dir / 'example_asset.txt' + example_asset.write_text(EXAMPLE_ASSET) + print("✅ Created assets/example_asset.txt") + except Exception as e: + print(f"❌ Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + print("2. Customize or delete the example files in scripts/, references/, and assets/") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + if len(sys.argv) < 4 or sys.argv[2] != '--path': + print("Usage: init_skill.py --path ") + print("\nSkill name requirements:") + print(" - Hyphen-case identifier (e.g., 'data-analyzer')") + print(" - Lowercase letters, digits, and hyphens only") + print(" - Max 40 characters") + print(" - Must match directory name exactly") + print("\nExamples:") + print(" init_skill.py my-new-skill --path skills/public") + print(" init_skill.py my-api-helper --path skills/private") + print(" init_skill.py custom-skill --path /custom/location") + sys.exit(1) + + skill_name = sys.argv[1] + path = sys.argv[3] + + print(f"🚀 Initializing skill: {skill_name}") + print(f" Location: {path}") + print() + + result = init_skill(skill_name, path) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/package_skill.py b/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..3ee8e8e --- /dev/null +++ b/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable zip file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path +from quick_validate import validate_skill + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a zip file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the zip file (defaults to current directory) + + Returns: + Path to the created zip file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"❌ Error: Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"❌ Error: Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"❌ Error: SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("🔍 Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"❌ Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"✅ {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + zip_filename = output_path / f"{skill_name}.zip" + + # Create the zip file + try: + with zipfile.ZipFile(zip_filename, 'w', zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob('*'): + if file_path.is_file(): + # Calculate the relative path within the zip + arcname = file_path.relative_to(skill_path.parent) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n✅ Successfully packaged skill to: {zip_filename}") + return zip_filename + + except Exception as e: + print(f"❌ Error creating zip file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"📦 Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/skill-creator/scripts/quick_validate.py b/skills/skill-creator/scripts/quick_validate.py new file mode 100755 index 0000000..6fa6c63 --- /dev/null +++ b/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Quick validation script for skills - minimal version +""" + +import sys +import os +import re +from pathlib import Path + +def validate_skill(skill_path): + """Basic validation of a skill""" + skill_path = Path(skill_path) + + # Check SKILL.md exists + skill_md = skill_path / 'SKILL.md' + if not skill_md.exists(): + return False, "SKILL.md not found" + + # Read and validate frontmatter + content = skill_md.read_text() + if not content.startswith('---'): + return False, "No YAML frontmatter found" + + # Extract frontmatter + match = re.match(r'^---\n(.*?)\n---', content, re.DOTALL) + if not match: + return False, "Invalid frontmatter format" + + frontmatter = match.group(1) + + # Check required fields + if 'name:' not in frontmatter: + return False, "Missing 'name' in frontmatter" + if 'description:' not in frontmatter: + return False, "Missing 'description' in frontmatter" + + # Extract name for validation + name_match = re.search(r'name:\s*(.+)', frontmatter) + if name_match: + name = name_match.group(1).strip() + # Check naming convention (hyphen-case: lowercase with hyphens) + if not re.match(r'^[a-z0-9-]+$', name): + return False, f"Name '{name}' should be hyphen-case (lowercase letters, digits, and hyphens only)" + if name.startswith('-') or name.endswith('-') or '--' in name: + return False, f"Name '{name}' cannot start/end with hyphen or contain consecutive hyphens" + + # Extract and validate description + desc_match = re.search(r'description:\s*(.+)', frontmatter) + if desc_match: + description = desc_match.group(1).strip() + # Check for angle brackets + if '<' in description or '>' in description: + return False, "Description cannot contain angle brackets (< or >)" + + return True, "Skill is valid!" + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) \ No newline at end of file diff --git a/skills/template-skill/SKILL.md b/skills/template-skill/SKILL.md new file mode 100644 index 0000000..50a4f9b --- /dev/null +++ b/skills/template-skill/SKILL.md @@ -0,0 +1,6 @@ +--- +name: template-skill +description: Replace with description of the skill and when Claude should use it. +--- + +# Insert instructions below diff --git a/skills/ui-styling/LICENSE.txt b/skills/ui-styling/LICENSE.txt new file mode 100644 index 0000000..7a4a3ea --- /dev/null +++ b/skills/ui-styling/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/skills/ui-styling/SKILL.md b/skills/ui-styling/SKILL.md new file mode 100644 index 0000000..b965199 --- /dev/null +++ b/skills/ui-styling/SKILL.md @@ -0,0 +1,321 @@ +--- +name: ui-styling +description: Create beautiful, accessible user interfaces with shadcn/ui components (built on Radix UI + Tailwind), Tailwind CSS utility-first styling, and canvas-based visual designs. Use when building user interfaces, implementing design systems, creating responsive layouts, adding accessible components (dialogs, dropdowns, forms, tables), customizing themes and colors, implementing dark mode, generating visual designs and posters, or establishing consistent styling patterns across applications. +license: MIT +version: 1.0.0 +--- + +# UI Styling Skill + +Comprehensive skill for creating beautiful, accessible user interfaces combining shadcn/ui components, Tailwind CSS utility styling, and canvas-based visual design systems. + +## Reference + +- shadcn/ui: https://ui.shadcn.com/llms.txt +- Tailwind CSS: https://tailwindcss.com/docs + +## When to Use This Skill + +Use when: +- Building UI with React-based frameworks (Next.js, Vite, Remix, Astro) +- Implementing accessible components (dialogs, forms, tables, navigation) +- Styling with utility-first CSS approach +- Creating responsive, mobile-first layouts +- Implementing dark mode and theme customization +- Building design systems with consistent tokens +- Generating visual designs, posters, or brand materials +- Rapid prototyping with immediate visual feedback +- Adding complex UI patterns (data tables, charts, command palettes) + +## Core Stack + +### Component Layer: shadcn/ui +- Pre-built accessible components via Radix UI primitives +- Copy-paste distribution model (components live in your codebase) +- TypeScript-first with full type safety +- Composable primitives for complex UIs +- CLI-based installation and management + +### Styling Layer: Tailwind CSS +- Utility-first CSS framework +- Build-time processing with zero runtime overhead +- Mobile-first responsive design +- Consistent design tokens (colors, spacing, typography) +- Automatic dead code elimination + +### Visual Design Layer: Canvas +- Museum-quality visual compositions +- Philosophy-driven design approach +- Sophisticated visual communication +- Minimal text, maximum visual impact +- Systematic patterns and refined aesthetics + +## Quick Start + +### Component + Styling Setup + +**Install shadcn/ui with Tailwind:** +```bash +npx shadcn@latest init +``` + +CLI prompts for framework, TypeScript, paths, and theme preferences. This configures both shadcn/ui and Tailwind CSS. + +**Add components:** +```bash +npx shadcn@latest add button card dialog form +``` + +**Use components with utility styling:** +```tsx +import { Button } from "@/components/ui/button" +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card" + +export function Dashboard() { + return ( +
                      + + + Analytics + + +

                      View your metrics

                      + +
                      +
                      +
                      + ) +} +``` + +### Alternative: Tailwind-Only Setup + +**Vite projects:** +```bash +npm install -D tailwindcss @tailwindcss/vite +``` + +```javascript +// vite.config.ts +import tailwindcss from '@tailwindcss/vite' +export default { plugins: [tailwindcss()] } +``` + +```css +/* src/index.css */ +@import "tailwindcss"; +``` + +## Component Library Guide + +**Comprehensive component catalog with usage patterns, installation, and composition examples.** + +See: `references/shadcn-components.md` + +Covers: +- Form & input components (Button, Input, Select, Checkbox, Date Picker, Form validation) +- Layout & navigation (Card, Tabs, Accordion, Navigation Menu) +- Overlays & dialogs (Dialog, Drawer, Popover, Toast, Command) +- Feedback & status (Alert, Progress, Skeleton) +- Display components (Table, Data Table, Avatar, Badge) + +## Theme & Customization + +**Theme configuration, CSS variables, dark mode implementation, and component customization.** + +See: `references/shadcn-theming.md` + +Covers: +- Dark mode setup with next-themes +- CSS variable system +- Color customization and palettes +- Component variant customization +- Theme toggle implementation + +## Accessibility Patterns + +**ARIA patterns, keyboard navigation, screen reader support, and accessible component usage.** + +See: `references/shadcn-accessibility.md` + +Covers: +- Radix UI accessibility features +- Keyboard navigation patterns +- Focus management +- Screen reader announcements +- Form validation accessibility + +## Tailwind Utilities + +**Core utility classes for layout, spacing, typography, colors, borders, and shadows.** + +See: `references/tailwind-utilities.md` + +Covers: +- Layout utilities (Flexbox, Grid, positioning) +- Spacing system (padding, margin, gap) +- Typography (font sizes, weights, alignment, line height) +- Colors and backgrounds +- Borders and shadows +- Arbitrary values for custom styling + +## Responsive Design + +**Mobile-first breakpoints, responsive utilities, and adaptive layouts.** + +See: `references/tailwind-responsive.md` + +Covers: +- Mobile-first approach +- Breakpoint system (sm, md, lg, xl, 2xl) +- Responsive utility patterns +- Container queries +- Max-width queries +- Custom breakpoints + +## Tailwind Customization + +**Config file structure, custom utilities, plugins, and theme extensions.** + +See: `references/tailwind-customization.md` + +Covers: +- @theme directive for custom tokens +- Custom colors and fonts +- Spacing and breakpoint extensions +- Custom utility creation +- Custom variants +- Layer organization (@layer base, components, utilities) +- Apply directive for component extraction + +## Visual Design System + +**Canvas-based design philosophy, visual communication principles, and sophisticated compositions.** + +See: `references/canvas-design-system.md` + +Covers: +- Design philosophy approach +- Visual communication over text +- Systematic patterns and composition +- Color, form, and spatial design +- Minimal text integration +- Museum-quality execution +- Multi-page design systems + +## Utility Scripts + +**Python automation for component installation and configuration generation.** + +### shadcn_add.py +Add shadcn/ui components with dependency handling: +```bash +python scripts/shadcn_add.py button card dialog +``` + +### tailwind_config_gen.py +Generate tailwind.config.js with custom theme: +```bash +python scripts/tailwind_config_gen.py --colors brand:blue --fonts display:Inter +``` + +## Best Practices + +1. **Component Composition**: Build complex UIs from simple, composable primitives +2. **Utility-First Styling**: Use Tailwind classes directly; extract components only for true repetition +3. **Mobile-First Responsive**: Start with mobile styles, layer responsive variants +4. **Accessibility-First**: Leverage Radix UI primitives, add focus states, use semantic HTML +5. **Design Tokens**: Use consistent spacing scale, color palettes, typography system +6. **Dark Mode Consistency**: Apply dark variants to all themed elements +7. **Performance**: Leverage automatic CSS purging, avoid dynamic class names +8. **TypeScript**: Use full type safety for better DX +9. **Visual Hierarchy**: Let composition guide attention, use spacing and color intentionally +10. **Expert Craftsmanship**: Every detail matters - treat UI as a craft + +## Reference Navigation + +**Component Library** +- `references/shadcn-components.md` - Complete component catalog +- `references/shadcn-theming.md` - Theming and customization +- `references/shadcn-accessibility.md` - Accessibility patterns + +**Styling System** +- `references/tailwind-utilities.md` - Core utility classes +- `references/tailwind-responsive.md` - Responsive design +- `references/tailwind-customization.md` - Configuration and extensions + +**Visual Design** +- `references/canvas-design-system.md` - Design philosophy and canvas workflows + +**Automation** +- `scripts/shadcn_add.py` - Component installation +- `scripts/tailwind_config_gen.py` - Config generation + +## Common Patterns + +**Form with validation:** +```tsx +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" + +const schema = z.object({ + email: z.string().email(), + password: z.string().min(8) +}) + +export function LoginForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { email: "", password: "" } + }) + + return ( +
                      + + ( + + Email + + + + + + )} /> + + + + ) +} +``` + +**Responsive layout with dark mode:** +```tsx +
                      +
                      +
                      + + +

                      + Content +

                      +
                      +
                      +
                      +
                      +
                      +``` + +## Resources + +- shadcn/ui Docs: https://ui.shadcn.com +- Tailwind CSS Docs: https://tailwindcss.com +- Radix UI: https://radix-ui.com +- Tailwind UI: https://tailwindui.com +- Headless UI: https://headlessui.com +- v0 (AI UI Generator): https://v0.dev diff --git a/skills/ui-styling/canvas-fonts/ArsenalSC-OFL.txt b/skills/ui-styling/canvas-fonts/ArsenalSC-OFL.txt new file mode 100644 index 0000000..1dad6ca --- /dev/null +++ b/skills/ui-styling/canvas-fonts/ArsenalSC-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Arsenal Project Authors (andrij.design@gmail.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/ArsenalSC-Regular.ttf b/skills/ui-styling/canvas-fonts/ArsenalSC-Regular.ttf new file mode 100644 index 0000000..fe5409b Binary files /dev/null and b/skills/ui-styling/canvas-fonts/ArsenalSC-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/BigShoulders-Bold.ttf b/skills/ui-styling/canvas-fonts/BigShoulders-Bold.ttf new file mode 100644 index 0000000..fc5f8fd Binary files /dev/null and b/skills/ui-styling/canvas-fonts/BigShoulders-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/BigShoulders-OFL.txt b/skills/ui-styling/canvas-fonts/BigShoulders-OFL.txt new file mode 100644 index 0000000..b220280 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/BigShoulders-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Big Shoulders Project Authors (https://github.com/xotypeco/big_shoulders) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/BigShoulders-Regular.ttf b/skills/ui-styling/canvas-fonts/BigShoulders-Regular.ttf new file mode 100644 index 0000000..de8308c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/BigShoulders-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Boldonse-OFL.txt b/skills/ui-styling/canvas-fonts/Boldonse-OFL.txt new file mode 100644 index 0000000..1890cb1 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Boldonse-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Boldonse Project Authors (https://github.com/googlefonts/boldonse) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Boldonse-Regular.ttf b/skills/ui-styling/canvas-fonts/Boldonse-Regular.ttf new file mode 100644 index 0000000..43fa30a Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Boldonse-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/BricolageGrotesque-Bold.ttf b/skills/ui-styling/canvas-fonts/BricolageGrotesque-Bold.ttf new file mode 100644 index 0000000..f3b1ded Binary files /dev/null and b/skills/ui-styling/canvas-fonts/BricolageGrotesque-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/BricolageGrotesque-OFL.txt b/skills/ui-styling/canvas-fonts/BricolageGrotesque-OFL.txt new file mode 100644 index 0000000..fc2b216 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/BricolageGrotesque-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/BricolageGrotesque-Regular.ttf b/skills/ui-styling/canvas-fonts/BricolageGrotesque-Regular.ttf new file mode 100644 index 0000000..0674ae3 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/BricolageGrotesque-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/CrimsonPro-Bold.ttf b/skills/ui-styling/canvas-fonts/CrimsonPro-Bold.ttf new file mode 100644 index 0000000..58730fb Binary files /dev/null and b/skills/ui-styling/canvas-fonts/CrimsonPro-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/CrimsonPro-Italic.ttf b/skills/ui-styling/canvas-fonts/CrimsonPro-Italic.ttf new file mode 100644 index 0000000..786a1bd Binary files /dev/null and b/skills/ui-styling/canvas-fonts/CrimsonPro-Italic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/CrimsonPro-OFL.txt b/skills/ui-styling/canvas-fonts/CrimsonPro-OFL.txt new file mode 100644 index 0000000..f976fdc --- /dev/null +++ b/skills/ui-styling/canvas-fonts/CrimsonPro-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Crimson Pro Project Authors (https://github.com/Fonthausen/CrimsonPro) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/CrimsonPro-Regular.ttf b/skills/ui-styling/canvas-fonts/CrimsonPro-Regular.ttf new file mode 100644 index 0000000..f5666b9 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/CrimsonPro-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/DMMono-OFL.txt b/skills/ui-styling/canvas-fonts/DMMono-OFL.txt new file mode 100644 index 0000000..5b17f0c --- /dev/null +++ b/skills/ui-styling/canvas-fonts/DMMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The DM Mono Project Authors (https://www.github.com/googlefonts/dm-mono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/DMMono-Regular.ttf b/skills/ui-styling/canvas-fonts/DMMono-Regular.ttf new file mode 100644 index 0000000..7efe813 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/DMMono-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/EricaOne-OFL.txt b/skills/ui-styling/canvas-fonts/EricaOne-OFL.txt new file mode 100644 index 0000000..490d012 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/EricaOne-OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2011 by LatinoType Limitada (luciano@latinotype.com), +with Reserved Font Names "Erica One" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/EricaOne-Regular.ttf b/skills/ui-styling/canvas-fonts/EricaOne-Regular.ttf new file mode 100644 index 0000000..8bd91d1 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/EricaOne-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/GeistMono-Bold.ttf b/skills/ui-styling/canvas-fonts/GeistMono-Bold.ttf new file mode 100644 index 0000000..736ff7c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/GeistMono-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/GeistMono-OFL.txt b/skills/ui-styling/canvas-fonts/GeistMono-OFL.txt new file mode 100644 index 0000000..679a685 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/GeistMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font.git) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/GeistMono-Regular.ttf b/skills/ui-styling/canvas-fonts/GeistMono-Regular.ttf new file mode 100644 index 0000000..1a30262 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/GeistMono-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Gloock-OFL.txt b/skills/ui-styling/canvas-fonts/Gloock-OFL.txt new file mode 100644 index 0000000..363acd3 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Gloock-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Gloock Project Authors (https://github.com/duartp/gloock) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Gloock-Regular.ttf b/skills/ui-styling/canvas-fonts/Gloock-Regular.ttf new file mode 100644 index 0000000..3e58c4e Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Gloock-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/IBMPlexMono-Bold.ttf b/skills/ui-styling/canvas-fonts/IBMPlexMono-Bold.ttf new file mode 100644 index 0000000..247979c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/IBMPlexMono-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/IBMPlexMono-OFL.txt b/skills/ui-styling/canvas-fonts/IBMPlexMono-OFL.txt new file mode 100644 index 0000000..e423b74 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/IBMPlexMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/IBMPlexMono-Regular.ttf b/skills/ui-styling/canvas-fonts/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..601ae94 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/IBMPlexMono-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/IBMPlexSerif-Bold.ttf b/skills/ui-styling/canvas-fonts/IBMPlexSerif-Bold.ttf new file mode 100644 index 0000000..78f6e50 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/IBMPlexSerif-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/IBMPlexSerif-BoldItalic.ttf b/skills/ui-styling/canvas-fonts/IBMPlexSerif-BoldItalic.ttf new file mode 100644 index 0000000..369b89d Binary files /dev/null and b/skills/ui-styling/canvas-fonts/IBMPlexSerif-BoldItalic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/IBMPlexSerif-Italic.ttf b/skills/ui-styling/canvas-fonts/IBMPlexSerif-Italic.ttf new file mode 100644 index 0000000..a4d859a Binary files /dev/null and b/skills/ui-styling/canvas-fonts/IBMPlexSerif-Italic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/IBMPlexSerif-Regular.ttf b/skills/ui-styling/canvas-fonts/IBMPlexSerif-Regular.ttf new file mode 100644 index 0000000..35f454c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/IBMPlexSerif-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/InstrumentSans-Bold.ttf b/skills/ui-styling/canvas-fonts/InstrumentSans-Bold.ttf new file mode 100644 index 0000000..f602dce Binary files /dev/null and b/skills/ui-styling/canvas-fonts/InstrumentSans-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/InstrumentSans-BoldItalic.ttf b/skills/ui-styling/canvas-fonts/InstrumentSans-BoldItalic.ttf new file mode 100644 index 0000000..122b273 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/InstrumentSans-BoldItalic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/InstrumentSans-Italic.ttf b/skills/ui-styling/canvas-fonts/InstrumentSans-Italic.ttf new file mode 100644 index 0000000..4b98fb8 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/InstrumentSans-Italic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/InstrumentSans-OFL.txt b/skills/ui-styling/canvas-fonts/InstrumentSans-OFL.txt new file mode 100644 index 0000000..4bb9914 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/InstrumentSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/InstrumentSans-Regular.ttf b/skills/ui-styling/canvas-fonts/InstrumentSans-Regular.ttf new file mode 100644 index 0000000..14c6113 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/InstrumentSans-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/InstrumentSerif-Italic.ttf b/skills/ui-styling/canvas-fonts/InstrumentSerif-Italic.ttf new file mode 100644 index 0000000..8fa958d Binary files /dev/null and b/skills/ui-styling/canvas-fonts/InstrumentSerif-Italic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/InstrumentSerif-Regular.ttf b/skills/ui-styling/canvas-fonts/InstrumentSerif-Regular.ttf new file mode 100644 index 0000000..9763031 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/InstrumentSerif-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Italiana-OFL.txt b/skills/ui-styling/canvas-fonts/Italiana-OFL.txt new file mode 100644 index 0000000..ba8af21 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Italiana-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2011, Santiago Orozco (hi@typemade.mx), with Reserved Font Name "Italiana". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Italiana-Regular.ttf b/skills/ui-styling/canvas-fonts/Italiana-Regular.ttf new file mode 100644 index 0000000..a9b828c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Italiana-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/JetBrainsMono-Bold.ttf b/skills/ui-styling/canvas-fonts/JetBrainsMono-Bold.ttf new file mode 100644 index 0000000..1926c80 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/JetBrainsMono-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/JetBrainsMono-OFL.txt b/skills/ui-styling/canvas-fonts/JetBrainsMono-OFL.txt new file mode 100644 index 0000000..5ceee00 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/JetBrainsMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/JetBrainsMono-Regular.ttf b/skills/ui-styling/canvas-fonts/JetBrainsMono-Regular.ttf new file mode 100644 index 0000000..436c982 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/JetBrainsMono-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Jura-Light.ttf b/skills/ui-styling/canvas-fonts/Jura-Light.ttf new file mode 100644 index 0000000..dffbb33 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Jura-Light.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Jura-Medium.ttf b/skills/ui-styling/canvas-fonts/Jura-Medium.ttf new file mode 100644 index 0000000..4bf91a3 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Jura-Medium.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Jura-OFL.txt b/skills/ui-styling/canvas-fonts/Jura-OFL.txt new file mode 100644 index 0000000..64ad4c6 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Jura-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Jura Project Authors (https://github.com/ossobuffo/jura) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/LibreBaskerville-OFL.txt b/skills/ui-styling/canvas-fonts/LibreBaskerville-OFL.txt new file mode 100644 index 0000000..8c531fa --- /dev/null +++ b/skills/ui-styling/canvas-fonts/LibreBaskerville-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2012 The Libre Baskerville Project Authors (https://github.com/impallari/Libre-Baskerville) with Reserved Font Name Libre Baskerville. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/LibreBaskerville-Regular.ttf b/skills/ui-styling/canvas-fonts/LibreBaskerville-Regular.ttf new file mode 100644 index 0000000..c1abc26 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/LibreBaskerville-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Lora-Bold.ttf b/skills/ui-styling/canvas-fonts/Lora-Bold.ttf new file mode 100644 index 0000000..edae21e Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Lora-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Lora-BoldItalic.ttf b/skills/ui-styling/canvas-fonts/Lora-BoldItalic.ttf new file mode 100644 index 0000000..12dea8c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Lora-BoldItalic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Lora-Italic.ttf b/skills/ui-styling/canvas-fonts/Lora-Italic.ttf new file mode 100644 index 0000000..e24b69b Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Lora-Italic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Lora-OFL.txt b/skills/ui-styling/canvas-fonts/Lora-OFL.txt new file mode 100644 index 0000000..4cf1b95 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Lora-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Lora-Regular.ttf b/skills/ui-styling/canvas-fonts/Lora-Regular.ttf new file mode 100644 index 0000000..dc751db Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Lora-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/NationalPark-Bold.ttf b/skills/ui-styling/canvas-fonts/NationalPark-Bold.ttf new file mode 100644 index 0000000..f4d7c02 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/NationalPark-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/NationalPark-OFL.txt b/skills/ui-styling/canvas-fonts/NationalPark-OFL.txt new file mode 100644 index 0000000..f4ec3fb --- /dev/null +++ b/skills/ui-styling/canvas-fonts/NationalPark-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2025 The National Park Project Authors (https://github.com/benhoepner/National-Park) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/NationalPark-Regular.ttf b/skills/ui-styling/canvas-fonts/NationalPark-Regular.ttf new file mode 100644 index 0000000..e4cbfbf Binary files /dev/null and b/skills/ui-styling/canvas-fonts/NationalPark-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/NothingYouCouldDo-OFL.txt b/skills/ui-styling/canvas-fonts/NothingYouCouldDo-OFL.txt new file mode 100644 index 0000000..c81eccd --- /dev/null +++ b/skills/ui-styling/canvas-fonts/NothingYouCouldDo-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2010, Kimberly Geswein (kimberlygeswein.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/NothingYouCouldDo-Regular.ttf b/skills/ui-styling/canvas-fonts/NothingYouCouldDo-Regular.ttf new file mode 100644 index 0000000..b086bce Binary files /dev/null and b/skills/ui-styling/canvas-fonts/NothingYouCouldDo-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Outfit-Bold.ttf b/skills/ui-styling/canvas-fonts/Outfit-Bold.ttf new file mode 100644 index 0000000..f9f2f72 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Outfit-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Outfit-OFL.txt b/skills/ui-styling/canvas-fonts/Outfit-OFL.txt new file mode 100644 index 0000000..fd0cb99 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Outfit-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Outfit-Regular.ttf b/skills/ui-styling/canvas-fonts/Outfit-Regular.ttf new file mode 100644 index 0000000..3939ab2 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Outfit-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/PixelifySans-Medium.ttf b/skills/ui-styling/canvas-fonts/PixelifySans-Medium.ttf new file mode 100644 index 0000000..95cd372 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/PixelifySans-Medium.ttf differ diff --git a/skills/ui-styling/canvas-fonts/PixelifySans-OFL.txt b/skills/ui-styling/canvas-fonts/PixelifySans-OFL.txt new file mode 100644 index 0000000..b02d1b6 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/PixelifySans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/PoiretOne-OFL.txt b/skills/ui-styling/canvas-fonts/PoiretOne-OFL.txt new file mode 100644 index 0000000..607bdad --- /dev/null +++ b/skills/ui-styling/canvas-fonts/PoiretOne-OFL.txt @@ -0,0 +1,93 @@ +Copyright (c) 2011, Denis Masharov (denis.masharov@gmail.com) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/PoiretOne-Regular.ttf b/skills/ui-styling/canvas-fonts/PoiretOne-Regular.ttf new file mode 100644 index 0000000..b339511 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/PoiretOne-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/RedHatMono-Bold.ttf b/skills/ui-styling/canvas-fonts/RedHatMono-Bold.ttf new file mode 100644 index 0000000..a6e3cf1 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/RedHatMono-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/RedHatMono-OFL.txt b/skills/ui-styling/canvas-fonts/RedHatMono-OFL.txt new file mode 100644 index 0000000..16cf394 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/RedHatMono-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2024 The Red Hat Project Authors (https://github.com/RedHatOfficial/RedHatFont) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/RedHatMono-Regular.ttf b/skills/ui-styling/canvas-fonts/RedHatMono-Regular.ttf new file mode 100644 index 0000000..3bf6a69 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/RedHatMono-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Silkscreen-OFL.txt b/skills/ui-styling/canvas-fonts/Silkscreen-OFL.txt new file mode 100644 index 0000000..a1fe7d5 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Silkscreen-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2001 The Silkscreen Project Authors (https://github.com/googlefonts/silkscreen) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Silkscreen-Regular.ttf b/skills/ui-styling/canvas-fonts/Silkscreen-Regular.ttf new file mode 100644 index 0000000..8abaa7c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Silkscreen-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/SmoochSans-Medium.ttf b/skills/ui-styling/canvas-fonts/SmoochSans-Medium.ttf new file mode 100644 index 0000000..0af9ead Binary files /dev/null and b/skills/ui-styling/canvas-fonts/SmoochSans-Medium.ttf differ diff --git a/skills/ui-styling/canvas-fonts/SmoochSans-OFL.txt b/skills/ui-styling/canvas-fonts/SmoochSans-OFL.txt new file mode 100644 index 0000000..4c2f033 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/SmoochSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2016 The Smooch Sans Project Authors (https://github.com/googlefonts/smooch-sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Tektur-Medium.ttf b/skills/ui-styling/canvas-fonts/Tektur-Medium.ttf new file mode 100644 index 0000000..34fc797 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Tektur-Medium.ttf differ diff --git a/skills/ui-styling/canvas-fonts/Tektur-OFL.txt b/skills/ui-styling/canvas-fonts/Tektur-OFL.txt new file mode 100644 index 0000000..2cad55f --- /dev/null +++ b/skills/ui-styling/canvas-fonts/Tektur-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2023 The Tektur Project Authors (https://www.github.com/hyvyys/Tektur) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/Tektur-Regular.ttf b/skills/ui-styling/canvas-fonts/Tektur-Regular.ttf new file mode 100644 index 0000000..f280fba Binary files /dev/null and b/skills/ui-styling/canvas-fonts/Tektur-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/WorkSans-Bold.ttf b/skills/ui-styling/canvas-fonts/WorkSans-Bold.ttf new file mode 100644 index 0000000..5c97989 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/WorkSans-Bold.ttf differ diff --git a/skills/ui-styling/canvas-fonts/WorkSans-BoldItalic.ttf b/skills/ui-styling/canvas-fonts/WorkSans-BoldItalic.ttf new file mode 100644 index 0000000..54418b8 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/WorkSans-BoldItalic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/WorkSans-Italic.ttf b/skills/ui-styling/canvas-fonts/WorkSans-Italic.ttf new file mode 100644 index 0000000..40529b6 Binary files /dev/null and b/skills/ui-styling/canvas-fonts/WorkSans-Italic.ttf differ diff --git a/skills/ui-styling/canvas-fonts/WorkSans-OFL.txt b/skills/ui-styling/canvas-fonts/WorkSans-OFL.txt new file mode 100644 index 0000000..070f341 --- /dev/null +++ b/skills/ui-styling/canvas-fonts/WorkSans-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/WorkSans-Regular.ttf b/skills/ui-styling/canvas-fonts/WorkSans-Regular.ttf new file mode 100644 index 0000000..d24586c Binary files /dev/null and b/skills/ui-styling/canvas-fonts/WorkSans-Regular.ttf differ diff --git a/skills/ui-styling/canvas-fonts/YoungSerif-OFL.txt b/skills/ui-styling/canvas-fonts/YoungSerif-OFL.txt new file mode 100644 index 0000000..f09443c --- /dev/null +++ b/skills/ui-styling/canvas-fonts/YoungSerif-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2023 The Young Serif Project Authors (https://github.com/noirblancrouge/YoungSerif) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/skills/ui-styling/canvas-fonts/YoungSerif-Regular.ttf b/skills/ui-styling/canvas-fonts/YoungSerif-Regular.ttf new file mode 100644 index 0000000..f454fbe Binary files /dev/null and b/skills/ui-styling/canvas-fonts/YoungSerif-Regular.ttf differ diff --git a/skills/ui-styling/references/canvas-design-system.md b/skills/ui-styling/references/canvas-design-system.md new file mode 100644 index 0000000..6f99bee --- /dev/null +++ b/skills/ui-styling/references/canvas-design-system.md @@ -0,0 +1,320 @@ +# Canvas Design System + +Visual design philosophy, systematic composition, and sophisticated visual communication. + +## Design Philosophy Approach + +Canvas design operates through two-phase process: + +### Phase 1: Design Philosophy Creation + +Create visual philosophy - aesthetic movement expressed through form, space, color, composition. Not layouts or templates, but pure visual philosophy. + +**What is created:** Design manifesto emphasizing: +- Visual expression over text +- Spatial communication +- Artistic interpretation +- Minimal words as visual accent + +**Philosophy structure (4-6 paragraphs):** +- Space and form principles +- Color and material approach +- Scale and rhythm guidance +- Composition and balance rules +- Visual hierarchy system + +### Phase 2: Visual Expression + +Express philosophy through canvas artifacts: +- 90% visual design +- 10% essential text +- Museum-quality execution +- Systematic patterns +- Sophisticated composition + +## Core Principles + +### 1. Visual Communication First + +Information lives in design, not paragraphs. Express ideas through: +- Color zones and fields +- Geometric precision +- Spatial relationships +- Visual weight and tension +- Form and structure + +### 2. Minimal Text Integration + +Text as rare, powerful gesture: +- Never paragraphs +- Only essential words +- Integrated into visual architecture +- Small labels, huge impact +- Typography as visual element + +### 3. Expert Craftsmanship + +Work must appear: +- Meticulously crafted +- Labored over with care +- Product of countless hours +- From absolute top of field +- Master-level execution + +### 4. Systematic Patterns + +Use scientific visual language: +- Repeating patterns +- Perfect shapes +- Dense accumulation of marks +- Layered elements +- Patient repetition rewards sustained viewing + +## Design Movement Examples + +### Concrete Poetry +**Philosophy:** Communication through monumental form and bold geometry. + +**Expression:** +- Massive color blocks +- Sculptural typography (huge words, tiny labels) +- Brutalist spatial divisions +- Polish poster energy meets Le Corbusier +- Ideas through visual weight and spatial tension +- Text as rare, powerful gesture + +### Chromatic Language +**Philosophy:** Color as primary information system. + +**Expression:** +- Geometric precision +- Color zones create meaning +- Typography minimal - small sans-serif labels +- Josef Albers' interaction meets data visualization +- Information encoded spatially and chromatically +- Words only anchor what color shows + +### Analog Meditation +**Philosophy:** Quiet visual contemplation through texture and breathing room. + +**Expression:** +- Paper grain, ink bleeds +- Vast negative space +- Photography and illustration dominate +- Typography whispered (small, restrained) +- Japanese photobook aesthetic +- Images breathe across pages +- Text appears sparingly - short phrases only + +### Organic Systems +**Philosophy:** Natural clustering and modular growth patterns. + +**Expression:** +- Rounded forms +- Organic arrangements +- Color from nature through architecture +- Information through visual diagrams +- Spatial relationships and iconography +- Text only for key labels floating in space +- Composition tells story through spatial orchestration + +### Geometric Silence +**Philosophy:** Pure order and restraint. + +**Expression:** +- Grid-based precision +- Bold photography or stark graphics +- Dramatic negative space +- Typography precise but minimal +- Small essential text, large quiet zones +- Swiss formalism meets Brutalist material honesty +- Structure communicates, not words +- Every alignment from countless refinements + +## Implementation Guidelines + +### Subtle Reference Integration + +Embed conceptual DNA without announcing: +- Niche reference woven invisibly +- Those who know feel it intuitively +- Others experience masterful abstract composition +- Like jazz musician quoting another song +- Sophisticated, never literal +- Reference enhances depth quietly + +### Color Approach + +**Intentional palette:** +- Limited colors (2-5) +- Cohesive system +- Purposeful relationships +- oklch color space for precision +- Each shade carries meaning + +**Example palette:** +``` +--color-primary: oklch(0.55 0.22 264) +--color-accent: oklch(0.75 0.18 45) +--color-neutral: oklch(0.90 0.02 264) +--color-dark: oklch(0.25 0.15 264) +``` + +### Typography System + +**Thin fonts preferred:** +- Light weights (200-300) +- Clean sans-serifs +- Geometric precision +- Small sizes for labels +- Large sizes for impact moments + +**Font integration:** +- Search `./canvas-fonts` directory +- Download needed fonts +- Bring typography onto canvas +- Part of art, not typeset digitally + +### Composition Rules + +**Systematic approach:** +- Repeating patterns establish rhythm +- Perfect geometric shapes +- Clinical typography +- Reference markers suggest imaginary discipline +- Dense accumulation builds meaning +- Layered patterns reward attention + +**Spacing discipline:** +- Nothing falls off page +- Nothing overlaps +- Every element within canvas boundaries +- Proper margins non-negotiable +- Breathing room and clear separation +- Professional execution mandatory + +### Canvas Boundaries + +**Technical specs:** +- Single page default (multi-page when requested) +- PDF or PNG output +- High resolution +- Clean margins +- Contained composition +- Flawless formatting + +## Multi-Page Design Systems + +When creating multiple pages: + +### Approach +- Treat first page as single page in coffee table book +- Create more pages along same philosophy +- Distinctly different but cohesive +- Pages tell story tastefully +- Full creative freedom + +### Consistency Elements +- Shared color palette +- Consistent typography system +- Related compositional approach +- Visual language continuity +- Philosophical thread throughout + +### Variation Strategy +- Unique twist per page +- Different focal points +- Varied spatial arrangements +- Complementary patterns +- Progressive visual narrative + +## Execution Checklist + +Before finalizing: + +- [ ] Philosophy guides every decision +- [ ] 90% visual, 10% text maximum +- [ ] Text minimal and integrated +- [ ] Nothing overlaps or falls off page +- [ ] Margins and spacing pristine +- [ ] Composition cohesive with art +- [ ] Appears meticulously crafted +- [ ] Master-level execution evident +- [ ] Sophisticated, never amateur +- [ ] Could be displayed in museum +- [ ] Proves undeniable expertise +- [ ] Formatting flawless +- [ ] Every detail perfect + +## Quality Standards + +### What to Avoid +- Cartoony aesthetics +- Amateur execution +- Text-heavy composition +- Random placement +- Overlapping elements +- Inconsistent spacing +- Obvious AI generation +- Lack of refinement + +### What to Achieve +- Museum quality +- Magazine worthy +- Art object status +- Countless hours appearance +- Top-of-field craftsmanship +- Philosophical coherence +- Visual sophistication +- Systematic precision + +## Refinement Process + +### Initial Pass +Create based on philosophy and principles. + +### Second Pass (Critical) +- Don't add more graphics +- Refine what exists +- Make extremely crisp +- Respect minimalism philosophy +- Increase cohesion with art +- Make existing elements more artistic +- Polish rather than expand + +### Final Verification +User already said: "It isn't perfect enough. Must be pristine, masterpiece of craftsmanship, as if about to be displayed in museum." + +Apply this standard before delivery. + +## Output Format + +**Required files:** +1. Design philosophy (.md file) +2. Visual expression (.pdf or .png) + +**Philosophy file contains:** +- Movement name +- 4-6 paragraph philosophy +- Visual principles +- Execution guidance + +**Canvas file contains:** +- Visual interpretation +- Minimal text +- Systematic composition +- Expert-level execution + +## Use Cases + +Apply canvas design for: +- Brand identity systems +- Poster designs +- Visual manifestos +- Design system documentation +- Art pieces and compositions +- Conceptual visual frameworks +- Editorial design +- Exhibition materials +- Coffee table books +- Design philosophy demonstrations diff --git a/skills/ui-styling/references/shadcn-accessibility.md b/skills/ui-styling/references/shadcn-accessibility.md new file mode 100644 index 0000000..d1cef4d --- /dev/null +++ b/skills/ui-styling/references/shadcn-accessibility.md @@ -0,0 +1,471 @@ +# shadcn/ui Accessibility Patterns + +ARIA patterns, keyboard navigation, screen reader support, and accessible component usage. + +## Foundation: Radix UI Primitives + +shadcn/ui built on Radix UI primitives - unstyled, accessible components following WAI-ARIA design patterns. + +Benefits: +- Keyboard navigation built-in +- Screen reader announcements +- Focus management +- ARIA attributes automatically applied +- Tested against accessibility standards + +## Keyboard Navigation + +### Focus Management + +**Focus visible states:** +```tsx + +``` + +**Skip to content:** +```tsx + + Skip to content + + +
                      + {/* Content */} +
                      +``` + +### Dialog/Modal Navigation + +Dialogs trap focus automatically via Radix Dialog primitive: + +```tsx +import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog" + + + Open + + {/* Focus trapped here */} + {/* Auto-focused */} + + {/* Esc to close, Tab to navigate */} + + +``` + +Features: +- Focus trapped within dialog +- Esc key closes +- Tab cycles through focusable elements +- Focus returns to trigger on close + +### Dropdown/Menu Navigation + +```tsx +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" + + + Open + + Profile + Settings + Logout + + +``` + +Keyboard shortcuts: +- `Space/Enter`: Open menu +- `Arrow Up/Down`: Navigate items +- `Esc`: Close menu +- `Tab`: Close and move focus + +### Command Palette Navigation + +```tsx +import { Command } from "@/components/ui/command" + + + + + + Calendar + Search + + + +``` + +Features: +- Type to filter +- Arrow keys to navigate +- Enter to select +- Esc to close + +## Screen Reader Support + +### Semantic HTML + +Use proper HTML elements: + +```tsx +// Good: Semantic HTML + + + +// Avoid: Div soup +
                      Click me
                      +``` + +### ARIA Labels + +**Label interactive elements:** +```tsx + + + +``` + +**Describe elements:** +```tsx + +

                      + This action permanently deletes your account and cannot be undone +

                      +``` + +### Screen Reader Only Text + +Use `sr-only` class for screen reader only content: + +```tsx + + +// CSS for sr-only +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} +``` + +### Live Regions + +Announce dynamic content: + +```tsx +
                      + {message} +
                      + +// For urgent updates +
                      + {error} +
                      +``` + +Toast component includes live region: +```tsx +const { toast } = useToast() + +toast({ + title: "Success", + description: "Profile updated" +}) +// Announced to screen readers automatically +``` + +## Form Accessibility + +### Labels and Descriptions + +**Always label inputs:** +```tsx +import { Label } from "@/components/ui/label" +import { Input } from "@/components/ui/input" + +
                      + + +
                      +``` + +**Add descriptions:** +```tsx +import { FormDescription, FormMessage } from "@/components/ui/form" + + + Username + + + + + Your public display name + + {/* Error messages */} + +``` + +### Error Handling + +Announce errors to screen readers: + +```tsx + ( + + Email + + + + + + )} +/> +``` + +### Required Fields + +Indicate required fields: + +```tsx + + +``` + +### Fieldset and Legend + +Group related fields: + +```tsx +
                      + + Contact Information + +
                      + + +
                      +
                      +``` + +## Component-Specific Patterns + +### Accordion + +```tsx +import { Accordion } from "@/components/ui/accordion" + + + + + {/* Includes aria-expanded, aria-controls automatically */} + Is it accessible? + + + {/* Hidden when collapsed, announced when expanded */} + Yes. Follows WAI-ARIA design pattern. + + + +``` + +### Tabs + +```tsx +import { Tabs } from "@/components/ui/tabs" + + + + {/* Arrow keys navigate, Space/Enter activates */} + Account + Password + + + {/* Hidden unless selected, aria-labelledby links to trigger */} + Account content + + +``` + +### Select + +```tsx +import { Select } from "@/components/ui/select" + + +``` + +### Checkbox and Radio + +```tsx +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" + +
                      + + +
                      +

                      + You agree to our Terms of Service and Privacy Policy +

                      +``` + +### Alert + +```tsx +import { Alert } from "@/components/ui/alert" + + + {/* Announced immediately to screen readers */} + Error + + Your session has expired + + +``` + +## Color Contrast + +Ensure sufficient contrast between text and background. + +**WCAG Requirements:** +- **AA**: 4.5:1 for normal text, 3:1 for large text +- **AAA**: 7:1 for normal text, 4.5:1 for large text + +**Check defaults:** +```tsx +// Good: High contrast +

                      Text

                      + +// Avoid: Low contrast +

                      Hard to read

                      +``` + +**Muted text:** +```tsx +// Use semantic muted foreground +

                      + Secondary text with accessible contrast +

                      +``` + +## Focus Indicators + +Always provide visible focus indicators: + +**Default focus ring:** +```tsx + +``` + +**Custom focus styles:** +```tsx + + Link + +``` + +**Don't remove focus styles:** +```tsx +// Avoid + + +// Use focus-visible instead + +``` + +## Motion and Animation + +Respect reduced motion preference: + +```css +@media (prefers-reduced-motion: reduce) { + * { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} +``` + +In components: +```tsx +
                      + Respects user preference +
                      +``` + +## Testing Checklist + +- [ ] All interactive elements keyboard accessible +- [ ] Focus indicators visible +- [ ] Screen reader announces all content correctly +- [ ] Form errors announced and associated +- [ ] Color contrast meets WCAG AA +- [ ] Semantic HTML used +- [ ] ARIA labels provided for icon-only buttons +- [ ] Modal/dialog focus trap works +- [ ] Dropdown/select keyboard navigable +- [ ] Live regions announce updates +- [ ] Respects reduced motion preference +- [ ] Works with browser zoom up to 200% +- [ ] Tab order logical +- [ ] Skip links provided for navigation + +## Tools + +**Testing tools:** +- Lighthouse accessibility audit +- axe DevTools browser extension +- NVDA/JAWS screen readers +- Keyboard-only navigation testing +- Color contrast checkers (Contrast Ratio, WebAIM) + +**Automated testing:** +```bash +npm install -D @axe-core/react +``` + +```tsx +import { useEffect } from 'react' + +if (process.env.NODE_ENV === 'development') { + import('@axe-core/react').then((axe) => { + axe.default(React, ReactDOM, 1000) + }) +} +``` diff --git a/skills/ui-styling/references/shadcn-components.md b/skills/ui-styling/references/shadcn-components.md new file mode 100644 index 0000000..b6c60b3 --- /dev/null +++ b/skills/ui-styling/references/shadcn-components.md @@ -0,0 +1,424 @@ +# shadcn/ui Component Reference + +Complete catalog of shadcn/ui components with usage patterns and installation. + +## Installation + +**Add specific components:** +```bash +npx shadcn@latest add button +npx shadcn@latest add button card dialog # Multiple +npx shadcn@latest add --all # All components +``` + +Components install to `components/ui/` with automatic dependency management. + +## Form & Input Components + +### Button +```tsx +import { Button } from "@/components/ui/button" + + + + + + +``` + +Variants: `default | destructive | outline | secondary | ghost | link` +Sizes: `default | sm | lg | icon` + +### Input +```tsx +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" + +
                      + + +
                      +``` + +### Form (with React Hook Form + Zod) +```tsx +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" + +const schema = z.object({ + username: z.string().min(2).max(50), + email: z.string().email() +}) + +function ProfileForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { username: "", email: "" } + }) + + return ( +
                      + + ( + + Username + + + + + + )} /> + + + + ) +} +``` + +### Select +```tsx +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + + +``` + +### Checkbox +```tsx +import { Checkbox } from "@/components/ui/checkbox" +import { Label } from "@/components/ui/label" + +
                      + + +
                      +``` + +### Radio Group +```tsx +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group" +import { Label } from "@/components/ui/label" + + +
                      + + +
                      +
                      + + +
                      +
                      +``` + +### Textarea +```tsx +import { Textarea } from "@/components/ui/textarea" + + +``` + +### Custom Plugin + +```javascript +// tailwind.config.js +const plugin = require('tailwindcss/plugin') + +export default { + plugins: [ + plugin(function({ addUtilities, addComponents, theme }) { + // Add utilities + addUtilities({ + '.text-shadow': { + textShadow: '2px 2px 4px rgba(0, 0, 0, 0.1)', + }, + '.text-shadow-lg': { + textShadow: '4px 4px 8px rgba(0, 0, 0, 0.2)', + }, + }) + + // Add components + addComponents({ + '.card-custom': { + backgroundColor: theme('colors.white'), + borderRadius: theme('borderRadius.lg'), + padding: theme('spacing.6'), + boxShadow: theme('boxShadow.md'), + }, + }) + }), + ], +} +``` + +## Configuration Examples + +### Complete Tailwind Config + +```javascript +// tailwind.config.ts +import type { Config } from 'tailwindcss' + +const config: Config = { + darkMode: ["class"], + content: [ + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + brand: { + 50: '#f0f9ff', + 500: '#3b82f6', + 900: '#1e3a8a', + }, + }, + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + display: ['Playfair Display', 'serif'], + }, + spacing: { + '18': '4.5rem', + '88': '22rem', + '128': '32rem', + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "slide-in": { + "0%": { transform: "translateX(-100%)" }, + "100%": { transform: "translateX(0)" }, + }, + }, + animation: { + "slide-in": "slide-in 0.5s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +} + +export default config +``` + +## Dark Mode Configuration + +```javascript +// tailwind.config.js +export default { + darkMode: ["class"], // or "media" for automatic + // ... +} +``` + +**Usage:** +```html + + +
                      + Responds to .dark class +
                      + + + +
                      + Responds to system preference automatically +
                      +``` + +## Content Configuration + +Specify files to scan for classes: + +```javascript +// tailwind.config.js +export default { + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + "./app/**/*.{js,jsx,ts,tsx}", + "./components/**/*.{js,jsx,ts,tsx}", + "./pages/**/*.{js,jsx,ts,tsx}", + ], + // ... +} +``` + +### Safelist + +Preserve dynamic classes: + +```javascript +export default { + safelist: [ + 'bg-red-500', + 'bg-green-500', + 'bg-blue-500', + { + pattern: /bg-(red|green|blue)-(100|500|900)/, + }, + ], +} +``` + +## Best Practices + +1. **Use @theme for simple customizations**: Prefer CSS-based customization +2. **Extract components sparingly**: Use @apply only for truly repeated patterns +3. **Leverage design tokens**: Define custom tokens in @theme +4. **Layer organization**: Keep base, components, and utilities separate +5. **Plugin for complex logic**: Use plugins for advanced customizations +6. **Test dark mode**: Ensure custom colors work in both themes +7. **Document custom utilities**: Add comments explaining custom classes +8. **Semantic naming**: Use descriptive names (primary not blue) diff --git a/skills/ui-styling/references/tailwind-responsive.md b/skills/ui-styling/references/tailwind-responsive.md new file mode 100644 index 0000000..f252e18 --- /dev/null +++ b/skills/ui-styling/references/tailwind-responsive.md @@ -0,0 +1,382 @@ +# Tailwind CSS Responsive Design + +Mobile-first breakpoints, responsive utilities, and adaptive layouts. + +## Mobile-First Approach + +Tailwind uses mobile-first responsive design. Base styles apply to all screen sizes, then use breakpoint prefixes to override at larger sizes. + +```html + +
                      +
                      Item 1
                      +
                      Item 2
                      +
                      Item 3
                      +
                      Item 4
                      +
                      +``` + +## Breakpoint System + +**Default breakpoints:** + +| Prefix | Min Width | CSS Media Query | +|--------|-----------|-----------------| +| `sm:` | 640px | `@media (min-width: 640px)` | +| `md:` | 768px | `@media (min-width: 768px)` | +| `lg:` | 1024px | `@media (min-width: 1024px)` | +| `xl:` | 1280px | `@media (min-width: 1280px)` | +| `2xl:` | 1536px | `@media (min-width: 1536px)` | + +## Responsive Patterns + +### Layout Changes + +```html + +
                      +
                      Left
                      +
                      Right
                      +
                      + + +
                      +
                      Item 1
                      +
                      Item 2
                      +
                      Item 3
                      +
                      +``` + +### Visibility + +```html + + + + +
                      + Mobile only content +
                      + + +
                      Mobile menu
                      + +``` + +### Typography + +```html + +

                      + Heading scales with screen size +

                      + +

                      + Body text scales appropriately +

                      +``` + +### Spacing + +```html + +
                      + More padding on larger screens +
                      + + +
                      +
                      Item 1
                      +
                      Item 2
                      +
                      +``` + +### Width + +```html + +
                      + Responsive width +
                      + + +
                      + Centered with responsive max width +
                      +``` + +## Common Responsive Layouts + +### Sidebar Layout + +```html +
                      + + + + +
                      + Main content +
                      +
                      +``` + +### Card Grid + +```html +
                      +
                      Card 1
                      +
                      Card 2
                      +
                      Card 3
                      +
                      Card 4
                      +
                      +``` + +### Hero Section + +```html +
                      +
                      +
                      +
                      +

                      + Hero Title +

                      +

                      + Hero description +

                      + +
                      +
                      + +
                      +
                      +
                      +
                      +``` + +### Navigation + +```html + +``` + +## Max-Width Queries + +Apply styles only below certain breakpoint using `max-*:` prefix: + +```html + +
                      + Centered on mobile/tablet, left-aligned on desktop +
                      + + +
                      + Hidden only on mobile +
                      +``` + +Available: `max-sm:` `max-md:` `max-lg:` `max-xl:` `max-2xl:` + +## Range Queries + +Apply styles between breakpoints: + +```html + +
                      + Visible only on tablets +
                      + + +
                      + 2 columns on tablet, 4 on extra large +
                      +``` + +## Container Queries + +Style elements based on parent container width: + +```html +
                      +
                      + Responds to parent width, not viewport +
                      +
                      +``` + +Container query breakpoints: `@sm:` `@md:` `@lg:` `@xl:` `@2xl:` + +## Custom Breakpoints + +Define custom breakpoints in theme: + +```css +@theme { + --breakpoint-3xl: 120rem; /* 1920px */ + --breakpoint-tablet: 48rem; /* 768px */ +} +``` + +```html +
                      + Uses custom breakpoints +
                      +``` + +## Responsive State Variants + +Combine responsive with hover/focus: + +```html + + + + + + Link + +``` + +## Best Practices + +### 1. Mobile-First Design + +Start with mobile styles, add complexity at larger breakpoints: + +```html + +
                      + + +
                      +``` + +### 2. Consistent Breakpoint Usage + +Use same breakpoints across related elements: + +```html +
                      + Spacing scales with layout +
                      +``` + +### 3. Test at Breakpoint Boundaries + +Test at exact breakpoint widths (640px, 768px, 1024px, etc.) to catch edge cases. + +### 4. Use Container for Content Width + +```html +
                      +
                      + Content with consistent max width +
                      +
                      +``` + +### 5. Progressive Enhancement + +Ensure core functionality works on mobile, enhance for larger screens: + +```html + +
                      + +
                      + Content +
                      +
                      +``` + +### 6. Avoid Too Many Breakpoints + +Use 2-3 breakpoints per element for maintainability: + +```html + +
                      + + +
                      +``` + +## Common Responsive Utilities + +### Responsive Display + +```html +
                      + Changes display type per breakpoint +
                      +``` + +### Responsive Position + +```html +
                      + Positioned differently per breakpoint +
                      +``` + +### Responsive Order + +```html +
                      +
                      First on desktop
                      +
                      First on mobile
                      +
                      +``` + +### Responsive Overflow + +```html +
                      + Scrollable on mobile, expanded on desktop +
                      +``` + +## Testing Checklist + +- [ ] Test at 320px (small mobile) +- [ ] Test at 640px (mobile breakpoint) +- [ ] Test at 768px (tablet breakpoint) +- [ ] Test at 1024px (desktop breakpoint) +- [ ] Test at 1280px (large desktop breakpoint) +- [ ] Test landscape orientation +- [ ] Verify touch targets (min 44x44px) +- [ ] Check text readability at all sizes +- [ ] Verify navigation works on mobile +- [ ] Test with browser zoom diff --git a/skills/ui-styling/references/tailwind-utilities.md b/skills/ui-styling/references/tailwind-utilities.md new file mode 100644 index 0000000..7b7b123 --- /dev/null +++ b/skills/ui-styling/references/tailwind-utilities.md @@ -0,0 +1,455 @@ +# Tailwind CSS Utility Reference + +Core utility classes for layout, spacing, typography, colors, borders, and shadows. + +## Layout Utilities + +### Display + +```html +
                      Block
                      +
                      Inline Block
                      +
                      Inline
                      +
                      Flexbox
                      +
                      Inline Flex
                      +
                      Grid
                      +
                      Inline Grid
                      + +``` + +### Flexbox + +**Container:** +```html +
                      Row (default)
                      +
                      Column
                      +
                      Reverse row
                      +
                      Reverse column
                      +``` + +**Justify (main axis):** +```html +
                      Start
                      +
                      Center
                      +
                      End
                      +
                      Space between
                      +
                      Space around
                      +
                      Space evenly
                      +``` + +**Align (cross axis):** +```html +
                      Start
                      +
                      Center
                      +
                      End
                      +
                      Baseline
                      +
                      Stretch
                      +``` + +**Gap:** +```html +
                      All sides
                      +
                      X and Y
                      +``` + +**Wrap:** +```html +
                      Wrap
                      +
                      No wrap
                      +``` + +### Grid + +**Columns:** +```html +
                      1 column
                      +
                      2 columns
                      +
                      3 columns
                      +
                      4 columns
                      +
                      12 columns
                      +
                      Custom
                      +``` + +**Rows:** +```html +
                      3 rows
                      +
                      Custom
                      +``` + +**Span:** +```html +
                      Span 2 columns
                      +
                      Span 3 rows
                      +``` + +**Gap:** +```html +
                      All sides
                      +
                      X and Y
                      +``` + +### Positioning + +```html +
                      Static (default)
                      +
                      Relative
                      +
                      Absolute
                      +
                      Fixed
                      +
                      Sticky
                      + + +
                      Top right
                      +
                      All sides 0
                      +
                      Left/right 4
                      +
                      Top/bottom 8
                      +``` + +### Z-Index + +```html +
                      z-index: 0
                      +
                      z-index: 10
                      +
                      z-index: 20
                      +
                      z-index: 50
                      +``` + +## Spacing Utilities + +### Padding + +```html +
                      All sides
                      +
                      Left and right
                      +
                      Top and bottom
                      +
                      Top
                      +
                      Right
                      +
                      Bottom
                      +
                      Left
                      +``` + +### Margin + +```html +
                      All sides
                      +
                      Center horizontally
                      +
                      Top and bottom
                      +
                      Top
                      +
                      Negative top
                      +
                      Push to right
                      +``` + +### Space Between + +```html +
                      Horizontal spacing
                      +
                      Vertical spacing
                      +``` + +### Spacing Scale + +- `0`: 0px +- `px`: 1px +- `0.5`: 0.125rem (2px) +- `1`: 0.25rem (4px) +- `2`: 0.5rem (8px) +- `3`: 0.75rem (12px) +- `4`: 1rem (16px) +- `6`: 1.5rem (24px) +- `8`: 2rem (32px) +- `12`: 3rem (48px) +- `16`: 4rem (64px) +- `24`: 6rem (96px) + +## Typography + +### Font Size + +```html +

                      Extra small (12px)

                      +

                      Small (14px)

                      +

                      Base (16px)

                      +

                      Large (18px)

                      +

                      XL (20px)

                      +

                      2XL (24px)

                      +

                      3XL (30px)

                      +

                      4XL (36px)

                      +

                      5XL (48px)

                      +``` + +### Font Weight + +```html +

                      Thin (100)

                      +

                      Light (300)

                      +

                      Normal (400)

                      +

                      Medium (500)

                      +

                      Semibold (600)

                      +

                      Bold (700)

                      +

                      Black (900)

                      +``` + +### Text Alignment + +```html +

                      Left

                      +

                      Center

                      +

                      Right

                      +

                      Justify

                      +``` + +### Line Height + +```html +

                      1

                      +

                      1.25

                      +

                      1.5

                      +

                      1.75

                      +

                      2

                      +``` + +### Combined Font Utilities + +```html +

                      + Font size 4xl with tight line height +

                      +``` + +### Text Transform + +```html +

                      UPPERCASE

                      +

                      lowercase

                      +

                      Capitalize

                      +

                      Normal

                      +``` + +### Text Decoration + +```html +

                      Underline

                      +

                      Line through

                      +

                      No underline

                      +``` + +### Text Overflow + +```html +

                      Truncate with ellipsis...

                      +

                      Clamp to 3 lines...

                      +

                      Ellipsis

                      +``` + +## Colors + +### Text Colors + +```html +

                      Black

                      +

                      White

                      +

                      Gray 500

                      +

                      Red 600

                      +

                      Blue 500

                      +

                      Green 600

                      +``` + +### Background Colors + +```html +
                      White
                      +
                      Gray 100
                      +
                      Blue 500
                      +
                      Red 600
                      +``` + +### Color Scale + +Each color has 11 shades (50-950): +- `50`: Lightest +- `100-400`: Light variations +- `500`: Base color +- `600-800`: Dark variations +- `950`: Darkest + +### Opacity Modifiers + +```html +
                      75% opacity
                      +
                      30% opacity
                      +
                      87% opacity
                      +``` + +### Gradients + +```html +
                      + Left to right gradient +
                      +
                      + With via color +
                      +``` + +Directions: `to-t | to-tr | to-r | to-br | to-b | to-bl | to-l | to-tl` + +## Borders + +### Border Width + +```html +
                      1px all sides
                      +
                      2px all sides
                      +
                      Top only
                      +
                      Right 4px
                      +
                      Bottom 2px
                      +
                      Left only
                      +
                      No border
                      +``` + +### Border Color + +```html +
                      Gray
                      +
                      Blue
                      +
                      Red with opacity
                      +``` + +### Border Radius + +```html +
                      0.25rem
                      +
                      0.375rem
                      +
                      0.5rem
                      +
                      0.75rem
                      +
                      1rem
                      +
                      9999px
                      + + +
                      Top corners
                      +
                      Bottom right
                      +``` + +### Border Style + +```html +
                      Solid
                      +
                      Dashed
                      +
                      Dotted
                      +``` + +## Shadows + +```html +
                      Small
                      +
                      Default
                      +
                      Medium
                      +
                      Large
                      +
                      Extra large
                      +
                      2XL
                      +
                      No shadow
                      +``` + +### Colored Shadows + +```html +
                      Blue shadow
                      +``` + +## Width & Height + +### Width + +```html +
                      100%
                      +
                      50%
                      +
                      33.333%
                      +
                      16rem
                      +
                      500px
                      +
                      100vw
                      + + +
                      min-width: 0
                      +
                      max-width: 28rem
                      +
                      max-width: 1280px
                      +``` + +### Height + +```html +
                      100%
                      +
                      100vh
                      +
                      16rem
                      +
                      500px
                      + + +
                      min-height: 100vh
                      +
                      max-height: 24rem
                      +``` + +## Arbitrary Values + +Use square brackets for custom values: + +```html + +
                      Custom padding
                      +
                      Custom position
                      + + +
                      Hex color
                      +
                      RGB
                      + + +
                      Custom width
                      +
                      Custom font size
                      + + +
                      CSS var
                      + + +
                      Custom grid
                      +``` + +## Aspect Ratio + +```html +
                      1:1
                      +
                      16:9
                      +
                      4:3
                      +``` + +## Overflow + +```html +
                      Auto scroll
                      +
                      Hidden
                      +
                      Always scroll
                      +
                      Horizontal scroll
                      +
                      No vertical scroll
                      +``` + +## Opacity + +```html +
                      0%
                      +
                      50%
                      +
                      75%
                      +
                      100%
                      +``` + +## Cursor + +```html +
                      Pointer
                      +
                      Wait
                      +
                      Not allowed
                      +
                      Default
                      +``` + +## User Select + +```html +
                      No select
                      +
                      Text selectable
                      +
                      Select all
                      +``` diff --git a/skills/ui-styling/scripts/requirements.txt b/skills/ui-styling/scripts/requirements.txt new file mode 100644 index 0000000..75f72ca --- /dev/null +++ b/skills/ui-styling/scripts/requirements.txt @@ -0,0 +1,17 @@ +# UI Styling Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This skill works with shadcn/ui and Tailwind CSS +# Requires Node.js and package managers: +# - Node.js 18+: https://nodejs.org/ +# - npm (comes with Node.js) +# +# shadcn/ui CLI is installed per-project: +# npx shadcn-ui@latest init diff --git a/skills/ui-styling/scripts/shadcn_add.py b/skills/ui-styling/scripts/shadcn_add.py new file mode 100644 index 0000000..e2a9799 --- /dev/null +++ b/skills/ui-styling/scripts/shadcn_add.py @@ -0,0 +1,292 @@ +#!/usr/bin/env python3 +""" +shadcn/ui Component Installer + +Add shadcn/ui components to project with automatic dependency handling. +Wraps shadcn CLI for programmatic component installation. +""" + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import List, Optional + + +class ShadcnInstaller: + """Handle shadcn/ui component installation.""" + + def __init__(self, project_root: Optional[Path] = None, dry_run: bool = False): + """ + Initialize installer. + + Args: + project_root: Project root directory (default: current directory) + dry_run: If True, show actions without executing + """ + self.project_root = project_root or Path.cwd() + self.dry_run = dry_run + self.components_json = self.project_root / "components.json" + + def check_shadcn_config(self) -> bool: + """ + Check if shadcn is initialized in project. + + Returns: + True if components.json exists + """ + return self.components_json.exists() + + def get_installed_components(self) -> List[str]: + """ + Get list of already installed components. + + Returns: + List of installed component names + """ + if not self.check_shadcn_config(): + return [] + + try: + with open(self.components_json) as f: + config = json.load(f) + + components_dir = self.project_root / config.get("aliases", {}).get( + "components", "components" + ).replace("@/", "") + ui_dir = components_dir / "ui" + + if not ui_dir.exists(): + return [] + + return [f.stem for f in ui_dir.glob("*.tsx") if f.is_file()] + except (json.JSONDecodeError, KeyError, OSError): + return [] + + def add_components( + self, components: List[str], overwrite: bool = False + ) -> tuple[bool, str]: + """ + Add shadcn/ui components. + + Args: + components: List of component names to add + overwrite: If True, overwrite existing components + + Returns: + Tuple of (success, message) + """ + if not components: + return False, "No components specified" + + if not self.check_shadcn_config(): + return ( + False, + "shadcn not initialized. Run 'npx shadcn@latest init' first", + ) + + # Check which components already exist + installed = self.get_installed_components() + already_installed = [c for c in components if c in installed] + + if already_installed and not overwrite: + return ( + False, + f"Components already installed: {', '.join(already_installed)}. " + "Use --overwrite to reinstall", + ) + + # Build command + cmd = ["npx", "shadcn@latest", "add"] + components + + if overwrite: + cmd.append("--overwrite") + + if self.dry_run: + return True, f"Would run: {' '.join(cmd)}" + + # Execute command + try: + result = subprocess.run( + cmd, + cwd=self.project_root, + capture_output=True, + text=True, + check=True, + ) + + success_msg = f"Successfully added components: {', '.join(components)}" + if result.stdout: + success_msg += f"\n\nOutput:\n{result.stdout}" + + return True, success_msg + + except subprocess.CalledProcessError as e: + error_msg = f"Failed to add components: {e.stderr or e.stdout or str(e)}" + return False, error_msg + except FileNotFoundError: + return False, "npx not found. Ensure Node.js is installed" + + def add_all_components(self, overwrite: bool = False) -> tuple[bool, str]: + """ + Add all available shadcn/ui components. + + Args: + overwrite: If True, overwrite existing components + + Returns: + Tuple of (success, message) + """ + if not self.check_shadcn_config(): + return ( + False, + "shadcn not initialized. Run 'npx shadcn@latest init' first", + ) + + cmd = ["npx", "shadcn@latest", "add", "--all"] + + if overwrite: + cmd.append("--overwrite") + + if self.dry_run: + return True, f"Would run: {' '.join(cmd)}" + + try: + result = subprocess.run( + cmd, + cwd=self.project_root, + capture_output=True, + text=True, + check=True, + ) + + success_msg = "Successfully added all components" + if result.stdout: + success_msg += f"\n\nOutput:\n{result.stdout}" + + return True, success_msg + + except subprocess.CalledProcessError as e: + error_msg = f"Failed to add all components: {e.stderr or e.stdout or str(e)}" + return False, error_msg + except FileNotFoundError: + return False, "npx not found. Ensure Node.js is installed" + + def list_installed(self) -> tuple[bool, str]: + """ + List installed components. + + Returns: + Tuple of (success, message with component list) + """ + if not self.check_shadcn_config(): + return False, "shadcn not initialized" + + installed = self.get_installed_components() + + if not installed: + return True, "No components installed" + + return True, f"Installed components:\n" + "\n".join(f" - {c}" for c in sorted(installed)) + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Add shadcn/ui components to your project", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Add single component + python shadcn_add.py button + + # Add multiple components + python shadcn_add.py button card dialog + + # Add all components + python shadcn_add.py --all + + # Overwrite existing components + python shadcn_add.py button --overwrite + + # Dry run (show what would be done) + python shadcn_add.py button card --dry-run + + # List installed components + python shadcn_add.py --list + """, + ) + + parser.add_argument( + "components", + nargs="*", + help="Component names to add (e.g., button, card, dialog)", + ) + + parser.add_argument( + "--all", + action="store_true", + help="Add all available components", + ) + + parser.add_argument( + "--overwrite", + action="store_true", + help="Overwrite existing components", + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without executing", + ) + + parser.add_argument( + "--list", + action="store_true", + help="List installed components", + ) + + parser.add_argument( + "--project-root", + type=Path, + help="Project root directory (default: current directory)", + ) + + args = parser.parse_args() + + # Initialize installer + installer = ShadcnInstaller( + project_root=args.project_root, + dry_run=args.dry_run, + ) + + # Handle list command + if args.list: + success, message = installer.list_installed() + print(message) + sys.exit(0 if success else 1) + + # Handle add all command + if args.all: + success, message = installer.add_all_components(overwrite=args.overwrite) + print(message) + sys.exit(0 if success else 1) + + # Handle add specific components + if not args.components: + parser.print_help() + sys.exit(1) + + success, message = installer.add_components( + args.components, + overwrite=args.overwrite, + ) + + print(message) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/ui-styling/scripts/tailwind_config_gen.py b/skills/ui-styling/scripts/tailwind_config_gen.py new file mode 100644 index 0000000..5109311 --- /dev/null +++ b/skills/ui-styling/scripts/tailwind_config_gen.py @@ -0,0 +1,456 @@ +#!/usr/bin/env python3 +""" +Tailwind CSS Configuration Generator + +Generate tailwind.config.js/ts with custom theme configuration. +Supports colors, fonts, spacing, breakpoints, and plugin recommendations. +""" + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + + +class TailwindConfigGenerator: + """Generate Tailwind CSS configuration files.""" + + def __init__( + self, + typescript: bool = True, + framework: str = "react", + output_path: Optional[Path] = None, + ): + """ + Initialize generator. + + Args: + typescript: If True, generate .ts config, else .js + framework: Framework name (react, vue, svelte, nextjs) + output_path: Output file path (default: auto-detect) + """ + self.typescript = typescript + self.framework = framework + self.output_path = output_path or self._default_output_path() + self.config: Dict[str, Any] = self._base_config() + + def _default_output_path(self) -> Path: + """Determine default output path.""" + ext = "ts" if self.typescript else "js" + return Path.cwd() / f"tailwind.config.{ext}" + + def _base_config(self) -> Dict[str, Any]: + """Create base configuration structure.""" + return { + "darkMode": ["class"], + "content": self._default_content_paths(), + "theme": { + "extend": {} + }, + "plugins": [] + } + + def _default_content_paths(self) -> List[str]: + """Get default content paths for framework.""" + paths = { + "react": [ + "./src/**/*.{js,jsx,ts,tsx}", + "./index.html", + ], + "vue": [ + "./src/**/*.{vue,js,ts,jsx,tsx}", + "./index.html", + ], + "svelte": [ + "./src/**/*.{svelte,js,ts}", + "./src/app.html", + ], + "nextjs": [ + "./app/**/*.{js,ts,jsx,tsx}", + "./pages/**/*.{js,ts,jsx,tsx}", + "./components/**/*.{js,ts,jsx,tsx}", + ], + } + return paths.get(self.framework, paths["react"]) + + def add_colors(self, colors: Dict[str, str]) -> None: + """ + Add custom colors to theme. + + Args: + colors: Dict of color_name: color_value + Value can be hex (#3b82f6) or variable (hsl(var(--primary))) + """ + if "colors" not in self.config["theme"]["extend"]: + self.config["theme"]["extend"]["colors"] = {} + + self.config["theme"]["extend"]["colors"].update(colors) + + def add_color_palette(self, name: str, base_color: str) -> None: + """ + Add full color palette (50-950 shades) for a base color. + + Args: + name: Color name (e.g., 'brand', 'primary') + base_color: Base color in oklch format or hex + """ + # For simplicity, use CSS variable approach + if "colors" not in self.config["theme"]["extend"]: + self.config["theme"]["extend"]["colors"] = {} + + self.config["theme"]["extend"]["colors"][name] = { + "50": f"var(--color-{name}-50)", + "100": f"var(--color-{name}-100)", + "200": f"var(--color-{name}-200)", + "300": f"var(--color-{name}-300)", + "400": f"var(--color-{name}-400)", + "500": f"var(--color-{name}-500)", + "600": f"var(--color-{name}-600)", + "700": f"var(--color-{name}-700)", + "800": f"var(--color-{name}-800)", + "900": f"var(--color-{name}-900)", + "950": f"var(--color-{name}-950)", + } + + def add_fonts(self, fonts: Dict[str, List[str]]) -> None: + """ + Add custom font families. + + Args: + fonts: Dict of font_type: [font_names] + e.g., {'sans': ['Inter', 'system-ui', 'sans-serif']} + """ + if "fontFamily" not in self.config["theme"]["extend"]: + self.config["theme"]["extend"]["fontFamily"] = {} + + self.config["theme"]["extend"]["fontFamily"].update(fonts) + + def add_spacing(self, spacing: Dict[str, str]) -> None: + """ + Add custom spacing values. + + Args: + spacing: Dict of name: value + e.g., {'18': '4.5rem', 'navbar': '4rem'} + """ + if "spacing" not in self.config["theme"]["extend"]: + self.config["theme"]["extend"]["spacing"] = {} + + self.config["theme"]["extend"]["spacing"].update(spacing) + + def add_breakpoints(self, breakpoints: Dict[str, str]) -> None: + """ + Add custom breakpoints. + + Args: + breakpoints: Dict of name: width + e.g., {'3xl': '1920px', 'tablet': '768px'} + """ + if "screens" not in self.config["theme"]["extend"]: + self.config["theme"]["extend"]["screens"] = {} + + self.config["theme"]["extend"]["screens"].update(breakpoints) + + def add_plugins(self, plugins: List[str]) -> None: + """ + Add plugin requirements. + + Args: + plugins: List of plugin names + e.g., ['@tailwindcss/typography', '@tailwindcss/forms'] + """ + for plugin in plugins: + if plugin not in self.config["plugins"]: + self.config["plugins"].append(plugin) + + def recommend_plugins(self) -> List[str]: + """ + Get plugin recommendations based on configuration. + + Returns: + List of recommended plugin package names + """ + recommendations = [] + + # Always recommend animation plugin + recommendations.append("tailwindcss-animate") + + # Framework-specific recommendations + if self.framework == "nextjs": + recommendations.append("@tailwindcss/typography") + + return recommendations + + def generate_config_string(self) -> str: + """ + Generate configuration file content. + + Returns: + Configuration file as string + """ + if self.typescript: + return self._generate_typescript() + return self._generate_javascript() + + def _generate_typescript(self) -> str: + """Generate TypeScript configuration.""" + plugins_str = self._format_plugins() + + config_json = json.dumps(self.config, indent=2) + + # Remove plugin array from JSON (we'll add it with require()) + config_obj = self.config.copy() + config_obj.pop("plugins", None) + config_json = json.dumps(config_obj, indent=2) + + return f"""import type {{ Config }} from 'tailwindcss' + +const config: Config = {{ +{self._indent_json(config_json, 1)} + plugins: [{plugins_str}], +}} + +export default config +""" + + def _generate_javascript(self) -> str: + """Generate JavaScript configuration.""" + plugins_str = self._format_plugins() + + config_obj = self.config.copy() + config_obj.pop("plugins", None) + config_json = json.dumps(config_obj, indent=2) + + return f"""/** @type {{import('tailwindcss').Config}} */ +module.exports = {{ +{self._indent_json(config_json, 1)} + plugins: [{plugins_str}], +}} +""" + + def _format_plugins(self) -> str: + """Format plugins array for config.""" + if not self.config["plugins"]: + return "" + + plugin_requires = [ + f"require('{plugin}')" for plugin in self.config["plugins"] + ] + return ", ".join(plugin_requires) + + def _indent_json(self, json_str: str, level: int) -> str: + """Add indentation to JSON string.""" + indent = " " * level + lines = json_str.split("\n") + # Skip first and last lines (braces) + indented = [indent + line for line in lines[1:-1]] + return "\n".join(indented) + + def write_config(self) -> tuple[bool, str]: + """ + Write configuration to file. + + Returns: + Tuple of (success, message) + """ + try: + config_content = self.generate_config_string() + + self.output_path.write_text(config_content) + + return True, f"Configuration written to {self.output_path}" + + except OSError as e: + return False, f"Failed to write config: {e}" + + def validate_config(self) -> tuple[bool, str]: + """ + Validate configuration. + + Returns: + Tuple of (valid, message) + """ + # Check content paths exist + if not self.config["content"]: + return False, "No content paths specified" + + # Check if extending empty theme + if not self.config["theme"]["extend"]: + return True, "Warning: No theme extensions defined" + + return True, "Configuration valid" + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Generate Tailwind CSS configuration", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Generate TypeScript config for Next.js + python tailwind_config_gen.py --framework nextjs + + # Generate JavaScript config with custom colors + python tailwind_config_gen.py --js --colors brand:#3b82f6 accent:#8b5cf6 + + # Add custom fonts + python tailwind_config_gen.py --fonts display:"Playfair Display,serif" + + # Add custom spacing and breakpoints + python tailwind_config_gen.py --spacing navbar:4rem --breakpoints 3xl:1920px + + # Add recommended plugins + python tailwind_config_gen.py --plugins + """, + ) + + parser.add_argument( + "--framework", + choices=["react", "vue", "svelte", "nextjs"], + default="react", + help="Target framework (default: react)", + ) + + parser.add_argument( + "--js", + action="store_true", + help="Generate JavaScript config instead of TypeScript", + ) + + parser.add_argument( + "--output", + type=Path, + help="Output file path", + ) + + parser.add_argument( + "--colors", + nargs="*", + metavar="NAME:VALUE", + help="Custom colors (e.g., brand:#3b82f6)", + ) + + parser.add_argument( + "--fonts", + nargs="*", + metavar="TYPE:FAMILY", + help="Custom fonts (e.g., sans:'Inter,system-ui')", + ) + + parser.add_argument( + "--spacing", + nargs="*", + metavar="NAME:VALUE", + help="Custom spacing (e.g., navbar:4rem)", + ) + + parser.add_argument( + "--breakpoints", + nargs="*", + metavar="NAME:WIDTH", + help="Custom breakpoints (e.g., 3xl:1920px)", + ) + + parser.add_argument( + "--plugins", + action="store_true", + help="Add recommended plugins", + ) + + parser.add_argument( + "--validate-only", + action="store_true", + help="Validate config without writing file", + ) + + args = parser.parse_args() + + # Initialize generator + generator = TailwindConfigGenerator( + typescript=not args.js, + framework=args.framework, + output_path=args.output, + ) + + # Add custom colors + if args.colors: + colors = {} + for color_spec in args.colors: + try: + name, value = color_spec.split(":", 1) + colors[name] = value + except ValueError: + print(f"Invalid color spec: {color_spec}", file=sys.stderr) + sys.exit(1) + generator.add_colors(colors) + + # Add custom fonts + if args.fonts: + fonts = {} + for font_spec in args.fonts: + try: + font_type, family = font_spec.split(":", 1) + fonts[font_type] = [f.strip().strip("'\"") for f in family.split(",")] + except ValueError: + print(f"Invalid font spec: {font_spec}", file=sys.stderr) + sys.exit(1) + generator.add_fonts(fonts) + + # Add custom spacing + if args.spacing: + spacing = {} + for spacing_spec in args.spacing: + try: + name, value = spacing_spec.split(":", 1) + spacing[name] = value + except ValueError: + print(f"Invalid spacing spec: {spacing_spec}", file=sys.stderr) + sys.exit(1) + generator.add_spacing(spacing) + + # Add custom breakpoints + if args.breakpoints: + breakpoints = {} + for bp_spec in args.breakpoints: + try: + name, width = bp_spec.split(":", 1) + breakpoints[name] = width + except ValueError: + print(f"Invalid breakpoint spec: {bp_spec}", file=sys.stderr) + sys.exit(1) + generator.add_breakpoints(breakpoints) + + # Add recommended plugins + if args.plugins: + recommended = generator.recommend_plugins() + generator.add_plugins(recommended) + print(f"Added recommended plugins: {', '.join(recommended)}") + print("\nInstall with:") + print(f" npm install -D {' '.join(recommended)}") + + # Validate + valid, message = generator.validate_config() + if not valid: + print(f"Validation failed: {message}", file=sys.stderr) + sys.exit(1) + + if message.startswith("Warning"): + print(message) + + # Validate only mode + if args.validate_only: + print("Configuration valid") + print("\nGenerated config:") + print(generator.generate_config_string()) + sys.exit(0) + + # Write config + success, message = generator.write_config() + print(message) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/ui-styling/scripts/tests/coverage-ui.json b/skills/ui-styling/scripts/tests/coverage-ui.json new file mode 100644 index 0000000..2a20568 --- /dev/null +++ b/skills/ui-styling/scripts/tests/coverage-ui.json @@ -0,0 +1 @@ +{"meta": {"format": 3, "version": "7.11.0", "timestamp": "2025-11-05T00:57:08.005243", "branch_coverage": false, "show_contexts": false}, "files": {"shadcn_add.py": {"executed_lines": [2, 9, 10, 11, 12, 13, 14, 17, 18, 20, 28, 29, 30, 32, 39, 41, 48, 49, 51, 52, 53, 55, 58, 60, 63, 67, 80, 81, 83, 84, 90, 91, 93, 94, 101, 103, 104, 106, 107, 110, 111, 119, 120, 121, 123, 125, 126, 127, 128, 129, 131, 141, 142, 147, 149, 152, 153, 155, 156, 164, 165, 166, 168, 176, 183, 184, 186, 188, 189, 191, 194, 291], "summary": {"covered_lines": 70, "num_statements": 103, "percent_covered": 67.96116504854369, "percent_covered_display": "68", "missing_lines": 33, "excluded_lines": 0}, "missing_lines": [61, 64, 65, 150, 170, 171, 172, 173, 174, 196, 221, 227, 233, 239, 245, 251, 257, 260, 266, 267, 268, 269, 272, 273, 274, 275, 278, 279, 280, 282, 287, 288, 292], "excluded_lines": [], "functions": {"ShadcnInstaller.__init__": {"executed_lines": [28, 29, 30], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "ShadcnInstaller.check_shadcn_config": {"executed_lines": [39], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "ShadcnInstaller.get_installed_components": {"executed_lines": [48, 49, 51, 52, 53, 55, 58, 60, 63], "summary": {"covered_lines": 9, "num_statements": 12, "percent_covered": 75.0, "percent_covered_display": "75", "missing_lines": 3, "excluded_lines": 0}, "missing_lines": [61, 64, 65], "excluded_lines": []}, "ShadcnInstaller.add_components": {"executed_lines": [80, 81, 83, 84, 90, 91, 93, 94, 101, 103, 104, 106, 107, 110, 111, 119, 120, 121, 123, 125, 126, 127, 128, 129], "summary": {"covered_lines": 24, "num_statements": 24, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "ShadcnInstaller.add_all_components": {"executed_lines": [141, 142, 147, 149, 152, 153, 155, 156, 164, 165, 166, 168], "summary": {"covered_lines": 12, "num_statements": 18, "percent_covered": 66.66666666666667, "percent_covered_display": "67", "missing_lines": 6, "excluded_lines": 0}, "missing_lines": [150, 170, 171, 172, 173, 174], "excluded_lines": []}, "ShadcnInstaller.list_installed": {"executed_lines": [183, 184, 186, 188, 189, 191], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "main": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 23, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 23, "excluded_lines": 0}, "missing_lines": [196, 221, 227, 233, 239, 245, 251, 257, 260, 266, 267, 268, 269, 272, 273, 274, 275, 278, 279, 280, 282, 287, 288], "excluded_lines": []}, "": {"executed_lines": [2, 9, 10, 11, 12, 13, 14, 17, 18, 20, 32, 41, 67, 131, 176, 194, 291], "summary": {"covered_lines": 15, "num_statements": 16, "percent_covered": 93.75, "percent_covered_display": "94", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [292], "excluded_lines": []}}, "classes": {"ShadcnInstaller": {"executed_lines": [28, 29, 30, 39, 48, 49, 51, 52, 53, 55, 58, 60, 63, 80, 81, 83, 84, 90, 91, 93, 94, 101, 103, 104, 106, 107, 110, 111, 119, 120, 121, 123, 125, 126, 127, 128, 129, 141, 142, 147, 149, 152, 153, 155, 156, 164, 165, 166, 168, 183, 184, 186, 188, 189, 191], "summary": {"covered_lines": 55, "num_statements": 64, "percent_covered": 85.9375, "percent_covered_display": "86", "missing_lines": 9, "excluded_lines": 0}, "missing_lines": [61, 64, 65, 150, 170, 171, 172, 173, 174], "excluded_lines": []}, "": {"executed_lines": [2, 9, 10, 11, 12, 13, 14, 17, 18, 20, 32, 41, 67, 131, 176, 194, 291], "summary": {"covered_lines": 15, "num_statements": 39, "percent_covered": 38.46153846153846, "percent_covered_display": "38", "missing_lines": 24, "excluded_lines": 0}, "missing_lines": [196, 221, 227, 233, 239, 245, 251, 257, 260, 266, 267, 268, 269, 272, 273, 274, 275, 278, 279, 280, 282, 287, 288, 292], "excluded_lines": []}}}, "tailwind_config_gen.py": {"executed_lines": [2, 9, 10, 11, 12, 13, 16, 17, 19, 33, 34, 35, 36, 38, 40, 41, 43, 45, 54, 56, 75, 77, 85, 86, 88, 90, 99, 100, 102, 116, 124, 125, 127, 129, 137, 138, 140, 142, 150, 151, 153, 155, 163, 164, 165, 167, 174, 177, 180, 181, 183, 185, 192, 193, 194, 196, 198, 200, 203, 204, 205, 207, 217, 219, 221, 222, 223, 225, 232, 234, 235, 237, 240, 242, 244, 245, 247, 248, 250, 257, 258, 260, 262, 264, 265, 267, 275, 276, 279, 280, 285, 455], "summary": {"covered_lines": 90, "num_statements": 164, "percent_covered": 54.8780487804878, "percent_covered_display": "55", "missing_lines": 74, "excluded_lines": 0}, "missing_lines": [282, 287, 309, 316, 322, 328, 335, 342, 349, 356, 362, 368, 371, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 426, 427, 428, 429, 430, 431, 434, 435, 436, 437, 439, 440, 443, 444, 445, 446, 447, 450, 451, 452, 456], "excluded_lines": [], "functions": {"TailwindConfigGenerator.__init__": {"executed_lines": [33, 34, 35, 36], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._default_output_path": {"executed_lines": [40, 41], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._base_config": {"executed_lines": [45], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._default_content_paths": {"executed_lines": [56, 75], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.add_colors": {"executed_lines": [85, 86, 88], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.add_color_palette": {"executed_lines": [99, 100, 102], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.add_fonts": {"executed_lines": [124, 125, 127], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.add_spacing": {"executed_lines": [137, 138, 140], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.add_breakpoints": {"executed_lines": [150, 151, 153], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.add_plugins": {"executed_lines": [163, 164, 165], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.recommend_plugins": {"executed_lines": [174, 177, 180, 181, 183], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.generate_config_string": {"executed_lines": [192, 193, 194], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._generate_typescript": {"executed_lines": [198, 200, 203, 204, 205, 207], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._generate_javascript": {"executed_lines": [219, 221, 222, 223, 225], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._format_plugins": {"executed_lines": [234, 235, 237, 240], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator._indent_json": {"executed_lines": [244, 245, 247, 248], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.write_config": {"executed_lines": [257, 258, 260, 262, 264, 265], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TailwindConfigGenerator.validate_config": {"executed_lines": [275, 276, 279, 280], "summary": {"covered_lines": 4, "num_statements": 5, "percent_covered": 80.0, "percent_covered_display": "80", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [282], "excluded_lines": []}, "main": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 72, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 72, "excluded_lines": 0}, "missing_lines": [287, 309, 316, 322, 328, 335, 342, 349, 356, 362, 368, 371, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 426, 427, 428, 429, 430, 431, 434, 435, 436, 437, 439, 440, 443, 444, 445, 446, 447, 450, 451, 452], "excluded_lines": []}, "": {"executed_lines": [2, 9, 10, 11, 12, 13, 16, 17, 19, 38, 43, 54, 77, 90, 116, 129, 142, 155, 167, 185, 196, 217, 232, 242, 250, 267, 285, 455], "summary": {"covered_lines": 26, "num_statements": 27, "percent_covered": 96.29629629629629, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [456], "excluded_lines": []}}, "classes": {"TailwindConfigGenerator": {"executed_lines": [33, 34, 35, 36, 40, 41, 45, 56, 75, 85, 86, 88, 99, 100, 102, 124, 125, 127, 137, 138, 140, 150, 151, 153, 163, 164, 165, 174, 177, 180, 181, 183, 192, 193, 194, 198, 200, 203, 204, 205, 207, 219, 221, 222, 223, 225, 234, 235, 237, 240, 244, 245, 247, 248, 257, 258, 260, 262, 264, 265, 275, 276, 279, 280], "summary": {"covered_lines": 64, "num_statements": 65, "percent_covered": 98.46153846153847, "percent_covered_display": "98", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [282], "excluded_lines": []}, "": {"executed_lines": [2, 9, 10, 11, 12, 13, 16, 17, 19, 38, 43, 54, 77, 90, 116, 129, 142, 155, 167, 185, 196, 217, 232, 242, 250, 267, 285, 455], "summary": {"covered_lines": 26, "num_statements": 99, "percent_covered": 26.262626262626263, "percent_covered_display": "26", "missing_lines": 73, "excluded_lines": 0}, "missing_lines": [287, 309, 316, 322, 328, 335, 342, 349, 356, 362, 368, 371, 378, 379, 380, 381, 382, 383, 384, 385, 386, 387, 390, 391, 392, 393, 394, 395, 396, 397, 398, 399, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 414, 415, 416, 417, 418, 419, 420, 421, 422, 423, 426, 427, 428, 429, 430, 431, 434, 435, 436, 437, 439, 440, 443, 444, 445, 446, 447, 450, 451, 452, 456], "excluded_lines": []}}}, "tests/test_shadcn_add.py": {"executed_lines": [1, 3, 4, 5, 6, 8, 11, 12, 14, 17, 18, 20, 21, 23, 24, 27, 28, 39, 40, 42, 44, 46, 47, 48, 50, 52, 53, 55, 57, 58, 60, 62, 63, 65, 67, 68, 70, 72, 73, 74, 76, 78, 81, 82, 84, 85, 87, 89, 91, 92, 93, 95, 97, 98, 100, 101, 103, 105, 106, 108, 109, 111, 113, 114, 116, 117, 119, 120, 121, 123, 125, 126, 128, 130, 131, 136, 138, 139, 140, 143, 144, 146, 148, 149, 151, 152, 153, 154, 156, 157, 159, 165, 166, 168, 169, 170, 171, 174, 175, 176, 177, 178, 180, 181, 183, 187, 188, 190, 191, 193, 194, 196, 198, 199, 201, 202, 204, 206, 207, 209, 210, 212, 214, 215, 217, 218, 219, 221, 222, 224, 229, 230, 232, 233, 236, 237, 239, 241, 242, 244, 245, 247, 249, 250, 252, 253, 255, 257, 258, 259, 261, 262, 264, 265, 266], "summary": {"covered_lines": 153, "num_statements": 153, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": [], "functions": {"TestShadcnInstaller.temp_project": {"executed_lines": [23, 24, 27, 28, 39, 40, 42], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_init_default_project_root": {"executed_lines": [46, 47, 48], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_init_custom_project_root": {"executed_lines": [52, 53], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_init_dry_run": {"executed_lines": [57, 58], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_check_shadcn_config_exists": {"executed_lines": [62, 63], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_check_shadcn_config_not_exists": {"executed_lines": [67, 68], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_get_installed_components_empty": {"executed_lines": [72, 73, 74], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_get_installed_components_with_files": {"executed_lines": [78, 81, 82, 84, 85, 87], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_get_installed_components_no_config": {"executed_lines": [91, 92, 93], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_no_components": {"executed_lines": [97, 98, 100, 101], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_no_config": {"executed_lines": [105, 106, 108, 109], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_already_installed": {"executed_lines": [113, 114, 116, 117, 119, 120, 121], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_with_overwrite": {"executed_lines": [125, 126, 128, 130, 131, 136, 138, 139, 140, 143, 144], "summary": {"covered_lines": 11, "num_statements": 11, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_dry_run": {"executed_lines": [148, 149, 151, 152, 153, 154], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_success": {"executed_lines": [159, 165, 166, 168, 169, 170, 171, 174, 175, 176, 177, 178], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_subprocess_error": {"executed_lines": [183, 187, 188, 190, 191], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_components_npx_not_found": {"executed_lines": [196, 198, 199, 201, 202], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_all_components_no_config": {"executed_lines": [206, 207, 209, 210], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_all_components_dry_run": {"executed_lines": [214, 215, 217, 218, 219], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_add_all_components_success": {"executed_lines": [224, 229, 230, 232, 233, 236, 237], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_list_installed_no_config": {"executed_lines": [241, 242, 244, 245], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_list_installed_empty": {"executed_lines": [249, 250, 252, 253], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestShadcnInstaller.test_list_installed_with_components": {"executed_lines": [257, 258, 259, 261, 262, 264, 265, 266], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 6, 8, 11, 12, 14, 17, 18, 20, 21, 44, 50, 55, 60, 65, 70, 76, 89, 95, 103, 111, 123, 146, 156, 157, 180, 181, 193, 194, 204, 212, 221, 222, 239, 247, 255], "summary": {"covered_lines": 37, "num_statements": 37, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "classes": {"TestShadcnInstaller": {"executed_lines": [23, 24, 27, 28, 39, 40, 42, 46, 47, 48, 52, 53, 57, 58, 62, 63, 67, 68, 72, 73, 74, 78, 81, 82, 84, 85, 87, 91, 92, 93, 97, 98, 100, 101, 105, 106, 108, 109, 113, 114, 116, 117, 119, 120, 121, 125, 126, 128, 130, 131, 136, 138, 139, 140, 143, 144, 148, 149, 151, 152, 153, 154, 159, 165, 166, 168, 169, 170, 171, 174, 175, 176, 177, 178, 183, 187, 188, 190, 191, 196, 198, 199, 201, 202, 206, 207, 209, 210, 214, 215, 217, 218, 219, 224, 229, 230, 232, 233, 236, 237, 241, 242, 244, 245, 249, 250, 252, 253, 257, 258, 259, 261, 262, 264, 265, 266], "summary": {"covered_lines": 116, "num_statements": 116, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 6, 8, 11, 12, 14, 17, 18, 20, 21, 44, 50, 55, 60, 65, 70, 76, 89, 95, 103, 111, 123, 146, 156, 157, 180, 181, 193, 194, 204, 212, 221, 222, 239, 247, 255], "summary": {"covered_lines": 37, "num_statements": 37, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}}, "tests/test_tailwind_config_gen.py": {"executed_lines": [1, 3, 5, 8, 9, 11, 14, 15, 17, 19, 20, 21, 23, 25, 26, 28, 30, 31, 32, 34, 36, 37, 39, 41, 42, 44, 46, 47, 48, 50, 52, 53, 55, 56, 57, 58, 59, 61, 63, 64, 66, 67, 69, 71, 72, 74, 75, 76, 78, 80, 81, 83, 85, 87, 88, 92, 94, 95, 96, 98, 100, 102, 103, 105, 106, 107, 109, 111, 112, 114, 116, 117, 118, 119, 120, 122, 124, 125, 129, 131, 132, 133, 135, 137, 138, 142, 144, 145, 146, 148, 150, 151, 155, 157, 158, 159, 161, 163, 164, 165, 167, 168, 170, 172, 173, 174, 176, 177, 179, 181, 182, 184, 185, 187, 189, 190, 192, 194, 196, 197, 199, 200, 201, 203, 205, 206, 208, 209, 211, 213, 214, 215, 217, 218, 220, 222, 223, 224, 226, 227, 229, 231, 232, 234, 236, 238, 239, 241, 243, 244, 246, 248, 251, 253, 254, 256, 258, 259, 261, 263, 264, 265, 267, 269, 270, 271, 273, 275, 276, 277, 279, 281, 283, 285, 286, 288, 290, 291, 298, 299, 300, 301, 302, 304, 305, 307, 310, 311, 312, 313, 314, 315, 317, 319, 320, 326, 327, 329, 330, 332, 334, 335, 336], "summary": {"covered_lines": 201, "num_statements": 201, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": [], "functions": {"TestTailwindConfigGenerator.test_init_default_typescript": {"executed_lines": [19, 20, 21], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_init_javascript": {"executed_lines": [25, 26], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_init_framework": {"executed_lines": [30, 31, 32], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_default_output_path_typescript": {"executed_lines": [36, 37], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_default_output_path_javascript": {"executed_lines": [41, 42], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_custom_output_path": {"executed_lines": [46, 47, 48], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_base_config_structure": {"executed_lines": [52, 53, 55, 56, 57, 58, 59], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_default_content_paths_react": {"executed_lines": [63, 64, 66, 67], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_default_content_paths_nextjs": {"executed_lines": [71, 72, 74, 75, 76], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_default_content_paths_vue": {"executed_lines": [80, 81, 83], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_colors": {"executed_lines": [87, 88, 92, 94, 95, 96], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_colors_multiple_times": {"executed_lines": [100, 102, 103, 105, 106, 107], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_color_palette": {"executed_lines": [111, 112, 114, 116, 117, 118, 119, 120], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_fonts": {"executed_lines": [124, 125, 129, 131, 132, 133], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_spacing": {"executed_lines": [137, 138, 142, 144, 145, 146], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_breakpoints": {"executed_lines": [150, 151, 155, 157, 158, 159], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_plugins": {"executed_lines": [163, 164, 165, 167, 168], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_add_plugins_no_duplicates": {"executed_lines": [172, 173, 174, 176, 177], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_recommend_plugins": {"executed_lines": [181, 182, 184, 185], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_recommend_plugins_nextjs": {"executed_lines": [189, 190, 192], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_generate_typescript_config": {"executed_lines": [196, 197, 199, 200, 201], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_generate_javascript_config": {"executed_lines": [205, 206, 208, 209], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_generate_config_with_colors": {"executed_lines": [213, 214, 215, 217, 218], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_generate_config_with_plugins": {"executed_lines": [222, 223, 224, 226, 227], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_validate_config_valid": {"executed_lines": [231, 232, 234], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_validate_config_no_content": {"executed_lines": [238, 239, 241, 243, 244], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_validate_config_empty_theme": {"executed_lines": [248, 251, 253, 254], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_write_config": {"executed_lines": [258, 259, 261, 263, 264, 265], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_write_config_creates_content": {"executed_lines": [269, 270, 271, 273, 275, 276, 277], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_write_config_invalid_path": {"executed_lines": [281, 283, 285, 286], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_full_configuration_typescript": {"executed_lines": [290, 291, 298, 299, 300, 301, 302, 304, 305, 307, 310, 311, 312, 313, 314, 315], "summary": {"covered_lines": 16, "num_statements": 16, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTailwindConfigGenerator.test_full_configuration_javascript": {"executed_lines": [319, 320, 326, 327, 329, 330, 332, 334, 335, 336], "summary": {"covered_lines": 10, "num_statements": 10, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 5, 8, 9, 11, 14, 15, 17, 23, 28, 34, 39, 44, 50, 61, 69, 78, 85, 98, 109, 122, 135, 148, 161, 170, 179, 187, 194, 203, 211, 220, 229, 236, 246, 256, 267, 279, 288, 317], "summary": {"covered_lines": 38, "num_statements": 38, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "classes": {"TestTailwindConfigGenerator": {"executed_lines": [19, 20, 21, 25, 26, 30, 31, 32, 36, 37, 41, 42, 46, 47, 48, 52, 53, 55, 56, 57, 58, 59, 63, 64, 66, 67, 71, 72, 74, 75, 76, 80, 81, 83, 87, 88, 92, 94, 95, 96, 100, 102, 103, 105, 106, 107, 111, 112, 114, 116, 117, 118, 119, 120, 124, 125, 129, 131, 132, 133, 137, 138, 142, 144, 145, 146, 150, 151, 155, 157, 158, 159, 163, 164, 165, 167, 168, 172, 173, 174, 176, 177, 181, 182, 184, 185, 189, 190, 192, 196, 197, 199, 200, 201, 205, 206, 208, 209, 213, 214, 215, 217, 218, 222, 223, 224, 226, 227, 231, 232, 234, 238, 239, 241, 243, 244, 248, 251, 253, 254, 258, 259, 261, 263, 264, 265, 269, 270, 271, 273, 275, 276, 277, 281, 283, 285, 286, 290, 291, 298, 299, 300, 301, 302, 304, 305, 307, 310, 311, 312, 313, 314, 315, 319, 320, 326, 327, 329, 330, 332, 334, 335, 336], "summary": {"covered_lines": 163, "num_statements": 163, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 5, 8, 9, 11, 14, 15, 17, 23, 28, 34, 39, 44, 50, 61, 69, 78, 85, 98, 109, 122, 135, 148, 161, 170, 179, 187, 194, 203, 211, 220, 229, 236, 246, 256, 267, 279, 288, 317], "summary": {"covered_lines": 38, "num_statements": 38, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}}}, "totals": {"covered_lines": 514, "num_statements": 621, "percent_covered": 82.76972624798712, "percent_covered_display": "83", "missing_lines": 107, "excluded_lines": 0}} \ No newline at end of file diff --git a/skills/ui-styling/scripts/tests/requirements.txt b/skills/ui-styling/scripts/tests/requirements.txt new file mode 100644 index 0000000..3a0f66d --- /dev/null +++ b/skills/ui-styling/scripts/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-mock>=3.11.1 diff --git a/skills/ui-styling/scripts/tests/test_shadcn_add.py b/skills/ui-styling/scripts/tests/test_shadcn_add.py new file mode 100644 index 0000000..03c8f31 --- /dev/null +++ b/skills/ui-styling/scripts/tests/test_shadcn_add.py @@ -0,0 +1,266 @@ +"""Tests for shadcn_add.py""" + +import json +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, mock_open, patch + +import pytest + +# Add parent directory to path for imports +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from shadcn_add import ShadcnInstaller + + +class TestShadcnInstaller: + """Test ShadcnInstaller class.""" + + @pytest.fixture + def temp_project(self, tmp_path): + """Create temporary project structure.""" + project_root = tmp_path / "test-project" + project_root.mkdir() + + # Create components.json + components_json = project_root / "components.json" + components_json.write_text( + json.dumps({ + "style": "new-york", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } + }) + ) + + # Create components directory + ui_dir = project_root / "components" / "ui" + ui_dir.mkdir(parents=True) + + return project_root + + def test_init_default_project_root(self): + """Test initialization with default project root.""" + installer = ShadcnInstaller() + assert installer.project_root == Path.cwd() + assert installer.dry_run is False + + def test_init_custom_project_root(self, tmp_path): + """Test initialization with custom project root.""" + installer = ShadcnInstaller(project_root=tmp_path) + assert installer.project_root == tmp_path + + def test_init_dry_run(self): + """Test initialization with dry run mode.""" + installer = ShadcnInstaller(dry_run=True) + assert installer.dry_run is True + + def test_check_shadcn_config_exists(self, temp_project): + """Test checking for existing shadcn config.""" + installer = ShadcnInstaller(project_root=temp_project) + assert installer.check_shadcn_config() is True + + def test_check_shadcn_config_not_exists(self, tmp_path): + """Test checking for non-existent shadcn config.""" + installer = ShadcnInstaller(project_root=tmp_path) + assert installer.check_shadcn_config() is False + + def test_get_installed_components_empty(self, temp_project): + """Test getting installed components when none exist.""" + installer = ShadcnInstaller(project_root=temp_project) + installed = installer.get_installed_components() + assert installed == [] + + def test_get_installed_components_with_files(self, temp_project): + """Test getting installed components when files exist.""" + ui_dir = temp_project / "components" / "ui" + + # Create component files + (ui_dir / "button.tsx").write_text("export const Button = () => {}") + (ui_dir / "card.tsx").write_text("export const Card = () => {}") + + installer = ShadcnInstaller(project_root=temp_project) + installed = installer.get_installed_components() + + assert sorted(installed) == ["button", "card"] + + def test_get_installed_components_no_config(self, tmp_path): + """Test getting installed components without config.""" + installer = ShadcnInstaller(project_root=tmp_path) + installed = installer.get_installed_components() + assert installed == [] + + def test_add_components_no_components(self, temp_project): + """Test adding components with empty list.""" + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.add_components([]) + + assert success is False + assert "No components specified" in message + + def test_add_components_no_config(self, tmp_path): + """Test adding components without shadcn config.""" + installer = ShadcnInstaller(project_root=tmp_path) + success, message = installer.add_components(["button"]) + + assert success is False + assert "not initialized" in message + + def test_add_components_already_installed(self, temp_project): + """Test adding components that are already installed.""" + ui_dir = temp_project / "components" / "ui" + (ui_dir / "button.tsx").write_text("export const Button = () => {}") + + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.add_components(["button"]) + + assert success is False + assert "already installed" in message + assert "button" in message + + def test_add_components_with_overwrite(self, temp_project): + """Test adding components with overwrite flag.""" + ui_dir = temp_project / "components" / "ui" + (ui_dir / "button.tsx").write_text("export const Button = () => {}") + + installer = ShadcnInstaller(project_root=temp_project) + + with patch("subprocess.run") as mock_run: + mock_run.return_value = MagicMock( + stdout="Component added successfully", + returncode=0 + ) + + success, message = installer.add_components(["button"], overwrite=True) + + assert success is True + assert "Successfully added" in message + mock_run.assert_called_once() + + # Verify --overwrite flag was passed + call_args = mock_run.call_args[0][0] + assert "--overwrite" in call_args + + def test_add_components_dry_run(self, temp_project): + """Test adding components in dry run mode.""" + installer = ShadcnInstaller(project_root=temp_project, dry_run=True) + success, message = installer.add_components(["button", "card"]) + + assert success is True + assert "Would run:" in message + assert "button" in message + assert "card" in message + + @patch("subprocess.run") + def test_add_components_success(self, mock_run, temp_project): + """Test successful component addition.""" + mock_run.return_value = MagicMock( + stdout="Components added successfully", + stderr="", + returncode=0 + ) + + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.add_components(["button", "card"]) + + assert success is True + assert "Successfully added" in message + assert "button" in message + assert "card" in message + + # Verify correct command was called + mock_run.assert_called_once() + call_args = mock_run.call_args[0][0] + assert call_args[:3] == ["npx", "shadcn@latest", "add"] + assert "button" in call_args + assert "card" in call_args + + @patch("subprocess.run") + def test_add_components_subprocess_error(self, mock_run, temp_project): + """Test component addition with subprocess error.""" + mock_run.side_effect = subprocess.CalledProcessError( + 1, "cmd", stderr="Error occurred" + ) + + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.add_components(["button"]) + + assert success is False + assert "Failed to add" in message + + @patch("subprocess.run") + def test_add_components_npx_not_found(self, mock_run, temp_project): + """Test component addition when npx is not found.""" + mock_run.side_effect = FileNotFoundError() + + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.add_components(["button"]) + + assert success is False + assert "npx not found" in message + + def test_add_all_components_no_config(self, tmp_path): + """Test adding all components without config.""" + installer = ShadcnInstaller(project_root=tmp_path) + success, message = installer.add_all_components() + + assert success is False + assert "not initialized" in message + + def test_add_all_components_dry_run(self, temp_project): + """Test adding all components in dry run mode.""" + installer = ShadcnInstaller(project_root=temp_project, dry_run=True) + success, message = installer.add_all_components() + + assert success is True + assert "Would run:" in message + assert "--all" in message + + @patch("subprocess.run") + def test_add_all_components_success(self, mock_run, temp_project): + """Test successful addition of all components.""" + mock_run.return_value = MagicMock( + stdout="All components added", + returncode=0 + ) + + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.add_all_components() + + assert success is True + assert "Successfully added all" in message + + # Verify --all flag was passed + call_args = mock_run.call_args[0][0] + assert "--all" in call_args + + def test_list_installed_no_config(self, tmp_path): + """Test listing installed components without config.""" + installer = ShadcnInstaller(project_root=tmp_path) + success, message = installer.list_installed() + + assert success is False + assert "not initialized" in message + + def test_list_installed_empty(self, temp_project): + """Test listing installed components when none exist.""" + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.list_installed() + + assert success is True + assert "No components installed" in message + + def test_list_installed_with_components(self, temp_project): + """Test listing installed components when they exist.""" + ui_dir = temp_project / "components" / "ui" + (ui_dir / "button.tsx").write_text("export const Button = () => {}") + (ui_dir / "card.tsx").write_text("export const Card = () => {}") + + installer = ShadcnInstaller(project_root=temp_project) + success, message = installer.list_installed() + + assert success is True + assert "button" in message + assert "card" in message diff --git a/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py b/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py new file mode 100644 index 0000000..a08414e --- /dev/null +++ b/skills/ui-styling/scripts/tests/test_tailwind_config_gen.py @@ -0,0 +1,336 @@ +"""Tests for tailwind_config_gen.py""" + +from pathlib import Path + +import pytest + +# Add parent directory to path for imports +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from tailwind_config_gen import TailwindConfigGenerator + + +class TestTailwindConfigGenerator: + """Test TailwindConfigGenerator class.""" + + def test_init_default_typescript(self): + """Test initialization with default settings.""" + generator = TailwindConfigGenerator() + assert generator.typescript is True + assert generator.framework == "react" + + def test_init_javascript(self): + """Test initialization for JavaScript config.""" + generator = TailwindConfigGenerator(typescript=False) + assert generator.typescript is False + + def test_init_framework(self): + """Test initialization with different frameworks.""" + for framework in ["react", "vue", "svelte", "nextjs"]: + generator = TailwindConfigGenerator(framework=framework) + assert generator.framework == framework + + def test_default_output_path_typescript(self): + """Test default output path for TypeScript.""" + generator = TailwindConfigGenerator(typescript=True) + assert generator.output_path.name == "tailwind.config.ts" + + def test_default_output_path_javascript(self): + """Test default output path for JavaScript.""" + generator = TailwindConfigGenerator(typescript=False) + assert generator.output_path.name == "tailwind.config.js" + + def test_custom_output_path(self, tmp_path): + """Test custom output path.""" + custom_path = tmp_path / "custom-config.ts" + generator = TailwindConfigGenerator(output_path=custom_path) + assert generator.output_path == custom_path + + def test_base_config_structure(self): + """Test base configuration structure.""" + generator = TailwindConfigGenerator() + config = generator.config + + assert "darkMode" in config + assert "content" in config + assert "theme" in config + assert "plugins" in config + assert "extend" in config["theme"] + + def test_default_content_paths_react(self): + """Test default content paths for React.""" + generator = TailwindConfigGenerator(framework="react") + paths = generator.config["content"] + + assert any("src/**/*.{js,jsx,ts,tsx}" in p for p in paths) + assert any("index.html" in p for p in paths) + + def test_default_content_paths_nextjs(self): + """Test default content paths for Next.js.""" + generator = TailwindConfigGenerator(framework="nextjs") + paths = generator.config["content"] + + assert any("app/**" in p for p in paths) + assert any("pages/**" in p for p in paths) + assert any("components/**" in p for p in paths) + + def test_default_content_paths_vue(self): + """Test default content paths for Vue.""" + generator = TailwindConfigGenerator(framework="vue") + paths = generator.config["content"] + + assert any("vue" in p for p in paths) + + def test_add_colors(self): + """Test adding custom colors.""" + generator = TailwindConfigGenerator() + colors = { + "brand": "#3b82f6", + "accent": "#8b5cf6" + } + generator.add_colors(colors) + + assert "colors" in generator.config["theme"]["extend"] + assert generator.config["theme"]["extend"]["colors"]["brand"] == "#3b82f6" + assert generator.config["theme"]["extend"]["colors"]["accent"] == "#8b5cf6" + + def test_add_colors_multiple_times(self): + """Test adding colors multiple times.""" + generator = TailwindConfigGenerator() + + generator.add_colors({"brand": "#3b82f6"}) + generator.add_colors({"accent": "#8b5cf6"}) + + colors = generator.config["theme"]["extend"]["colors"] + assert "brand" in colors + assert "accent" in colors + + def test_add_color_palette(self): + """Test adding full color palette.""" + generator = TailwindConfigGenerator() + generator.add_color_palette("brand", "#3b82f6") + + brand = generator.config["theme"]["extend"]["colors"]["brand"] + + assert isinstance(brand, dict) + assert "50" in brand + assert "500" in brand + assert "950" in brand + assert "var(--color-brand" in brand["500"] + + def test_add_fonts(self): + """Test adding custom fonts.""" + generator = TailwindConfigGenerator() + fonts = { + "sans": ["Inter", "system-ui", "sans-serif"], + "display": ["Playfair Display", "serif"] + } + generator.add_fonts(fonts) + + font_family = generator.config["theme"]["extend"]["fontFamily"] + assert font_family["sans"] == ["Inter", "system-ui", "sans-serif"] + assert font_family["display"] == ["Playfair Display", "serif"] + + def test_add_spacing(self): + """Test adding custom spacing.""" + generator = TailwindConfigGenerator() + spacing = { + "18": "4.5rem", + "navbar": "4rem" + } + generator.add_spacing(spacing) + + spacing_config = generator.config["theme"]["extend"]["spacing"] + assert spacing_config["18"] == "4.5rem" + assert spacing_config["navbar"] == "4rem" + + def test_add_breakpoints(self): + """Test adding custom breakpoints.""" + generator = TailwindConfigGenerator() + breakpoints = { + "3xl": "1920px", + "tablet": "768px" + } + generator.add_breakpoints(breakpoints) + + screens = generator.config["theme"]["extend"]["screens"] + assert screens["3xl"] == "1920px" + assert screens["tablet"] == "768px" + + def test_add_plugins(self): + """Test adding plugins.""" + generator = TailwindConfigGenerator() + plugins = ["@tailwindcss/typography", "@tailwindcss/forms"] + generator.add_plugins(plugins) + + assert "@tailwindcss/typography" in generator.config["plugins"] + assert "@tailwindcss/forms" in generator.config["plugins"] + + def test_add_plugins_no_duplicates(self): + """Test that adding same plugin twice doesn't duplicate.""" + generator = TailwindConfigGenerator() + generator.add_plugins(["@tailwindcss/typography"]) + generator.add_plugins(["@tailwindcss/typography"]) + + count = generator.config["plugins"].count("@tailwindcss/typography") + assert count == 1 + + def test_recommend_plugins(self): + """Test plugin recommendations.""" + generator = TailwindConfigGenerator() + recommendations = generator.recommend_plugins() + + assert isinstance(recommendations, list) + assert "tailwindcss-animate" in recommendations + + def test_recommend_plugins_nextjs(self): + """Test plugin recommendations for Next.js.""" + generator = TailwindConfigGenerator(framework="nextjs") + recommendations = generator.recommend_plugins() + + assert "@tailwindcss/typography" in recommendations + + def test_generate_typescript_config(self): + """Test generating TypeScript configuration.""" + generator = TailwindConfigGenerator(typescript=True) + config = generator.generate_config_string() + + assert "import type { Config } from 'tailwindcss'" in config + assert "const config: Config" in config + assert "export default config" in config + + def test_generate_javascript_config(self): + """Test generating JavaScript configuration.""" + generator = TailwindConfigGenerator(typescript=False) + config = generator.generate_config_string() + + assert "module.exports" in config + assert "@type" in config + + def test_generate_config_with_colors(self): + """Test generating config with custom colors.""" + generator = TailwindConfigGenerator() + generator.add_colors({"brand": "#3b82f6"}) + config = generator.generate_config_string() + + assert "colors" in config + assert "brand" in config + + def test_generate_config_with_plugins(self): + """Test generating config with plugins.""" + generator = TailwindConfigGenerator() + generator.add_plugins(["tailwindcss-animate"]) + config = generator.generate_config_string() + + assert "plugins:" in config + assert "require('tailwindcss-animate')" in config + + def test_validate_config_valid(self): + """Test validating valid configuration.""" + generator = TailwindConfigGenerator() + valid, message = generator.validate_config() + + assert valid is True + + def test_validate_config_no_content(self): + """Test validating config with no content paths.""" + generator = TailwindConfigGenerator() + generator.config["content"] = [] + + valid, message = generator.validate_config() + + assert valid is False + assert "No content paths" in message + + def test_validate_config_empty_theme(self): + """Test validating config with empty theme extensions.""" + generator = TailwindConfigGenerator() + # Default has empty theme.extend + + valid, message = generator.validate_config() + + assert valid is True + assert "Warning" in message + + def test_write_config(self, tmp_path): + """Test writing configuration to file.""" + output_path = tmp_path / "tailwind.config.ts" + generator = TailwindConfigGenerator(output_path=output_path) + + success, message = generator.write_config() + + assert success is True + assert output_path.exists() + assert "written to" in message + + def test_write_config_creates_content(self, tmp_path): + """Test that written config contains expected content.""" + output_path = tmp_path / "tailwind.config.ts" + generator = TailwindConfigGenerator(output_path=output_path) + generator.add_colors({"brand": "#3b82f6"}) + + generator.write_config() + + content = output_path.read_text() + assert "import type { Config }" in content + assert "brand" in content + + def test_write_config_invalid_path(self): + """Test writing config to invalid path.""" + generator = TailwindConfigGenerator(output_path=Path("/invalid/path/config.ts")) + + success, message = generator.write_config() + + assert success is False + assert "Failed to write" in message + + def test_full_configuration_typescript(self, tmp_path): + """Test generating complete TypeScript configuration.""" + output_path = tmp_path / "tailwind.config.ts" + generator = TailwindConfigGenerator( + typescript=True, + framework="nextjs", + output_path=output_path + ) + + # Add various customizations + generator.add_colors({"brand": "#3b82f6", "accent": "#8b5cf6"}) + generator.add_fonts({"sans": ["Inter", "sans-serif"]}) + generator.add_spacing({"navbar": "4rem"}) + generator.add_breakpoints({"3xl": "1920px"}) + generator.add_plugins(["tailwindcss-animate"]) + + success, _ = generator.write_config() + assert success is True + + content = output_path.read_text() + + # Verify all customizations are present + assert "brand" in content + assert "accent" in content + assert "Inter" in content + assert "navbar" in content + assert "3xl" in content + assert "tailwindcss-animate" in content + + def test_full_configuration_javascript(self, tmp_path): + """Test generating complete JavaScript configuration.""" + output_path = tmp_path / "tailwind.config.js" + generator = TailwindConfigGenerator( + typescript=False, + framework="react", + output_path=output_path + ) + + generator.add_colors({"primary": "#3b82f6"}) + generator.add_plugins(["@tailwindcss/forms"]) + + success, _ = generator.write_config() + assert success is True + + content = output_path.read_text() + + assert "module.exports" in content + assert "primary" in content + assert "@tailwindcss/forms" in content diff --git a/skills/web-frameworks/SKILL.md b/skills/web-frameworks/SKILL.md new file mode 100644 index 0000000..547383c --- /dev/null +++ b/skills/web-frameworks/SKILL.md @@ -0,0 +1,324 @@ +--- +name: web-frameworks +description: Build modern full-stack web applications with Next.js (App Router, Server Components, RSC, PPR, SSR, SSG, ISR), Turborepo (monorepo management, task pipelines, remote caching, parallel execution), and RemixIcon (3100+ SVG icons in outlined/filled styles). Use when creating React applications, implementing server-side rendering, setting up monorepos with multiple packages, optimizing build performance and caching strategies, adding icon libraries, managing shared dependencies, or working with TypeScript full-stack projects. +license: MIT +version: 1.0.0 +--- + +# Web Frameworks Skill Group + +Comprehensive guide for building modern full-stack web applications using Next.js, Turborepo, and RemixIcon. + +## Overview + +This skill group combines three powerful tools for web development: + +**Next.js** - React framework with SSR, SSG, RSC, and optimization features +**Turborepo** - High-performance monorepo build system for JavaScript/TypeScript +**RemixIcon** - Icon library with 3,100+ outlined and filled style icons + +## When to Use This Skill Group + +- Building new full-stack web applications with modern React +- Setting up monorepos with multiple apps and shared packages +- Implementing server-side rendering and static generation +- Optimizing build performance with intelligent caching +- Creating consistent UI with professional iconography +- Managing workspace dependencies across multiple projects +- Deploying production-ready applications with proper optimization + +## Stack Selection Guide + +### Single Application: Next.js + RemixIcon + +Use when building a standalone application: +- E-commerce sites +- Marketing websites +- SaaS applications +- Documentation sites +- Blogs and content platforms + +**Setup:** +```bash +npx create-next-app@latest my-app +cd my-app +npm install remixicon +``` + +### Monorepo: Next.js + Turborepo + RemixIcon + +Use when building multiple applications with shared code: +- Microfrontends +- Multi-tenant platforms +- Internal tools with shared component library +- Multiple apps (web, admin, mobile-web) sharing logic +- Design system with documentation site + +**Setup:** +```bash +npx create-turbo@latest my-monorepo +# Then configure Next.js apps in apps/ directory +# Install remixicon in shared UI packages +``` + +### Framework Features Comparison + +| Feature | Next.js | Turborepo | RemixIcon | +|---------|---------|-----------|-----------| +| Primary Use | Web framework | Build system | UI icons | +| Best For | SSR/SSG apps | Monorepos | Consistent iconography | +| Performance | Built-in optimization | Caching & parallel tasks | Lightweight fonts/SVG | +| TypeScript | Full support | Full support | Type definitions available | + +## Quick Start + +### Next.js Application + +```bash +# Create new project +npx create-next-app@latest my-app +cd my-app + +# Install RemixIcon +npm install remixicon + +# Import in layout +# app/layout.tsx +import 'remixicon/fonts/remixicon.css' + +# Start development +npm run dev +``` + +### Turborepo Monorepo + +```bash +# Create monorepo +npx create-turbo@latest my-monorepo +cd my-monorepo + +# Structure: +# apps/web/ - Next.js application +# apps/docs/ - Documentation site +# packages/ui/ - Shared components with RemixIcon +# packages/config/ - Shared configs +# turbo.json - Pipeline configuration + +# Run all apps +npm run dev + +# Build all packages +npm run build +``` + +### RemixIcon Integration + +```tsx +// Webfont (HTML/CSS) + + + +// React component +import { RiHomeLine, RiSearchFill } from "@remixicon/react" + + +``` + +## Reference Navigation + +**Next.js References:** +- [App Router Architecture](./references/nextjs-app-router.md) - Routing, layouts, pages, parallel routes +- [Server Components](./references/nextjs-server-components.md) - RSC patterns, client vs server, streaming +- [Data Fetching](./references/nextjs-data-fetching.md) - fetch API, caching, revalidation, loading states +- [Optimization](./references/nextjs-optimization.md) - Images, fonts, scripts, bundle analysis, PPR + +**Turborepo References:** +- [Setup & Configuration](./references/turborepo-setup.md) - Installation, workspace config, package structure +- [Task Pipelines](./references/turborepo-pipelines.md) - Dependencies, parallel execution, task ordering +- [Caching Strategies](./references/turborepo-caching.md) - Local cache, remote cache, cache invalidation + +**RemixIcon References:** +- [Integration Guide](./references/remix-icon-integration.md) - Installation, usage, customization, accessibility + +## Common Patterns & Workflows + +### Pattern 1: Full-Stack Monorepo + +``` +my-monorepo/ +├── apps/ +│ ├── web/ # Customer-facing Next.js app +│ ├── admin/ # Admin dashboard Next.js app +│ └── docs/ # Documentation site +├── packages/ +│ ├── ui/ # Shared UI with RemixIcon +│ ├── api-client/ # API client library +│ ├── config/ # ESLint, TypeScript configs +│ └── types/ # Shared TypeScript types +└── turbo.json # Build pipeline +``` + +**turbo.json:** +```json +{ + "$schema": "https://turbo.build/schema.json", + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": {}, + "test": { + "dependsOn": ["build"] + } + } +} +``` + +### Pattern 2: Shared Component Library + +```tsx +// packages/ui/src/button.tsx +import { RiLoader4Line } from "@remixicon/react" + +export function Button({ children, loading, icon }) { + return ( + + ) +} + +// apps/web/app/page.tsx +import { Button } from "@repo/ui/button" +import { RiHomeLine } from "@remixicon/react" + +export default function Page() { + return +} +``` + +### Pattern 3: Optimized Data Fetching + +```tsx +// app/posts/[slug]/page.tsx +import { notFound } from 'next/navigation' + +// Static generation at build time +export async function generateStaticParams() { + const posts = await getPosts() + return posts.map(post => ({ slug: post.slug })) +} + +// Revalidate every hour +async function getPost(slug: string) { + const res = await fetch(`https://api.example.com/posts/${slug}`, { + next: { revalidate: 3600 } + }) + if (!res.ok) return null + return res.json() +} + +export default async function Post({ params }: { params: { slug: string } }) { + const post = await getPost(params.slug) + if (!post) notFound() + + return
                      {post.content}
                      +} +``` + +### Pattern 4: Monorepo CI/CD Pipeline + +```yaml +# .github/workflows/ci.yml +name: CI +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - run: npm install + - run: npx turbo run build test lint + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} +``` + +## Utility Scripts + +Python utilities in `scripts/` directory: + +**nextjs-init.py** - Initialize Next.js project with best practices +**turborepo-migrate.py** - Convert existing monorepo to Turborepo + +Usage examples: +```bash +# Initialize new Next.js app with TypeScript and recommended setup +python scripts/nextjs-init.py --name my-app --typescript --app-router + +# Migrate existing monorepo to Turborepo with dry-run +python scripts/turborepo-migrate.py --path ./my-monorepo --dry-run + +# Run tests +cd scripts/tests +pytest +``` + +## Best Practices + +**Next.js:** +- Default to Server Components, use Client Components only when needed +- Implement proper loading and error states +- Use Image component for automatic optimization +- Set proper metadata for SEO +- Leverage caching strategies (force-cache, revalidate, no-store) + +**Turborepo:** +- Structure monorepo with clear separation (apps/, packages/) +- Define task dependencies correctly (^build for topological) +- Configure outputs for proper caching +- Enable remote caching for team collaboration +- Use filters to run tasks on changed packages only + +**RemixIcon:** +- Use line style for minimal interfaces, fill for emphasis +- Maintain 24x24 grid alignment for crisp rendering +- Provide aria-labels for accessibility +- Use currentColor for flexible theming +- Prefer webfonts for multiple icons, SVG for single icons + +## Resources + +- Next.js: https://nextjs.org/docs/llms.txt +- Turborepo: https://turbo.build/repo/docs +- RemixIcon: https://remixicon.com + +## Implementation Checklist + +Building with this stack: + +- [ ] Create project structure (single app or monorepo) +- [ ] Configure TypeScript and ESLint +- [ ] Set up Next.js with App Router +- [ ] Configure Turborepo pipeline (if monorepo) +- [ ] Install and configure RemixIcon +- [ ] Implement routing and layouts +- [ ] Add loading and error states +- [ ] Configure image and font optimization +- [ ] Set up data fetching patterns +- [ ] Configure caching strategies +- [ ] Add API routes as needed +- [ ] Implement shared component library (if monorepo) +- [ ] Configure remote caching (if monorepo) +- [ ] Set up CI/CD pipeline +- [ ] Configure deployment platform diff --git a/skills/web-frameworks/references/nextjs-app-router.md b/skills/web-frameworks/references/nextjs-app-router.md new file mode 100644 index 0000000..68d17dc --- /dev/null +++ b/skills/web-frameworks/references/nextjs-app-router.md @@ -0,0 +1,465 @@ +# Next.js App Router Architecture + +Modern file-system based routing with React Server Components support. + +## File Conventions + +Special files define route behavior: + +- `page.tsx` - Page UI, makes route publicly accessible +- `layout.tsx` - Shared UI wrapper for segment and children +- `loading.tsx` - Loading UI, automatically wraps page in Suspense +- `error.tsx` - Error UI, wraps page in Error Boundary +- `not-found.tsx` - 404 UI for route segment +- `route.ts` - API endpoint (Route Handler) +- `template.tsx` - Re-rendered layout (doesn't preserve state) +- `default.tsx` - Fallback for parallel routes + +## Basic Routing + +### Static Routes + +``` +app/ +├── page.tsx → / +├── about/ +│ └── page.tsx → /about +├── blog/ +│ └── page.tsx → /blog +└── contact/ + └── page.tsx → /contact +``` + +### Dynamic Routes + +Single parameter: +```tsx +// app/blog/[slug]/page.tsx +export default function BlogPost({ params }: { params: { slug: string } }) { + return

                      Post: {params.slug}

                      +} +// Matches: /blog/hello-world, /blog/my-post +``` + +Catch-all segments: +```tsx +// app/shop/[...slug]/page.tsx +export default function Shop({ params }: { params: { slug: string[] } }) { + return

                      Category: {params.slug.join('/')}

                      +} +// Matches: /shop/clothes, /shop/clothes/shirts, /shop/clothes/shirts/red +``` + +Optional catch-all: +```tsx +// app/docs/[[...slug]]/page.tsx +// Matches: /docs, /docs/getting-started, /docs/api/reference +``` + +## Layouts + +### Root Layout (Required) + +Must include `` and `` tags: + +```tsx +// app/layout.tsx +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + +
                      Global Header
                      + {children} +
                      Global Footer
                      + + + ) +} +``` + +### Nested Layouts + +```tsx +// app/dashboard/layout.tsx +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( +
                      + +
                      {children}
                      +
                      + ) +} +``` + +Layout characteristics: +- Preserve state during navigation +- Do not re-render on navigation between child routes +- Can fetch data +- Cannot access pathname or searchParams (use Client Component) + +## Route Groups + +Organize routes without affecting URL structure: + +``` +app/ +├── (marketing)/ # Group without URL segment +│ ├── about/page.tsx → /about +│ ├── blog/page.tsx → /blog +│ └── layout.tsx # Marketing layout +├── (shop)/ +│ ├── products/page.tsx → /products +│ ├── cart/page.tsx → /cart +│ └── layout.tsx # Shop layout +└── layout.tsx # Root layout +``` + +Use cases: +- Multiple root layouts +- Organize code by feature/team +- Different layouts for different sections + +## Parallel Routes + +Render multiple pages simultaneously in same layout: + +``` +app/ +├── @team/ # Named slot +│ └── page.tsx +├── @analytics/ # Named slot +│ └── page.tsx +├── page.tsx # Default children +└── layout.tsx # Consumes slots +``` + +```tsx +// app/layout.tsx +export default function Layout({ + children, + team, + analytics, +}: { + children: React.ReactNode + team: React.ReactNode + analytics: React.ReactNode +}) { + return ( + <> + {children} +
                      + {team} + {analytics} +
                      + + ) +} +``` + +Use cases: +- Split views (dashboards) +- Modals +- Conditional rendering based on auth state + +## Intercepting Routes + +Intercept navigation to show content in different context: + +``` +app/ +├── feed/ +│ └── page.tsx +├── photo/ +│ └── [id]/ +│ └── page.tsx # Full photo page +└── (..)photo/ # Intercepts /photo/[id] + └── [id]/ + └── page.tsx # Modal photo view +``` + +Matching conventions: +- `(.)` - Match same level +- `(..)` - Match one level above +- `(..)(..)` - Match two levels above +- `(...)` - Match from app root + +Use case: Display modal when navigating from feed, show full page when URL accessed directly + +## Loading States + +### Loading File + +Automatically wraps page in Suspense: + +```tsx +// app/dashboard/loading.tsx +export default function Loading() { + return
                      Loading dashboard...
                      +} +``` + +### Manual Suspense + +Fine-grained control: + +```tsx +// app/page.tsx +import { Suspense } from 'react' + +async function Posts() { + const posts = await fetchPosts() + return +} + +export default function Page() { + return ( +
                      +

                      My Blog

                      + Loading posts...
                      }> + + +
                      + ) +} +``` + +## Error Handling + +### Error File + +Wraps segment in Error Boundary: + +```tsx +// app/error.tsx +'use client' // Error components must be Client Components + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( +
                      +

                      Something went wrong!

                      +

                      {error.message}

                      + +
                      + ) +} +``` + +### Global Error + +Catches errors in root layout: + +```tsx +// app/global-error.tsx +'use client' + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( + + +

                      Application Error!

                      + + + + ) +} +``` + +### Not Found + +```tsx +// app/blog/[slug]/page.tsx +import { notFound } from 'next/navigation' + +export default async function Post({ params }: { params: { slug: string } }) { + const post = await getPost(params.slug) + + if (!post) { + notFound() // Triggers not-found.tsx + } + + return
                      {post.content}
                      +} + +// app/blog/[slug]/not-found.tsx +export default function NotFound() { + return

                      Post not found

                      +} +``` + +## Navigation + +### Link Component + +```tsx +import Link from 'next/link' + +// Basic link +About + +// Dynamic route +Read Post + +// With object + + Read Post + + +// Prefetch control + + Dashboard + + +// Replace history + + Search + +``` + +### useRouter Hook (Client) + +```tsx +'use client' + +import { useRouter } from 'next/navigation' + +export function NavigateButton() { + const router = useRouter() + + return ( + <> + + + + + + + ) +} +``` + +### Programmatic Navigation (Server) + +```tsx +import { redirect } from 'next/navigation' + +export default async function Page() { + const session = await getSession() + + if (!session) { + redirect('/login') + } + + return
                      Protected content
                      +} +``` + +## Accessing Route Information + +### searchParams (Server) + +```tsx +// app/shop/page.tsx +export default function Shop({ + searchParams, +}: { + searchParams: { sort?: string; filter?: string } +}) { + const sort = searchParams.sort || 'newest' + const filter = searchParams.filter + + return
                      Showing: {filter}, sorted by {sort}
                      +} +// Accessed via: /shop?sort=price&filter=shirts +``` + +### useSearchParams (Client) + +```tsx +'use client' + +import { useSearchParams } from 'next/navigation' + +export function SearchFilter() { + const searchParams = useSearchParams() + const query = searchParams.get('q') + + return
                      Search query: {query}
                      +} +``` + +### usePathname (Client) + +```tsx +'use client' + +import { usePathname } from 'next/navigation' +import Link from 'next/link' + +export function Navigation() { + const pathname = usePathname() + + return ( + + ) +} +``` + +## Project Structure Best Practices + +``` +app/ +├── (auth)/ # Route group for auth pages +│ ├── login/ +│ ├── signup/ +│ └── layout.tsx # Auth layout +├── (dashboard)/ # Route group for dashboard +│ ├── dashboard/ +│ ├── settings/ +│ └── layout.tsx # Dashboard layout +├── api/ # API routes +│ ├── auth/ +│ └── posts/ +├── _components/ # Private folder (not routes) +│ ├── header.tsx +│ └── footer.tsx +├── _lib/ # Private utilities +│ ├── auth.ts +│ └── db.ts +├── layout.tsx # Root layout +├── page.tsx # Home page +├── loading.tsx +├── error.tsx +└── not-found.tsx +``` + +Use underscore prefix for folders that shouldn't be routes. diff --git a/skills/web-frameworks/references/nextjs-data-fetching.md b/skills/web-frameworks/references/nextjs-data-fetching.md new file mode 100644 index 0000000..7019e1e --- /dev/null +++ b/skills/web-frameworks/references/nextjs-data-fetching.md @@ -0,0 +1,459 @@ +# Next.js Data Fetching + +Server-side data fetching, caching strategies, revalidation, and loading patterns. + +## Fetch API Extensions + +Next.js extends native fetch with caching and revalidation: + +```tsx +// Force cache (default) - cache forever +fetch('https://api.example.com/data', { cache: 'force-cache' }) + +// No cache - fetch on every request +fetch('https://api.example.com/data', { cache: 'no-store' }) + +// Revalidate - cache with time-based revalidation +fetch('https://api.example.com/data', { next: { revalidate: 3600 } }) + +// Tag-based revalidation +fetch('https://api.example.com/data', { next: { tags: ['posts'] } }) +``` + +## Caching Strategies + +### Static Data (Default) + +Fetched at build time, cached indefinitely: + +```tsx +// app/posts/page.tsx +async function getPosts() { + const res = await fetch('https://api.example.com/posts') + // Same as: fetch(url, { cache: 'force-cache' }) + return res.json() +} + +export default async function Posts() { + const posts = await getPosts() + return +} +``` + +Use for: Content that rarely changes, static pages + +### Dynamic Data + +Fetched on every request: + +```tsx +async function getUser() { + const res = await fetch('https://api.example.com/user', { + cache: 'no-store' + }) + return res.json() +} + +export default async function Profile() { + const user = await getUser() + return
                      {user.name}
                      +} +``` + +Use for: User-specific data, real-time content + +### Incremental Static Regeneration (ISR) + +Revalidate cached data after time period: + +```tsx +async function getPosts() { + const res = await fetch('https://api.example.com/posts', { + next: { revalidate: 60 } // Revalidate every 60 seconds + }) + return res.json() +} + +export default async function Posts() { + const posts = await getPosts() + return +} +``` + +How it works: +1. First request: Generate page, cache it +2. Subsequent requests: Serve cached page +3. After 60s: Next request triggers regeneration in background +4. New page cached, served to subsequent requests + +Use for: News sites, blogs, product listings + +## Revalidation Strategies + +### Time-Based Revalidation + +```tsx +// Revalidate every hour +fetch('https://api.example.com/posts', { + next: { revalidate: 3600 } +}) + +// Revalidate every 10 seconds +fetch('https://api.example.com/trending', { + next: { revalidate: 10 } +}) +``` + +### On-Demand Revalidation + +Revalidate specific paths or tags programmatically: + +```tsx +// app/actions.ts +'use server' + +import { revalidatePath, revalidateTag } from 'next/cache' + +export async function createPost(formData: FormData) { + const post = await db.post.create({ + data: { + title: formData.get('title'), + content: formData.get('content') + } + }) + + // Revalidate specific path + revalidatePath('/posts') + revalidatePath(`/posts/${post.id}`) + + // Or revalidate by tag + revalidateTag('posts') +} +``` + +Tag-based revalidation: +```tsx +// Fetch with tags +async function getPosts() { + const res = await fetch('https://api.example.com/posts', { + next: { tags: ['posts', 'content'] } + }) + return res.json() +} + +async function getComments(postId: string) { + const res = await fetch(`https://api.example.com/comments/${postId}`, { + next: { tags: ['comments', `post-${postId}`] } + }) + return res.json() +} + +// Revalidate all 'posts' tagged requests +revalidateTag('posts') + +// Revalidate specific post comments +revalidateTag(`post-${postId}`) +``` + +### Route Segment Config + +Configure entire route segment: + +```tsx +// app/posts/page.tsx +export const revalidate = 3600 // Revalidate every hour + +export default async function Posts() { + const posts = await fetch('https://api.example.com/posts').then(r => r.json()) + return +} +``` + +Options: +```tsx +export const dynamic = 'auto' // default +export const dynamic = 'force-dynamic' // no caching +export const dynamic = 'error' // error if dynamic +export const dynamic = 'force-static' // force static + +export const revalidate = false // never revalidate (default) +export const revalidate = 0 // no cache +export const revalidate = 60 // revalidate every 60s + +export const fetchCache = 'auto' // default +export const fetchCache = 'default-cache' +export const fetchCache = 'only-cache' +export const fetchCache = 'force-cache' +export const fetchCache = 'default-no-store' +export const fetchCache = 'only-no-store' +export const fetchCache = 'force-no-store' +``` + +## Data Fetching Patterns + +### Parallel Fetching + +Fetch multiple resources simultaneously: + +```tsx +async function getData() { + // Start both requests in parallel + const [posts, users] = await Promise.all([ + fetch('https://api.example.com/posts').then(r => r.json()), + fetch('https://api.example.com/users').then(r => r.json()) + ]) + + return { posts, users } +} + +export default async function Page() { + const { posts, users } = await getData() + return ( +
                      + + +
                      + ) +} +``` + +### Sequential Fetching + +Fetch dependent data: + +```tsx +async function getData(postId: string) { + // Fetch post first + const post = await fetch(`https://api.example.com/posts/${postId}`).then(r => r.json()) + + // Then fetch author based on post data + const author = await fetch(`https://api.example.com/users/${post.authorId}`).then(r => r.json()) + + return { post, author } +} + +export default async function Post({ params }: { params: { id: string } }) { + const { post, author } = await getData(params.id) + return ( +
                      +

                      {post.title}

                      +

                      By {author.name}

                      +
                      {post.content}
                      +
                      + ) +} +``` + +### Preloading Data + +Optimize sequential waterfalls: + +```tsx +// lib/data.ts +import { cache } from 'react' + +export const getUser = cache(async (id: string) => { + const res = await fetch(`https://api.example.com/users/${id}`) + return res.json() +}) + +// app/user/[id]/page.tsx +import { getUser } from '@/lib/data' + +// Preload before component renders +async function preload(id: string) { + void getUser(id) // Start fetching immediately +} + +export default async function User({ params }: { params: { id: string } }) { + preload(params.id) // Start fetch + // Render other UI + const user = await getUser(params.id) // Will use cached result + + return
                      {user.name}
                      +} +``` + +## Loading States + +### Loading File + +Automatic loading UI: + +```tsx +// app/dashboard/loading.tsx +export default function Loading() { + return
                      Loading dashboard...
                      +} + +// app/dashboard/page.tsx +export default async function Dashboard() { + const data = await fetchDashboard() + return +} +``` + +### Suspense Boundaries + +Granular loading states: + +```tsx +// app/dashboard/page.tsx +import { Suspense } from 'react' + +async function Revenue() { + const data = await fetchRevenue() // 2s + return +} + +async function Sales() { + const data = await fetchSales() // 0.5s + return +} + +export default function Dashboard() { + return ( +
                      +

                      Dashboard

                      + + }> + + + + }> + + +
                      + ) +} +``` + +Sales loads after 0.5s, Revenue after 2s - no blocking. + +## Static Generation + +### generateStaticParams + +Pre-render dynamic routes at build time: + +```tsx +// app/posts/[slug]/page.tsx +export async function generateStaticParams() { + const posts = await fetch('https://api.example.com/posts').then(r => r.json()) + + return posts.map(post => ({ + slug: post.slug + })) +} + +export default async function Post({ params }: { params: { slug: string } }) { + const post = await fetch(`https://api.example.com/posts/${params.slug}`).then(r => r.json()) + return
                      {post.content}
                      +} +``` + +Generates static pages at build: +- `/posts/hello-world` +- `/posts/nextjs-guide` +- `/posts/react-tips` + +### Dynamic Params Handling + +```tsx +// app/posts/[slug]/page.tsx +export const dynamicParams = true // default - generate on-demand if not pre-rendered + +export const dynamicParams = false // 404 for paths not in generateStaticParams +``` + +## Error Handling + +### Try-Catch in Server Components + +```tsx +async function getData() { + try { + const res = await fetch('https://api.example.com/data') + + if (!res.ok) { + throw new Error('Failed to fetch data') + } + + return res.json() + } catch (error) { + console.error('Data fetch error:', error) + return null + } +} + +export default async function Page() { + const data = await getData() + + if (!data) { + return
                      Failed to load data
                      + } + + return +} +``` + +### Error Boundaries + +```tsx +// app/error.tsx +'use client' + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string } + reset: () => void +}) { + return ( +
                      +

                      Something went wrong!

                      + +
                      + ) +} +``` + +## Database Queries + +Direct database access in Server Components: + +```tsx +// lib/db.ts +import { PrismaClient } from '@prisma/client' + +const prisma = new PrismaClient() + +export async function getPosts() { + return prisma.post.findMany({ + include: { author: true }, + orderBy: { createdAt: 'desc' } + }) +} + +// app/posts/page.tsx +import { getPosts } from '@/lib/db' + +export default async function Posts() { + const posts = await getPosts() + return +} +``` + +## Best Practices + +1. **Default to static** - Use `cache: 'force-cache'` or default behavior +2. **Use ISR for semi-dynamic content** - Balance freshness and performance +3. **Fetch in parallel** - Use `Promise.all()` for independent requests +4. **Add loading states** - Use Suspense for better UX +5. **Handle errors gracefully** - Provide fallbacks and error boundaries +6. **Use on-demand revalidation** - Trigger updates after mutations +7. **Tag your fetches** - Enable granular cache invalidation +8. **Dedupe automatically** - Next.js dedupes identical fetch requests +9. **Avoid client-side fetching** - Use Server Components when possible +10. **Cache database queries** - Use React cache() for expensive queries diff --git a/skills/web-frameworks/references/nextjs-optimization.md b/skills/web-frameworks/references/nextjs-optimization.md new file mode 100644 index 0000000..18e6a44 --- /dev/null +++ b/skills/web-frameworks/references/nextjs-optimization.md @@ -0,0 +1,511 @@ +# Next.js Optimization + +Performance optimization techniques for images, fonts, scripts, and bundles. + +## Image Optimization + +### Next.js Image Component + +Automatic optimization with modern formats (WebP, AVIF): + +```tsx +import Image from 'next/image' + +export default function Page() { + return ( + <> + {/* Local image */} + Hero image + + {/* Remote image */} + Photo + + {/* Responsive fill */} +
                      + Background +
                      + + {/* With blur placeholder */} + Profile + + ) +} +``` + +### Image Props Reference + +**Required:** +- `src` - Image path (string or static import) +- `alt` - Alt text for accessibility +- `width`, `height` - Dimensions (required unless using `fill`) + +**Optional:** +- `fill` - Fill parent container (makes width/height optional) +- `sizes` - Responsive sizes hint for srcset +- `quality` - 1-100 (default 75) +- `priority` - Disable lazy loading, preload image +- `placeholder` - 'blur' | 'empty' (default 'empty') +- `blurDataURL` - Data URL for blur placeholder +- `loading` - 'lazy' | 'eager' (default 'lazy') +- `style` - CSS styles +- `className` - CSS class +- `onLoad` - Callback when loaded + +### Responsive Images with Sizes + +```tsx +Hero +``` + +This tells browser: +- Mobile (<768px): Use 100% viewport width +- Tablet (768-1200px): Use 50% viewport width +- Desktop (>1200px): Use 33% viewport width + +### Static Import for Local Images + +```tsx +import heroImage from '@/public/hero.jpg' + +Hero +``` + +### Remote Image Configuration + +```js +// next.config.js +module.exports = { + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'example.com', + pathname: '/images/**', + }, + { + protocol: 'https', + hostname: 'cdn.example.com', + } + ], + // Device sizes for srcset + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + // Image sizes for srcset + imageSizes: [16, 32, 48, 64, 96, 128, 256, 384], + // Supported formats + formats: ['image/webp'], + // Cache optimization images for 60 days + minimumCacheTTL: 60 * 60 * 24 * 60, + } +} +``` + +## Font Optimization + +### Google Fonts + +Automatic optimization with zero layout shift: + +```tsx +// app/layout.tsx +import { Inter, Roboto_Mono, Playfair_Display } from 'next/font/google' + +const inter = Inter({ + subsets: ['latin'], + display: 'swap', + variable: '--font-inter', +}) + +const robotoMono = Roboto_Mono({ + subsets: ['latin'], + display: 'swap', + weight: ['400', '700'], + variable: '--font-roboto-mono', +}) + +const playfair = Playfair_Display({ + subsets: ['latin'], + display: 'swap', + weight: ['400', '700', '900'], + style: ['normal', 'italic'], +}) + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} +``` + +Use CSS variables: +```css +.code { + font-family: var(--font-roboto-mono); +} +``` + +### Local Fonts + +```tsx +import localFont from 'next/font/local' + +const myFont = localFont({ + src: [ + { + path: './fonts/my-font-regular.woff2', + weight: '400', + style: 'normal', + }, + { + path: './fonts/my-font-bold.woff2', + weight: '700', + style: 'normal', + } + ], + variable: '--font-my-font', + display: 'swap', +}) + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} +``` + +### Font Display Strategies + +```tsx +const font = Inter({ + display: 'swap', // Show fallback immediately, swap when loaded (recommended) + // display: 'optional', // Only use font if available immediately + // display: 'block', // Hide text until font loads (max 3s) + // display: 'fallback', // Show fallback briefly, swap if loaded quickly + // display: 'auto', // Browser default +}) +``` + +## Script Optimization + +### Script Component + +Control loading behavior: + +```tsx +import Script from 'next/script' + +export default function Page() { + return ( + <> + {/* Load after page is interactive (recommended for analytics) */} + + + {/* With onLoad callback */} + + + +``` + +### 4. Direct SVG + +```tsx +// Download SVG file and import +import HomeIcon from '@/icons/home-line.svg' + +export function Component() { + return Home +} +``` + +### 5. SVG Sprite + +```html + + + +``` + +```css +.icon { + width: 24px; + height: 24px; + fill: currentColor; +} +``` + +## Icon Categories + +20 semantic categories with 3,100+ icons: + +**Navigation & UI:** +- Arrows (arrow-left, arrow-right, arrow-up-down) +- System (settings, delete, add, close, more) +- Editor (bold, italic, link, list, code) + +**Communication:** +- Communication (chat, phone, mail, message) +- User (user, account, team, contacts) + +**Media & Content:** +- Media (play, pause, volume, camera, video) +- Document (file, folder, article, draft) +- Design (brush, palette, magic, crop) + +**Business & Commerce:** +- Business (briefcase, pie-chart, bar-chart) +- Finance (money, wallet, bank-card, coin) +- Map (map, pin, compass, navigation) + +**Objects & Places:** +- Buildings (home, bank, hospital, store) +- Device (phone, laptop, tablet, printer) +- Food (restaurant, cake, cup, knife) +- Weather (sun, cloud, rain, moon) + +**Development & Logos:** +- Development (code, terminal, bug, git-branch) +- Logos (github, twitter, facebook, google) + +**Health & Medical:** +- Health (heart-pulse, capsule, stethoscope) + +## Common Patterns + +### Navigation Menu + +```tsx +// Webfont approach +export function Navigation() { + return ( + + ) +} + +// React component approach +import { RiHomeLine, RiSearchLine, RiUserLine } from "@remixicon/react" + +export function Navigation() { + return ( + + ) +} +``` + +### Button with Icon + +```tsx +import { RiDownloadLine } from "@remixicon/react" + +export function DownloadButton() { + return ( + + ) +} +``` + +### Status Indicators + +```tsx +import { + RiCheckboxCircleFill, + RiErrorWarningFill, + RiAlertFill, + RiInformationFill +} from "@remixicon/react" + +type Status = 'success' | 'error' | 'warning' | 'info' + +export function StatusIcon({ status }: { status: Status }) { + const icons = { + success: , + error: , + warning: , + info: + } + + return icons[status] +} +``` + +### Input with Icon + +```tsx +import { RiSearchLine } from "@remixicon/react" + +export function SearchInput() { + return ( +
                      + + +
                      + ) +} +``` + +```css +.input-group { + position: relative; +} + +.input-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #666; +} + +input { + padding-left: 40px; +} +``` + +### Dynamic Icon Selection + +```tsx +import { RiHomeLine, RiHeartFill, RiStarLine } from "@remixicon/react" + +const iconMap = { + home: RiHomeLine, + heart: RiHeartFill, + star: RiStarLine, +} + +export function DynamicIcon({ name, size = 24 }: { name: string; size?: number }) { + const Icon = iconMap[name] + return Icon ? : null +} + +// Usage + +``` + +## Styling & Customization + +### Color + +```tsx +// Inherit from parent + + +// React component + + + +``` + +### Size + +```tsx +// CSS class + + +// Inline style + + +// React component + + +``` + +### Responsive Sizing + +```css +.icon { + font-size: 24px; +} + +@media (max-width: 768px) { + .icon { + font-size: 20px; + } +} +``` + +### Animations + +```css +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +``` + +```tsx + +``` + +### Hover Effects + +```css +.icon-button { + transition: color 0.2s; +} + +.icon-button:hover { + color: #007bff; +} +``` + +## Accessibility + +### Provide Labels + +**Icon-only buttons:** +```tsx + + +// Or with React + +``` + +### Decorative Icons + +Hide from screen readers: + +```tsx + + +// React + +``` + +### Icon with Text + +```tsx + +``` + +Text provides context, icon is decorative. + +## Framework Integration + +### Next.js + +```tsx +// app/layout.tsx +import 'remixicon/fonts/remixicon.css' + +export default function RootLayout({ children }) { + return ( + + {children} + + ) +} + +// app/page.tsx +import { RiHomeLine } from "@remixicon/react" + +export default function Page() { + return +} +``` + +### Tailwind CSS + +```tsx + + + +``` + +### CSS Modules + +```tsx +import styles from './component.module.css' +import 'remixicon/fonts/remixicon.css' + +export function Component() { + return +} +``` + +## Performance Considerations + +### Webfont (Recommended for Multiple Icons) + +**Pros:** +- Single HTTP request +- All icons available +- Easy to use + +**Cons:** +- 179KB WOFF2 file +- Loads all icons even if unused + +**Best for:** Apps using 10+ different icons + +### Individual SVG (Recommended for Few Icons) + +**Pros:** +- Only load what you need +- Smallest bundle size +- Tree-shakeable with React package + +**Cons:** +- Multiple imports + +**Best for:** Apps using 1-5 icons + +### React/Vue Package + +**Pros:** +- Tree-shakeable (only imports used icons) +- TypeScript support +- Component API + +**Cons:** +- Slightly larger than raw SVG +- Requires React/Vue + +**Best for:** React/Vue apps with TypeScript + +## Troubleshooting + +### Icons Not Displaying + +**Check CSS import:** +```tsx +import 'remixicon/fonts/remixicon.css' +``` + +**Verify class name:** +```html + + + + + + +``` + +**Check font loading:** +```css +/* Ensure font-family is applied */ +[class^="ri-"], [class*=" ri-"] { + font-family: "remixicon" !important; +} +``` + +### Icons Look Blurry + +Use multiples of 24px for crisp rendering: + +```tsx +// Good + + + +// Bad (breaks pixel grid) + + +``` + +### Wrong Icon Size + +**Set parent font-size:** +```css +.icon-container { + font-size: 24px; +} +``` + +**Or use size prop:** +```tsx + +``` + +## Best Practices + +1. **Choose style consistently** - Use line or fill throughout app +2. **Maintain 24px grid** - Use sizes: 24, 48, 72, 96px +3. **Provide accessibility** - Add aria-labels to icon-only buttons +4. **Use currentColor** - Icons inherit text color by default +5. **Optimize bundle** - Use React package for tree-shaking +6. **Cache webfonts** - CDN or long cache headers +7. **Lazy load icons** - Dynamic import for heavy icon sets +8. **Test on devices** - Ensure icons scale properly +9. **Document usage** - Create icon component library +10. **Version lock** - Pin RemixIcon version for consistency + +## Resources + +- Website: https://remixicon.com +- GitHub: https://github.com/Remix-Design/RemixIcon +- React Package: @remixicon/react +- Vue Package: @remixicon/vue +- License: Apache 2.0 +- Total Icons: 3,100+ +- Current Version: 4.7.0 diff --git a/skills/web-frameworks/references/turborepo-caching.md b/skills/web-frameworks/references/turborepo-caching.md new file mode 100644 index 0000000..a11370f --- /dev/null +++ b/skills/web-frameworks/references/turborepo-caching.md @@ -0,0 +1,551 @@ +# Turborepo Caching Strategies + +Local caching, remote caching, cache invalidation, and optimization techniques. + +## Local Caching + +### How It Works + +Turborepo caches task outputs based on inputs: + +1. **Hash inputs**: Source files, dependencies, environment variables, config +2. **Run task**: If hash not in cache +3. **Save outputs**: Store in `.turbo/cache` +4. **Restore on match**: Instant completion on cache hit + +Default cache location: `./node_modules/.cache/turbo` + +### Cache Configuration + +```json +// turbo.json +{ + "pipeline": { + "build": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "cache": true // default + }, + "dev": { + "cache": false // don't cache dev servers + } + } +} +``` + +### Outputs Configuration + +Specify what gets cached: + +```json +{ + "build": { + "outputs": [ + "dist/**", // All files in dist + "build/**", // Build directory + ".next/**", // Next.js output + "!.next/cache/**", // Exclude Next.js cache + "storybook-static/**", // Storybook build + "*.tsbuildinfo" // TypeScript build info + ] + } +} +``` + +**Best practices:** +- Include all build artifacts +- Exclude nested caches +- Include type definitions +- Include generated files + +### Clear Local Cache + +```bash +# Remove cache directory +rm -rf ./node_modules/.cache/turbo + +# Or use turbo command with --force +turbo run build --force + +# Clear and rebuild +turbo run clean && turbo run build +``` + +## Remote Caching + +Share cache across team and CI/CD. + +### Vercel Remote Cache (Recommended) + +**Setup:** +```bash +# Login to Vercel +turbo login + +# Link repository +turbo link +``` + +**Use in CI:** +```yaml +# .github/workflows/ci.yml +env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + +steps: + - run: turbo run build test +``` + +Get tokens from Vercel dashboard: +1. Go to https://vercel.com/account/tokens +2. Create new token +3. Add as GitHub secrets + +### Custom Remote Cache + +Configure custom remote cache server: + +```json +// .turbo/config.json +{ + "teamid": "team_xxx", + "apiurl": "https://cache.example.com", + "token": "your-token" +} +``` + +Or use environment variables: +```bash +export TURBO_API="https://cache.example.com" +export TURBO_TOKEN="your-token" +export TURBO_TEAM="team_xxx" +``` + +### Remote Cache Verification + +```bash +# Check cache status +turbo run build --output-logs=hash-only + +# Output shows: +# • web:build: cache hit, replaying logs [hash] +# • api:build: cache miss, executing [hash] +``` + +## Cache Signatures + +Cache invalidated when these change: + +### 1. Source Files + +All tracked Git files in package: +``` +packages/ui/ +├── src/ +│ ├── button.tsx # Tracked +│ └── input.tsx # Tracked +├── dist/ # Ignored (in .gitignore) +└── node_modules/ # Ignored +``` + +### 2. Package Dependencies + +Changes in package.json: +```json +{ + "dependencies": { + "react": "18.2.0" // Version change invalidates cache + } +} +``` + +### 3. Environment Variables + +Configured in pipeline: +```json +{ + "build": { + "env": ["NODE_ENV", "API_URL"] // Changes invalidate cache + } +} +``` + +### 4. Global Dependencies + +Files affecting all packages: +```json +{ + "globalDependencies": [ + "**/.env.*local", + "tsconfig.json", + ".eslintrc.js" + ] +} +``` + +### 5. Task Configuration + +Changes to turbo.json pipeline: +```json +{ + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"] // Config changes invalidate cache + } +} +``` + +## Input Control + +### Override Input Detection + +Explicitly define what affects cache: + +```json +{ + "build": { + "inputs": [ + "src/**/*.ts", // Include TS files + "src/**/*.tsx", // Include TSX files + "!src/**/*.test.ts", // Exclude tests + "!src/**/*.stories.tsx", // Exclude stories + "package.json", // Include package.json + "tsconfig.json" // Include config + ] + } +} +``` + +Use cases: +- Exclude test files from build cache +- Exclude documentation from production builds +- Include only source files, not generated files + +### Global vs Package Inputs + +**Global inputs** (affect all packages): +```json +{ + "globalDependencies": [".env", "tsconfig.json"] +} +``` + +**Package inputs** (affect specific tasks): +```json +{ + "pipeline": { + "build": { + "inputs": ["src/**"] + } + } +} +``` + +## Environment Variables + +### Cached Environment Variables + +Include in cache signature: + +```json +{ + "pipeline": { + "build": { + "env": [ + "NODE_ENV", // Must match for cache hit + "NEXT_PUBLIC_API_URL", + "DATABASE_URL" + ] + } + } +} +``` + +Cache invalidated when values change. + +### Pass-Through Environment Variables + +Don't affect cache: + +```json +{ + "pipeline": { + "build": { + "passThroughEnv": [ + "DEBUG", // Different values use same cache + "LOG_LEVEL", + "VERBOSE" + ] + } + } +} +``` + +Use for: Debug flags, log levels, non-production settings + +### Global Environment Variables + +Available to all tasks: + +```json +{ + "globalEnv": [ + "NODE_ENV", + "CI", + "VERCEL" + ] +} +``` + +## Cache Optimization Strategies + +### 1. Granular Outputs + +Define precise outputs to minimize cache size: + +```json +// ❌ Bad - caches too much +{ + "build": { + "outputs": ["**"] + } +} + +// ✅ Good - specific outputs +{ + "build": { + "outputs": ["dist/**", "!dist/**/*.map"] + } +} +``` + +### 2. Exclude Unnecessary Files + +```json +{ + "build": { + "outputs": [ + ".next/**", + "!.next/cache/**", // Exclude Next.js cache + "!.next/server/**/*.js.map", // Exclude source maps + "!.next/static/**/*.map" + ] + } +} +``` + +### 3. Separate Cacheable Tasks + +```json +{ + "pipeline": { + "build": { + "dependsOn": ["^build"], + "cache": true + }, + "test": { + "dependsOn": ["build"], + "cache": true // Separate from build + }, + "dev": { + "cache": false // Never cache + } + } +} +``` + +### 4. Use Input Filters + +Only track relevant files: + +```json +{ + "build": { + "inputs": [ + "src/**/*.{ts,tsx}", + "!src/**/*.{test,spec}.{ts,tsx}", + "public/**", + "package.json" + ] + } +} +``` + +## Cache Analysis + +### Inspect Cache Hits/Misses + +```bash +# Dry run with JSON output +turbo run build --dry-run=json | jq '.tasks[] | {package: .package, task: .task, cache: .cache}' +``` + +### View Task Graph + +```bash +# Generate task graph +turbo run build --graph + +# Output: graph.html (open in browser) +``` + +### Cache Statistics + +```bash +# Run with summary +turbo run build --summarize + +# Output: .turbo/runs/[hash].json +``` + +## CI/CD Cache Configuration + +### GitHub Actions + +```yaml +name: CI +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install dependencies + run: npm install + + - name: Build and test + run: turbo run build test lint + env: + TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} + TURBO_TEAM: ${{ secrets.TURBO_TEAM }} + + # Optional: Cache node_modules + - uses: actions/cache@v3 + with: + path: node_modules + key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} +``` + +### GitLab CI + +```yaml +image: node:18 + +cache: + key: ${CI_COMMIT_REF_SLUG} + paths: + - node_modules/ + - .turbo/ + +build: + stage: build + script: + - npm install + - turbo run build test + variables: + TURBO_TOKEN: $TURBO_TOKEN + TURBO_TEAM: $TURBO_TEAM +``` + +## Troubleshooting + +### Cache Not Working + +**Check outputs are defined:** +```bash +turbo run build --dry-run=json | jq '.tasks[] | {task: .task, outputs: .outputs}' +``` + +**Verify cache location:** +```bash +ls -la ./node_modules/.cache/turbo +``` + +**Check environment variables:** +```bash +echo $TURBO_TOKEN +echo $TURBO_TEAM +``` + +### Cache Too Large + +**Analyze cache size:** +```bash +du -sh ./node_modules/.cache/turbo +``` + +**Reduce outputs:** +```json +{ + "build": { + "outputs": [ + "dist/**", + "!dist/**/*.map", // Exclude source maps + "!dist/**/*.test.js" // Exclude test files + ] + } +} +``` + +**Clear old cache:** +```bash +# Turborepo doesn't auto-clean, manually remove: +rm -rf ./node_modules/.cache/turbo +``` + +### Remote Cache Connection Issues + +**Test connection:** +```bash +curl -I https://cache.example.com +``` + +**Verify token:** +```bash +turbo link +# Should show: "Remote caching enabled" +``` + +**Check logs:** +```bash +turbo run build --output-logs=full +``` + +## Best Practices + +1. **Define precise outputs** - Only cache necessary files +2. **Exclude nested caches** - Don't cache caches (.next/cache) +3. **Use remote caching** - Share cache across team and CI +4. **Track relevant inputs** - Use `inputs` to filter files +5. **Separate env vars** - Use `passThroughEnv` for debug flags +6. **Cache test results** - Include coverage in outputs +7. **Don't cache dev servers** - Set `cache: false` for dev tasks +8. **Use global dependencies** - Share config across packages +9. **Monitor cache performance** - Use `--summarize` to analyze +10. **Clear cache periodically** - Remove stale cache manually + +## Cache Performance Tips + +**For CI/CD:** +- Enable remote caching +- Run only changed packages: `--filter='...[origin/main]'` +- Use `--continue` to see all errors +- Cache node_modules separately + +**For Local Development:** +- Keep local cache enabled +- Don't force rebuild unless needed +- Use filters to build only what changed +- Clear cache if issues arise + +**For Large Monorepos:** +- Use granular outputs +- Implement input filters +- Monitor cache size regularly +- Consider cache size limits on remote cache diff --git a/skills/web-frameworks/references/turborepo-pipelines.md b/skills/web-frameworks/references/turborepo-pipelines.md new file mode 100644 index 0000000..4aa6b0e --- /dev/null +++ b/skills/web-frameworks/references/turborepo-pipelines.md @@ -0,0 +1,517 @@ +# Turborepo Task Pipelines + +Task orchestration, dependencies, and parallel execution strategies. + +## Pipeline Configuration + +Define tasks in `turbo.json`: + +```json +{ + "$schema": "https://turbo.build/schema.json", + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**"] + }, + "test": { + "dependsOn": ["build"], + "outputs": ["coverage/**"] + }, + "lint": {}, + "dev": { + "cache": false, + "persistent": true + } + } +} +``` + +## Task Dependencies + +### Topological Dependencies (^) + +`^` means "run this task in dependencies first": + +```json +{ + "pipeline": { + "build": { + "dependsOn": ["^build"] + } + } +} +``` + +Example flow: +``` +packages/ui (dependency) + ↓ builds first +apps/web (depends on @repo/ui) + ↓ builds second +``` + +### Internal Dependencies + +Run tasks in same package first: + +```json +{ + "pipeline": { + "deploy": { + "dependsOn": ["build", "test"] + } + } +} +``` + +Execution order in same package: +1. Run `build` +2. Run `test` +3. Run `deploy` + +### Combined Dependencies + +Mix topological and internal: + +```json +{ + "pipeline": { + "test": { + "dependsOn": ["^build", "lint"] + } + } +} +``` + +Execution order: +1. Build all dependencies (`^build`) +2. Lint current package (`lint`) +3. Run tests (`test`) + +## Task Configuration Options + +### outputs + +Define what gets cached: + +```json +{ + "build": { + "outputs": [ + "dist/**", // All files in dist + ".next/**", // Next.js build + "!.next/cache/**", // Exclude Next.js cache + "build/**", // Build directory + "public/dist/**" // Public assets + ] + } +} +``` + +### cache + +Enable/disable caching: + +```json +{ + "dev": { + "cache": false // Don't cache dev server + }, + "build": { + "cache": true // Cache build (default) + } +} +``` + +### persistent + +Keep task running (for dev servers): + +```json +{ + "dev": { + "cache": false, + "persistent": true // Don't kill after completion + } +} +``` + +### env + +Environment variables affecting output: + +```json +{ + "build": { + "env": [ + "NODE_ENV", + "NEXT_PUBLIC_API_URL", + "DATABASE_URL" + ] + } +} +``` + +### passThroughEnv + +Pass env vars without affecting cache: + +```json +{ + "build": { + "passThroughEnv": [ + "DEBUG", // Pass through but don't invalidate cache + "LOG_LEVEL" + ] + } +} +``` + +### inputs + +Override default input detection: + +```json +{ + "build": { + "inputs": [ + "src/**/*.ts", + "!src/**/*.test.ts", // Exclude test files + "package.json" + ] + } +} +``` + +### outputMode + +Control output display: + +```json +{ + "build": { + "outputMode": "full" // Show all output + }, + "dev": { + "outputMode": "hash-only" // Show cache hash only + }, + "test": { + "outputMode": "new-only" // Show new output only + }, + "lint": { + "outputMode": "errors-only" // Show errors only + } +} +``` + +## Running Tasks + +### Basic Execution + +```bash +# Run build in all packages +turbo run build + +# Run multiple tasks +turbo run build test lint + +# Run with specific package manager +pnpm turbo run build +``` + +### Filtering + +Run tasks in specific packages: + +```bash +# Single package +turbo run build --filter=web +turbo run build --filter=@repo/ui + +# Multiple packages +turbo run build --filter=web --filter=api + +# All apps +turbo run build --filter='./apps/*' + +# Pattern matching +turbo run test --filter='*-api' +``` + +### Dependency Filtering + +```bash +# Package and its dependencies +turbo run build --filter='...web' + +# Package's dependencies only (exclude package itself) +turbo run build --filter='...^web' + +# Package and its dependents +turbo run test --filter='ui...' + +# Package's dependents only +turbo run test --filter='^ui...' +``` + +### Git-Based Filtering + +Run only on changed packages: + +```bash +# Changed since main branch +turbo run build --filter='[main]' + +# Changed since HEAD~1 +turbo run build --filter='[HEAD~1]' + +# Changed in working directory +turbo run test --filter='...[HEAD]' + +# Package and dependencies, only if changed +turbo run build --filter='...[origin/main]' +``` + +## Concurrency Control + +### Parallel Execution (Default) + +Turborepo runs tasks in parallel when safe: + +```bash +# Run with default parallelism +turbo run build +``` + +### Limit Concurrency + +```bash +# Max 3 tasks at once +turbo run build --concurrency=3 + +# 50% of CPU cores +turbo run build --concurrency=50% + +# No parallelism (sequential) +turbo run build --concurrency=1 +``` + +### Continue on Error + +```bash +# Don't stop on first error +turbo run test --continue +``` + +## Task Execution Order + +Example monorepo: +``` +apps/ +├── web (depends on @repo/ui, @repo/utils) +└── docs (depends on @repo/ui) +packages/ +├── ui (depends on @repo/utils) +└── utils (no dependencies) +``` + +With config: +```json +{ + "pipeline": { + "build": { + "dependsOn": ["^build"] + } + } +} +``` + +Execution order for `turbo run build`: +1. **Wave 1** (parallel): `@repo/utils` (no dependencies) +2. **Wave 2** (parallel): `@repo/ui` (depends on utils) +3. **Wave 3** (parallel): `web` and `docs` (both depend on ui) + +## Complex Pipeline Examples + +### Full-Stack Application + +```json +{ + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "dist/**"] + }, + "test": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] + }, + "lint": { + "dependsOn": ["^build"] + }, + "typecheck": { + "dependsOn": ["^build"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "deploy": { + "dependsOn": ["build", "test", "lint", "typecheck"] + } + } +} +``` + +### Monorepo with Code Generation + +```json +{ + "pipeline": { + "generate": { + "cache": false, + "outputs": ["src/generated/**"] + }, + "build": { + "dependsOn": ["^build", "generate"], + "outputs": ["dist/**"] + }, + "test": { + "dependsOn": ["generate"], + "outputs": ["coverage/**"] + } + } +} +``` + +### Database-Dependent Pipeline + +```json +{ + "pipeline": { + "db:generate": { + "cache": false + }, + "db:migrate": { + "cache": false + }, + "build": { + "dependsOn": ["^build", "db:generate"], + "outputs": ["dist/**"] + }, + "test:unit": { + "dependsOn": ["build"] + }, + "test:integration": { + "dependsOn": ["db:migrate"], + "cache": false + } + } +} +``` + +## Dry Run + +Preview execution without running: + +```bash +# See what would run +turbo run build --dry-run + +# JSON output for scripts +turbo run build --dry-run=json + +# Show full task graph +turbo run build --graph +``` + +## Force Execution + +Ignore cache and run tasks: + +```bash +# Force rebuild everything +turbo run build --force + +# Force specific package +turbo run build --filter=web --force +``` + +## Output Control + +```bash +# Show only errors +turbo run build --output-logs=errors-only + +# Show new logs only +turbo run build --output-logs=new-only + +# Show cache hash only +turbo run build --output-logs=hash-only + +# Show full output +turbo run build --output-logs=full +``` + +## Best Practices + +1. **Use topological dependencies** - `^build` ensures correct build order +2. **Cache build outputs** - Define `outputs` for faster rebuilds +3. **Disable cache for dev** - Set `cache: false` for dev servers +4. **Mark persistent tasks** - Use `persistent: true` for long-running tasks +5. **Filter strategically** - Use filters to run only affected tasks +6. **Control concurrency** - Limit parallelism for resource-intensive tasks +7. **Configure env vars** - Include vars that affect output in `env` +8. **Use dry-run** - Preview execution plan before running +9. **Continue on error in CI** - Use `--continue` to see all errors +10. **Leverage git filtering** - Run only on changed packages in CI + +## Common Patterns + +### CI/CD Pipeline + +```yaml +# .github/workflows/ci.yml +jobs: + build: + steps: + - run: turbo run build test lint --filter='...[origin/main]' +``` + +Only build/test/lint changed packages and their dependents. + +### Development Workflow + +```bash +# Start all dev servers +turbo run dev + +# Start specific app with dependencies +turbo run dev --filter=web... +``` + +### Pre-commit Hook + +```json +// package.json +{ + "scripts": { + "pre-commit": "turbo run lint test --filter='...[HEAD]'" + } +} +``` + +Only lint/test changed packages. + +### Deployment + +```bash +# Build and test specific app +turbo run build test --filter=web... + +# Deploy if successful +turbo run deploy --filter=web +``` + +Build app and its dependencies, then deploy. diff --git a/skills/web-frameworks/references/turborepo-setup.md b/skills/web-frameworks/references/turborepo-setup.md new file mode 100644 index 0000000..d917c4a --- /dev/null +++ b/skills/web-frameworks/references/turborepo-setup.md @@ -0,0 +1,542 @@ +# Turborepo Setup & Configuration + +Installation, workspace configuration, and project structure for monorepos. + +## Installation + +### Create New Monorepo + +Using official starter: +```bash +npx create-turbo@latest my-monorepo +cd my-monorepo +``` + +Interactive prompts: +- Project name +- Package manager (npm, yarn, pnpm, bun) +- Example template + +### Manual Installation + +Install in existing project: +```bash +# npm +npm install turbo --save-dev + +# yarn +yarn add turbo --dev + +# pnpm +pnpm add turbo --save-dev + +# bun +bun add turbo --dev +``` + +## Workspace Configuration + +### Package Manager Setup + +**pnpm (recommended):** +```yaml +# pnpm-workspace.yaml +packages: + - 'apps/*' + - 'packages/*' +``` + +**npm/yarn:** +```json +// package.json (root) +{ + "name": "my-monorepo", + "private": true, + "workspaces": [ + "apps/*", + "packages/*" + ] +} +``` + +### Root Package.json + +```json +{ + "name": "my-monorepo", + "private": true, + "workspaces": ["apps/*", "packages/*"], + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "test": "turbo run test", + "clean": "turbo run clean" + }, + "devDependencies": { + "turbo": "latest", + "typescript": "^5.0.0" + }, + "packageManager": "pnpm@8.0.0" +} +``` + +## Project Structure + +### Recommended Directory Structure + +``` +my-monorepo/ +├── apps/ # Applications +│ ├── web/ # Next.js web app +│ │ ├── app/ +│ │ ├── package.json +│ │ └── next.config.js +│ ├── docs/ # Documentation site +│ │ ├── app/ +│ │ └── package.json +│ └── api/ # Backend API +│ ├── src/ +│ └── package.json +├── packages/ # Shared packages +│ ├── ui/ # UI component library +│ │ ├── src/ +│ │ ├── package.json +│ │ └── tsconfig.json +│ ├── config/ # Shared configs +│ │ ├── eslint/ +│ │ └── typescript/ +│ ├── utils/ # Utility functions +│ │ ├── src/ +│ │ └── package.json +│ └── types/ # Shared TypeScript types +│ ├── src/ +│ └── package.json +├── turbo.json # Turborepo config +├── package.json # Root package.json +├── pnpm-workspace.yaml # Workspace config (pnpm) +└── .gitignore +``` + +## Application Package Setup + +### Next.js App + +```json +// apps/web/package.json +{ + "name": "web", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@repo/ui": "*", + "@repo/utils": "*", + "next": "latest", + "react": "latest", + "react-dom": "latest" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "@repo/eslint-config": "*", + "typescript": "^5.0.0" + } +} +``` + +### Backend API App + +```json +// apps/api/package.json +{ + "name": "api", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsup src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src/" + }, + "dependencies": { + "@repo/utils": "*", + "@repo/types": "*", + "express": "^4.18.0" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "@types/express": "^4.17.0", + "tsx": "^4.0.0", + "tsup": "^8.0.0" + } +} +``` + +## Shared Package Setup + +### UI Component Library + +```json +// packages/ui/package.json +{ + "name": "@repo/ui", + "version": "0.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./button": { + "types": "./dist/button.d.ts", + "default": "./dist/button.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "lint": "eslint src/", + "clean": "rm -rf dist" + }, + "dependencies": { + "react": "latest" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "typescript": "^5.0.0" + } +} +``` + +```json +// packages/ui/tsconfig.json +{ + "extends": "@repo/typescript-config/react-library.json", + "compilerOptions": { + "outDir": "dist", + "declarationDir": "dist" + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} +``` + +### Utility Library + +```json +// packages/utils/package.json +{ + "name": "@repo/utils", + "version": "0.0.0", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "jest" + }, + "devDependencies": { + "@repo/typescript-config": "*", + "jest": "^29.0.0", + "typescript": "^5.0.0" + } +} +``` + +## Shared Configuration Packages + +### TypeScript Config Package + +``` +packages/typescript-config/ +├── base.json +├── nextjs.json +├── react-library.json +└── package.json +``` + +```json +// packages/typescript-config/package.json +{ + "name": "@repo/typescript-config", + "version": "0.0.0", + "main": "base.json", + "files": [ + "base.json", + "nextjs.json", + "react-library.json" + ] +} +``` + +```json +// packages/typescript-config/base.json +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleResolution": "bundler", + "target": "ES2020", + "module": "ESNext" + }, + "exclude": ["node_modules"] +} +``` + +```json +// packages/typescript-config/nextjs.json +{ + "extends": "./base.json", + "compilerOptions": { + "jsx": "preserve", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "noEmit": true, + "incremental": true, + "plugins": [{ "name": "next" }] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} +``` + +### ESLint Config Package + +``` +packages/eslint-config/ +├── library.js +├── next.js +└── package.json +``` + +```json +// packages/eslint-config/package.json +{ + "name": "@repo/eslint-config", + "version": "0.0.0", + "main": "library.js", + "files": ["library.js", "next.js"], + "dependencies": { + "eslint-config-next": "latest", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-react": "latest" + } +} +``` + +```js +// packages/eslint-config/library.js +module.exports = { + extends: ['eslint:recommended', 'prettier'], + env: { + node: true, + es2020: true, + }, + parserOptions: { + ecmaVersion: 2020, + sourceType: 'module', + }, + rules: { + 'no-console': 'warn', + }, +} +``` + +```js +// packages/eslint-config/next.js +module.exports = { + extends: ['next', 'prettier'], + rules: { + '@next/next/no-html-link-for-pages': 'off', + }, +} +``` + +## Dependency Management + +### Internal Dependencies + +Use workspace protocol: + +**pnpm:** +```json +{ + "dependencies": { + "@repo/ui": "workspace:*" + } +} +``` + +**npm/yarn:** +```json +{ + "dependencies": { + "@repo/ui": "*" + } +} +``` + +### Version Syncing + +Keep dependencies in sync across packages: + +```json +// Root package.json +{ + "devDependencies": { + "react": "18.2.0", + "react-dom": "18.2.0", + "typescript": "5.0.0" + } +} +``` + +Packages inherit from root or specify versions explicitly. + +## Turbo.json Configuration + +Basic configuration file: + +```json +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [ + "**/.env.*local", + "tsconfig.json" + ], + "globalEnv": [ + "NODE_ENV" + ], + "pipeline": { + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "!.next/cache/**", "dist/**"] + }, + "dev": { + "cache": false, + "persistent": true + }, + "lint": { + "dependsOn": ["^build"] + }, + "test": { + "dependsOn": ["build"], + "outputs": ["coverage/**"] + }, + "clean": { + "cache": false + } + } +} +``` + +## Environment Variables + +### Global Environment Variables + +```json +// turbo.json +{ + "globalEnv": [ + "NODE_ENV", + "CI" + ] +} +``` + +### Package-Specific Environment Variables + +```json +{ + "pipeline": { + "build": { + "env": ["NEXT_PUBLIC_API_URL", "DATABASE_URL"], + "passThroughEnv": ["CUSTOM_VAR"] + } + } +} +``` + +### .env Files + +``` +my-monorepo/ +├── .env # Global env vars +├── .env.local # Local overrides (gitignored) +├── apps/ +│ └── web/ +│ ├── .env # App-specific +│ └── .env.local # Local overrides +``` + +## Gitignore Configuration + +```gitignore +# Dependencies +node_modules/ +.pnp +.pnp.js + +# Turbo +.turbo + +# Build outputs +dist/ +.next/ +out/ +build/ + +# Environment +.env.local +.env.*.local + +# Testing +coverage/ + +# Misc +.DS_Store +*.log +``` + +## NPM Scripts + +Common scripts in root package.json: + +```json +{ + "scripts": { + "build": "turbo run build", + "dev": "turbo run dev", + "lint": "turbo run lint", + "test": "turbo run test", + "format": "prettier --write \"**/*.{ts,tsx,md}\"", + "clean": "turbo run clean && rm -rf node_modules", + "typecheck": "turbo run typecheck" + } +} +``` + +## Initialization Checklist + +Setting up new Turborepo: + +- [ ] Install Turborepo (create-turbo or manual) +- [ ] Configure workspace (pnpm-workspace.yaml or package.json) +- [ ] Create directory structure (apps/, packages/) +- [ ] Set up shared config packages (typescript-config, eslint-config) +- [ ] Create turbo.json with pipeline +- [ ] Configure gitignore +- [ ] Set up environment variables +- [ ] Define package dependencies +- [ ] Add root scripts +- [ ] Test build and dev commands diff --git a/skills/web-frameworks/scripts/__init__.py b/skills/web-frameworks/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/skills/web-frameworks/scripts/nextjs_init.py b/skills/web-frameworks/scripts/nextjs_init.py new file mode 100644 index 0000000..e81d32e --- /dev/null +++ b/skills/web-frameworks/scripts/nextjs_init.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 +""" +Next.js Project Initialization Script + +Initialize new Next.js project with best practices, TypeScript, and optimized configuration. +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path +from typing import Optional + + +class NextJSInitializer: + """Initialize Next.js project with best practices.""" + + def __init__( + self, + name: str, + directory: Optional[Path] = None, + typescript: bool = True, + app_router: bool = True, + src_dir: bool = False, + tailwind: bool = False, + eslint: bool = True, + import_alias: str = "@/*", + ): + """ + Initialize NextJSInitializer. + + Args: + name: Project name + directory: Target directory (default: current directory / name) + typescript: Enable TypeScript + app_router: Use App Router (recommended) + src_dir: Use src/ directory + tailwind: Include Tailwind CSS + eslint: Include ESLint + import_alias: Import alias pattern + """ + self.name = name + self.directory = directory or Path.cwd() / name + self.typescript = typescript + self.app_router = app_router + self.src_dir = src_dir + self.tailwind = tailwind + self.eslint = eslint + self.import_alias = import_alias + + def validate_name(self) -> None: + """Validate project name.""" + if not self.name: + raise ValueError("Project name cannot be empty") + + if not self.name.replace("-", "").replace("_", "").isalnum(): + raise ValueError( + "Project name can only contain letters, numbers, hyphens, and underscores" + ) + + if self.name[0].isdigit(): + raise ValueError("Project name cannot start with a number") + + def check_directory(self) -> None: + """Check if target directory exists.""" + if self.directory.exists(): + raise FileExistsError(f"Directory '{self.directory}' already exists") + + def create_directory_structure(self) -> None: + """Create project directory structure.""" + print(f"Creating directory structure in {self.directory}...") + + # Create base directories + self.directory.mkdir(parents=True, exist_ok=True) + + # Determine app/pages directory location + base_dir = self.directory / "src" if self.src_dir else self.directory + + if self.app_router: + app_dir = base_dir / "app" + app_dir.mkdir(parents=True, exist_ok=True) + (app_dir / "favicon.ico").touch() + self._create_app_router_files(app_dir) + else: + pages_dir = base_dir / "pages" + pages_dir.mkdir(parents=True, exist_ok=True) + self._create_pages_router_files(pages_dir) + + # Create additional directories + (self.directory / "public").mkdir(exist_ok=True) + (base_dir / "components").mkdir(parents=True, exist_ok=True) + (base_dir / "lib").mkdir(parents=True, exist_ok=True) + + def _create_app_router_files(self, app_dir: Path) -> None: + """Create App Router files.""" + ext = "tsx" if self.typescript else "jsx" + + # Create layout + layout_content = self._get_layout_content() + (app_dir / f"layout.{ext}").write_text(layout_content) + + # Create page + page_content = self._get_page_content() + (app_dir / f"page.{ext}").write_text(page_content) + + # Create global styles + if self.tailwind: + globals_content = self._get_tailwind_globals() + else: + globals_content = self._get_basic_globals() + (app_dir / "globals.css").write_text(globals_content) + + def _create_pages_router_files(self, pages_dir: Path) -> None: + """Create Pages Router files.""" + ext = "tsx" if self.typescript else "jsx" + + # Create _app + app_content = self._get_app_content() + (pages_dir / f"_app.{ext}").write_text(app_content) + + # Create index + index_content = self._get_index_content() + (pages_dir / f"index.{ext}").write_text(index_content) + + def create_config_files(self) -> None: + """Create configuration files.""" + print("Creating configuration files...") + + # package.json + package_json = self._get_package_json() + (self.directory / "package.json").write_text( + json.dumps(package_json, indent=2) + ) + + # next.config.js + next_config = self._get_next_config() + (self.directory / "next.config.js").write_text(next_config) + + # tsconfig.json + if self.typescript: + tsconfig = self._get_tsconfig() + (self.directory / "tsconfig.json").write_text( + json.dumps(tsconfig, indent=2) + ) + + # .eslintrc.json + if self.eslint: + eslint_config = self._get_eslint_config() + (self.directory / ".eslintrc.json").write_text( + json.dumps(eslint_config, indent=2) + ) + + # tailwind.config + if self.tailwind: + tailwind_config = self._get_tailwind_config() + ext = "ts" if self.typescript else "js" + (self.directory / f"tailwind.config.{ext}").write_text(tailwind_config) + + postcss_config = self._get_postcss_config() + (self.directory / "postcss.config.js").write_text(postcss_config) + + # .gitignore + gitignore = self._get_gitignore() + (self.directory / ".gitignore").write_text(gitignore) + + # README.md + readme = self._get_readme() + (self.directory / "README.md").write_text(readme) + + def _get_package_json(self) -> dict: + """Generate package.json content.""" + dependencies = { + "next": "latest", + "react": "latest", + "react-dom": "latest", + } + + dev_dependencies = {} + + if self.typescript: + dev_dependencies.update( + { + "typescript": "^5.0.0", + "@types/node": "^20.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + } + ) + + if self.eslint: + dev_dependencies["eslint"] = "^8.0.0" + dev_dependencies["eslint-config-next"] = "latest" + + if self.tailwind: + dependencies["tailwindcss"] = "^3.3.0" + dependencies["autoprefixer"] = "^10.0.0" + dependencies["postcss"] = "^8.0.0" + + return { + "name": self.name, + "version": "0.1.0", + "private": True, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" if self.eslint else None, + }, + "dependencies": dependencies, + "devDependencies": dev_dependencies, + } + + def _get_layout_content(self) -> str: + """Generate layout.tsx content.""" + import_css = ( + "import './globals.css'\n" if not self.tailwind else "import './globals.css'\n" + ) + + if self.typescript: + return f"""{import_css} +export const metadata = {{ + title: '{self.name}', + description: 'Generated by Next.js', +}} + +export default function RootLayout({{ + children, +}}: {{ + children: React.ReactNode +}}) {{ + return ( + + {{children}} + + ) +}} +""" + return f"""{import_css} +export const metadata = {{ + title: '{self.name}', + description: 'Generated by Next.js', +}} + +export default function RootLayout({{ children }}) {{ + return ( + + {{children}} + + ) +}} +""" + + def _get_page_content(self) -> str: + """Generate page.tsx content.""" + return """export default function Home() { + return ( +
                      +

                      Welcome to Next.js!

                      +

                      Get started by editing this page.

                      +
                      + ) +} +""" + + def _get_next_config(self) -> str: + """Generate next.config.js content.""" + return """/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + images: { + remotePatterns: [ + // Add your image domains here + ], + }, +} + +module.exports = nextConfig +""" + + def _get_tsconfig(self) -> dict: + """Generate tsconfig.json content.""" + return { + "compilerOptions": { + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": True, + "skipLibCheck": True, + "strict": True, + "noEmit": True, + "esModuleInterop": True, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": True, + "isolatedModules": True, + "jsx": "preserve", + "incremental": True, + "plugins": [{"name": "next"}], + "paths": {self.import_alias: ["./*"]}, + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"], + } + + def _get_eslint_config(self) -> dict: + """Generate .eslintrc.json content.""" + return {"extends": "next/core-web-vitals"} + + def _get_tailwind_config(self) -> str: + """Generate tailwind.config content.""" + if self.typescript: + return """import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: {}, + }, + plugins: [], +} +export default config +""" + return """/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: {}, + }, + plugins: [], +} +""" + + def _get_postcss_config(self) -> str: + """Generate postcss.config.js content.""" + return """module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} +""" + + def _get_tailwind_globals(self) -> str: + """Generate globals.css with Tailwind.""" + return """@tailwind base; +@tailwind components; +@tailwind utilities; +""" + + def _get_basic_globals(self) -> str: + """Generate basic globals.css.""" + return """* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +html, +body { + max-width: 100vw; + overflow-x: hidden; +} + +a { + color: inherit; + text-decoration: none; +} +""" + + def _get_gitignore(self) -> str: + """Generate .gitignore content.""" + return """# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +""" + + def _get_readme(self) -> str: + """Generate README.md content.""" + return f"""# {self.name} + +This is a [Next.js](https://nextjs.org/) project bootstrapped with next.js initialization script. + +## Getting Started + +First, install dependencies: + +```bash +npm install +# or +yarn install +# or +pnpm install +``` + +Then, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new). + +Check out the [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +""" + + def _get_app_content(self) -> str: + """Generate _app content for Pages Router.""" + return """export default function App({ Component, pageProps }) { + return +} +""" + + def _get_index_content(self) -> str: + """Generate index content for Pages Router.""" + return """export default function Home() { + return ( +
                      +

                      Welcome to Next.js!

                      +

                      Get started by editing this page.

                      +
                      + ) +} +""" + + def initialize(self) -> None: + """Run full initialization process.""" + try: + print(f"Initializing Next.js project: {self.name}") + print(f"TypeScript: {self.typescript}") + print(f"App Router: {self.app_router}") + print(f"Tailwind CSS: {self.tailwind}") + print(f"ESLint: {self.eslint}") + print() + + self.validate_name() + self.check_directory() + self.create_directory_structure() + self.create_config_files() + + print() + print(f"✓ Project initialized successfully!") + print() + print(f"Next steps:") + print(f" cd {self.name}") + print(f" npm install") + print(f" npm run dev") + print() + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Initialize Next.js project with best practices" + ) + parser.add_argument("name", help="Project name") + parser.add_argument( + "--directory", type=Path, help="Target directory (default: ./)" + ) + parser.add_argument( + "--no-typescript", action="store_true", help="Disable TypeScript" + ) + parser.add_argument( + "--pages-router", action="store_true", help="Use Pages Router instead of App Router" + ) + parser.add_argument("--src-dir", action="store_true", help="Use src/ directory") + parser.add_argument("--tailwind", action="store_true", help="Include Tailwind CSS") + parser.add_argument("--no-eslint", action="store_true", help="Disable ESLint") + parser.add_argument( + "--import-alias", default="@/*", help="Import alias pattern (default: @/*)" + ) + + args = parser.parse_args() + + initializer = NextJSInitializer( + name=args.name, + directory=args.directory, + typescript=not args.no_typescript, + app_router=not args.pages_router, + src_dir=args.src_dir, + tailwind=args.tailwind, + eslint=not args.no_eslint, + import_alias=args.import_alias, + ) + + initializer.initialize() + + +if __name__ == "__main__": + main() diff --git a/skills/web-frameworks/scripts/requirements.txt b/skills/web-frameworks/scripts/requirements.txt new file mode 100644 index 0000000..57446c2 --- /dev/null +++ b/skills/web-frameworks/scripts/requirements.txt @@ -0,0 +1,16 @@ +# Web Frameworks Skill Dependencies +# Python 3.10+ required + +# No Python package dependencies - uses only standard library + +# Testing dependencies (dev) +pytest>=8.0.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 + +# Note: This skill works with Node.js frameworks +# Requires Node.js and package managers: +# - Node.js 18+: https://nodejs.org/ +# - npm (comes with Node.js) +# - pnpm: npm install -g pnpm +# - yarn: npm install -g yarn diff --git a/skills/web-frameworks/scripts/tests/coverage-web.json b/skills/web-frameworks/scripts/tests/coverage-web.json new file mode 100644 index 0000000..2c25bb8 --- /dev/null +++ b/skills/web-frameworks/scripts/tests/coverage-web.json @@ -0,0 +1 @@ +{"meta": {"format": 3, "version": "7.11.0", "timestamp": "2025-11-05T00:56:58.689936", "branch_coverage": false, "show_contexts": false}, "files": {"__init__.py": {"executed_lines": [0], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": [], "functions": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "classes": {"": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 0, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}}, "nextjs_init.py": {"executed_lines": [2, 8, 9, 10, 11, 12, 13, 14, 17, 18, 20, 44, 45, 46, 47, 48, 49, 50, 51, 53, 55, 56, 58, 59, 63, 64, 66, 68, 69, 71, 73, 76, 79, 81, 82, 83, 84, 85, 87, 88, 89, 92, 93, 94, 96, 98, 101, 102, 105, 106, 109, 110, 112, 113, 115, 117, 120, 121, 124, 125, 127, 129, 132, 133, 138, 139, 142, 143, 144, 149, 150, 151, 156, 157, 158, 159, 161, 162, 165, 166, 169, 170, 172, 174, 180, 182, 183, 192, 193, 194, 196, 197, 198, 199, 201, 215, 217, 221, 222, 240, 255, 257, 267, 269, 282, 284, 306, 308, 310, 312, 313, 328, 342, 344, 352, 354, 359, 361, 379, 381, 416, 418, 460, 462, 467, 469, 479, 481, 482, 483, 484, 485, 486, 487, 489, 490, 491, 492, 494, 495, 496, 497, 498, 499, 500, 501, 508, 546], "summary": {"covered_lines": 146, "num_statements": 162, "percent_covered": 90.12345679012346, "percent_covered_display": "90", "missing_lines": 16, "excluded_lines": 0}, "missing_lines": [503, 504, 505, 510, 513, 514, 517, 520, 523, 524, 525, 526, 530, 532, 543, 547], "excluded_lines": [], "functions": {"NextJSInitializer.__init__": {"executed_lines": [44, 45, 46, 47, 48, 49, 50, 51], "summary": {"covered_lines": 8, "num_statements": 8, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer.validate_name": {"executed_lines": [55, 56, 58, 59, 63, 64], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer.check_directory": {"executed_lines": [68, 69], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer.create_directory_structure": {"executed_lines": [73, 76, 79, 81, 82, 83, 84, 85, 87, 88, 89, 92, 93, 94], "summary": {"covered_lines": 14, "num_statements": 14, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._create_app_router_files": {"executed_lines": [98, 101, 102, 105, 106, 109, 110, 112, 113], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._create_pages_router_files": {"executed_lines": [117, 120, 121, 124, 125], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer.create_config_files": {"executed_lines": [129, 132, 133, 138, 139, 142, 143, 144, 149, 150, 151, 156, 157, 158, 159, 161, 162, 165, 166, 169, 170], "summary": {"covered_lines": 21, "num_statements": 21, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_package_json": {"executed_lines": [174, 180, 182, 183, 192, 193, 194, 196, 197, 198, 199, 201], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_layout_content": {"executed_lines": [217, 221, 222, 240], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_page_content": {"executed_lines": [257], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_next_config": {"executed_lines": [269], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_tsconfig": {"executed_lines": [284], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_eslint_config": {"executed_lines": [308], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_tailwind_config": {"executed_lines": [312, 313, 328], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_postcss_config": {"executed_lines": [344], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_tailwind_globals": {"executed_lines": [354], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_basic_globals": {"executed_lines": [361], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_gitignore": {"executed_lines": [381], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_readme": {"executed_lines": [418], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_app_content": {"executed_lines": [462], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer._get_index_content": {"executed_lines": [469], "summary": {"covered_lines": 1, "num_statements": 1, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "NextJSInitializer.initialize": {"executed_lines": [481, 482, 483, 484, 485, 486, 487, 489, 490, 491, 492, 494, 495, 496, 497, 498, 499, 500, 501], "summary": {"covered_lines": 19, "num_statements": 22, "percent_covered": 86.36363636363636, "percent_covered_display": "86", "missing_lines": 3, "excluded_lines": 0}, "missing_lines": [503, 504, 505], "excluded_lines": []}, "main": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 12, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 12, "excluded_lines": 0}, "missing_lines": [510, 513, 514, 517, 520, 523, 524, 525, 526, 530, 532, 543], "excluded_lines": []}, "": {"executed_lines": [2, 8, 9, 10, 11, 12, 13, 14, 17, 18, 20, 53, 66, 71, 96, 115, 127, 172, 215, 255, 267, 282, 306, 310, 342, 352, 359, 379, 416, 460, 467, 479, 508, 546], "summary": {"covered_lines": 32, "num_statements": 33, "percent_covered": 96.96969696969697, "percent_covered_display": "97", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [547], "excluded_lines": []}}, "classes": {"NextJSInitializer": {"executed_lines": [44, 45, 46, 47, 48, 49, 50, 51, 55, 56, 58, 59, 63, 64, 68, 69, 73, 76, 79, 81, 82, 83, 84, 85, 87, 88, 89, 92, 93, 94, 98, 101, 102, 105, 106, 109, 110, 112, 113, 117, 120, 121, 124, 125, 129, 132, 133, 138, 139, 142, 143, 144, 149, 150, 151, 156, 157, 158, 159, 161, 162, 165, 166, 169, 170, 174, 180, 182, 183, 192, 193, 194, 196, 197, 198, 199, 201, 217, 221, 222, 240, 257, 269, 284, 308, 312, 313, 328, 344, 354, 361, 381, 418, 462, 469, 481, 482, 483, 484, 485, 486, 487, 489, 490, 491, 492, 494, 495, 496, 497, 498, 499, 500, 501], "summary": {"covered_lines": 114, "num_statements": 117, "percent_covered": 97.43589743589743, "percent_covered_display": "97", "missing_lines": 3, "excluded_lines": 0}, "missing_lines": [503, 504, 505], "excluded_lines": []}, "": {"executed_lines": [2, 8, 9, 10, 11, 12, 13, 14, 17, 18, 20, 53, 66, 71, 96, 115, 127, 172, 215, 255, 267, 282, 306, 310, 342, 352, 359, 379, 416, 460, 467, 479, 508, 546], "summary": {"covered_lines": 32, "num_statements": 45, "percent_covered": 71.11111111111111, "percent_covered_display": "71", "missing_lines": 13, "excluded_lines": 0}, "missing_lines": [510, 513, 514, 517, 520, 523, 524, 525, 526, 530, 532, 543, 547], "excluded_lines": []}}}, "tests/test_nextjs_init.py": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 15, 16, 18, 20, 25, 26, 27, 28, 29, 30, 32, 34, 36, 37, 41, 43, 45, 52, 53, 58, 59, 61, 63, 64, 66, 71, 72, 74, 76, 77, 83, 86, 87, 88, 89, 92, 93, 94, 96, 98, 99, 105, 108, 109, 110, 112, 114, 122, 124, 125, 126, 129, 130, 131, 132, 135, 136, 137, 140, 141, 142, 145, 148, 150, 152, 159, 161, 162, 163, 164, 165, 167, 169, 175, 177, 178, 179, 180, 182, 184, 190, 192, 193, 194, 196, 198, 205, 207, 208, 209, 211, 213, 220, 222, 223, 225, 227, 232, 234, 235, 236, 237, 239, 241, 246, 248, 249, 250, 252, 254, 255, 263, 264, 267, 268, 269, 270, 271, 272, 273, 274, 277, 278, 279, 281, 283, 284, 292, 295, 298, 299, 302, 303, 304, 306, 308, 309, 315, 318, 319], "summary": {"covered_lines": 145, "num_statements": 145, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": [], "functions": {"TestNextJSInitializer.test_init_with_defaults": {"executed_lines": [20, 25, 26, 27, 28, 29, 30], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_validate_name_valid": {"executed_lines": [34, 36, 37, 41], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_validate_name_invalid": {"executed_lines": [45, 52, 53, 58, 59], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_check_directory_exists": {"executed_lines": [63, 64, 66, 71, 72], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_create_directory_structure_app_router": {"executed_lines": [76, 77, 83, 86, 87, 88, 89, 92, 93, 94], "summary": {"covered_lines": 10, "num_statements": 10, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_create_directory_structure_with_src": {"executed_lines": [98, 99, 105, 108, 109, 110], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_package_json_generation": {"executed_lines": [114, 122, 124, 125, 126, 129, 130, 131, 132, 135, 136, 137, 140, 141, 142, 145, 148], "summary": {"covered_lines": 17, "num_statements": 17, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_tsconfig_generation": {"executed_lines": [152, 159, 161, 162, 163, 164, 165], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_layout_content_typescript": {"executed_lines": [169, 175, 177, 178, 179, 180], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_layout_content_javascript": {"executed_lines": [184, 190, 192, 193, 194], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_tailwind_config_typescript": {"executed_lines": [198, 205, 207, 208, 209], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_tailwind_config_javascript": {"executed_lines": [213, 220, 222, 223], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_gitignore_generation": {"executed_lines": [227, 232, 234, 235, 236, 237], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_readme_generation": {"executed_lines": [241, 246, 248, 249, 250], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_create_config_files": {"executed_lines": [254, 255, 263, 264, 267, 268, 269, 270, 271, 272, 273, 274, 277, 278, 279], "summary": {"covered_lines": 15, "num_statements": 15, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_full_initialization": {"executed_lines": [283, 284, 292, 295, 298, 299, 302, 303, 304], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestNextJSInitializer.test_pages_router_structure": {"executed_lines": [308, 309, 315, 318, 319], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 15, 16, 18, 32, 43, 61, 74, 96, 112, 150, 167, 182, 196, 211, 225, 239, 252, 281, 306], "summary": {"covered_lines": 24, "num_statements": 24, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "classes": {"TestNextJSInitializer": {"executed_lines": [20, 25, 26, 27, 28, 29, 30, 34, 36, 37, 41, 45, 52, 53, 58, 59, 63, 64, 66, 71, 72, 76, 77, 83, 86, 87, 88, 89, 92, 93, 94, 98, 99, 105, 108, 109, 110, 114, 122, 124, 125, 126, 129, 130, 131, 132, 135, 136, 137, 140, 141, 142, 145, 148, 152, 159, 161, 162, 163, 164, 165, 169, 175, 177, 178, 179, 180, 184, 190, 192, 193, 194, 198, 205, 207, 208, 209, 213, 220, 222, 223, 227, 232, 234, 235, 236, 237, 241, 246, 248, 249, 250, 254, 255, 263, 264, 267, 268, 269, 270, 271, 272, 273, 274, 277, 278, 279, 283, 284, 292, 295, 298, 299, 302, 303, 304, 308, 309, 315, 318, 319], "summary": {"covered_lines": 121, "num_statements": 121, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 15, 16, 18, 32, 43, 61, 74, 96, 112, 150, 167, 182, 196, 211, 225, 239, 252, 281, 306], "summary": {"covered_lines": 24, "num_statements": 24, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}}, "tests/test_turborepo_migrate.py": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 15, 16, 19, 29, 32, 33, 35, 36, 37, 53, 56, 57, 59, 60, 61, 75, 77, 80, 81, 83, 85, 91, 92, 93, 95, 97, 98, 100, 102, 104, 105, 107, 109, 110, 112, 114, 115, 117, 119, 120, 122, 124, 125, 127, 129, 130, 132, 133, 134, 136, 139, 145, 150, 151, 153, 154, 156, 158, 159, 160, 162, 164, 165, 166, 168, 170, 171, 172, 174, 177, 178, 179, 182, 183, 185, 187, 188, 189, 191, 194, 195, 196, 198, 200, 201, 202, 204, 205, 207, 208, 211, 212, 213, 216, 217, 220, 226, 228, 229, 230, 232, 235, 236, 239, 240, 241, 244, 246, 247, 248, 250, 251, 252, 254, 256, 257, 258, 259, 260, 263, 264, 266, 268, 269, 270, 272, 273, 274, 276, 279, 282, 283, 285, 287, 288, 289, 291, 292, 293, 295, 298, 301, 302, 303, 304, 307, 308, 309, 311, 313, 314, 317, 320, 321, 322, 324, 326, 327, 330, 332, 333, 334, 335, 336, 339, 340, 341, 342, 344, 346, 351, 352, 354, 355, 357, 358, 359, 360, 362, 365, 370, 373, 374], "summary": {"covered_lines": 188, "num_statements": 188, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": [], "functions": {"mock_monorepo": {"executed_lines": [19, 29, 32, 33, 35, 36, 37, 53, 56, 57, 59, 60, 61, 75, 77], "summary": {"covered_lines": 15, "num_statements": 15, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_init": {"executed_lines": [85, 91, 92, 93], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_validate_path_exists": {"executed_lines": [97, 98], "summary": {"covered_lines": 2, "num_statements": 2, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_validate_path_not_exists": {"executed_lines": [102, 104, 105], "summary": {"covered_lines": 3, "num_statements": 3, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_validate_path_not_directory": {"executed_lines": [109, 110, 112, 114, 115], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_validate_path_no_package_json": {"executed_lines": [119, 120, 122, 124, 125], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_analyze_workspace_npm": {"executed_lines": [129, 130, 132, 133, 134], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_analyze_workspace_pnpm": {"executed_lines": [139, 145, 150, 151, 153, 154], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_discover_packages": {"executed_lines": [158, 159, 160, 162, 164, 165, 166], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_analyze_scripts": {"executed_lines": [170, 171, 172, 174, 177, 178, 179, 182, 183], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_infer_build_outputs": {"executed_lines": [187, 188, 189, 191, 194, 195, 196], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_generate_turbo_config": {"executed_lines": [200, 201, 202, 204, 205, 207, 208, 211, 212, 213, 216, 217, 220], "summary": {"covered_lines": 13, "num_statements": 13, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_update_root_package_json": {"executed_lines": [228, 229, 230, 232, 235, 236, 239, 240, 241], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_generate_migration_report": {"executed_lines": [246, 247, 248, 250, 251, 252, 254, 256, 257, 258, 259, 260, 263, 264], "summary": {"covered_lines": 14, "num_statements": 14, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_write_files_dry_run": {"executed_lines": [268, 269, 270, 272, 273, 274, 276, 279, 282, 283], "summary": {"covered_lines": 10, "num_statements": 10, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_write_files_actual": {"executed_lines": [287, 288, 289, 291, 292, 293, 295, 298, 301, 302, 303, 304, 307, 308, 309], "summary": {"covered_lines": 15, "num_statements": 15, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_full_migration_dry_run": {"executed_lines": [313, 314, 317, 320, 321, 322], "summary": {"covered_lines": 6, "num_statements": 6, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_full_migration_actual": {"executed_lines": [326, 327, 330, 332, 333, 334, 335, 336, 339, 340, 341, 342], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_parse_pnpm_workspace": {"executed_lines": [346, 351, 352, 354, 355, 357, 358, 359, 360], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TestTurborepoMigrator.test_monorepo_without_workspaces": {"executed_lines": [365, 370, 373, 374], "summary": {"covered_lines": 4, "num_statements": 4, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 15, 16, 80, 81, 83, 95, 100, 107, 117, 127, 136, 156, 168, 185, 198, 226, 244, 266, 285, 311, 324, 344, 362], "summary": {"covered_lines": 28, "num_statements": 28, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}, "classes": {"TestTurborepoMigrator": {"executed_lines": [85, 91, 92, 93, 97, 98, 102, 104, 105, 109, 110, 112, 114, 115, 119, 120, 122, 124, 125, 129, 130, 132, 133, 134, 139, 145, 150, 151, 153, 154, 158, 159, 160, 162, 164, 165, 166, 170, 171, 172, 174, 177, 178, 179, 182, 183, 187, 188, 189, 191, 194, 195, 196, 200, 201, 202, 204, 205, 207, 208, 211, 212, 213, 216, 217, 220, 228, 229, 230, 232, 235, 236, 239, 240, 241, 246, 247, 248, 250, 251, 252, 254, 256, 257, 258, 259, 260, 263, 264, 268, 269, 270, 272, 273, 274, 276, 279, 282, 283, 287, 288, 289, 291, 292, 293, 295, 298, 301, 302, 303, 304, 307, 308, 309, 313, 314, 317, 320, 321, 322, 326, 327, 330, 332, 333, 334, 335, 336, 339, 340, 341, 342, 346, 351, 352, 354, 355, 357, 358, 359, 360, 365, 370, 373, 374], "summary": {"covered_lines": 145, "num_statements": 145, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "": {"executed_lines": [1, 3, 4, 5, 7, 10, 12, 15, 16, 19, 29, 32, 33, 35, 36, 37, 53, 56, 57, 59, 60, 61, 75, 77, 80, 81, 83, 95, 100, 107, 117, 127, 136, 156, 168, 185, 198, 226, 244, 266, 285, 311, 324, 344, 362], "summary": {"covered_lines": 43, "num_statements": 43, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}}}, "turborepo_migrate.py": {"executed_lines": [2, 8, 9, 10, 11, 12, 13, 16, 17, 19, 33, 34, 35, 36, 37, 39, 41, 42, 44, 45, 47, 48, 49, 53, 55, 57, 58, 59, 62, 63, 67, 68, 73, 77, 79, 81, 83, 84, 85, 93, 94, 96, 97, 98, 100, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 115, 117, 120, 122, 123, 124, 126, 127, 128, 130, 140, 142, 144, 146, 147, 148, 149, 150, 152, 158, 159, 160, 162, 164, 166, 168, 171, 172, 178, 179, 185, 186, 189, 194, 199, 202, 208, 210, 212, 214, 215, 218, 219, 220, 222, 223, 224, 225, 228, 230, 232, 234, 235, 236, 239, 240, 242, 245, 248, 249, 251, 252, 254, 256, 260, 262, 263, 264, 265, 267, 268, 269, 270, 272, 273, 274, 275, 276, 278, 279, 280, 281, 282, 283, 285, 286, 287, 288, 290, 291, 292, 293, 294, 295, 296, 298, 299, 301, 302, 303, 305, 306, 308, 310, 312, 313, 314, 315, 316, 318, 321, 322, 323, 324, 327, 328, 329, 330, 332, 334, 335, 336, 337, 339, 340, 341, 343, 344, 345, 347, 348, 350, 351, 352, 354, 355, 356, 359, 393], "summary": {"covered_lines": 194, "num_statements": 213, "percent_covered": 91.07981220657277, "percent_covered_display": "91", "missing_lines": 19, "excluded_lines": 0}, "missing_lines": [86, 89, 90, 190, 191, 195, 196, 200, 221, 226, 246, 361, 364, 370, 375, 382, 384, 390, 394], "excluded_lines": [], "functions": {"TurborepoMigrator.__init__": {"executed_lines": [33, 34, 35, 36, 37], "summary": {"covered_lines": 5, "num_statements": 5, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.validate_path": {"executed_lines": [41, 42, 44, 45, 47, 48, 49], "summary": {"covered_lines": 7, "num_statements": 7, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.analyze_workspace": {"executed_lines": [55, 57, 58, 59, 62, 63, 67, 68, 73, 77], "summary": {"covered_lines": 10, "num_statements": 10, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.discover_packages": {"executed_lines": [81, 83, 84, 85, 93, 94, 96, 97, 98], "summary": {"covered_lines": 9, "num_statements": 12, "percent_covered": 75.0, "percent_covered_display": "75", "missing_lines": 3, "excluded_lines": 0}, "missing_lines": [86, 89, 90], "excluded_lines": []}, "TurborepoMigrator._parse_pnpm_workspace": {"executed_lines": [102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator._find_packages_by_pattern": {"executed_lines": [117, 120, 122, 123, 124, 126, 127, 128, 130], "summary": {"covered_lines": 9, "num_statements": 9, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.analyze_scripts": {"executed_lines": [142, 144, 146, 147, 148, 149, 150, 152, 158, 159, 160, 162], "summary": {"covered_lines": 12, "num_statements": 12, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.generate_turbo_config": {"executed_lines": [166, 168, 171, 172, 178, 179, 185, 186, 189, 194, 199, 202, 208], "summary": {"covered_lines": 13, "num_statements": 18, "percent_covered": 72.22222222222223, "percent_covered_display": "72", "missing_lines": 5, "excluded_lines": 0}, "missing_lines": [190, 191, 195, 196, 200], "excluded_lines": []}, "TurborepoMigrator._infer_build_outputs": {"executed_lines": [212, 214, 215, 218, 219, 220, 222, 223, 224, 225, 228], "summary": {"covered_lines": 11, "num_statements": 13, "percent_covered": 84.61538461538461, "percent_covered_display": "85", "missing_lines": 2, "excluded_lines": 0}, "missing_lines": [221, 226], "excluded_lines": []}, "TurborepoMigrator.update_root_package_json": {"executed_lines": [232, 234, 235, 236, 239, 240, 242, 245, 248, 249, 251, 252, 254], "summary": {"covered_lines": 13, "num_statements": 14, "percent_covered": 92.85714285714286, "percent_covered_display": "93", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [246], "excluded_lines": []}, "TurborepoMigrator.generate_migration_report": {"executed_lines": [260, 262, 263, 264, 265, 267, 268, 269, 270, 272, 273, 274, 275, 276, 278, 279, 280, 281, 282, 283, 285, 286, 287, 288, 290, 291, 292, 293, 294, 295, 296, 298, 299, 301, 302, 303, 305, 306, 308], "summary": {"covered_lines": 39, "num_statements": 39, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.write_files": {"executed_lines": [312, 313, 314, 315, 316, 318, 321, 322, 323, 324, 327, 328, 329, 330], "summary": {"covered_lines": 14, "num_statements": 14, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "TurborepoMigrator.migrate": {"executed_lines": [334, 335, 336, 337, 339, 340, 341, 343, 344, 345, 347, 348, 350, 351, 352, 354, 355, 356], "summary": {"covered_lines": 18, "num_statements": 18, "percent_covered": 100.0, "percent_covered_display": "100", "missing_lines": 0, "excluded_lines": 0}, "missing_lines": [], "excluded_lines": []}, "main": {"executed_lines": [], "summary": {"covered_lines": 0, "num_statements": 7, "percent_covered": 0.0, "percent_covered_display": "0", "missing_lines": 7, "excluded_lines": 0}, "missing_lines": [361, 364, 370, 375, 382, 384, 390], "excluded_lines": []}, "": {"executed_lines": [2, 8, 9, 10, 11, 12, 13, 16, 17, 19, 39, 53, 79, 100, 115, 140, 164, 210, 230, 256, 310, 332, 359, 393], "summary": {"covered_lines": 22, "num_statements": 23, "percent_covered": 95.65217391304348, "percent_covered_display": "96", "missing_lines": 1, "excluded_lines": 0}, "missing_lines": [394], "excluded_lines": []}}, "classes": {"TurborepoMigrator": {"executed_lines": [33, 34, 35, 36, 37, 41, 42, 44, 45, 47, 48, 49, 55, 57, 58, 59, 62, 63, 67, 68, 73, 77, 81, 83, 84, 85, 93, 94, 96, 97, 98, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 117, 120, 122, 123, 124, 126, 127, 128, 130, 142, 144, 146, 147, 148, 149, 150, 152, 158, 159, 160, 162, 166, 168, 171, 172, 178, 179, 185, 186, 189, 194, 199, 202, 208, 212, 214, 215, 218, 219, 220, 222, 223, 224, 225, 228, 232, 234, 235, 236, 239, 240, 242, 245, 248, 249, 251, 252, 254, 260, 262, 263, 264, 265, 267, 268, 269, 270, 272, 273, 274, 275, 276, 278, 279, 280, 281, 282, 283, 285, 286, 287, 288, 290, 291, 292, 293, 294, 295, 296, 298, 299, 301, 302, 303, 305, 306, 308, 312, 313, 314, 315, 316, 318, 321, 322, 323, 324, 327, 328, 329, 330, 334, 335, 336, 337, 339, 340, 341, 343, 344, 345, 347, 348, 350, 351, 352, 354, 355, 356], "summary": {"covered_lines": 172, "num_statements": 183, "percent_covered": 93.98907103825137, "percent_covered_display": "94", "missing_lines": 11, "excluded_lines": 0}, "missing_lines": [86, 89, 90, 190, 191, 195, 196, 200, 221, 226, 246], "excluded_lines": []}, "": {"executed_lines": [2, 8, 9, 10, 11, 12, 13, 16, 17, 19, 39, 53, 79, 100, 115, 140, 164, 210, 230, 256, 310, 332, 359, 393], "summary": {"covered_lines": 22, "num_statements": 30, "percent_covered": 73.33333333333333, "percent_covered_display": "73", "missing_lines": 8, "excluded_lines": 0}, "missing_lines": [361, 364, 370, 375, 382, 384, 390, 394], "excluded_lines": []}}}}, "totals": {"covered_lines": 673, "num_statements": 708, "percent_covered": 95.05649717514125, "percent_covered_display": "95", "missing_lines": 35, "excluded_lines": 0}} \ No newline at end of file diff --git a/skills/web-frameworks/scripts/tests/requirements.txt b/skills/web-frameworks/scripts/tests/requirements.txt new file mode 100644 index 0000000..ba4bd9d --- /dev/null +++ b/skills/web-frameworks/scripts/tests/requirements.txt @@ -0,0 +1,3 @@ +pytest>=7.0.0 +pytest-cov>=4.0.0 +pytest-mock>=3.10.0 diff --git a/skills/web-frameworks/scripts/tests/test_nextjs_init.py b/skills/web-frameworks/scripts/tests/test_nextjs_init.py new file mode 100644 index 0000000..bac46d3 --- /dev/null +++ b/skills/web-frameworks/scripts/tests/test_nextjs_init.py @@ -0,0 +1,319 @@ +"""Tests for nextjs-init.py script.""" + +import json +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path to import the script +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from nextjs_init import NextJSInitializer + + +class TestNextJSInitializer: + """Test suite for NextJSInitializer.""" + + def test_init_with_defaults(self, tmp_path): + """Test initialization with default parameters.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app" + ) + + assert initializer.name == "test-app" + assert initializer.typescript is True + assert initializer.app_router is True + assert initializer.src_dir is False + assert initializer.tailwind is False + assert initializer.eslint is True + + def test_validate_name_valid(self, tmp_path): + """Test name validation with valid names.""" + valid_names = ["my-app", "my_app", "myapp123", "test-app-1"] + + for name in valid_names: + initializer = NextJSInitializer( + name=name, + directory=tmp_path / name + ) + initializer.validate_name() # Should not raise + + def test_validate_name_invalid(self, tmp_path): + """Test name validation with invalid names.""" + invalid_cases = [ + ("", ValueError, "empty"), + ("123app", ValueError, "starts with number"), + ("my app", ValueError, "contains space"), + ("my@app", ValueError, "contains special char"), + ] + + for name, expected_error, reason in invalid_cases: + initializer = NextJSInitializer( + name=name, + directory=tmp_path / (name or "empty") + ) + + with pytest.raises(expected_error): + initializer.validate_name() + + def test_check_directory_exists(self, tmp_path): + """Test directory existence check.""" + existing_dir = tmp_path / "existing" + existing_dir.mkdir() + + initializer = NextJSInitializer( + name="test-app", + directory=existing_dir + ) + + with pytest.raises(FileExistsError): + initializer.check_directory() + + def test_create_directory_structure_app_router(self, tmp_path): + """Test directory structure creation with App Router.""" + project_dir = tmp_path / "test-app" + initializer = NextJSInitializer( + name="test-app", + directory=project_dir, + app_router=True + ) + + initializer.create_directory_structure() + + # Check directories + assert (project_dir / "app").exists() + assert (project_dir / "public").exists() + assert (project_dir / "components").exists() + assert (project_dir / "lib").exists() + + # Check App Router files + assert (project_dir / "app" / "layout.tsx").exists() + assert (project_dir / "app" / "page.tsx").exists() + assert (project_dir / "app" / "globals.css").exists() + + def test_create_directory_structure_with_src(self, tmp_path): + """Test directory structure with src/ directory.""" + project_dir = tmp_path / "test-app" + initializer = NextJSInitializer( + name="test-app", + directory=project_dir, + src_dir=True + ) + + initializer.create_directory_structure() + + # Check src structure + assert (project_dir / "src" / "app").exists() + assert (project_dir / "src" / "components").exists() + assert (project_dir / "src" / "lib").exists() + + def test_package_json_generation(self, tmp_path): + """Test package.json generation.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app", + typescript=True, + tailwind=True, + eslint=True + ) + + package_json = initializer._get_package_json() + + assert package_json["name"] == "test-app" + assert package_json["version"] == "0.1.0" + assert package_json["private"] is True + + # Check scripts + assert "dev" in package_json["scripts"] + assert "build" in package_json["scripts"] + assert "start" in package_json["scripts"] + assert "lint" in package_json["scripts"] + + # Check dependencies + assert "next" in package_json["dependencies"] + assert "react" in package_json["dependencies"] + assert "react-dom" in package_json["dependencies"] + + # Check TypeScript dependencies + assert "typescript" in package_json["devDependencies"] + assert "@types/node" in package_json["devDependencies"] + assert "@types/react" in package_json["devDependencies"] + + # Check Tailwind dependencies + assert "tailwindcss" in package_json["dependencies"] + + # Check ESLint dependencies + assert "eslint" in package_json["devDependencies"] + + def test_tsconfig_generation(self, tmp_path): + """Test tsconfig.json generation.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app", + typescript=True, + import_alias="@/*" + ) + + tsconfig = initializer._get_tsconfig() + + assert "compilerOptions" in tsconfig + assert tsconfig["compilerOptions"]["strict"] is True + assert tsconfig["compilerOptions"]["jsx"] == "preserve" + assert "@/*" in tsconfig["compilerOptions"]["paths"] + assert "next-env.d.ts" in tsconfig["include"] + + def test_layout_content_typescript(self, tmp_path): + """Test layout.tsx content generation.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app", + typescript=True + ) + + content = initializer._get_layout_content() + + assert "import './globals.css'" in content + assert "export const metadata" in content + assert "children: React.ReactNode" in content + assert "" in content + + def test_layout_content_javascript(self, tmp_path): + """Test layout.jsx content generation.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app", + typescript=False + ) + + content = initializer._get_layout_content() + + assert "import './globals.css'" in content + assert "export const metadata" in content + assert "React.ReactNode" not in content # No TypeScript types + + def test_tailwind_config_typescript(self, tmp_path): + """Test Tailwind config generation with TypeScript.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app", + typescript=True, + tailwind=True + ) + + config = initializer._get_tailwind_config() + + assert "import type { Config }" in config + assert "const config: Config" in config + assert "content:" in config + + def test_tailwind_config_javascript(self, tmp_path): + """Test Tailwind config generation with JavaScript.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app", + typescript=False, + tailwind=True + ) + + config = initializer._get_tailwind_config() + + assert "module.exports" in config + assert "content:" in config + + def test_gitignore_generation(self, tmp_path): + """Test .gitignore generation.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app" + ) + + gitignore = initializer._get_gitignore() + + assert "/node_modules" in gitignore + assert "/.next/" in gitignore + assert ".env*.local" in gitignore + assert ".DS_Store" in gitignore + + def test_readme_generation(self, tmp_path): + """Test README.md generation.""" + initializer = NextJSInitializer( + name="test-app", + directory=tmp_path / "test-app" + ) + + readme = initializer._get_readme() + + assert "# test-app" in readme + assert "Next.js" in readme + assert "npm run dev" in readme + + def test_create_config_files(self, tmp_path): + """Test configuration files creation.""" + project_dir = tmp_path / "test-app" + initializer = NextJSInitializer( + name="test-app", + directory=project_dir, + typescript=True, + tailwind=True, + eslint=True + ) + + initializer.create_directory_structure() + initializer.create_config_files() + + # Check all config files exist + assert (project_dir / "package.json").exists() + assert (project_dir / "next.config.js").exists() + assert (project_dir / "tsconfig.json").exists() + assert (project_dir / ".eslintrc.json").exists() + assert (project_dir / "tailwind.config.ts").exists() + assert (project_dir / "postcss.config.js").exists() + assert (project_dir / ".gitignore").exists() + assert (project_dir / "README.md").exists() + + # Verify package.json is valid JSON + with open(project_dir / "package.json") as f: + package_json = json.load(f) + assert package_json["name"] == "test-app" + + def test_full_initialization(self, tmp_path): + """Test full initialization process.""" + project_dir = tmp_path / "test-app" + initializer = NextJSInitializer( + name="test-app", + directory=project_dir, + typescript=True, + app_router=True, + tailwind=True + ) + + initializer.initialize() + + # Verify directory exists + assert project_dir.exists() + + # Verify structure + assert (project_dir / "app").exists() + assert (project_dir / "public").exists() + + # Verify config files + assert (project_dir / "package.json").exists() + assert (project_dir / "tsconfig.json").exists() + assert (project_dir / "next.config.js").exists() + + def test_pages_router_structure(self, tmp_path): + """Test Pages Router directory structure.""" + project_dir = tmp_path / "test-app" + initializer = NextJSInitializer( + name="test-app", + directory=project_dir, + app_router=False # Use Pages Router + ) + + initializer.create_directory_structure() + + # Check Pages Router files + assert (project_dir / "pages" / "_app.tsx").exists() + assert (project_dir / "pages" / "index.tsx").exists() diff --git a/skills/web-frameworks/scripts/tests/test_turborepo_migrate.py b/skills/web-frameworks/scripts/tests/test_turborepo_migrate.py new file mode 100644 index 0000000..01691a4 --- /dev/null +++ b/skills/web-frameworks/scripts/tests/test_turborepo_migrate.py @@ -0,0 +1,374 @@ +"""Tests for turborepo-migrate.py script.""" + +import json +import sys +from pathlib import Path + +import pytest + +# Add parent directory to path to import the script +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from turborepo_migrate import TurborepoMigrator + + +@pytest.fixture +def mock_monorepo(tmp_path): + """Create a mock monorepo structure.""" + # Root package.json + root_pkg = { + "name": "test-monorepo", + "private": True, + "workspaces": ["apps/*", "packages/*"], + "scripts": { + "build": "npm run build --workspaces", + "test": "npm run test --workspaces" + } + } + + (tmp_path / "package.json").write_text(json.dumps(root_pkg, indent=2)) + + # Create apps + apps_dir = tmp_path / "apps" + apps_dir.mkdir() + + web_dir = apps_dir / "web" + web_dir.mkdir() + (web_dir / "package.json").write_text(json.dumps({ + "name": "web", + "version": "1.0.0", + "scripts": { + "dev": "next dev", + "build": "next build", + "test": "jest", + "lint": "eslint ." + }, + "dependencies": { + "@repo/ui": "*", + "next": "latest" + } + }, indent=2)) + + # Create Next.js output directory + (web_dir / ".next").mkdir() + + # Create packages + packages_dir = tmp_path / "packages" + packages_dir.mkdir() + + ui_dir = packages_dir / "ui" + ui_dir.mkdir() + (ui_dir / "package.json").write_text(json.dumps({ + "name": "@repo/ui", + "version": "0.0.0", + "scripts": { + "build": "tsc", + "test": "jest", + "lint": "eslint ." + }, + "dependencies": { + "react": "latest" + } + }, indent=2)) + + # Create dist directory + (ui_dir / "dist").mkdir() + + return tmp_path + + +class TestTurborepoMigrator: + """Test suite for TurborepoMigrator.""" + + def test_init(self, tmp_path): + """Test migrator initialization.""" + migrator = TurborepoMigrator( + path=tmp_path, + dry_run=True, + package_manager="npm" + ) + + assert migrator.path == tmp_path.resolve() + assert migrator.dry_run is True + assert migrator.package_manager == "npm" + + def test_validate_path_exists(self, mock_monorepo): + """Test path validation with valid monorepo.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.validate_path() # Should not raise + + def test_validate_path_not_exists(self, tmp_path): + """Test path validation with non-existent path.""" + migrator = TurborepoMigrator(path=tmp_path / "nonexistent") + + with pytest.raises(FileNotFoundError): + migrator.validate_path() + + def test_validate_path_not_directory(self, tmp_path): + """Test path validation with file instead of directory.""" + file_path = tmp_path / "file.txt" + file_path.touch() + + migrator = TurborepoMigrator(path=file_path) + + with pytest.raises(NotADirectoryError): + migrator.validate_path() + + def test_validate_path_no_package_json(self, tmp_path): + """Test path validation without package.json.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + + migrator = TurborepoMigrator(path=empty_dir) + + with pytest.raises(FileNotFoundError): + migrator.validate_path() + + def test_analyze_workspace_npm(self, mock_monorepo): + """Test workspace analysis for npm/yarn workspaces.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + + assert migrator.workspace_config["type"] == "npm/yarn" + assert "apps/*" in migrator.workspace_config["patterns"] + assert "packages/*" in migrator.workspace_config["patterns"] + + def test_analyze_workspace_pnpm(self, tmp_path): + """Test workspace analysis for pnpm workspaces.""" + # Create root package.json without workspaces + (tmp_path / "package.json").write_text(json.dumps({ + "name": "test-monorepo", + "private": True + })) + + # Create pnpm-workspace.yaml + (tmp_path / "pnpm-workspace.yaml").write_text("""packages: + - 'apps/*' + - 'packages/*' +""") + + migrator = TurborepoMigrator(path=tmp_path) + migrator.analyze_workspace() + + assert migrator.workspace_config["type"] == "pnpm" + assert migrator.workspace_config["file"] == "pnpm-workspace.yaml" + + def test_discover_packages(self, mock_monorepo): + """Test package discovery.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + migrator.discover_packages() + + assert len(migrator.packages) == 2 + + package_names = {pkg["name"] for pkg in migrator.packages} + assert "web" in package_names + assert "@repo/ui" in package_names + + def test_analyze_scripts(self, mock_monorepo): + """Test script analysis.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + migrator.discover_packages() + + common_scripts = migrator.analyze_scripts() + + # All packages have build, test, lint + assert "build" in common_scripts + assert "test" in common_scripts + assert "lint" in common_scripts + + # Check package counts + assert len(common_scripts["build"]) == 2 + assert len(common_scripts["test"]) == 2 + + def test_infer_build_outputs(self, mock_monorepo): + """Test build output inference.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + migrator.discover_packages() + + outputs = migrator._infer_build_outputs() + + # Should detect .next and dist directories + assert ".next/**" in outputs + assert "!.next/cache/**" in outputs + assert "dist/**" in outputs + + def test_generate_turbo_config(self, mock_monorepo): + """Test turbo.json generation.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + migrator.discover_packages() + + common_scripts = migrator.analyze_scripts() + turbo_config = migrator.generate_turbo_config(common_scripts) + + assert "$schema" in turbo_config + assert "pipeline" in turbo_config + + # Check build task + assert "build" in turbo_config["pipeline"] + assert turbo_config["pipeline"]["build"]["dependsOn"] == ["^build"] + assert "outputs" in turbo_config["pipeline"]["build"] + + # Check test task + assert "test" in turbo_config["pipeline"] + assert "coverage/**" in turbo_config["pipeline"]["test"]["outputs"] + + # Check lint task + assert "lint" in turbo_config["pipeline"] + + # Note: dev task won't be in pipeline because it's only in 1 package + # (needs to be in 2+ packages to be considered "common") + # This is correct behavior - only truly common scripts are included + + def test_update_root_package_json(self, mock_monorepo): + """Test root package.json update.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + migrator.discover_packages() + + updated_package_json = migrator.update_root_package_json() + + # Check turbo added to devDependencies + assert "turbo" in updated_package_json["devDependencies"] + assert updated_package_json["devDependencies"]["turbo"] == "latest" + + # Check scripts updated (only common scripts are added) + assert updated_package_json["scripts"]["build"] == "turbo run build" + assert updated_package_json["scripts"]["test"] == "turbo run test" + assert updated_package_json["scripts"]["lint"] == "turbo run lint" + # dev is only in one package, so it won't be added + + def test_generate_migration_report(self, mock_monorepo): + """Test migration report generation.""" + migrator = TurborepoMigrator(path=mock_monorepo) + migrator.analyze_workspace() + migrator.discover_packages() + + common_scripts = migrator.analyze_scripts() + turbo_config = migrator.generate_turbo_config(common_scripts) + updated_package_json = migrator.update_root_package_json() + + report = migrator.generate_migration_report(turbo_config, updated_package_json) + + assert "TURBOREPO MIGRATION REPORT" in report + assert "PACKAGES:" in report + assert "TURBO.JSON PIPELINE:" in report + assert "ROOT PACKAGE.JSON SCRIPTS:" in report + assert "RECOMMENDATIONS:" in report + + # Check package names appear + assert "web" in report + assert "@repo/ui" in report + + def test_write_files_dry_run(self, mock_monorepo, capsys): + """Test file writing in dry-run mode.""" + migrator = TurborepoMigrator(path=mock_monorepo, dry_run=True) + migrator.analyze_workspace() + migrator.discover_packages() + + common_scripts = migrator.analyze_scripts() + turbo_config = migrator.generate_turbo_config(common_scripts) + updated_package_json = migrator.update_root_package_json() + + migrator.write_files(turbo_config, updated_package_json) + + # Check files not created + assert not (mock_monorepo / "turbo.json").exists() + + # Check output + captured = capsys.readouterr() + assert "DRY RUN" in captured.out + + def test_write_files_actual(self, mock_monorepo): + """Test actual file writing.""" + migrator = TurborepoMigrator(path=mock_monorepo, dry_run=False) + migrator.analyze_workspace() + migrator.discover_packages() + + common_scripts = migrator.analyze_scripts() + turbo_config = migrator.generate_turbo_config(common_scripts) + updated_package_json = migrator.update_root_package_json() + + migrator.write_files(turbo_config, updated_package_json) + + # Check turbo.json created + assert (mock_monorepo / "turbo.json").exists() + + # Verify content + with open(mock_monorepo / "turbo.json") as f: + saved_config = json.load(f) + assert saved_config["$schema"] == turbo_config["$schema"] + assert "pipeline" in saved_config + + # Check package.json updated + with open(mock_monorepo / "package.json") as f: + saved_package = json.load(f) + assert "turbo" in saved_package["devDependencies"] + + def test_full_migration_dry_run(self, mock_monorepo): + """Test full migration process in dry-run mode.""" + migrator = TurborepoMigrator(path=mock_monorepo, dry_run=True) + migrator.migrate() + + # Files should not be created in dry-run + assert not (mock_monorepo / "turbo.json").exists() + + # Original package.json should be unchanged + with open(mock_monorepo / "package.json") as f: + package_json = json.load(f) + assert "turbo" not in package_json.get("devDependencies", {}) + + def test_full_migration_actual(self, mock_monorepo): + """Test full migration process.""" + migrator = TurborepoMigrator(path=mock_monorepo, dry_run=False) + migrator.migrate() + + # Check turbo.json created + assert (mock_monorepo / "turbo.json").exists() + + with open(mock_monorepo / "turbo.json") as f: + turbo_config = json.load(f) + assert "$schema" in turbo_config + assert "pipeline" in turbo_config + assert "build" in turbo_config["pipeline"] + + # Check package.json updated + with open(mock_monorepo / "package.json") as f: + package_json = json.load(f) + assert "turbo" in package_json["devDependencies"] + assert package_json["scripts"]["build"] == "turbo run build" + + def test_parse_pnpm_workspace(self, tmp_path): + """Test pnpm-workspace.yaml parsing.""" + yaml_content = """packages: + - 'apps/*' + - 'packages/*' + - 'tools/*' +""" + yaml_file = tmp_path / "pnpm-workspace.yaml" + yaml_file.write_text(yaml_content) + + migrator = TurborepoMigrator(path=tmp_path) + patterns = migrator._parse_pnpm_workspace(yaml_file) + + assert len(patterns) == 3 + assert "apps/*" in patterns + assert "packages/*" in patterns + assert "tools/*" in patterns + + def test_monorepo_without_workspaces(self, tmp_path): + """Test migration fails for non-workspace monorepo.""" + # Create package.json without workspaces + (tmp_path / "package.json").write_text(json.dumps({ + "name": "not-a-monorepo", + "version": "1.0.0" + })) + + migrator = TurborepoMigrator(path=tmp_path) + + # migrate() calls sys.exit(1) on error, so we catch SystemExit + with pytest.raises(SystemExit): + migrator.migrate() diff --git a/skills/web-frameworks/scripts/turborepo_migrate.py b/skills/web-frameworks/scripts/turborepo_migrate.py new file mode 100644 index 0000000..1b2937f --- /dev/null +++ b/skills/web-frameworks/scripts/turborepo_migrate.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +Turborepo Migration Script + +Convert existing monorepo to Turborepo with intelligent pipeline generation. +""" + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Dict, List, Optional, Set + + +class TurborepoMigrator: + """Migrate existing monorepo to Turborepo.""" + + def __init__( + self, + path: Path, + dry_run: bool = False, + package_manager: str = "npm", + ): + """ + Initialize TurborepoMigrator. + + Args: + path: Path to existing monorepo + dry_run: Preview changes without writing files + package_manager: Package manager (npm, yarn, pnpm, bun) + """ + self.path = path.resolve() + self.dry_run = dry_run + self.package_manager = package_manager + self.packages: List[Dict] = [] + self.workspace_config: Dict = {} + + def validate_path(self) -> None: + """Validate monorepo path.""" + if not self.path.exists(): + raise FileNotFoundError(f"Path '{self.path}' does not exist") + + if not self.path.is_dir(): + raise NotADirectoryError(f"Path '{self.path}' is not a directory") + + package_json = self.path / "package.json" + if not package_json.exists(): + raise FileNotFoundError( + f"No package.json found in '{self.path}'. Not a valid monorepo." + ) + + def analyze_workspace(self) -> None: + """Analyze existing workspace configuration.""" + print("Analyzing workspace...") + + package_json = self.path / "package.json" + with open(package_json) as f: + root_config = json.load(f) + + # Detect workspace configuration + if "workspaces" in root_config: + self.workspace_config = { + "type": "npm/yarn", + "patterns": root_config["workspaces"], + } + elif (self.path / "pnpm-workspace.yaml").exists(): + self.workspace_config = { + "type": "pnpm", + "file": "pnpm-workspace.yaml", + } + else: + raise ValueError( + "No workspace configuration found. Monorepo structure not detected." + ) + + print(f" Workspace type: {self.workspace_config['type']}") + + def discover_packages(self) -> None: + """Discover all packages in workspace.""" + print("Discovering packages...") + + if self.workspace_config["type"] == "npm/yarn": + patterns = self.workspace_config["patterns"] + if isinstance(patterns, dict): + patterns = patterns.get("packages", []) + else: + # Parse pnpm-workspace.yaml + yaml_file = self.path / "pnpm-workspace.yaml" + patterns = self._parse_pnpm_workspace(yaml_file) + + # Find all packages matching patterns + for pattern in patterns: + self._find_packages_by_pattern(pattern) + + print(f" Found {len(self.packages)} packages") + for pkg in self.packages: + print(f" - {pkg['name']} ({pkg['path'].relative_to(self.path)})") + + def _parse_pnpm_workspace(self, yaml_file: Path) -> List[str]: + """Parse pnpm-workspace.yaml file.""" + patterns = [] + with open(yaml_file) as f: + in_packages = False + for line in f: + line = line.strip() + if line.startswith("packages:"): + in_packages = True + continue + if in_packages and line.startswith("- "): + pattern = line[2:].strip().strip("'\"") + patterns.append(pattern) + return patterns + + def _find_packages_by_pattern(self, pattern: str) -> None: + """Find packages matching glob pattern.""" + import glob + + # Convert pattern to absolute path + search_pattern = str(self.path / pattern) + + for match in glob.glob(search_pattern): + match_path = Path(match) + package_json = match_path / "package.json" + + if package_json.exists(): + with open(package_json) as f: + pkg_data = json.load(f) + + self.packages.append( + { + "name": pkg_data.get("name", match_path.name), + "path": match_path, + "scripts": pkg_data.get("scripts", {}), + "dependencies": pkg_data.get("dependencies", {}), + "devDependencies": pkg_data.get("devDependencies", {}), + } + ) + + def analyze_scripts(self) -> Dict[str, Set[str]]: + """Analyze common scripts across packages.""" + print("Analyzing scripts...") + + script_map: Dict[str, Set[str]] = {} + + for pkg in self.packages: + for script_name in pkg["scripts"]: + if script_name not in script_map: + script_map[script_name] = set() + script_map[script_name].add(pkg["name"]) + + common_scripts = { + name: packages + for name, packages in script_map.items() + if len(packages) >= 2 # Present in at least 2 packages + } + + print(f" Found {len(common_scripts)} common scripts:") + for script, packages in common_scripts.items(): + print(f" - {script} ({len(packages)} packages)") + + return common_scripts + + def generate_turbo_config(self, common_scripts: Dict[str, Set[str]]) -> Dict: + """Generate turbo.json configuration.""" + print("Generating turbo.json configuration...") + + pipeline = {} + + # Build task + if "build" in common_scripts: + pipeline["build"] = { + "dependsOn": ["^build"], + "outputs": self._infer_build_outputs(), + } + + # Test task + if "test" in common_scripts: + pipeline["test"] = { + "dependsOn": ["build"], + "outputs": ["coverage/**"], + } + + # Lint task + if "lint" in common_scripts: + pipeline["lint"] = {"dependsOn": ["^build"]} + + # Typecheck task + if "typecheck" in common_scripts or "type-check" in common_scripts: + task_name = "typecheck" if "typecheck" in common_scripts else "type-check" + pipeline[task_name] = {"dependsOn": ["^build"]} + + # Dev task + if "dev" in common_scripts or "start" in common_scripts: + dev_task = "dev" if "dev" in common_scripts else "start" + pipeline[dev_task] = {"cache": False, "persistent": True} + + # Clean task + if "clean" in common_scripts: + pipeline["clean"] = {"cache": False} + + turbo_config = { + "$schema": "https://turbo.build/schema.json", + "globalDependencies": ["**/.env.*local"], + "pipeline": pipeline, + } + + return turbo_config + + def _infer_build_outputs(self) -> List[str]: + """Infer build output directories from packages.""" + outputs = set() + + for pkg in self.packages: + pkg_path = pkg["path"] + + # Check common output directories + if (pkg_path / "dist").exists(): + outputs.add("dist/**") + if (pkg_path / "build").exists(): + outputs.add("build/**") + if (pkg_path / ".next").exists(): + outputs.add(".next/**") + outputs.add("!.next/cache/**") + if (pkg_path / "out").exists(): + outputs.add("out/**") + + return sorted(list(outputs)) or ["dist/**"] + + def update_root_package_json(self) -> Dict: + """Update root package.json with Turborepo scripts.""" + print("Updating root package.json...") + + package_json_path = self.path / "package.json" + with open(package_json_path) as f: + package_json = json.load(f) + + # Add turbo to devDependencies + if "devDependencies" not in package_json: + package_json["devDependencies"] = {} + + package_json["devDependencies"]["turbo"] = "latest" + + # Update scripts to use turbo + if "scripts" not in package_json: + package_json["scripts"] = {} + + common_tasks = ["build", "dev", "test", "lint", "typecheck", "clean"] + for task in common_tasks: + # Check if task exists in any package + if any(task in pkg["scripts"] for pkg in self.packages): + package_json["scripts"][task] = f"turbo run {task}" + + return package_json + + def generate_migration_report( + self, turbo_config: Dict, updated_package_json: Dict + ) -> str: + """Generate migration report.""" + report = [] + + report.append("=" * 60) + report.append("TURBOREPO MIGRATION REPORT") + report.append("=" * 60) + report.append("") + + report.append(f"Monorepo Path: {self.path}") + report.append(f"Package Manager: {self.package_manager}") + report.append(f"Total Packages: {len(self.packages)}") + report.append("") + + report.append("PACKAGES:") + for pkg in self.packages: + rel_path = pkg["path"].relative_to(self.path) + report.append(f" - {pkg['name']} ({rel_path})") + report.append("") + + report.append("TURBO.JSON PIPELINE:") + for task, config in turbo_config["pipeline"].items(): + report.append(f" {task}:") + for key, value in config.items(): + report.append(f" {key}: {value}") + report.append("") + + report.append("ROOT PACKAGE.JSON SCRIPTS:") + for script, command in updated_package_json.get("scripts", {}).items(): + report.append(f" {script}: {command}") + report.append("") + + report.append("RECOMMENDATIONS:") + report.append(" 1. Review generated turbo.json pipeline configuration") + report.append(" 2. Adjust output directories based on your build tools") + report.append(" 3. Configure remote caching: turbo login && turbo link") + report.append(" 4. Run 'npm install' to install Turborepo") + report.append(" 5. Test with: turbo run build --dry-run") + report.append("") + + if self.dry_run: + report.append("DRY RUN MODE: No files were modified") + else: + report.append("FILES CREATED/MODIFIED:") + report.append(f" - {self.path / 'turbo.json'}") + report.append(f" - {self.path / 'package.json'}") + + report.append("") + report.append("=" * 60) + + return "\n".join(report) + + def write_files(self, turbo_config: Dict, updated_package_json: Dict) -> None: + """Write configuration files.""" + if self.dry_run: + print("\nDRY RUN - Files that would be created/modified:") + print(f" - {self.path / 'turbo.json'}") + print(f" - {self.path / 'package.json'}") + return + + print("Writing files...") + + # Write turbo.json + turbo_json_path = self.path / "turbo.json" + with open(turbo_json_path, "w") as f: + json.dump(turbo_config, f, indent=2) + print(f" ✓ Created {turbo_json_path}") + + # Write updated package.json + package_json_path = self.path / "package.json" + with open(package_json_path, "w") as f: + json.dump(updated_package_json, f, indent=2) + print(f" ✓ Updated {package_json_path}") + + def migrate(self) -> None: + """Run migration process.""" + try: + print(f"Migrating monorepo to Turborepo: {self.path}") + print(f"Dry run: {self.dry_run}") + print() + + self.validate_path() + self.analyze_workspace() + self.discover_packages() + + common_scripts = self.analyze_scripts() + turbo_config = self.generate_turbo_config(common_scripts) + updated_package_json = self.update_root_package_json() + + print() + self.write_files(turbo_config, updated_package_json) + + print() + report = self.generate_migration_report(turbo_config, updated_package_json) + print(report) + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + description="Migrate existing monorepo to Turborepo" + ) + parser.add_argument( + "--path", + type=Path, + default=Path.cwd(), + help="Path to monorepo (default: current directory)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview changes without writing files", + ) + parser.add_argument( + "--package-manager", + choices=["npm", "yarn", "pnpm", "bun"], + default="npm", + help="Package manager (default: npm)", + ) + + args = parser.parse_args() + + migrator = TurborepoMigrator( + path=args.path, + dry_run=args.dry_run, + package_manager=args.package_manager, + ) + + migrator.migrate() + + +if __name__ == "__main__": + main()