পাতা সাজানো হচ্ছে•••
বিজ্ঞাপনের স্থানহেডারের নিচে

Wednesday, October 21, 2026

WordPress Reading Time Shortcode Plugin: বাংলা পোস্টের আনুমানিক পড়ার সময়

WordPress Reading Time Shortcode Plugin: বাংলা পোস্টের আনুমানিক পড়ার সময়

পাঠককে একটি পোস্ট শেষ করতে আনুমানিক সময় জানাতে ছোট shortcode plugin যথেষ্ট। আমরা [reading_time] দিয়ে বর্তমান পোস্ট এবং [reading_time id="123"] দিয়ে নির্দিষ্ট পোস্ট গণনা করব। এটি staging-এ পরীক্ষা করুন, backup রাখুন, এবং মনে রাখুন: বাংলা ও মিশ্র ভাষায় ফলটি estimate, মাপা পাঠ-সময় নয়।

গণনার নিয়ম

Raw content থেকে আগে নিবন্ধিত shortcode সরাব, পরে HTML, script ও style tag বাদ দেব। তারপর Unicode-aware whitespace দিয়ে preg_split('/\s+/u', ...) করব। প্রতি মিনিটে ১৮০ শব্দ ধরে ceil(words / 180), ন্যূনতম ১ মিনিট। punctuation, সংখ্যা, emoji, code ও ছোট শব্দের কারণে বাস্তব সময় আলাদা হতে পারে।

ফাইল বানান

wp-content/plugins/reading-time-bangla/reading-time-bangla.php তৈরি করুন:

<?php
/**
 * Plugin Name: Reading Time Bangla
 * Description: Shows an estimated reading time with a shortcode.
 * Version: 1.0.0
 * Requires at least: 6.0
 * Requires PHP: 7.4
 * License: GPL-2.0-or-later
 */
defined( 'ABSPATH' ) || exit;

File header না থাকলে WordPress plugin চিনবে না। ABSPATH check সরাসরি URL দিয়ে ফাইল চালানো আটকায়।

Shortcode handler

shortcode_atts() optional ID গ্রহণ করে। ID না থাকলে get_the_ID() Loop-এর বর্তমান পোস্ট নির্বাচন করে।

function rt_bangla_shortcode( $atts = array() ) {
    $atts = shortcode_atts(
        array( 'id' => 0 ),
        (array) $atts,
        'reading_time'
    );
    $post_id = absint( $atts['id'] );
    if ( ! $post_id ) {
        $post_id = get_the_ID();
    }
    if ( ! $post_id ) {
        return '';
    }
    $post = get_post( $post_id );
    if ( ! $post instanceof WP_Post ) {
        return '';
    }
    $text = strip_shortcodes( $post->post_content );
    $text = wp_strip_all_tags( $text, true );
    $text = trim( $text );
    if ( '' === $text ) {
        $words = 0;
    } else {
        $parts = preg_split( '/\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
        $words = is_array( $parts ) ? count( $parts ) : 0;
    }
    $minutes = max( 1, (int) ceil( $words / 180 ) );
    return esc_html( sprintf( 'পড়তে আনুমানিক %d মিনিট', $minutes ) );
}
add_shortcode( 'reading_time', 'rt_bangla_shortcode' );

কেন ধাপগুলো নিরাপদ

strip_shortcodes() নিবন্ধিত shortcode tag সরায়। wp_strip_all_tags() HTML সরানোর পাশাপাশি script/style-এর content-ও বাদ দেয়। কাঁচা user markup output-এ ফেরত দিচ্ছি না এবং শেষ ফল esc_html()-এ escape করছি। তাই return plain text। Handler-এ ID দিলে সেই পোস্টের content গণনা হয়; Loop-এর বাইরে থাকলে explicit ID দিন। Private content প্রকাশের জন্য এটি ব্যবহার করবেন না; চাইলে পরের সংস্করণে get_post_status() যাচাই যোগ করুন।

ব্যবহার ও পরীক্ষা

Plugin সক্রিয় করে draft বা staging পোস্টে বসান:

[reading_time]
[reading_time id="42"]

খালি content-এ ফল ১ মিনিট থাকবে। বাংলা paragraph, English sentence, heading, link, উদ্ধৃতি এবং অন্য shortcode দিয়ে পরীক্ষা করুন। ১৮০ শব্দ/মিনিট স্থির heuristic; এটি screen-reader সময় বা নিশ্চিত পাঠ-সময় নয়।

রক্ষণাবেক্ষণ

Label অনুবাদযোগ্য করতে __() ও text domain যোগ করা যায়, তবে অনুবাদও escape করুন। বড় সাইটে cache করা সম্ভব, কিন্তু প্রথম সংস্করণে সরলতা ও কম ভুল বেশি গুরুত্বপূর্ণ। Function ও shortcode prefix অনন্য রাখুন, কারণ অন্য plugin একই tag নিবন্ধন করলে আচরণ বদলাতে পারে। Live-এ নেওয়ার আগে PHP lint, staging test, rollback copy এবং backup রাখুন। Core/plugin update-এর পর UTF-8 বাংলা, empty content, invalid ID ও private post আবার পরীক্ষা করুন।

সীমাবদ্ধতা বুঝে ব্যবহার

Unicode regex-এর u flag গুরুত্বপূর্ণ; সেটি ছাড়া UTF-8 text-এ whitespace আচরণ অপ্রত্যাশিত হতে পারে। PREG_SPLIT_NO_EMPTY একাধিক space বা line break থেকে খালি item বাদ দেয়। এটি ভাষাগত tokenization নয়—বাংলা শব্দের সঙ্গে punctuation লেগে থাকলে একটি token-ই ধরা হবে। ফলে এই shortcode analytics বা billing-এর জন্য ব্যবহার করবেন না। এটি পাঠককে দ্রুত একটি ধারণা দেওয়ার UI সহায়ক।

Shortcode-এর নাম prefix করা যায়, কিন্তু এখানে public ব্যবহার সহজ রাখতে reading_time রাখা হয়েছে। একই নামের conflict এড়াতে site-এ সক্রিয় plugin তালিকা দেখুন। Error হলে প্রথমে shortcode spelling, plugin activation, PHP syntax এবং post ID পরীক্ষা করুন। একটি child theme-এ code কপি না করে plugin হিসেবেই রাখুন, যাতে theme বদলালেও reading-time feature থাকে।

Security হিসেবে ID দিয়ে অন্য post পড়ার সম্ভাবনাও ভাবুন। সাধারণ published post-এর জন্য এটি ঠিক, কিন্তু private, pending বা draft content display context-এর বাইরে আনবেন না। প্রয়োজন হলে current_user_can() এবং status check যোগ করুন। Production deployment-এ least privilege, backup, staging এবং rollback ধাপ লিখে রাখলে ছোট plugin-ও রক্ষণাবেক্ষণযোগ্য থাকে।

Testing checklist-এ কয়েকটি edge case রাখুন: ID হিসাবে অক্ষর, শূন্য, deleted post, password-protected post এবং non-post object। absint() ভুল input-কে নিরাপদ integer-এ নামায়, কিন্তু authorization-এর বিকল্প নয়। তাই নির্দিষ্ট ID নিয়ে ফল দেখানোর নীতি সাইটের content visibility-এর সঙ্গে মিলিয়ে নিন। একই সঙ্গে UTF-8 database, Bengali punctuation এবং line break সহ একটি fixture রাখুন।

Output-এ HTML না ফেরানোর সিদ্ধান্ত ইচ্ছাকৃত: theme অনুযায়ী wrapper চাইলে পরে <span> যোগ করে attribute escape করতে হবে। এখন plain text হওয়ায় CSS dependency নেই এবং post editor-এর ভিতরে ব্যবহার সহজ। Version bump ও changelog লিখলে ভবিষ্যতে ১৮০-এর বদলে configurable speed যোগ করাও পরিষ্কার থাকবে।

Official sources

WordPress-এ শূন্য থেকে Minimal Block Theme: theme.json v3, Header ও Footer

WordPress-এ শূন্য থেকে Minimal Block Theme: theme.json v3, Header ও Footer

ব্লক থিম বানাতে জটিল PHP বা বিল্ড টুল আবশ্যক নয়। ফাইল, ব্লক মার্কআপ এবং theme.json দিয়েই একটি ছোট, সম্পাদনাযোগ্য থিম তৈরি করা যায়। এই গাইডের baseline WordPress 6.6+; version 3 schema-কে 6.6-এর সমর্থন হিসেবে ধরা হয়েছে, সর্বশেষ সংস্করণের দাবি হিসেবে নয়। কাজের আগে staging সাইট ও ব্যাকআপ রাখুন।

ফোল্ডার কাঠামো

wp-content/themes/minimal-bangla/-এ নিচের ফাইল রাখুন। templates/index.html থাকাই একটি block theme শনাক্তের ন্যূনতম শর্ত।

minimal-bangla/
├── style.css
├── theme.json
├── templates/index.html
└── parts/{header.html,footer.html}

style.css পরিচয়

সব CSS এখানে না রেখেও থিম তালিকায় পরিচয় দেখাতে header লিখুন:

/*
Theme Name: Minimal Bangla
Author: Your Name
Description: A small learning block theme.
Version: 1.0.0
Text Domain: minimal-bangla
*/

রঙ, spacing ও typography-এর মূল নিয়ন্ত্রণ theme.json-এ রাখলে Site Editor-এ পরিবর্তন করা যায়।

theme.json v3

Official reference অনুযায়ী version 3 WordPress 6.6 বা পরে কাজ করে। নিচে ছোট, পরিচিত সেটিং এবং দুইটি নিবন্ধিত template part আছে:

{
  "$schema": "https://schemas.wp.org/wp/6.6/theme.json",
  "version": 3,
  "settings": {
    "appearanceTools": true,
    "layout": { "contentSize": "720px", "wideSize": "1100px" },
    "color": { "palette": [
      { "slug": "ink", "color": "#202124", "name": "Ink" },
      { "slug": "paper", "color": "#fffdf8", "name": "Paper" },
      { "slug": "accent", "color": "#146c5b", "name": "Accent" }
    ] },
    "typography": { "dropCap": false }
  },
  "styles": {
    "color": { "background": "var(--wp--preset--color--paper)", "text": "var(--wp--preset--color--ink)" },
    "spacing": { "blockGap": "1.4rem" },
    "typography": { "lineHeight": "1.7" }
  },
  "templateParts": [
    { "name": "header", "title": "Header", "area": "header" },
    { "name": "footer", "title": "Footer", "area": "footer" }
  ]
}

$schema editor validation-এ সাহায্য করে। 6.6-এর নিচে version 3 বৈশিষ্ট্য পরীক্ষিত নাও হতে পারে; তাই production-এর আগে target WordPress-এই যাচাই করুন।

Header ও Footer parts

parts/header.html-এ কেবল block markup রাখুন:

<!-- wp:group {"align":"wide","layout":{"type":"flex","justifyContent":"space-between"}} -->
<div class="wp-block-group alignwide">
<!-- wp:site-title /-->
<!-- wp:navigation /-->
</div>
<!-- /wp:group -->

parts/footer.html এমন হতে পারে:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- wp:paragraph {"align":"center"} -->
<p class="has-text-align-center">সাদামাটা ও সম্পাদনাযোগ্য।</p>
<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->

templates/index.html

Parts নিজে থেকে লোড হয় না; slug-সহ Template Part block লাগবে। Query Loop-এ পোস্টের title, excerpt ও pagination রাখুন:

<!-- wp:template-part {"slug":"header","tagName":"header"} /-->
<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
<!-- wp:query {"query":{"perPage":10,"inherit":true}} -->
<div class="wp-block-query">
<!-- wp:post-template -->
<!-- wp:post-title {"isLink":true} /-->
<!-- wp:post-excerpt /-->
<!-- /wp:post-template -->
<!-- wp:query-pagination /-->
</div>
<!-- /wp:query -->
</main>
<!-- /wp:group -->
<!-- wp:template-part {"slug":"footer","tagName":"footer"} /-->

সক্রিয় ও যাচাই

Appearance → Themes থেকে থিম সক্রিয় করুন, তারপর Appearance → Editor-এ template খুলুন। Home, মোবাইল প্রস্থ, pagination এবং keyboard navigation পরীক্ষা করুন। Editor-এ পুরনো নকশা দেখালে database-এ saved template আপনার ফাইলকে override করতে পারে। staging-এ saved version সরিয়ে বা পুনরায় কপি করে যাচাই করুন। পরে single.html, page.html ও pattern যোগ করা যাবে। contrast, heading order, backup এবং rollback কপি অবশ্যই পরীক্ষা করুন।

সাধারণ ভুল ও সমাধান

JSON-এ comma বাদ গেলে থিম নাও লোড হতে পারে; তাই একটি JSON validator বা editor schema ব্যবহার করুন। Block comment-এর opening ও closing অংশ জোড়া না থাকলে Editor markup অস্বাভাবিক দেখাতে পারে। Query Loop-এ inherit সত্য রাখলে বর্তমান archive query অনুসরণ করে; home page-এ ফল না এলে Reading Settings, published post এবং permalink পরীক্ষা করুন। Navigation block-এ menu না থাকলে Site Editor থেকে menu নির্বাচন করুন—এটি কোডের ত্রুটি নয়।

একসঙ্গে theme.json ও Editor-এর saved style বদলালে কোনটি কার্যকর হচ্ছে বুঝতে অসুবিধা হয়। প্রথমে ফাইলের baseline রাখুন, তারপর Editor-এ একবারে একটি পরিবর্তন করুন। Git বা অন্তত তারিখ-সহ zip backup রাখলে rollback সহজ। Accessibility-এর জন্য শুধু রঙের উপর নির্ভর করবেন না, focus state এবং heading-এর ক্রম দেখুন। ছোট থিমের উদ্দেশ্য দ্রুত শুরু করা; production design-এ typography, language attribute, responsive spacing এবং error template আলাদা করে যোগ করুন।

আরও একটি বাস্তব পরামর্শ হলো, প্রথমে কেবল এই ন্যূনতম ফাইল দিয়ে থিম চালু করে browser inspector দেখুন। তারপর একবারে একটি color বা spacing পরিবর্তন করুন। এতে theme.json-এর ভুল এবং block markup-এর ভুল আলাদা করে ধরা যায়। Child theme বা plugin-এর style যদি একই selector বদলায়, computed style দেখে উৎস শনাক্ত করুন। কোনো third-party block যোগ করলে তার fallback output ও translation-ও পরীক্ষা করুন।

থিম বিতরণের আগে text domain, license এবং readme যোগ করা ভালো। WordPress 6.6 baseline লিখে রাখুন, কারণ ব্যবহারকারী পুরনো core চালালে version 3 না-ও বুঝতে পারে। Template-এর নাম WordPress hierarchy-র সঙ্গে সামঞ্জস্যপূর্ণ রাখুন এবং database-এ saved customization ফাইলের সঙ্গে আলাদা—এই বিষয়টি changelog-এ জানান।

এই ধাপে পাওয়া কাঠামোটি ইচ্ছাকৃতভাবে ছোট, তাই নতুন বৈশিষ্ট্য যোগ করার আগে baseline কপি করে রাখুন এবং প্রতিটি পরিবর্তন staging-এ যাচাই করুন।

Official sources

Portfolio Custom Post Type Plugin: WordPress-এ Work Archive বানান

Portfolio Custom Post Type Plugin: WordPress-এ Work Archive বানান

Portfolio item যদি সাধারণ post-এর সঙ্গে মিশে যায়, admin-এ কাজ গোছানো থাকে না এবং URL-ও অর্থপূর্ণ হয় না। Custom Post Type (CPT) আলাদা content model দেয়। এই plugin-এ portfolio type, REST/block editor support, archive এবং public URL /work/ বানাব। এটি metadata বা custom fields নয়—সেগুলি পরে আলাদা করে যোগ করা যায়। পরিবর্তনের আগে staging clone ও backup নিন।

Plugin ফাইল

wp-content/plugins/portfolio-content-type/portfolio-content-type.php তৈরি করে নিচের কোড রাখুন। Main plugin file-এই activation hook দেওয়া হয়েছে, যা গুরুত্বপূর্ণ।

<?php
/**
 * Plugin Name: Portfolio Content Type
 * Description: Adds a Portfolio custom post type.
 * Version: 1.0.0
 */
if ( ! defined( 'ABSPATH' ) ) { exit; }

function pct_register_portfolio() {
    register_post_type( 'portfolio', array(
        'labels' => array(
            'name' => __( 'Portfolios', 'pct' ),
            'singular_name' => __( 'Portfolio', 'pct' ),
            'add_new_item' => __( 'Add Portfolio', 'pct' ),
        ),
        'public' => true,
        'show_in_rest' => true,
        'has_archive' => true,
        'rewrite' => array( 'slug' => 'work', 'with_front' => false ),
        'supports' => array( 'title', 'editor', 'thumbnail', 'excerpt' ),
        'menu_icon' => 'dashicons-portfolio',
    ) );
}
add_action( 'init', 'pct_register_portfolio' );

function pct_activate() {
    pct_register_portfolio();
    flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'pct_activate' );

function pct_deactivate() {
    flush_rewrite_rules();
}
register_deactivation_hook( __FILE__, 'pct_deactivate' );

কেন init এবং show_in_rest?

WordPress-এর init action-এর আগে post type register করা উচিত নয়। তাই callback-টি init-এ যুক্ত করেছি। show_in_rest => true দিলে type REST API-তে আসে এবং block editor ব্যবহার করতে পারে। public ও has_archive front end query এবং archive তৈরি করে। rewrite-এর slug work, তাই একক item-এর URL সাধারণত /work/project-name/ হবে। Post type key ২০ অক্ষরের মধ্যে lowercase রাখা হয়েছে।

Activation flush কেন একবার?

নতুন rewrite structure WordPress-এর permalink rules-এ সঙ্গে সঙ্গে নাও বসতে পারে; ফলে সঠিক post-এ 404 দেখা যায়। Activation routine আগে registration চালিয়ে তারপর flush_rewrite_rules() করে। এই flush প্রত্যেক request-এ করা উচিত নয়—তা অপ্রয়োজনীয় database কাজ তৈরি করে। Deactivation-এ flush রাখা হয়েছে যাতে plugin বন্ধের পর rules পরিষ্কার হয়। Plugin activate করার পর Settings → Permalinks-এ গিয়ে Save করাও manual fallback, কিন্তু code-এর one-time activation যথেষ্ট হওয়া উচিত।

চালানোর ধাপ

Plugins থেকে Portfolio Content Type activate করুন। Admin menu-তে Portfolios আসবে। একটি item-এ title, editor, featured image ও excerpt দিন। তারপর /work/ archive এবং single URL খুলুন। 404 হলে একবার permalink Save করুন এবং permalink structure পরীক্ষা করুন। Block editor না এলে show_in_rest বানানটি ও plugin activation যাচাই করুন।

নিরাপত্তা ও পরের ধাপ

এখানে user input echo করা হয়নি, কারণ WordPress core UI ও templates তা সামলায়। পরে custom template লিখলে title, excerpt, image URL এবং metadata output-এর সময় escape করবেন। taxonomy দরকার হলে register_taxonomy() অথবা register call-এর taxonomies যুক্ত করুন। Capability, custom fields এবং REST exposure বদলালে editorial role ও privacy requirements আগে নির্ধারণ করুন। Uninstall hook-এ data মুছবেন কেবল স্পষ্ট সিদ্ধান্ত থাকলে; deactivation-এর কাজ data delete নয়।

এই ছোট plugin production-ready architecture-এর ভিত্তি, সম্পূর্ণ portfolio design নয়। CSS, archive template এবং structured data আপনার brand ও accessibility পরীক্ষা অনুযায়ী যোগ করুন।

ডেটা মডেল নিয়ে ভাবুন

CPT-এর নাম ভবিষ্যতে বদলানো কঠিন, তাই singular key ছোট ও স্থির রাখুন। Label মানুষের জন্য, key code-এর জন্য—দুটিকে আলাদা ভাবুন। Archive লাগবে না হলে has_archive false করতে পারেন; কিন্তু portfolio collection-এর ক্ষেত্রে archive useful। with_front false দিলে site-এর blog prefix থাকলেও work URL পরিষ্কার থাকে।

REST exposure মানে editor integration এবং API response-এর সুবিধা; এটি private data প্রকাশের অনুমতি নয়। public content-এর ক্ষেত্রেই ব্যবহার করুন। নির্দিষ্ট field REST-এ দিলে permission ও sanitization callback ঠিক করুন। Featured image ব্যবহার করলে theme বা archive template-এ image-এর alt text সঠিকভাবে রক্ষিত হচ্ছে কি না পরীক্ষা করুন।

Rewrite flush ব্যয়বহুল হওয়ায় activation এবং deactivation-এ সীমাবদ্ধ রাখুন। Code update-এ নতুন slug আনলে user-কে deactivate/activate বা permalinks save করার নির্দেশ দিতে পারেন। Activation failure হলে PHP syntax, file path এবং plugin header যাচাই করুন। বাস্তব site-এ migration-এর আগে URL redirect পরিকল্পনা করুন, যাতে পুরোনো portfolio link হারিয়ে না যায়।

ছোট কিন্তু জরুরি যাচাই

কোড বসানোর পরে শুধু editor-এ সবুজ দেখালেই শেষ নয়। আলাদা browser-এ logged-out অবস্থায় page খুলুন, keyboard দিয়ে link ও control চালান, এবং mobile width-এ horizontal scroll হচ্ছে কি না দেখুন। PHP ও WordPress version-এর compatibility নোট করুন। staging-এ error log দেখে warning থাকলে সমাধান করুন; live সাইটে অযথা debug output দেখাবেন না। Backup ফেরত আনার পদ্ধতিও আগে পরীক্ষা করে রাখুন। কোনও third-party plugin একই hook বা class ব্যবহার করছে কি না দেখলে prefix বদলান। ছোট পরিবর্তন একবারে deploy করলে সমস্যা হলে কোন অংশ rollback করতে হবে তা স্পষ্ট থাকে। এই নিয়মগুলি theme, shortcode এবং CPT—তিন ক্ষেত্রেই প্রযোজ্য।

নোট লিখে রাখুন কোন file কোথায় রাখা হয়েছে এবং কোন shortcode বা URL পরীক্ষা করা হয়েছে। এতে ভবিষ্যতের update-এ ভুল কমে এবং অন্য developer দ্রুত দায়িত্ব নিতে পারেন। এই অভ্যাসটি রাখুন।

আরও পড়ুন

সতর্কতা: লাইভ সাইটে বদলানোর আগে staging কপি, পূর্ণ backup এবং rollback পরিকল্পনা রাখুন। কোডটি নিজের WordPress/PHP সংস্করণে পরীক্ষা করুন।

নিরাপদ Accessible Notice Box-এর জন্য Custom WordPress Shortcode Plugin

নিরাপদ Accessible Notice Box-এর জন্য Custom WordPress Shortcode Plugin

বারবার সতর্কতা, টিপস বা সাফল্যের বার্তা লিখতে HTML কপি-পেস্ট করা বিরক্তিকর। একটি ছোট shortcode plugin এই কাজকে সম্পাদকের জন্য সহজ করে, আবার theme বদলালেও কাজটি রেখে দেয়। এই উদাহরণে [bns_notice] tag, তিনটি সীমিত type এবং accessible aside ব্যবহার করব। শুরুতেই staging-এ plugin চালান; production-এ নেওয়ার আগে backup রাখুন।

কেন plugin, theme নয়?

Shortcode হলো content-এর ছোট placeholder, যা render-এর সময় callback-এ যায়। Theme-এর functions.php-তে রাখলে theme বদলালে হারাতে পারে; plugin-এ রাখলে content portability ভালো থাকে। Tag-এ নিজের prefix ব্যবহার করুন, কারণ একই নামের shortcode হলে পরে load হওয়া callback আগেরটিকে override করতে পারে।

ফাইল ও কোড

wp-content/plugins/bengali-notice-shortcode/bengali-notice-shortcode.php বানিয়ে এই কোড বসান:

<?php
/**
 * Plugin Name: Bengali Notice Shortcode
 * Description: Accessible notice box shortcode.
 * Version: 1.0.0
 */
if ( ! defined( 'ABSPATH' ) ) { exit; }

function bns_notice_shortcode( $atts, $content = null ) {
    $atts = shortcode_atts(
        array( 'type' => 'info', 'title' => '' ),
        $atts,
        'bns_notice'
    );
    $allowed = array( 'info', 'success', 'warning' );
    $type = in_array( $atts['type'], $allowed, true ) ? $atts['type'] : 'info';
    $title = sanitize_text_field( $atts['title'] );
    $body  = wp_kses_post( do_shortcode( $content ?? '' ) );
    $label = $title ? '<strong class="bns-title">' . esc_html( $title ) . '</strong>' : '';
    return '<aside class="bns-notice bns-' . esc_attr( $type ) . '" role="status">' . $label . '<div>' . $body . '</div></aside>';
}
add_shortcode( 'bns_notice', 'bns_notice_shortcode' );

কোডটি কীভাবে নিরাপদ?

shortcode_atts() শুধু অনুমোদিত type ও title রাখে এবং default পূরণ করে। type-কে allow-list দিয়ে মিলিয়েছি; অজানা value হলে info হবে। title plain text, তাই sanitize_text_field() দিয়ে পরিষ্কার করে output-এ esc_html() করেছি। class attribute-এ esc_attr() আছে। ভেতরের content-এ সাধারণ formatting দরকার বলে wp_kses_post() ব্যবহার করেছি—এটি arbitrary script ঢুকতে দেয় না। Shortcode callback-এ echo নয়, string return করা উচিত।

ব্যবহার

[bns_notice type="warning" title="ব্যাকআপ নিন"]লাইভ সাইটে পরিবর্তনের আগে staging পরীক্ষা করুন।[/bns_notice]

[bns_notice type="success" title="সম্পন্ন"]ফাইলটি আপলোড হয়েছে।[/bns_notice]

[bns_notice]শুধু তথ্যের বার্তা।[/bns_notice]

Plugin activate করে একটি post-এ shortcode দিন। Output দেখে keyboard focus, contrast এবং screen reader-এ অর্থপূর্ণ ঘোষণা হচ্ছে কি না পরীক্ষা করুন। role="status" সাধারণ status বার্তার জন্য উপযোগী; জরুরি বিপদ হলে আলাদা আচরণ দরকার, তাই নির্বিচারে alert ব্যবহার করবেন না। CSS না থাকলেও semantic aside পাঠযোগ্য থাকবে। চাইলে plugin-এ enqueue করা ছোট stylesheet যোগ করুন—inline style দিয়ে content ভারী করবেন না।

সীমা ও রক্ষণাবেক্ষণ

এই shortcode title-এ markup ইচ্ছাকৃতভাবে নিষিদ্ধ। body-তে allowed HTML রাখা হয়েছে, তবুও untrusted shortcode nesting বা third-party filter থাকলে staging-এ পরীক্ষা জরুরি। Function prefix করা হয়েছে যাতে global namespace সংঘর্ষ কমে। ভবিষ্যতে নতুন type যোগ করলে allow-list, CSS এবং documentation একসঙ্গে বদলান।

সম্পাদকের জন্য ছোট নিয়ম

একটি notice-এ একটাই উদ্দেশ্য রাখুন। title সংক্ষিপ্ত, body-তে সরাসরি নির্দেশনা এবং প্রয়োজন হলে একটি descriptive link দিন। একই post-এ অনেক nested notice দিলে screen reader-এর reading flow ভারী হতে পারে। তাই editorial guideline-এ type-এর অর্থ লিখে দিন: info সাধারণ তথ্য, success সম্পন্ন কাজ, warning সতর্কতা।

CSS যোগ করার সময় শুধু রঙের উপর নির্ভর করবেন না; border, icon-এর accessible label বা heading-ও ব্যবহার করুন। contrast checker দিয়ে foreground ও background পরীক্ষা করুন। HTML output পরিবর্তন করলে view-source এবং keyboard navigation দুটোই যাচাই করুন। Shortcode content-এ user-generated text থাকলে allowed markup সীমিত রাখাই নিরাপদ।

Plugin update করার আগে staging-এ পুরোনো post render হচ্ছে কি না দেখুন। shortcode tag rename করলে পুরোনো content ভেঙে যাবে, তাই দরকার হলে পুরোনো tag-এর compatibility alias রাখুন। Function name-এ prefix এবং unique CSS class ব্যবহার করলে অন্য plugin-এর সঙ্গে সংঘর্ষের সম্ভাবনা কমে।

ছোট কিন্তু জরুরি যাচাই

কোড বসানোর পরে শুধু editor-এ সবুজ দেখালেই শেষ নয়। আলাদা browser-এ logged-out অবস্থায় page খুলুন, keyboard দিয়ে link ও control চালান, এবং mobile width-এ horizontal scroll হচ্ছে কি না দেখুন। PHP ও WordPress version-এর compatibility নোট করুন। staging-এ error log দেখে warning থাকলে সমাধান করুন; live সাইটে অযথা debug output দেখাবেন না। Backup ফেরত আনার পদ্ধতিও আগে পরীক্ষা করে রাখুন। কোনও third-party plugin একই hook বা class ব্যবহার করছে কি না দেখলে prefix বদলান। ছোট পরিবর্তন একবারে deploy করলে সমস্যা হলে কোন অংশ rollback করতে হবে তা স্পষ্ট থাকে। এই নিয়মগুলি theme, shortcode এবং CPT—তিন ক্ষেত্রেই প্রযোজ্য।

নোট লিখে রাখুন কোন file কোথায় রাখা হয়েছে এবং কোন shortcode বা URL পরীক্ষা করা হয়েছে। এতে ভবিষ্যতের update-এ ভুল কমে এবং অন্য developer দ্রুত দায়িত্ব নিতে পারেন। এই অভ্যাসটি রাখুন।

আরও পড়ুন

সতর্কতা: লাইভ সাইটে বদলানোর আগে staging কপি, পূর্ণ backup এবং rollback পরিকল্পনা রাখুন। কোডটি নিজের WordPress/PHP সংস্করণে পরীক্ষা করুন।

শূন্য থেকে Minimal Classic WordPress Theme বানানোর সহজ গাইড

শূন্য থেকে Minimal Classic WordPress Theme বানানোর সহজ গাইড

নিজের হাতে একটি classic WordPress theme বানানো শেখার সবচেয়ে ভালো উপায় হলো ছোট রাখা। এখানে আমরা block theme নয়, template-ভিত্তিক একটি ন্যূনতম theme বানাব। লক্ষ্য সুন্দর ডিজাইন নয়; বরং WordPress কীভাবে header, loop, asset এবং footer জোড়া দেয় তা বোঝা। কাজের আগে staging সাইটে পরীক্ষা করুন এবং বর্তমান theme-এর backup রাখুন।

ফোল্ডার তৈরি

wp-content/themes/shobuj-minimal/ নামে ফোল্ডার বানান। Classic theme চিনতে WordPress-এর জন্য style.css দরকার; শিরোনামের comment-এ অন্তত Theme Name থাকতে হবে। পাশাপাশি functions.php, index.php, header.php এবং footer.php রাখব।

style.css: পরিচয় এবং নকশা

ফাইলের প্রথম comment-এ theme metadata দিন। নিচের CSS ইচ্ছাকৃতভাবে সাধারণ; পরে নিজের রং ও layout যোগ করতে পারবেন।

/**
 * Theme Name: Shobuj Minimal
 * Description: A tiny classic theme for learning.
 * Version: 1.0
 * Author: Your Name
 * Text Domain: shobuj-minimal
 */

body { margin: 0; font: 16px/1.6 system-ui, sans-serif; color: #222; }
.site-header, .site-footer { padding: 1rem; background: #173b2a; color: #fff; }
.site-main { max-width: 760px; margin: 2rem auto; padding: 0 1rem; }
a { color: #075e45; }

functions.php: asset ও feature

CSS সরাসরি template-এ বসানোর বদলে enqueue করা WordPress-এর স্বাভাবিক পদ্ধতি। after_setup_theme-এ title tag, featured image এবং menu support যোগ করছি।

<?php
/** Load the theme stylesheet. */
function shobuj_minimal_assets() {
    wp_enqueue_style(
        'shobuj-minimal',
        get_stylesheet_uri(),
        array(),
        wp_get_theme()->get('Version')
    );
}
add_action('wp_enqueue_scripts', 'shobuj_minimal_assets');

function shobuj_minimal_setup() {
    add_theme_support('title-tag');
    add_theme_support('post-thumbnails');
    register_nav_menus(array('primary' => __('Primary Menu', 'shobuj-minimal')));
}
add_action('after_setup_theme', 'shobuj_minimal_setup');

header.php: hook ভুলবেন না

language_attributes() ও charset accessibility এবং আন্তর্জাতিকীকরণে সহায়ক। wp_head() closing head-এর আগে, আর opening body-র পরেই wp_body_open() রাখুন। Plugin-এর scripts, styles, analytics বা accessibility tooling এই hook ব্যবহার করতে পারে।

<!doctype html>
<html <?php language_attributes(); ?>>
<head>
    <meta charset="<?php bloginfo('charset'); ?>">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<header class="site-header"><a href="<?php echo esc_url(home_url('/')); ?>"><?php bloginfo('name'); ?></a></header>

index.php: নিরাপদ Loop

Loop-এ have_posts() দিয়ে ফল আছে কি না দেখা হয়, তারপর the_post() global post প্রস্তুত করে। শিরোনামের URL-এ esc_url(), তারিখে esc_html() ব্যবহার করেছি। Content দেখানোর জন্য the_excerpt() ব্যবহার করলে WordPress-এর তৈরি excerpt যথাযথ থাকে।

<?php get_header(); ?>

<main id="primary" class="site-main">
    <?php if (have_posts()) : ?>
        <?php while (have_posts()) : the_post(); ?>
            <article id="post-<?php the_ID(); ?>" <?php post_class(); ?>>
                <h2><a href="<?php echo esc_url(get_permalink()); ?>"><?php the_title(); ?></a></h2>
                <p><?php echo esc_html(get_the_date()); ?></p>
                <?php the_excerpt(); ?>
            </article>
        <?php endwhile; ?>
        <?php the_posts_pagination(); ?>
    <?php else : ?>
        <p><?php esc_html_e('কোনও লেখা পাওয়া যায়নি।', 'shobuj-minimal'); ?></p>
    <?php endif; ?>
</main>

<?php get_footer(); ?>

footer.php এবং পরীক্ষা

footer-এর closing body-এর ঠিক আগে wp_footer() থাকা আবশ্যক।

<footer class="site-footer"><p><?php echo esc_html(get_bloginfo('name')); ?></p></footer>
<?php wp_footer(); ?>
</body>
</html>

এখন Appearance থেকে theme activate করুন। Home page, কোনও post, search, empty result এবং mobile width দেখুন। Menu না দেখালেও প্রথম ধাপে সমস্যা নয়; পরের ধাপে wp_nav_menu() বসাতে পারবেন। PHP syntax error হলে staging-এর error log দেখুন, live site-এ অন্ধভাবে paste করবেন না।

দৈনন্দিন workflow

প্রথমে requirements লিখে নিন: site title কোথায়, navigation কতটি, post excerpt কতটা এবং typography কী হবে। তারপর HTML structure স্থির করে CSS দিন। Template-এ business logic না রেখে ছোট helper functions রাখলে theme maintain করা সহজ হয়। WordPress-এর template hierarchy পরে শিখলে home, single এবং archive আলাদা ফাইল করতে পারবেন; শুরুতে index.php fallback হিসেবে যথেষ্ট।

Loop-এর ভেতরে বড় raw HTML বা user-provided attribute সরাসরি বসাবেন না। URL, class, text এবং HTML-এর জন্য প্রসঙ্গভেদে আলাদা escaping দরকার। এই অভ্যাস ভবিষ্যতের theme-এ নিরাপত্তা সমস্যা কমায়। Accessibility-এর জন্য heading ক্রম ঠিক রাখুন, link text অর্থপূর্ণ করুন এবং keyboard-এ menu চালানো যায় কি না দেখুন।

শেষে একটি ছোট checklist রাখুন: theme activate, front page, search, pagination, 404, logged-out visitor এবং mobile viewport। PHP warning দেখা গেলে debug log-এ কারণ খুঁজুন, কিন্তু debug display live-এ চালু রাখবেন না। এই minimal theme ইচ্ছাকৃতভাবে comments, sidebar বা customizer যোগ করেনি—সেগুলি আলাদা feature হিসেবে একবারে একটিই যোগ করুন।

ছোট কিন্তু জরুরি যাচাই

কোড বসানোর পরে শুধু editor-এ সবুজ দেখালেই শেষ নয়। আলাদা browser-এ logged-out অবস্থায় page খুলুন, keyboard দিয়ে link ও control চালান, এবং mobile width-এ horizontal scroll হচ্ছে কি না দেখুন। PHP ও WordPress version-এর compatibility নোট করুন। staging-এ error log দেখে warning থাকলে সমাধান করুন; live সাইটে অযথা debug output দেখাবেন না। Backup ফেরত আনার পদ্ধতিও আগে পরীক্ষা করে রাখুন। কোনও third-party plugin একই hook বা class ব্যবহার করছে কি না দেখলে prefix বদলান। ছোট পরিবর্তন একবারে deploy করলে সমস্যা হলে কোন অংশ rollback করতে হবে তা স্পষ্ট থাকে। এই নিয়মগুলি theme, shortcode এবং CPT—তিন ক্ষেত্রেই প্রযোজ্য।

নোট লিখে রাখুন কোন file কোথায় রাখা হয়েছে এবং কোন shortcode বা URL পরীক্ষা করা হয়েছে। এতে ভবিষ্যতের update-এ ভুল কমে এবং অন্য developer দ্রুত দায়িত্ব নিতে পারেন। এই অভ্যাসটি রাখুন।

আরও পড়ুন

সতর্কতা: লাইভ সাইটে বদলানোর আগে staging কপি, পূর্ণ backup এবং rollback পরিকল্পনা রাখুন। কোডটি নিজের WordPress/PHP সংস্করণে পরীক্ষা করুন।

Tuesday, October 20, 2026

JavaScript Fetch API দিয়ে Local Product Catalog App বানানো

Fetch API ও JSON—পণ্যের কার্ড ও ডেটা আদানপ্রদানের মৌলিক চিত্র

Python SQLite দিয়ে ব্যক্তিগত বই লাইব্রেরি CLI: add, list ও search

Python SQLite লাইব্রেরি—বই, ডেটাবেস ও ল্যাপটপের মৌলিক চিত্র

HTML ও CSS দিয়ে Contact Form: JavaScript ভ্যালিডেশনসহ বাংলা গাইড

HTML ও CSS ফর্ম—ইনপুট ও যাচাই চিহ্নের মৌলিক চিত্র

এই লেখার কোড একটি শিক্ষামূলক, front-end-only ডেমো। এটি কোনো বার্তা পাঠায় না, কোনো API-তে request করে না এবং কোনো backend submit নেই। Submit চাপলে সফল দেখালেও স্পষ্টভাবে “NOT sent” লেখা হয়—তাই এটিকে production contact system মনে করবেন না।

কেন contact form-এ শুধু সুন্দর input যথেষ্ট নয়

Contact form দেখতে ছোট হলেও এটি ব্যবহারকারীর ভাষা, browser-এর native validation, keyboard navigation, assistive technology এবং ভবিষ্যৎ server নিরাপত্তার মাঝখানে একটি চুক্তি তৈরি করে। একটি input-এর পাশে placeholder বসালেই সেই চুক্তি পূর্ণ হয় না। Placeholder ফাঁকা হলে label হারিয়ে যায়; লেবেল না থাকলে screen reader ব্যবহারকারী কোন তথ্য চাইছে বুঝতে পারেন না; error কেবল লাল border হলে রঙ না দেখা বা কম contrast-এর ব্যবহারকারী কারণটি জানতে পারেন না। তাই এখানে লক্ষ্য হল—একই markup থেকে আগে HTML-এর স্বাভাবিক সুবিধা পাওয়া, পরে অল্প JavaScript দিয়ে বার্তা ও interaction উন্নত করা।

এই article-এ আমরা নাম, email, বিষয় এবং বার্তার চারটি field দিয়ে একটি ছোট contact form বানাব। প্রতিটি field-এর দৃশ্যমান label থাকবে, required এবং type="email" browser-কে প্রাথমিক constraint দেবে, CSS :focus-visible keyboard focus আলাদা করে দেখাবে, আর aria-live status ফলাফল জানাবে। JavaScript প্রথমে native validity-কে সম্মান করবে; invalid হলে browser-এর reportValidity() চালিয়ে প্রথম ভুল field-এ মনোযোগ দেবে। সব ঠিক থাকলে কোনো network call নয়, কেবল demonstration message দেখাবে। এই progressive নকশায় HTML constraints (required, email ও minlength) native validation-এর নিয়ম হিসেবে থাকে। তবে এই local-only demo-তে submit button JavaScript চালু না হওয়া পর্যন্ত disabled থাকে; তাই JavaScript বন্ধ থাকলে কোনো submission বা success message হয় না, এবং কোনো বার্তাও পাঠানো হয় না।

প্রথম সিদ্ধান্ত: form-এর semantic চুক্তি

<form> element ব্যবহারকারীর interactive controls-এর একটি document section। বাস্তব form-এ action ও method দিয়ে endpoint ও HTTP আচরণ নির্ধারিত হয়। এই শিক্ষামূলক demo-তে method="dialog" রাখা হয়েছে, কিন্তু form-টির কোনো <dialog> ancestor নেই; তাই এটি dialog close করার form নয় এবং browser-এর কোনো form network submissionও হবে না। JavaScript listener validation ও demo status চালায়, কিন্তু no-network নিশ্চয়তার একমাত্র ভিত্তি preventDefault() নয়। ভবিষ্যতে backend যোগ করার সময় এই demo-র method, message ও privacy notice নতুন করে লিখতে হবে।

প্রতিটি label-এর for value তার input-এর id-এর সঙ্গে মেলে। এতে label-এ click করলে field focus হয় এবং screen reader relationship বুঝতে পারে। name attribute বাদ দিইনি: আজ request না গেলেও বাস্তব backend-এ কোন key পাঠানো হবে সেটি name নির্ধারণ করে। Name, email ও message-এ autocomplete-এর অর্থপূর্ণ মান দিয়েছি; subject-এর জন্য off বা custom value ব্যবহারের বদলে browser-কে অপ্রয়োজনীয় autofill না করতে বলেছি। aria-describedby field-কে ছোট hint এবং error-এর সঙ্গে যুক্ত করবে, যদিও error element শুরুতে hidden।

দেখুন MDN-এর form element reference। সেখানে action, method, enctype, novalidate এবং form-এর native আচরণ সম্পর্কে browser-সম্মত ব্যাখ্যা আছে। এই article-এ novalidate ব্যবহার করা হয়নি, কারণ native constraint validation-ই আমাদের প্রথম প্রতিরক্ষা।

Field বাছাই এবং constraint-এর যুক্তি

নামের ক্ষেত্রে minlength="2" দিয়েছি—এক অক্ষরের নাম সবসময় ভুল নয়, কিন্তু এই sample-এর product rule হিসেবে দুই অক্ষর চেয়েছি। Email input-এ type="email" browser-কে email syntax যাচাই করতে সাহায্য করে, আর maxlength অস্বাভাবিক দীর্ঘ input কমায়। Subject-এ required এবং সীমিত maxlength; বার্তার textarea-তে minlength="20" রেখেছি যাতে “হাই” ধরনের অস্পষ্ট request না যায়। এটি কোনো universal truth নয়—বাস্তব product-এর ভাষা, ব্যবহারকারী এবং business rule অনুযায়ী সীমা বদলাবেন।

required শুধু UI সাজানোর চিহ্ন নয়; browser validity state-এ valueMissing সেট করে। type=email ভুল syntax হলে typeMismatch তৈরি করে। minlength ও maxlength client-side feedback উন্নত করে, কিন্তু malicious বা hand-crafted HTTP request আটকায় না। একজন ব্যবহারকারী DevTools দিয়ে HTML বদলাতে পারেন, কিংবা form বাদ দিয়ে সরাসরি request পাঠাতে পারেন। সেই কারণে client constraint-কে সুবিধা হিসেবে দেখুন, security boundary হিসেবে নয়।

Error-এর জন্য আমি প্রতিটি field-এর নিচে একটি <p> রেখেছি। JavaScript error string লিখবে এবং hidden খুলবে; aria-describedby আগে থেকেই সেই node-কে সম্পর্কিত করে। role="alert" সব error-এ দিলে typing-এর সময় অতিরিক্ত interruption হতে পারে, তাই field error-কে polite live region এবং আলাদা global status-কে role="status" রাখা হয়েছে। Submit ব্যর্থ হলে browser-এর native popup এবং দৃশ্যমান error—দুটির মধ্যে বিরোধ না তৈরি করতে প্রথমে reportValidity() ব্যবহার করেছি, তারপর custom message কেবল প্রয়োজনীয় ক্ষেত্রে আপডেট করেছি।

CSS: focus এবং error-কে শুধু রঙে আটকে না রাখা

ফর্মের visual hierarchy ইচ্ছাকৃতভাবে ছোট: card, label, input, hint, error, action। এখানে কোনো generic responsive design tutorial নেই; CSS-এর কেন্দ্র field state। :focus-visible কেবল keyboard বা modality-তে দরকার হলে outline দেখায়, তাই mouse user-এর অপ্রত্যাশিত ring-এর বদলে keyboard user স্পষ্ট indication পান। Outline সরিয়ে ফেললে তা অবশ্যই নিজস্ব, দৃশ্যমান alternative দিয়ে পূরণ করতে হবে—এখানে outline বজায় রেখেছি।

:user-invalid সব browser-এ একইভাবে পুরোনো নয়, তাই demo-তে JavaScript-এর .has-error class-এর সঙ্গে :focus এবং :required ব্যবহার করেছি। Error field-এ border color বদলেছে, কিন্তু পাশে text message এবং icon-এর বদলে “সমস্যা” শব্দও আছে—এতে color-only dependency কমে। High-contrast mode বা forced-colors-এ user-agent-এর system color সম্মান করতে @media (forced-colors: active)-এ CanvasText ও Highlight ব্যবহার করেছি। Reduced-motion preference-এ animation নেই; কারণ validation message-এর জন্য animation কোনো দরকারি তথ্য যোগ করে না।

Progressive JavaScript flow

Script-এর flow চার ধাপে পড়ুন। প্রথমে form ও field references নেওয়া হয়। দ্বিতীয়ত, showFieldError() value, validity এবং custom message দেখে text বসায়। তৃতীয়ত, input event-এ user আবার লেখা শুরু করলে stale error সরানো হয়; এতে আগের ভুল বার্তা আটকে থাকে না। চতুর্থত, submit handler form.checkValidity() দিয়ে সামগ্রিক ফল পায়। false হলে form.reportValidity() browser-কে native message দেখাতে দেয়, তারপর প্রথম invalid element-এ focus() করা হয়। এই call-গুলো Constraint Validation API-এর অংশ; বিস্তারিত MDN Constraint validation guide পড়ুন।

সব field valid হলে handler event.preventDefault()-এর পরও কোনো fetch, XMLHttpRequest, form submit বা storage API কল করে না। status.textContent-এ লেখা হয়: “Demo success: validation passed, but message NOT sent.” এই বাক্যটি ইচ্ছাকৃতভাবে success শব্দ এবং non-delivery দুটোই বলে। Form reset না করায় learner চাইলে input দেখে DOM inspect করতে পারেন; reset করলে value হারানোর ঝুঁকি থাকে। বাস্তব app-এ server response আসা পর্যন্ত button disabled, idempotency এবং retry policy লাগতে পারে—এই demo সেগুলোর ভান করে না।

Custom validity কখন ব্যবহার করবেন? দুই field-এর সম্পর্ক যাচাই করতে setCustomValidity() ভালো উপায়, যেমন confirm email বা date range। কিন্তু এই sample-এ প্রতিটি field-এর rule native attribute-এই যথেষ্ট, তাই কৃত্রিম custom validity বসাইনি। এতে একটি গুরুত্বপূর্ণ শিক্ষা থাকে: JavaScript দিয়ে browser-এর built-in constraint আবার কপি না করে, native rule-কে feedback layer দিয়ে সমর্থন করুন। একই সঙ্গে validation message localization, browser-specific wording এবং accessible explanation-এর ভারসাম্য রাখুন।

সম্পূর্ণ standalone index.html

নিচের একটিমাত্র file save করে সরাসরি browser-এ খুলুন। কোনো package, build step বা external JavaScript দরকার নেই। Internet ছাড়াও demo চলবে, কারণ CSS ও script inline। method="dialog" এখানে কেবল demo-র জন্য; কোনো <dialog> ancestor না থাকায় form কোনো network submission করবে না। JavaScript চালু হলে তবেই demo button সক্রিয় হয়।

<!doctype html>
<html lang="bn">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Accessible Contact Form Demo</title>
  <style>
    :root { color-scheme: light; font-family: system-ui, sans-serif; line-height: 1.5; }
    * { box-sizing: border-box; }
    body { margin: 0; background: #eef2ff; color: #172033; }
    main { width: min(100% - 2rem, 42rem); margin: 4rem auto; }
    .card { background: #fff; padding: clamp(1.25rem, 4vw, 2.5rem); border: 1px solid #cbd5e1; border-radius: 1rem; box-shadow: 0 1rem 2rem #17203318; }
    h1 { margin-top: 0; }
    .notice { padding: .8rem 1rem; border-left: .3rem solid #7c3aed; background: #f5f3ff; }
    .field { margin-block: 1.25rem; }
    label { display: block; font-weight: 700; margin-bottom: .35rem; }
    input, textarea { width: 100%; border: 2px solid #94a3b8; border-radius: .5rem; padding: .7rem .8rem; font: inherit; color: inherit; background: #fff; }
    textarea { min-height: 9rem; resize: vertical; }
    input:focus-visible, textarea:focus-visible, button:focus-visible { outline: 3px solid #f59e0b; outline-offset: 3px; }
    input:invalid:not(:placeholder-shown), textarea:invalid:not(:placeholder-shown), .has-error { border-color: #b42318; }
    .hint, .error { font-size: .9rem; margin: .35rem 0 0; }
    .hint { color: #475569; }
    .error { color: #b42318; font-weight: 650; }
    button { border: 0; border-radius: .5rem; padding: .75rem 1.1rem; font: inherit; font-weight: 700; cursor: pointer; background: #3730a3; color: #fff; }
    button:hover { background: #312e81; }
    #status { min-height: 1.6rem; margin-top: 1rem; font-weight: 700; }
    .success { color: #166534; }
    @media (forced-colors: active) { input, textarea { border: 2px solid CanvasText; } input:focus-visible, textarea:focus-visible, button:focus-visible { outline-color: Highlight; } }
  </style>
</head>
<body>
  <main>
    <section class="card" aria-labelledby="form-title">
      <h1 id="form-title">যোগাযোগের ডেমো</h1>
      <p class="notice">এটি শেখার জন্য front-end demo। Submit করলে কোনো বার্তা পাঠানো হবে না।</p>
      <form id="contact-form" method="dialog">
        <div class="field">
          <label for="name">নাম <span aria-hidden="true">*</span></label>
          <input id="name" name="name" type="text" autocomplete="name" minlength="2" maxlength="80" required aria-describedby="name-hint name-error">
          <p id="name-hint" class="hint">কমপক্ষে ২ অক্ষর লিখুন।</p>
          <p id="name-error" class="error" hidden></p>
        </div>
        <div class="field">
          <label for="email">ইমেইল <span aria-hidden="true">*</span></label>
          <input id="email" name="email" type="email" autocomplete="email" maxlength="254" required aria-describedby="email-hint email-error">
          <p id="email-hint" class="hint">উত্তরের জন্য একটি বৈধ email দিন।</p>
          <p id="email-error" class="error" hidden></p>
        </div>
        <div class="field">
          <label for="subject">বিষয় <span aria-hidden="true">*</span></label>
          <input id="subject" name="subject" type="text" maxlength="120" required aria-describedby="subject-hint subject-error">
          <p id="subject-hint" class="hint">আপনার প্রশ্নের সংক্ষিপ্ত শিরোনাম।</p>
          <p id="subject-error" class="error" hidden></p>
        </div>
        <div class="field">
          <label for="message">বার্তা <span aria-hidden="true">*</span></label>
          <textarea id="message" name="message" minlength="20" maxlength="2000" required aria-describedby="message-hint message-error"></textarea>
          <p id="message-hint" class="hint">কমপক্ষে ২০ অক্ষরে সমস্যাটি লিখুন।</p>
          <p id="message-error" class="error" hidden></p>
        </div>
        <button id="submit-button" type="submit" disabled>ডেমো যাচাই করুন</button>
        <p id="status" role="status" aria-live="polite"></p>
      </form>
    </section>
  </main>
  <script>
    const form = document.querySelector('#contact-form');
    const fields = [...form.querySelectorAll('input, textarea')];
    const status = document.querySelector('#status');
    const submitButton = document.querySelector('#submit-button');

    function messageFor(field) {
      if (field.validity.valueMissing) return 'এই ঘরটি পূরণ করুন।';
      if (field.validity.typeMismatch) return 'একটি বৈধ email format দিন।';
      if (field.validity.tooShort) return `কমপক্ষে ${field.minLength} অক্ষর লিখুন।`;
      if (field.validity.tooLong) return `সর্বোচ্চ ${field.maxLength} অক্ষর রাখা যাবে।`;
      return '';
    }

    function showFieldError(field) {
      const error = document.querySelector(`#${field.id}-error`);
      const text = messageFor(field);
      error.textContent = text;
      error.hidden = !text;
      field.classList.toggle('has-error', Boolean(text));
      field.setAttribute('aria-invalid', text ? 'true' : 'false');
      return !text;
    }

    fields.forEach((field) => {
      field.addEventListener('input', () => {
        showFieldError(field);
        if (fields.every((item) => item.validity.valid)) status.textContent = '';
      });
    });

    form.addEventListener('submit', (event) => {
      event.preventDefault(); // Demo: deliberately prevents any network submission.
      status.className = '';
      const valid = form.checkValidity();
      fields.forEach(showFieldError);
      if (!valid) {
        form.reportValidity();
        const firstInvalid = fields.find((field) => !field.validity.valid);
        firstInvalid?.focus();
        status.textContent = 'কিছু ঘর ঠিক করুন, তারপর আবার চেষ্টা করুন।';
        return;
      }
      status.className = 'success';
      status.textContent = 'Demo success: validation passed, but message NOT sent.';
    });

    // JavaScript is required for this local-only demonstration button.
    submitButton.disabled = false;
  </script>
</body>
</html>

কোড পড়ার সময় যে ছোট সিদ্ধান্তগুলো খেয়াল করবেন

aria-invalid="false" শুরুতেই লেখা নেই; user প্রথম submit বা input করার পর script সেট করে। এতে untouched field-এ শুরুতেই error state ঘোষণা হয় না। Error paragraph-এর hidden attribute screen reader এবং visual user—দুজনের জন্যই শুরুতে content সরিয়ে রাখে। Text বদলানোর পর aria-describedby-এর সম্পর্ক একই থাকে, তাই error প্রকাশিত হলে assistive technology সেটি পড়তে পারে। role="status" polite হওয়ায় typing-এর মাঝখানে জরুরি alert-এর মতো interrupt করবে না। Submit-এর পরে browser native bubble দেখালেও আমাদের status-এ সংক্ষিপ্ত নির্দেশ থাকে, ফলে keyboard user বুঝতে পারেন যে কী করবেন।

input listener-এ আমরা custom regular expression দিয়ে email বানাইনি। Email format-এর জন্য browser-এর type=email যথেষ্ট প্রাথমিক rule; messageFor()-এর typeMismatch message কেবল বাংলা feedback। Browser-এর validity property-কে source of truth রাখায় rule এবং message আলাদা হয়ে অসঙ্গত হওয়ার সম্ভাবনা কম। maxlength user input-এ browser সাধারণত সীমা মানে, কিন্তু server-এ একই সীমা আবার enforce করতে হবে। কোনো field-এ whitespace-only policy দরকার হলে backend-এ trim ও business validation করুন; এই demo সেই policy গোপনে ধরে নেয় না।

Validation test cases: ইচ্ছাকৃতভাবে ভাঙুন

প্রথম test-এ সব ঘর খালি রেখে button চাপুন। Browser প্রথম required field দেখাবে, script সব error paragraph প্রকাশ করবে এবং status বলবে কিছু ঘর ঠিক করতে। এরপর name-এ এক অক্ষর দিন; tooShort message দেখা উচিত। দুই বা তার বেশি অক্ষর দিলে সেই error অদৃশ্য হবে। এই sequence-টি নিশ্চিত করে যে invalid state আটকে নেই এবং user input-এ feedback আপডেট হচ্ছে।

দ্বিতীয় test-এ email-এ hello লিখুন। Browser typeMismatch জানাবে এবং বাংলা message “বৈধ email format” দেখাবে। person@example.com দিলে email error সরে যাওয়ার কথা। তৃতীয় test-এ subject ফাঁকা রাখুন, message-এ ১৯টি অক্ষর দিন; message-এর minimum constraint আলাদা করে কাজ করবে। অনেক বড় text paste করে maxlength-এর আচরণও দেখুন।

চতুর্থ test হল keyboard-only: mouse না নিয়ে Tab দিয়ে label-এর পর field-এ যান, Enter/Space-এ submit করুন। কমলা focus-visible outline দেখা উচিত এবং invalid হলে প্রথম ভুল field focus পাওয়া উচিত। পঞ্চম test-এ browser DevTools থেকে JavaScript নিষ্ক্রিয় করে page reload করুন। Script-এর বাংলা custom error ও demo success থাকবে না, কিন্তু required, email, minlength-এর native validation চলবে। এই fallback-এ browser-এর native constraints দেখা যায়, কিন্তু demo button ইচ্ছাকৃতভাবে disabled থাকায় JavaScript ছাড়া submission শুরু হয় না; ফলে network-এ কিছু পাঠানোর দাবিও নেই।

ষষ্ঠ test-এ screen reader বা browser accessibility tree দিয়ে status ও error পড়ে দেখুন। Error text কেবল color নয় এবং label/hint/error সম্পর্কিত কিনা যাচাই করুন। সপ্তম test-এ forced-colors mode এবং zoom 200% চালু করুন; content কাটা বা focus হারানো উচিত নয়। সব test সফল হলেও এটি security test নয়—network, authorization, abuse prevention ও data retention আলাদা যাচাই।

Production-এ গেলে server validation অপরিহার্য

এই page-এ client-side validation user experience-এর জন্য, বিশ্বাসের জন্য নয়। Production endpoint-এ server অবশ্যই request body parse করে নাম, email, subject ও message-এর type, length, encoding এবং business rule যাচাই করবে। Unknown fields বাদ দিন, canonicalize করার সময় data নষ্ট না করে নীতিমালা লিখুন, এবং error response-এ sensitive details ফাঁস করবেন না। Server-side validation client rule-এর সঙ্গে সামঞ্জস্যপূর্ণ হতে পারে, কিন্তু client code কপি করে নিরাপত্তা সম্পন্ন হয়েছে ভাবা যাবে না।

বাস্তব POST form-এ CSRF প্রতিরোধ আলাদা বিষয়। Cookie-based session থাকলে server-side CSRF token তৈরি করে form-এ যুক্ত করুন এবং server-এ token মিলিয়ে দেখুন; SameSite cookie, Origin/Referer যাচাই এবং framework-এর vetted CSRF middleware সহায়ক প্রতিরক্ষা। এই demo-তে token নেই, কারণ backend নেই—তাই এটিকে authenticated production form হিসেবে ব্যবহার করবেন না। এই demo-র method="dialog" কোনো CSRF protection নয়; বাস্তব endpoint যোগ করলে action, method, CSRF ও privacy policy নতুন করে নির্ধারণ করতে হবে।

Contact form সাধারণত spam target। Rate limit, IP বা account ভিত্তিক quota, exponential backoff, captcha/turnstile-এর মতো risk-based challenge এবং abuse monitoring দরকার হতে পারে। Rate limit server বা edge-এ প্রয়োগ করুন; JavaScript-এ button একবার disable করলেই attacker থামে না। Message email-এ forward করলে header injection, HTML escaping, attachment policy এবং outbound provider-এর failure handling ভাবুন। সফল response তখনই বলবেন যখন server সত্যিই গ্রহণ করেছে; এই demo-র “NOT sent” ভাষা সেই distinction শেখায়।

Privacy, retention এবং error আচরণ

Form-এ email ও message ব্যক্তিগত তথ্য হতে পারে। Production-এ কেন তথ্য নিচ্ছেন, কতদিন রাখবেন, কে দেখবে, delete/access request কীভাবে হবে—সংক্ষিপ্ত privacy notice-এ বলুন। Consent checkbox দরকার হলে তা optional marketing consent-এর সঙ্গে বাধ্যতামূলক contact request মেশাবেন না। TLS ছাড়া sensitive form পাঠাবেন না; logs-এ সম্পূর্ণ message বা email না রাখাই ভালো, আর analytics tool যেন field value capture না করে তা যাচাই করুন।

Error response-এ “email already exists” বা account enumeration-এর মতো তথ্য প্রকাশের আগে threat model করুন। Accessibility-এর জন্য error message স্পষ্ট, সংক্ষিপ্ত এবং করণীয়মুখী রাখুন; “invalid” বলার বদলে “একটি বৈধ email format দিন” ভালো। Server 400, 429 বা 500 দিলে UI আলাদা state দেখাবে—network failure-কে validation error বলবে না। Retry-এর আগে user-এর entered value অক্ষুণ্ণ রাখুন, তবে shared/public device-এ sensitive data ধরে রাখার trade-off ব্যাখ্যা করুন।

Demo থেকে বাস্তব integration-এর checklist

  1. action-এ HTTPS endpoint দিন এবং method="post" রাখুন; GET-এ message পাঠাবেন না। ২. Server-side schema validation, CSRF token, authentication/authorization এবং rate limit যোগ করুন। ৩. Success message server-এর authoritative response থেকে বানান, front-end অনুমান থেকে নয়। ৪. Error payload-এ field-level safe messages দিন এবং unknown exception user-কে দেখাবেন না। ৫. Privacy notice, retention deletion এবং log redaction লিখিত policy-তে আনুন। ৬. Keyboard, screen reader, zoom, forced-colors, slow network এবং JavaScript-off test চালান। ৭. fetch ব্যবহার করলে timeout, abort, retry policy ও duplicate submission সামলান—এই file-এ সেগুলো ইচ্ছাকৃতভাবে নেই।

শেষ কথা: একটি ভালো form-এর মূল্য submit button-এর gradient-এ নয়; user কী চাইছে বুঝতে পারছেন কিনা, ভুল হলে কীভাবে ফেরত আসবেন, keyboard-এ সব reachable কিনা, এবং data কোথায় যাচ্ছে তা সত্যি বলা হচ্ছে কিনা—এসবের সমষ্টিতে। এই standalone demo native HTML দিয়ে শুরু করে CSS focus feedback ও JavaScript status যোগ করেছে, কিন্তু কোনো fake backend বা misleading success বানায়নি। সেই সীমা বজায় রেখেই পরের ধাপে নিজের server contract যোগ করুন।

রেফারেন্স

JavaScript To-do App তৈরি: DOM, localStorage ও নিরাপদ ডেটা ব্যবস্থাপনা

JavaScript To-do অ্যাপ—চেকলিস্ট ও ব্রাউজার স্টোরেজের মৌলিক চিত্র

ওয়েব ডেভেলপমেন্ট শেখার শুরুতে To-do app একটি চমৎকার প্রকল্প। কারণ ছোট একটি ইন্টারফেসের মধ্যে HTML, CSS, JavaScript, DOM নির্বাচন, ইভেন্ট, ডেটা মডেল এবং ব্রাউজার স্টোরেজ—সব একসঙ্গে অনুশীলন করা যায়। এই নিবন্ধে আমরা এমন একটি সম্পূর্ণ To-do app বানাব, যেখানে নতুন কাজ যোগ করা, কাজ সম্পন্ন হিসেবে চিহ্নিত করা, কাজ মুছে ফেলা এবং পেজ রিফ্রেশের পরেও কাজের তালিকা ধরে রাখা যাবে। পুরো প্রকল্পটি একটি মাত্র index.html ফাইলে থাকবে; কোনও framework, package বা CDN লাগবে না।

এখানে লক্ষ্য শুধু কয়েকটি বাটন চালু করা নয়। আমরা কেন একটি নির্দিষ্ট ডেটা কাঠামো বেছে নিচ্ছি, DOM-এ কীভাবে নিরাপদে লেখা বসাব, localStorage থেকে আসা JSON কীভাবে যাচাই করব, নষ্ট ডেটা পেলে কেন ব্যবহারকারীকে জানাব এবং সংবেদনশীল তথ্য কেন ব্রাউজারের এই স্টোরেজে রাখা উচিত নয়—এসবও ধাপে ধাপে দেখা হবে। ফলে একই ধারণা পরে নোট, বুকমার্ক, খরচের হিসাব বা ছোট inventory app-এ প্রয়োগ করতে পারবেন।

কী বানাব

অ্যাপটির মূল কাজ চারটি। প্রথমত, ইনপুটে লেখা একটি কাজ তালিকায় যোগ হবে। দ্বিতীয়ত, প্রতিটি কাজের পাশে checkbox থাকবে; সেটি বদলালে completed মান পরিবর্তিত হবে এবং দৃশ্যমান স্টাইলও বদলাবে। তৃতীয়ত, delete বোতাম নির্দিষ্ট কাজটি সরাবে। চতুর্থত, প্রতিটি পরিবর্তনের পরে তালিকা JSON আকারে localStorage-এ সংরক্ষিত হবে। পেজ খুললে সংরক্ষিত তালিকা আবার পড়া হবে।

প্রতিটি task-কে আমরা একটি object হিসেবে রাখব। উদাহরণস্বরূপ, একটি item-এর আকার হতে পারে { id, text, completed, createdAt }। id দিয়ে কোনও কাজকে আলাদা করে চেনা হবে, text হবে ব্যবহারকারীর লেখা, completed কেবল boolean এবং createdAt ভবিষ্যতে sort বা audit-এর কাজে লাগতে পারে। পুরো তালিকা হবে এই object-গুলোর array। এই সরল schema রাখা জরুরি, কারণ পরে নতুন property যোগ করলেও পুরনো ডেটা বোঝা সহজ থাকে।

DOM এবং state-এর সম্পর্ক

DOM হলো ব্রাউজারের তৈরি HTML tree, যেখানে প্রতিটি heading, form, input এবং list item একটি node। JavaScript দিয়ে আমরা querySelector করে node ধরে event listener বসাব। কিন্তু DOM-কে একমাত্র সত্য ধরে নিয়ে আলাদা আলাদা জায়গায় পরিবর্তন করলে state ছড়িয়ে যায় এবং bug বাড়ে। তাই এখানে tasks নামের একটি JavaScript array-কে application state হিসেবে রাখব। state বদলানোর পরে render() function পুরো list নতুন করে আঁকবে এবং saveTasks() সেটি সংরক্ষণ করবে।

এই পদ্ধতির সুবিধা হলো checkbox বদলানো, নতুন item যোগ করা বা delete করা—সব action-এর পরে একই নিয়ম কাজ করে। render() প্রথমে list খালি করে, তারপর state-এর প্রতিটি task-এর জন্য প্রয়োজনীয় element বানায়। এখানে সাধারণ text-এর জন্য textContent ব্যবহার করব, innerHTML নয়। ব্যবহারকারী যদি <img onerror=...>-এর মতো কিছু লেখেন, textContent সেটিকে কেবল লেখা হিসেবে দেখাবে; ব্রাউজার সেটিকে HTML হিসেবে চালাবে না। নিরাপদ output handling-এর এই অভ্যাস ছোট প্রকল্পেও বজায় রাখা উচিত।

localStorage কীভাবে কাজ করে

localStorage key এবং string value হিসেবে ডেটা রাখে। তাই array সরাসরি রাখা যায় না; JSON.stringify(tasks) দিয়ে string বানিয়ে রাখতে হয়, আবার পড়ার সময় JSON.parse() করতে হয়। MDN-এর Window.localStorage-এর অফিসিয়াল ডকুমেন্টেশন অনুযায়ী storage origin এবং protocol-ভিত্তিক। অর্থাৎ একই origin-এ পরে খোলা পেজ একই data পেতে পারে, কিন্তু http ও https আলাদা হতে পারে।

এই উদাহরণটি file:// দিয়ে double-click করে খোলার জন্য নির্ভরযোগ্যভাবে তৈরি করা হয়নি, কারণ file scheme-এ localStorage-এর আচরণ browser ভেদে undefined হতে পারে। বরং project folder-এ terminal খুলে python3 -m http.server 8000 চালান এবং http://localhost:8000 খুলুন। private বা incognito browsing-এ storage সাধারণত session শেষ হলে মুছে যেতে পারে। browser-এর policy, blocked storage বা নিরাপত্তা সেটিংসের কারণে SecurityError-ও হতে পারে; তাই read এবং write—দুই জায়গাতেই error handling রাখা হয়েছে।

localStorage database নয়। এতে password, access token, ব্যক্তিগত স্বাস্থ্যতথ্য বা অন্য sensitive data রাখা উচিত নয়, কারণ একই browser profile-এ চলা script বা ব্যবহারকারী-নিয়ন্ত্রিত code storage পড়তে পারে। এই app-এ শুধু সাধারণ task text রাখা হচ্ছে। বাস্তব authentication বা গোপন তথ্যের ক্ষেত্রে server-side নিরাপদ ব্যবস্থা, access control এবং উপযুক্ত encryption architecture প্রয়োজন।

নষ্ট বা অপ্রত্যাশিত storage সামলানো

শুধু JSON.parse() লিখে ফেললে malformed string এলে পুরো script error করতে পারে। আরও সমস্যা হলো valid JSON হলেও সেটি array নাও হতে পারে, অথবা array-এর item-এ সঠিক id, text কিংবা boolean completed নাও থাকতে পারে। তাই readTasks() প্রথমে storage থেকে value নেয়, parse করে, তারপর Array.isArray এবং item-level validator দিয়ে shape যাচাই করে। text trim করে empty item বাদ দেওয়া হয়; id না থাকলে সেই record-কে valid ধরা হয় না।

যদি parse ব্যর্থ হয় বা shape ভুল হয়, app silently overwrite করবে না। বরং একটি warning message দেখাবে এবং memory-তে খালি তালিকা ব্যবহার করবে। ব্যবহারকারী চাইলে warning-এর “সরান” বোতাম চাপতে পারবেন; তখন কেবল এই app-এর key মুছে ফেলা হবে। এই নকশা গুরুত্বপূর্ণ: corrupt data অজান্তে মুছে না দিয়ে আগে সমস্যা জানানো হচ্ছে। warning মুছে দিলে নতুন task সংরক্ষণ করা সম্ভব হবে।

Storage read/write-এ exception হলেও app পুরোপুরি অচল হবে না। storageAvailable flag-এর মাধ্যমে আমরা বুঝব persistence কাজ করছে কি না। storage unavailable হলে status message দেখানো হবে এবং task অন্তত বর্তমান পেজের memory-তে কাজ করবে। refresh-এর পরে তা না-ও থাকতে পারে—এটি ব্যবহারকারীকে স্পষ্টভাবে জানানো ভালো, মিথ্যা “saved” বার্তা দেখানো নয়।

unique ID কেন দরকার

শুধু array index দিয়ে delete করলে সমস্যা হয়। একটি item সরানোর পরে পরের item-এর index বদলে যায়; asynchronous event বা ভবিষ্যৎ sort যোগ করলে ভুল item মুছে যেতে পারে। তাই প্রতিটি item-এর স্থায়ী id ব্যবহার করছি। আধুনিক browser-এ crypto.randomUUID() সহজে unique UUID দেয়। localhost-এর পুরনো বা সীমিত পরিবেশে এটি না থাকলে fallback হিসেবে timestamp, random অংশ এবং counter মিলিয়ে একটি ID বানানো হয়েছে। এটি cryptographically secure পরিচয়ের বিকল্প নয়, কিন্তু এই local To-do app-এ সংঘর্ষের সম্ভাবনা কমানোর জন্য যথেষ্ট।

সম্পূর্ণ index.html

নিচের ফাইলটি index.html নামে সংরক্ষণ করুন। CSS একই ফাইলে আছে, ফলে internet বা external dependency লাগবে না। Form submit-এ preventDefault() ব্যবহার করা হয়েছে যাতে page reload না হয়। Checkbox-এর change event এবং delete button-এর click event দুটিই নির্দিষ্ট ID ধরে state পরিবর্তন করে।

<!doctype html>
<html lang="bn">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>আমার To-do তালিকা</title>
  <style>
    :root { color-scheme: light; font-family: system-ui, sans-serif; }
    body { margin: 0; background: #f3f6fb; color: #172033; }
    main { width: min(680px, calc(100% - 2rem)); margin: 3rem auto; }
    .panel { background: #fff; padding: 1.5rem; border-radius: 14px; box-shadow: 0 8px 30px #16213d18; }
    form { display: flex; gap: .6rem; margin: 1rem 0; }
    input[type="text"] { flex: 1; min-width: 0; padding: .75rem; border: 1px solid #aab5c8; border-radius: 8px; font: inherit; }
    button { border: 0; border-radius: 8px; padding: .7rem 1rem; cursor: pointer; font: inherit; }
    form button { background: #2457d6; color: white; }
    ul { list-style: none; padding: 0; margin: 1rem 0 0; }
    li { display: flex; align-items: center; gap: .7rem; padding: .75rem 0; border-bottom: 1px solid #e3e7ef; }
    li span { flex: 1; overflow-wrap: anywhere; }
    li.done span { text-decoration: line-through; color: #667085; }
    .delete { background: #fee4e2; color: #a4231b; }
    .message { min-height: 1.4rem; color: #475467; }
    .warning { padding: .7rem; border-radius: 8px; background: #fff4cc; color: #684d00; }
    .empty { color: #667085; text-align: center; }
    @media (max-width: 480px) { form { flex-direction: column; } }
  </style>
</head>
<body>
  <main>
    <section class="panel" aria-labelledby="title">
      <h1 id="title">আমার To-do তালিকা</h1>
      <p>কাজ যোগ করুন, শেষ হলে চিহ্ন দিন।</p>
      <p id="status" class="message" role="status" aria-live="polite"></p>
      <form id="task-form">
        <label for="task-input">নতুন কাজ</label>
        <input id="task-input" type="text" maxlength="200" required autocomplete="off" placeholder="যেমন: JavaScript অনুশীলন">
        <button type="submit">যোগ করুন</button>
      </form>
      <div id="storage-warning" hidden></div>
      <ul id="task-list" aria-label="কাজের তালিকা"></ul>
    </section>
  </main>
  <script>
    'use strict';

    const STORAGE_KEY = 'bn-todo-tasks-v1';
    const form = document.querySelector('#task-form');
    const input = document.querySelector('#task-input');
    const list = document.querySelector('#task-list');
    const status = document.querySelector('#status');
    const warning = document.querySelector('#storage-warning');
    let tasks = [];
    let storageAvailable = true;
    let idCounter = 0;

    function makeId() {
      if (globalThis.crypto && typeof globalThis.crypto.randomUUID === 'function') {
        return globalThis.crypto.randomUUID();
      }
      idCounter += 1;
      return `task-${Date.now()}-${idCounter}-${Math.random().toString(36).slice(2)}`;
    }

    function validTask(value) {
      return value && typeof value === 'object' &&
        typeof value.id === 'string' && value.id.length > 0 &&
        typeof value.text === 'string' && value.text.trim().length > 0 &&
        typeof value.completed === 'boolean' &&
        typeof value.createdAt === 'string';
    }

    function showWarning(message, canClear = false) {
      warning.textContent = '';
      warning.hidden = false;
      warning.className = 'warning';
      const text = document.createElement('span');
      text.textContent = message;
      warning.append(text);
      if (canClear) {
        const clear = document.createElement('button');
        clear.type = 'button';
        clear.textContent = 'নষ্ট ডেটা সরান';
        clear.addEventListener('click', () => {
          try {
            localStorage.removeItem(STORAGE_KEY);
            warning.hidden = true;
            status.textContent = 'পুরনো নষ্ট ডেটা সরানো হয়েছে।';
            storageAvailable = true;
          } catch (error) {
            status.textContent = 'ডেটা সরানো যায়নি; browser storage অনুমতি পরীক্ষা করুন।';
          }
        });
        warning.append(' ', clear);
      }
    }

    function readTasks() {
      let raw;
      try {
        raw = localStorage.getItem(STORAGE_KEY);
      } catch (error) {
        storageAvailable = false;
        showWarning('Browser storage ব্যবহার করা যাচ্ছে না। কাজ refresh-এর পর নাও থাকতে পারে।');
        return [];
      }
      if (raw === null) return [];
      try {
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed)) throw new Error('not an array');
        const clean = parsed.filter(validTask).map(task => ({
          id: task.id,
          text: task.text.trim(),
          completed: task.completed,
          createdAt: task.createdAt
        }));
        if (clean.length !== parsed.length) {
          showWarning('কিছু storage record-এর কাঠামো ঠিক নয়; সেগুলো দেখানো হয়নি।', true);
        }
        return clean;
      } catch (error) {
        showWarning('সংরক্ষিত To-do ডেটা নষ্ট বা অচেনা। এটি নিজে থেকে মুছে ফেলা হয়নি।', true);
        return [];
      }
    }

    function saveTasks() {
      try {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
        storageAvailable = true;
        if (!warning.classList.contains('warning')) status.textContent = 'পরিবর্তন সংরক্ষিত হয়েছে।';
      } catch (error) {
        storageAvailable = false;
        showWarning('কাজটি এই session-এ আছে, কিন্তু browser storage-এ সংরক্ষণ করা যায়নি।');
      }
    }

    function render() {
      list.textContent = '';
      if (tasks.length === 0) {
        const empty = document.createElement('li');
        empty.className = 'empty';
        empty.textContent = 'এখনও কোনও কাজ যোগ করা হয়নি।';
        list.append(empty);
        return;
      }
      tasks.forEach(task => {
        const item = document.createElement('li');
        if (task.completed) item.className = 'done';
        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.checked = task.completed;
        checkbox.setAttribute('aria-label', `${task.text} সম্পন্ন হয়েছে`);
        checkbox.addEventListener('change', () => {
          task.completed = checkbox.checked;
          render();
          saveTasks();
        });
        const text = document.createElement('span');
        text.textContent = task.text;
        const remove = document.createElement('button');
        remove.type = 'button';
        remove.className = 'delete';
        remove.textContent = 'মুছুন';
        remove.addEventListener('click', () => {
          tasks = tasks.filter(candidate => candidate.id !== task.id);
          render();
          saveTasks();
        });
        item.append(checkbox, text, remove);
        list.append(item);
      });
    }

    form.addEventListener('submit', event => {
      event.preventDefault();
      const text = input.value.trim();
      if (!text) {
        status.textContent = 'একটি কাজ লিখে তারপর যোগ করুন।';
        input.focus();
        return;
      }
      tasks.push({ id: makeId(), text, completed: false, createdAt: new Date().toISOString() });
      input.value = '';
      render();
      saveTasks();
      input.focus();
    });

    tasks = readTasks();
    render();
    if (!storageAvailable) status.textContent = 'Persistence বন্ধ আছে; এই পেজে কাজ করা যাবে।';
  </script>
</body>
</html>

কোডটি বোঝার গুরুত্বপূর্ণ অংশ

validTask() একটি ছোট schema validator। এটি সব সম্ভাব্য malicious data ঠেকানোর পূর্ণ security boundary নয়, কিন্তু application যেন array-এর বদলে object, সংখ্যা বা অপ্রত্যাশিত property নিয়ে render না করে—সে নিশ্চয়তা দেয়। map()-এ আমরা কেবল প্রয়োজনীয় চারটি property নিই। ফলে storage-এ অতিরিক্ত object থাকলেও UI-তে তা অন্ধভাবে ছড়িয়ে পড়ে না। বাস্তব প্রকল্পে schema version, maximum record count এবং text length-এর আরও সীমা যোগ করা যেতে পারে।

showWarning()-এ warning message বসানোর সময় textContent ব্যবহার করা হয়েছে। এমনকি storage থেকে আসা নষ্ট record-এর কোনও text সরাসরি HTML হিসেবে ব্যবহার করা হয়নি। বাটনটিও createElement দিয়ে তৈরি। এই pattern একটু বেশি দীর্ঘ মনে হলেও untrusted input-এর ক্ষেত্রে এটি নিরাপদ এবং পরিষ্কার। list.textContent = '' করে আগের child সরানোর পর প্রতিটি node নতুন করে তৈরি হওয়ায় event listener-ও পুরনো node-এ আটকে থাকে না।

saveTasks()-এর try/catch কেবল quota error-এর জন্য নয়; browser storage নিষিদ্ধ বা security policy-র কারণেও ব্যর্থ হতে পারে। save ব্যর্থ হলেও tasks memory-তে আপডেট থাকে, তাই ব্যবহারকারী বর্তমান page-এ কাজ চালাতে পারেন। তবে status-এ সীমাবদ্ধতা বলা হচ্ছে। কোনও error silently গিলে ফেলা হলে debugging এবং user trust—দুটিই নষ্ট হয়।

চালানো এবং পরীক্ষা

প্রথমে একটি folder-এ index.html রাখুন। তারপর সেই folder-এ terminal খুলে এই command চালান:

python3 -m http.server 8000

এবার browser-এ http://localhost:8000 খুলুন। একটি কাজ লিখে যোগ করুন, checkbox বদলান, delete করুন এবং refresh দিয়ে persistence পরীক্ষা করুন। Developer Tools-এর Application/Storage অংশে key-টির value JSON হিসেবে দেখতে পারবেন। ইচ্ছাকৃতভাবে value-টি not-json করে refresh দিলে warning দেখা উচিত এবং app কোনও data নিজে থেকে মুছে ফেলবে না। পরে warning-এর button চাপলে কেবল এই app-এর key মুছবে।

আরও কিছু পরীক্ষা করা যায়। এক origin-এ দুইটি tab খুলে দেখুন—এক tab-এর পরিবর্তন অন্য tab-এ স্বয়ংক্রিয়ভাবে render হবে না, কারণ এই ছোট app storage event শোনে না। বহু-tab synchronization দরকার হলে window.addEventListener('storage', ...) যোগ করা যায়। ২০০ অক্ষরের বেশি task input browser-এর maxlength-এ আটকে যাবে। ফাঁকা space submit করলে validation বার্তা দেখাবে। HTML-সদৃশ লেখা দিলে সেটি plain text হিসেবে দেখা উচিত।

উন্নত করার ধারণা

পরবর্তী ধাপে filter যোগ করতে পারেন: সব, চলমান এবং সম্পন্ন। তখন tasks.filter() দিয়ে render-এর আগে দৃশ্যমান তালিকা বানানো যায়। edit button যোগ করলে text আবার input-এ এনে validation করতে হবে। completed item-এর সংখ্যা, “সব সম্পন্ন মুছুন” action এবং keyboard accessibility-ও যোগ করা যায়। তবে প্রতিটি feature যোগের আগে state transition, ID এবং save timing পরিষ্কার রাখুন।

বড় ডেটার জন্য localStorage ধীর বা সীমিত হতে পারে; তখন IndexedDB উপযুক্ত বিকল্প। server-এ sync করতে গেলে conflict resolution, authentication, authorization এবং privacy policy দরকার হবে। localStorage-কে server database ভাবা যাবে না। বিশেষত অন্য ব্যবহারকারীর account data বা secret রাখার জন্য এটি ব্যবহার করবেন না।

ধাপে ধাপে data flow বুঝে নিন

ব্যবহারকারী যখন form submit করেন, প্রথমে browser-এর default navigation বন্ধ হয়। এরপর input-এর value থেকে দুই পাশের ফাঁকা জায়গা বাদ দেওয়া হয়। এই trim করার কারণ খুব বাস্তব: শুধু space লিখে task যোগ করলে সেটি তালিকায় অর্থহীন খালি item তৈরি করবে। maxlength="200" ব্যবহারকারীকে অসীম বড় string পাঠানো থেকেও রক্ষা করে, যদিও server-side app হলে server-এও একই সীমা যাচাই করতে হতো। client-side validation সুবিধার জন্য, নিরাপত্তার একমাত্র স্তর হিসেবে নয়।

Valid text পাওয়া গেলে tasks.push() একটি নতুন object যোগ করে। এখানে completed: false দিয়ে শুরু করা হয়েছে, কারণ সদ্য তৈরি কাজ শেষ হয়ে গেছে ধরে নেওয়া ঠিক নয়। createdAt ISO string-এ রাখা হয়েছে; এটি সংখ্যার চেয়ে পড়তে এবং অন্য system-এ পাঠাতে সহজ। পরে date অনুযায়ী তালিকা সাজাতে চাইলে new Date(task.createdAt) ব্যবহার করা যাবে। তবে sort যোগ করার সময় stable ID অপরিবর্তিত রাখতে হবে, যাতে checkbox বা delete action ভুল record-এ না যায়।

নতুন object যোগ হওয়ার পরে তিনটি ধাপ দেখা যায়: input.value = '' input পরিষ্কার করে, render() DOM-কে বর্তমান state-এর সঙ্গে মিলিয়ে নেয়, এবং saveTasks() পরবর্তী refresh-এর জন্য state লিখে রাখে। এই ক্রমটি বোঝা দরকার। যদি save করার আগে state বদলানো না হয়, পুরনো data-ই সংরক্ষিত হবে। আবার render না করলে state বদলালেও screen-এ ব্যবহারকারী তা দেখতে পাবেন না। ছোট app-এ এই তিনটি function পাশাপাশি থাকায় flow সহজে অনুসরণ করা যায়।

Checkbox-এর ক্ষেত্রে task object সরাসরি পাওয়া যায়, কারণ forEach-এর callback-এ সেই object-এর reference আছে। checkbox-এর নতুন checked value object-এর completed property-তে বসে। তারপর আবার render এবং save হয়। Render পুরনো list item সরিয়ে নতুন item তৈরি করলেও task object state-এ থাকে। এই separation-টি DOM এবং data-এর দায়িত্ব আলাদা রাখে: DOM হলো বর্তমান দৃশ্য, array হলো আসল state।

Delete action-এ filter() ব্যবহার করে একই ID নয় এমন item-গুলো নিয়ে নতুন array বানানো হয়েছে। এটি splice(index, 1)-এর চেয়ে এখানে বোধগম্য, কারণ index খুঁজে রাখার দরকার নেই। ID string থেকে item খোঁজা বা মুছে ফেলার সময় ব্যবহারকারীর task text-এর উপর নির্ভর করতে হয় না। একই text-এর দুটি কাজ থাকলেও প্রত্যেকটির UUID আলাদা থাকবে। ফলে “বাজার করা” নামের দুটি task থাকলে একটি মুছলে অন্যটি অক্ষত থাকবে।

accessibility ও ব্যবহারযোগ্যতার খুঁটিনাটি

শুধু functionality থাকলেই ভালো UI হয় না। input-এর সঙ্গে দৃশ্যমান label যুক্ত করা হয়েছে, যাতে screen reader এবং নতুন ব্যবহারকারী উভয়েই field-এর উদ্দেশ্য বুঝতে পারেন। list-এর checkbox-এ একটি aria-label আছে; task text দৃশ্যমান হলেও control-এর নাম স্পষ্ট থাকা সহায়ক। status-এর aria-live="polite" attribute দিয়ে সংরক্ষণ বা validation-এর বার্তা assistive technology-কে জানানো যায়, কিন্তু সেটি ব্যবহারকারীর কাজ বাধাগ্রস্ত করে না।

Delete button-এ পরিষ্কার বাংলা লেখা আছে, শুধু অচেনা icon নয়। keyboard দিয়ে tab করে form control-এ যাওয়া যায়, কারণ native button এবং input ব্যবহার করা হয়েছে। custom clickable div এড়িয়ে native element নেওয়া হয়েছে; এতে keyboard behavior, focus এবং semantics browser নিজেই ভালোভাবে দেয়। production app-এ focus ring যেন CSS দিয়ে অদৃশ্য না করা হয়, সেটিও খেয়াল করবেন। রঙের মাধ্যমে completed state দেখানোর পাশাপাশি text-decoration রাখা হয়েছে, যাতে রঙের পার্থক্য একমাত্র সংকেত না হয়।

Responsive CSS-এ ছোট screen হলে form column layout নেয়। দীর্ঘ task-এর জন্য overflow-wrap: anywhere আছে, তাই অস্বাভাবিক দীর্ঘ URL বা একটানা অক্ষর card-এর বাইরে বেরিয়ে যাবে না। একই কারণে user text-এর জন্য কোনও HTML string template বানানো হয়নি। নিরাপদ output এবং ব্যবহারযোগ্য layout এখানে একসঙ্গে কাজ করছে।

browser storage-এর সীমা এবং বাস্তব সিদ্ধান্ত

একটি origin-এর storage অন্য origin-এর storage থেকে আলাদা। http://localhost:8000 এবং অন্য port-এ চলা localhost-কে একই ধরে নেওয়া যাবে না; port origin-এর অংশ। তাই server বন্ধ করে ভিন্ন port-এ আবার চালালে পুরনো task না দেখা স্বাভাবিক। একইভাবে https deployment-এ upload করার পরে local http-এর data সেখানে চলে যাবে না। এই আচরণ bug নয়, browser-এর isolation model।

Private browsing-এ page বন্ধ করার পর data মুছে যেতে পারে। browser storage পূর্ণ হয়ে গেলেও setItem() ব্যর্থ হতে পারে। আবার ব্যবহারকারী browser settings-এ site data block করলে get বা remove operation-ও exception দিতে পারে। এই tutorial-এ exception ধরা হয়েছে, কিন্তু সব browser policy এক রকম নয়। তাই UI-তে “স্থায়ীভাবে সংরক্ষিত” না লিখে “browser storage-এ সংরক্ষিত” বলা অধিক সঠিক। ব্যবহারকারীকে নিয়মিত export বা backup সুবিধা দিতে চাইলে JSON download যোগ করা যায়।

Storage-তে data রাখার আগে quota-র কথাও মনে রাখা দরকার। হাজার হাজার বড় note-এর জন্য localStorage উপযুক্ত নয়, কারণ এটি synchronous এবং string-based। JSON.stringify() চলার সময় main thread আটকে যেতে পারে। ছোট task list-এ সমস্যা সাধারণত চোখে পড়বে না, কিন্তু বড় offline application-এ IndexedDB বা Cache API বিবেচনা করা উচিত। Server sync দরকার হলে API design, retry, conflict এবং offline queue-ও আলাদা করে পরিকল্পনা করতে হবে।

Corrupt data warning-এর নকশাটিও একটি UX সিদ্ধান্ত। আমরা parse error-এ raw string দেখাইনি, কারণ তার মধ্যে HTML-এর মতো লেখা থাকতে পারে; warning-এ স্থির সাধারণ বার্তা দেখিয়েছি। আমরা storage সঙ্গে সঙ্গে clear করিনি, কারণ সেই data-তে ব্যবহারকারীর মূল্যবান task থাকতে পারে। “নষ্ট ডেটা সরান” বোতামটি explicit consent নেয়। shape আংশিক ভুল হলে valid item দেখানো হচ্ছে, কিন্তু warning রেখে দেওয়া হচ্ছে—এতে ব্যবহারকারী বুঝতে পারেন যে সব record ফিরে আসেনি।

নিজের কোড উন্নত করার checklist

প্রথমে function-গুলোর দায়িত্ব আলাদা রাখুন: ID বানানো, shape যাচাই, read, save, render এবং event handling একসঙ্গে বিশাল function-এ লিখবেন না। দ্বিতীয়ত, প্রত্যেক user-controlled string কোথা থেকে আসছে তা চিহ্নিত করুন এবং DOM-এ বসানোর সময় textContent বা নিরাপদ property ব্যবহার করুন। তৃতীয়ত, state পরিবর্তনের পর সবসময় persistence দরকার কি না ঠিক করুন; temporary filter state storage-এ না রাখলেও চলে। চতুর্থত, error হলে user-কে সংক্ষিপ্ত কিন্তু সত্য বার্তা দিন।

পরীক্ষার জন্য happy path-এর পাশাপাশি failure path লিখে ফেলুন: empty submit, শুধু whitespace, একই text, অনেক লম্বা text, corrupt JSON, valid JSON কিন্তু object, ভুল type-এর property, storage unavailable এবং storage quota পূর্ণ। browser refresh, নতুন tab এবং private window-এ আলাদা ফলাফলও নোট করুন। এই checklist অনুসরণ করলে tutorial-এর demo ধীরে ধীরে একটি বেশি নির্ভরযোগ্য ছোট application-এ পরিণত হবে।

উপসংহার

এই ছোট প্রকল্পে আমরা একটি বাস্তবসম্মত frontend workflow দেখলাম: state model করা, DOM node নিরাপদে তৈরি করা, event দিয়ে state বদলানো, JSON হিসেবে persistence করা এবং অনির্ভরযোগ্য storage-কে যাচাই করা। textContent, schema validation, explicit warning, unique ID এবং storage error handling—এই অভ্যাসগুলি app বড় হওয়ার আগেই গড়ে তুললে পরের প্রকল্পে অনেক নিরাপদ code লেখা যায়। এখন index.html চালিয়ে নিজে feature যোগ করুন, কিন্তু প্রতিটি পরিবর্তনের পর data shape এবং failure case পরীক্ষা করতে ভুলবেন না।

Decimal ও CSV দিয়ে Python CLI Expense Tracker বানান

Python ও CSV দিয়ে খরচের হিসাব—ল্যাপটপ, কয়েন ও খাতার মৌলিক চিত্র

কেন Python দিয়ে খরচের হিসাব রাখবেন

মাসের শেষে টাকা কোথায় গেল, এই প্রশ্নের উত্তর খুঁজতে অনেকেই ব্যাংক স্টেটমেন্ট, মোবাইল নোট এবং স্মৃতির ওপর নির্ভর করেন। ছোট একটি কমান্ড-লাইন প্রোগ্রাম সেই বিচ্ছিন্ন তথ্যকে একটি CSV ফাইলে সাজিয়ে রাখতে পারে। এই প্রকল্পে আমরা Python দিয়ে এমন একটি expense tracker বানাব, যেখানে প্রতিটি খরচের তারিখ, বর্ণনা, বিভাগ এবং পরিমাণ সংরক্ষিত হবে। প্রোগ্রামটি নতুন খরচ যোগ করবে, সব রেকর্ড দেখাবে এবং বিভাগভিত্তিক মোট হিসাব বের করবে।

এটি ইচ্ছাকৃতভাবে ছোট, কিন্তু খেলনা নয়। ফাইল তৈরি, ব্যবহারকারীর ইনপুট যাচাই, টাকার দশমিক নির্ভুলতা এবং CSV পড়া-লেখার বাস্তব কৌশল একসঙ্গে অনুশীলন করা যাবে। GUI, ডেটাবেস বা অতিরিক্ত প্যাকেজ না থাকায় শিক্ষার্থী নিজের কম্পিউটারে Python 3 থাকলেই শুরু করতে পারবেন। পরে একই ধারণাকে ওয়েব অ্যাপ, মোবাইল অ্যাপ অথবা SQLite-ভিত্তিক ব্যবস্থায় বিস্তৃত করা সম্ভব।

কী বানাব এবং কীভাবে কাজ করবে

আমাদের ফাইলটির নাম expense_tracker.py এবং ডেটা থাকবে expenses.csv নামে। চালানোর সময় প্রথম আর্গুমেন্ট হিসেবে add, list অথবা summary লিখতে হবে। add কমান্ড চারটি তথ্য চাইবে: তারিখ, বর্ণনা, বিভাগ এবং পরিমাণ। list প্রতিটি রেকর্ড টেবিলের মতো দেখাবে। summary একই বিভাগের খরচ যোগ করে বড় থেকে ছোট ক্রমে দেখাবে। CSV-র কলাম হবে date, description, category, amount।

প্রথমবার চালালে CSV না থাকলেও সমস্যা হবে না; প্রোগ্রাম নিজে শিরোনামসহ ফাইল তৈরি করবে। আমরা csv.DictWriter ব্যবহার করব, কারণ কলামের নাম ধরে ডিকশনারি লেখা পড়তে সহজ। CSV মডিউলের মানগুলো মূলত স্ট্রিং হিসেবে ফেরত দেয়, তাই হিসাব করার আগে প্রতিটি amount-কে Decimal-এ রূপান্তর করতে হবে। float এখানে এড়ানো হবে, কারণ ০.১-এর মতো দশমিক বাইনারি ভাসমান সংখ্যায় সব সময় নিখুঁতভাবে প্রকাশিত হয় না।

কেন Decimal গুরুত্বপূর্ণ

টাকার হিসাবের ক্ষেত্রে এক পয়সার পার্থক্যও বিভ্রান্তিকর হতে পারে। Decimal দশমিক সংখ্যাকে এমনভাবে পরিচালনা করে, যা মূল্য গণনার জন্য অনেক বেশি পূর্বানুমেয়। ইনপুটকে সরাসরি Decimal(text) বানালে ব্যবহারকারীর দেওয়া দশমিক রূপ সংরক্ষণ করা যায়। আমরা ফল দেখানোর সময় দুই ঘর দশমিক ব্যবহার করব এবং যোগফলের জন্যও একই ধরনের মান রাখব।

শুধু Decimal ব্যবহার করলেই সব ভুল বন্ধ হয় না। খালি ইনপুট, অক্ষর মেশানো ইনপুট, ঋণাত্মক সংখ্যা এবং NaN বা অসীমের মতো বিশেষ মান আলাদা করে আটকাতে হবে। এই জন্য Decimal.is_finite() পরীক্ষা করা হবে। তারপর শূন্যের চেয়ে বড় কি না দেখা হবে। ফলে 12.50 গ্রহণযোগ্য, কিন্তু 0, -4, abc, NaN এবং Infinity প্রত্যাখ্যাত হবে। ব্যবহারকারী ভুল করলে প্রোগ্রাম বন্ধ না হয়ে আবার ইনপুট চাইবে।

সম্পূর্ণ executable কোড

নিচের কোডটি expense_tracker.py নামে সংরক্ষণ করুন। এটি কেবল Python-এর standard library ব্যবহার করে, তাই কোনো pip install দরকার নেই। newline='' দিয়ে CSV ফাইল খোলা হয়েছে, যা বিভিন্ন অপারেটিং সিস্টেমে অতিরিক্ত ফাঁকা লাইন এড়াতে গুরুত্বপূর্ণ। encoding='utf-8-sig' ব্যবহার করায় বাংলা বর্ণনা Windows Excel-সহ সাধারণ স্প্রেডশিটে তুলনামূলকভাবে ভালোভাবে খোলা যায়।

from __future__ import annotations

import argparse
import csv
from collections import defaultdict
from datetime import date
from decimal import Decimal, InvalidOperation
from pathlib import Path

CSV_FILE = Path("expenses.csv")
FIELDNAMES = ["date", "description", "category", "amount"]


def ensure_csv_file(path: Path = CSV_FILE) -> None:
    """Create the CSV file with a header if it does not exist."""
    if not path.exists():
        with path.open("w", newline="", encoding="utf-8-sig") as file:
            writer = csv.DictWriter(file, fieldnames=FIELDNAMES)
            writer.writeheader()


def ask_nonempty(prompt: str) -> str:
    """Keep asking until the user enters non-whitespace text."""
    while True:
        value = input(prompt).strip()
        if value:
            return value
        print("ইনপুট খালি রাখা যাবে না। আবার চেষ্টা করুন।")


def ask_date() -> str:
    """Read a real date and return it in ISO format."""
    while True:
        value = input("তারিখ (YYYY-MM-DD, খালি হলে আজ): ").strip()
        if not value:
            return date.today().isoformat()
        try:
            return date.fromisoformat(value).isoformat()
        except ValueError:
            print("তারিখের রূপ সঠিক নয়। যেমন: 2026-01-31")


def ask_positive_amount() -> Decimal:
    """Read a finite amount greater than zero."""
    while True:
        text = input("পরিমাণ: ").strip()
        try:
            amount = Decimal(text)
        except InvalidOperation:
            print("পরিমাণে শুধু বৈধ সংখ্যা লিখুন। যেমন: 125.50")
            continue
        if not amount.is_finite() or amount <= Decimal("0"):
            print("পরিমাণ অবশ্যই শূন্যের চেয়ে বড় একটি সসীম সংখ্যা হতে হবে।")
            continue
        return amount.quantize(Decimal("0.01"))


def add_expense(path: Path = CSV_FILE) -> None:
    ensure_csv_file(path)
    record = {
        "date": ask_date(),
        "description": ask_nonempty("বর্ণনা: "),
        "category": ask_nonempty("বিভাগ: "),
        "amount": f"{ask_positive_amount():.2f}",
    }
    with path.open("a", newline="", encoding="utf-8-sig") as file:
        writer = csv.DictWriter(file, fieldnames=FIELDNAMES)
        writer.writerow(record)
    print("খরচ সফলভাবে সংরক্ষণ হয়েছে।")


def read_expenses(path: Path = CSV_FILE) -> list[dict[str, str]]:
    ensure_csv_file(path)
    with path.open("r", newline="", encoding="utf-8-sig") as file:
        return list(csv.DictReader(file))


def list_expenses(path: Path = CSV_FILE) -> None:
    rows = read_expenses(path)
    if not rows:
        print("এখনও কোনো খরচ নেই।")
        return
    print(f"{'তারিখ':<12} {'বর্ণনা':<24} {'বিভাগ':<16} {'পরিমাণ':>12}")
    print("-" * 68)
    for row in rows:
        print(f"{row['date']:<12} {row['description']:<24} "
              f"{row['category']:<16} {Decimal(row['amount']):>12.2f}")


def show_summary(path: Path = CSV_FILE) -> None:
    totals: defaultdict[str, Decimal] = defaultdict(lambda: Decimal("0.00"))
    for row in read_expenses(path):
        try:
            amount = Decimal(row["amount"])
        except (InvalidOperation, KeyError):
            print("সতর্কতা: একটি ভুল amount সারি বাদ দেওয়া হয়েছে।")
            continue
        if amount.is_finite() and amount > Decimal("0"):
            totals[row["category"]] += amount
    if not totals:
        print("সারাংশ দেখানোর মতো কোনো খরচ নেই।")
        return
    print("বিভাগভিত্তিক মোট:")
    for category, total in sorted(totals.items(), key=lambda item: item[1], reverse=True):
        print(f"- {category}: {total:.2f}")
    grand_total = sum(totals.values(), Decimal("0.00"))
    print(f"সর্বমোট: {grand_total:.2f}")


def main() -> None:
    parser = argparse.ArgumentParser(description="A small CSV expense tracker")
    parser.add_argument("command", choices=("add", "list", "summary"))
    args = parser.parse_args()
    if args.command == "add":
        add_expense()
    elif args.command == "list":
        list_expenses()
    else:
        show_summary()


if __name__ == "__main__":
    main()

চালানোর ধাপ

একটি নতুন ফোল্ডারে ফাইলটি রাখুন। টার্মিনালে সেই ফোল্ডারে গিয়ে python3 expense_tracker.py add চালান। Windows-এ প্রয়োজনে একই কমান্ডের জায়গায় py expense_tracker.py add লিখতে পারেন। তারিখের ঘরে 2026-10-20 লিখুন, তারপর বাস ভাড়া, যাতায়াত এবং 45.50 লিখুন। আবার add চালিয়ে দুপুরের খাবার, খাবার এবং 180 যোগ করুন।

সব রেকর্ড দেখতে কমান্ডটি হবে:

python3 expense_tracker.py list

সম্ভাব্য আউটপুট এমন হবে:

তারিখ        বর্ণনা                   বিভাগ                 পরিমাণ
--------------------------------------------------------------------
2026-10-20   বাস ভাড়া                যাতায়াত                 45.50
2026-10-20   দুপুরের খাবার            খাবার                   180.00

বিভাগভিত্তিক যোগ দেখতে লিখুন:

python3 expense_tracker.py summary

আউটপুট হবে:

বিভাগভিত্তিক মোট:
- খাবার: 180.00
- যাতায়াত: 45.50
সর্বমোট: 225.50

বাস্তবে কলামের প্রস্থ ছোট হলে দীর্ঘ বাংলা বর্ণনা একটু সরে যেতে পারে; সেটি ডেটা নষ্ট হওয়া নয়, কেবল টার্মিনালের অক্ষর-প্রস্থের সীমাবদ্ধতা। CSV ফাইলটি সরাসরি টেক্সট এডিটরে খুলে দেখলে প্রতিটি রেকর্ডের গঠন বুঝতে পারবেন। বর্ণনার মধ্যে কমা থাকলেও csv মডিউল প্রয়োজনীয় উদ্ধৃতি নিজে সামলাবে।

কোডটি পড়ার কৌশল

প্রথমে ensure_csv_file দেখুন। এটি বারবার চললেও বিদ্যমান ফাইল মুছে না দিয়ে শুধু অনুপস্থিত ফাইল তৈরি করে। ask_nonempty এবং ask_positive_amount হলো ইনপুটের দরজা; এখানে ভুল আটকানো হলে পরের অংশকে তুলনামূলক পরিষ্কার রাখা যায়। ask_date তারিখকে ISO বিন্যাসে একরকম রাখে, ফলে পরে সাজানো বা অন্য প্রোগ্রামে ব্যবহার করা সহজ।

read_expenses সব সারি ডিকশনারির তালিকা হিসেবে আনে। মনে রাখবেন, CSV থেকে আসা amount স্ট্রিং। show_summary প্রতিটি স্ট্রিংকে Decimal করে এবং defaultdict-এ বিভাগের নামে মোট জমা করে। sorted-এর reverse=True বড় খরচ আগে দেখায়। শেষে sum-এর শুরুতে Decimal("0.00") দেওয়া হয়েছে, যাতে Python কোনো পূর্ণসংখ্যা দিয়ে যোগ শুরু না করে।

argparse কমান্ডের বানান সীমিত করে। তাই python3 expense_tracker.py remove লিখলে প্রোগ্রাম পরিষ্কার ত্রুটি বার্তা দেখাবে, নীরবে ভুল কাজ করবে না। এই ছোট নকশাটি CLI অ্যাপকে ভবিষ্যতে আরও কমান্ড যোগ করার উপযোগী রাখে।

যাচাই, নিরাপত্তা এবং CSV সম্পাদনা

পরিমাণের ঘরে 12.3.4, abc, -10, 0, NaN এবং Infinity একে একে দিয়ে দেখুন। প্রথম দুটিতে রূপান্তর ব্যর্থ হবে, পরেরগুলোতে সসীম-ধনাত্মক পরীক্ষায় আটকে যাবে। খুব বড় পরিমাণও Decimal গ্রহণ করতে পারে, তবে বাস্তব ব্যবহারে নিজের মুদ্রার সীমা এবং দুই দশমিকের নিয়ম নির্ধারণ করুন। এখানে quantize দুই ঘর পর্যন্ত গোল করে; গোল করার নীতি ব্যবসায়িক হিসাবের জন্য যথেষ্ট কি না আলাদা করে যাচাই করা দরকার।

CSV ফাইল হাত দিয়ে সম্পাদনা করলে শিরোনামের বানান বদলাবেন না। amount-এ মুদ্রার চিহ্ন যেমন ৳ লিখবেন না, কারণ যোগ করার আগে সেটি সংখ্যায় রূপান্তরিত হতে হবে। ফাইলের ব্যাকআপ রাখুন, বিশেষ করে একমাত্র কপি হিসেবে ব্যবহার করলে। CSV এনক্রিপ্টেড নয়; একই কম্পিউটারে অন্য ব্যবহারকারী ফাইলটি পড়তে পারেন। গোপন আর্থিক তথ্যের জন্য অনুমতি নিয়ন্ত্রণ, এনক্রিপশন এবং নির্ভরযোগ্য ব্যাকআপ প্রয়োজন।

সাধারণ সমস্যা ও সমাধান

python3: command not found দেখালে Python 3 ইনস্টল আছে কি না পরীক্ষা করুন। Windows-এ py --version চেষ্টা করুন। No such file or directory সাধারণত ভুল ফোল্ডার থেকে কমান্ড চালানোর ইঙ্গিত; ls বা dir দিয়ে স্ক্রিপ্টের অবস্থান মিলিয়ে নিন। Permission denied হলে এমন ফোল্ডার বেছে নিন যেখানে আপনার লেখার অনুমতি আছে।

যদি প্রতিবার খালি CSV তৈরি হয়, আপনি সম্ভবত অন্য working directory-তে আছেন। python3 -c "import pathlib; print(pathlib.Path.cwd())" দিয়ে বর্তমান অবস্থান দেখুন। বাংলা অক্ষর বিকৃত হলে টার্মিনালের UTF-8 সেটিং এবং ফাইলের utf-8-sig এনকোডিং পরীক্ষা করুন। CSV-তে পুরনো কোনো সারির amount ভুল হলে summary সতর্ক করে সারিটি বাদ দেবে; মূল ফাইল ঠিক করে আবার চালান।

অনুশীলন ও পরবর্তী উন্নতি

প্রথম অনুশীলনে delete কমান্ড যোগ করুন, তবে মুছে ফেলার আগে রেকর্ডের নম্বর দেখিয়ে নিশ্চিতকরণ নিন। দ্বিতীয় অনুশীলনে নির্দিষ্ট মাসের সারাংশ বের করুন। তৃতীয় অনুশীলনে export কমান্ড লিখে বিভাগভিত্তিক রিপোর্ট আলাদা CSV-তে সংরক্ষণ করুন। চতুর্থ অনুশীলনে একটি ঐচ্ছিক note কলাম যোগ করে দেখুন DictWriter-এর fieldnames এবং প্রতিটি রেকর্ড কোথায় বদলাতে হয়।

আরও বড় হলে CSV-র বদলে SQLite ব্যবহার, ইউনিট টেস্ট, কনফিগারেশন ফাইল, মুদ্রার নাম এবং মাসিক বাজেট যোগ করা যায়। পরীক্ষার সময় temporary directory ব্যবহার করলে আসল হিসাব নষ্ট হবে না। ইনপুট ফাংশনগুলো আলাদা রাখার ফলে পরে GUI বা ওয়েব ফর্ম থেকেও একই হিসাবের ফাংশন পুনর্ব্যবহার করা সম্ভব।

সীমাবদ্ধতা ও দায়িত্বশীল ব্যবহার

এই প্রকল্প শেখার জন্য তৈরি; এটি পূর্ণাঙ্গ হিসাবরক্ষণ, কর গণনা, ব্যাংক সমন্বয় বা বিনিয়োগ ব্যবস্থাপনা সফটওয়্যার নয়। একাধিক ব্যক্তি একই CSV একসঙ্গে লিখলে রেকর্ড হারাতে পারে। ফাইল দুর্নীতির বিরুদ্ধে লেনদেন ব্যবস্থা নেই, ব্যবহারকারী পরিচয় যাচাই নেই, এবং তারিখের ভিত্তিতে রিপোর্ট সীমিত। তাই গুরুত্বপূর্ণ আর্থিক সিদ্ধান্ত নেওয়ার একমাত্র উৎস হিসেবে এটিকে ব্যবহার করবেন না। এটি কোনো আর্থিক পরামর্শও নয়; নিজের পরিস্থিতি অনুযায়ী যোগ্য পেশাদারের পরামর্শ নিন।

এই ছোট CLI-র মূল শিক্ষা হলো একটি সমস্যাকে পরিষ্কার ডেটা মডেল, নির্ভরযোগ্য ইনপুট যাচাই এবং সহজ ফাইল ফরম্যাটে ভাঙা। প্রতিটি পরিবর্তনের পর কয়েকটি বৈধ ও অবৈধ ইনপুট দিয়ে পরীক্ষা করুন, CSV-র ব্যাকআপ রাখুন এবং ধীরে ধীরে নতুন সুবিধা যোগ করুন। এভাবেই একটি ছোট স্ক্রিপ্ট Python শেখার বাস্তব, পরিমাপযোগ্য প্রকল্পে পরিণত হয়।

একটি রেকর্ডের জীবনচক্র

একটি খরচ যোগ করার মুহূর্ত থেকে রিপোর্ট দেখানো পর্যন্ত কয়েকটি ধাপ ঘটে। প্রথমে argparse কমান্ড শনাক্ত করে। এরপর তারিখ ফাংশন ইনপুটকে date অবজেক্টে যাচাই করে ISO স্ট্রিং বানায়। বর্ণনা ও বিভাগে strip() চালানো হয়, তাই শুরু বা শেষে অনিচ্ছাকৃত ফাঁকা জায়গা জমে থাকে না। পরিমাণের ক্ষেত্রে আলাদা পথ অনুসরণ করা হয়: টেক্সট থেকে দশমিক মান, তারপর সসীমতা, তারপর ধনাত্মকতা এবং শেষে দুই ঘরে quantize।

এই ক্রমটি গুরুত্বপূর্ণ। আগে Decimal বানিয়ে না নিলে NaN বা অসীম মানকে সাধারণ সংখ্যার মতো ধরে ফেলতে পারতাম। আবার কেবল সংখ্যায় রূপান্তর করলেই খালি স্ট্রিংয়ের সমস্যা মিটত না। প্রত্যেক যাচাই একটি নির্দিষ্ট দায়িত্ব পালন করছে। সফল হলে রেকর্ড ডিকশনারিতে গিয়ে DictWriter.writerow-এর মাধ্যমে একটি CSV সারি হয়। একই কলামের নাম সব জায়গায় ব্যবহার করায় বানান ভুলের ঝুঁকি কমে।

পরে list কমান্ড সেই ফাইলকে পড়ে। DictReader প্রথম সারির শিরোনাম থেকে কী তৈরি করে এবং প্রতিটি মান স্ট্রিং হিসেবে দেয়। তাই প্রদর্শনের সময় amount আবার Decimal হয়েছে। summary একই রকম পড়ে, কিন্তু এবার category-কে মানচিত্রের key ধরে যোগ করে। ডেটা লেখার সময় এবং পড়ার সময় আলাদা ধরনের কাজ হচ্ছে—লেখার সময় বৈধতা, পড়ার সময় রূপান্তর ও প্রতিরোধমূলক পরীক্ষা। এই বিভাজন নতুন ফিচার যোগ করা সহজ করে।

নিজের কমান্ড-লাইন পরীক্ষার তালিকা

একটি ছোট টেস্ট তালিকা লিখে রাখলে পরিবর্তনের পর আত্মবিশ্বাস বাড়ে। প্রথমে কোনো expenses.csv না থাকা অবস্থায় list চালান। প্রত্যাশা হবে খালি ফাইল তৈরি এবং “এখনও কোনো খরচ নেই” বার্তা। তারপর একটি সাধারণ সঠিক রেকর্ড যোগ করুন এবং list ও summary দুটোই পরীক্ষা করুন। বর্ণনায় কমা, বাংলা অক্ষর এবং দীর্ঘ বাক্য দিয়ে আরেকটি রেকর্ড যোগ করুন; CSV উদ্ধৃতি ঠিক রাখছে কি না দেখুন।

এরপর ভুল তারিখ, খালি বিভাগ এবং একে একে abc, 0, -2, NaN, Infinity দিন। প্রতিটি ক্ষেত্রে প্রোগ্রামকে নতুন ইনপুট চাইতে হবে এবং অর্ধেক লেখা সারি ফাইলে ঢোকানো যাবে না। Ctrl-C চাপলে ব্যবহারকারী মাঝপথে বেরিয়ে যেতে পারেন—এটি স্বাভাবিক কমান্ড-লাইন আচরণ, যদিও সুন্দর বার্তার জন্য ভবিষ্যতে KeyboardInterrupt ধরা যায়। ভুল হাতে সম্পাদিত CSV-র amount দিয়ে summary চালিয়ে সতর্কবার্তাও পরীক্ষা করুন।

স্বয়ংক্রিয় পরীক্ষার ধারণা শেখার জন্য tempfile.TemporaryDirectory কাজে লাগে। সেখানে আলাদা CSV বানিয়ে read_expenses এবং show_summary পরীক্ষা করা যায়, ফলে প্রকৃত হিসাবের ফাইল বদলে যাবে না। ইনপুট-নির্ভর ফাংশন পরীক্ষা করতে unittest.mock.patch দিয়ে input-এর উত্তর নির্ধারণ করা যায়। এখনই পরীক্ষার ফ্রেমওয়ার্ক দরকার নেই, কিন্তু প্রতিটি প্রত্যাশা লিখে রাখা বড় প্রকল্পের অভ্যাস গড়ে দেয়।

CSV নকশার কয়েকটি বাস্তব সিদ্ধান্ত

CSV মানুষের চোখে পড়ে এবং spreadsheet-এ খোলা যায়, তাই শেখার প্রকল্পে সুবিধাজনক। তবে এটি schema-র নিয়ম নিজে জোর করে না। কেউ কলামের ক্রম বদলালে বা পুরনো সারি অসম্পূর্ণ করলে পড়ার সময় সমস্যা হতে পারে। এই উদাহরণে DictReader-এর সুবিধা নেওয়া হয়েছে, কিন্তু row["category"] অনুপস্থিত হলে যে কোনো ত্রুটি হতে পারে। production পর্যায়ে প্রতিটি সারির field পরীক্ষা, ভুল সারির আলাদা লগ এবং সংস্করণযুক্ত schema যোগ করা দরকার।

utf-8-sig বেছে নেওয়ার কারণও ব্যবহারিক। UTF-8 মূল ডেটা এনকোডিং হিসেবে বহনযোগ্য, আর BOM-সহ রূপ কিছু Windows প্রোগ্রামকে বাংলা কলাম চিনতে সাহায্য করে। একই ফাইল অন্য টুলে নিলে তার encoding অনুমান না করে স্পষ্টভাবে UTF-8 নির্বাচন করুন। newline='' বাদ দিলে Python-এর text newline translation এবং CSV parser-এর নিজস্ব newline ব্যবস্থার মধ্যে দ্বন্দ্ব তৈরি হতে পারে; বিশেষ করে Windows-এ ফাঁকা সারি দেখা দিতে পারে।

টাকার মুদ্রা এই কোডে আলাদা কলাম নয়। তাই একই ফাইলে টাকা, ডলার বা অন্য মুদ্রার রেকর্ড মেশানো ঠিক হবে না। উন্নত সংস্করণে currency field যোগ করে প্রতিটি রেকর্ডের মুদ্রা আলাদা রাখা, অথবা শুরুতেই একটি মুদ্রা নির্দিষ্ট করা উচিত। বিনিময় হার, কর, ফেরত এবং ঋণাত্মক refund যোগ করলে নিয়ম স্পষ্ট লিখে তারপর কোড বদলাবেন; শুধু minus চিহ্ন অনুমতি দিলেই হিসাবের অর্থ পরিষ্কার হয় না।

শেখার পথ এবং সীমা

এই প্রোগ্রাম দিয়ে Python-এর কয়েকটি গুরুত্বপূর্ণ ধারণা একসঙ্গে দেখা যায়: ফাংশন, type hint, exception, loop, dictionary, list, file context manager এবং command-line argument। প্রতিটি অংশ আলাদা করে পুনর্লিখুন। যেমন প্রথমে শুধু CSV header তৈরি করুন, পরে একটি স্থির রেকর্ড লিখুন, তারপর ব্যবহারকারীর input আনুন। ধাপে ধাপে করলে ত্রুটির উৎস বোঝা সহজ হয় এবং কপি-পেস্টের ওপর নির্ভরতা কমে।

কোডটি ছোট রাখার অর্থ ইচ্ছাকৃত। এতে login নেই, একই সময়ে বহু প্রক্রিয়ার file locking নেই, undo নেই, encryption নেই, search বা date filter নেই। দীর্ঘমেয়াদি হিসাবের ক্ষেত্রে backup strategy অপরিহার্য: নিয়মিত কপি রাখুন, backup-এর ভেতরেও সংবেদনশীল তথ্যের নিরাপত্তা বিবেচনা করুন, এবং restore করে দেখা কপি সত্যিই পড়া যায় কি না যাচাই করুন। কোনো গুরুত্বপূর্ণ সিদ্ধান্তের আগে উৎস নথির সঙ্গে হিসাব মিলিয়ে নিন।

শেষ পর্যন্ত expense tracker-কে একটি শিক্ষা-প্রকল্প হিসেবে দেখুন। আপনি এখানে অর্থনৈতিক পরামর্শ পাচ্ছেন না এবং এই ফলাফল কর, ঋণ, বাজেট বা বিনিয়োগের জন্য যথেষ্ট প্রমাণ নয়। ব্যক্তিগত পরিস্থিতি জটিল হলে যোগ্য হিসাবরক্ষক বা আর্থিক পেশাদারের সহায়তা নিন। নিজের ডেটার মালিকানা, গোপনীয়তা ও ব্যাকআপের দায়িত্বও নিজের। ছোট স্ক্রিপ্টে এই সতর্কতা যোগ করাই পরিণত সফটওয়্যার চিন্তার প্রথম ধাপ।

রেফারেন্স: Python-এর csv মডিউলের অফিসিয়াল ডকুমেন্টেশন দেখুন।

বিজ্ঞাপনের স্থানপোস্ট ১
বিজ্ঞাপনের স্থানপোস্ট ২
বিজ্ঞাপনের স্থানপোস্ট ৩
About Me
পরিচয়
নাম

নাজমুল ইসলাম

ভূমিকা

Tech Specialist

আরও পরিচিতি
শিক্ষা / পড়াশোনার বিষয়
HSC
কাজ / প্রযুক্তির ক্ষেত্র / দক্ষতা
শহর / জেলা
শখ / আগ্রহ
ব্লগের বিষয় / পাঠকদের জন্য সংক্ষিপ্ত পরিচিতি
ব্যক্তিগত লেখা, ছোট গল্প ও প্রযুক্তি ভাবনা নিয়ে লিখি।
পাবলিক ওয়েবসাইট / সোশ্যাল লিংক
শেষের পরিচিতি
নাজমুল ইসলাম · Tech Specialist

লেখার আর্কাইভ

মাস অনুযায়ী পুরোনো লেখাগুলো খুঁজে নিন।